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/actions/actions.go | 24 + routers/api/actions/artifact.pb.go | 1058 ++++++ routers/api/actions/artifact.proto | 73 + routers/api/actions/artifacts.go | 507 +++ routers/api/actions/artifacts_chunks.go | 301 ++ routers/api/actions/artifacts_utils.go | 94 + routers/api/actions/artifactsv4.go | 599 +++ routers/api/actions/ping/ping.go | 38 + routers/api/actions/ping/ping_test.go | 61 + routers/api/actions/runner/interceptor.go | 80 + routers/api/actions/runner/runner.go | 289 ++ routers/api/actions/runner/utils.go | 189 + routers/api/forgejo/v1/api.go | 20 + routers/api/forgejo/v1/forgejo.go | 23 + routers/api/forgejo/v1/generated.go | 167 + routers/api/forgejo/v1/root.go | 14 + routers/api/packages/README.md | 50 + routers/api/packages/alpine/alpine.go | 287 ++ routers/api/packages/api.go | 916 +++++ routers/api/packages/arch/arch.go | 269 ++ routers/api/packages/cargo/cargo.go | 311 ++ routers/api/packages/chef/auth.go | 274 ++ routers/api/packages/chef/chef.go | 403 +++ routers/api/packages/composer/api.go | 117 + routers/api/packages/composer/composer.go | 261 ++ routers/api/packages/conan/auth.go | 48 + routers/api/packages/conan/conan.go | 807 +++++ routers/api/packages/conan/search.go | 163 + routers/api/packages/conda/conda.go | 303 ++ routers/api/packages/container/auth.go | 49 + routers/api/packages/container/blob.go | 202 ++ routers/api/packages/container/container.go | 785 ++++ routers/api/packages/container/errors.go | 52 + routers/api/packages/container/manifest.go | 483 +++ routers/api/packages/cran/cran.go | 264 ++ routers/api/packages/debian/debian.go | 309 ++ routers/api/packages/generic/generic.go | 212 ++ routers/api/packages/generic/generic_test.go | 65 + routers/api/packages/goproxy/goproxy.go | 224 ++ routers/api/packages/helm/helm.go | 217 ++ routers/api/packages/helper/helper.go | 63 + routers/api/packages/maven/api.go | 50 + routers/api/packages/maven/maven.go | 433 +++ routers/api/packages/npm/api.go | 114 + routers/api/packages/npm/npm.go | 462 +++ routers/api/packages/nuget/api_v2.go | 402 ++ routers/api/packages/nuget/api_v3.go | 255 ++ routers/api/packages/nuget/auth.go | 47 + routers/api/packages/nuget/links.go | 52 + routers/api/packages/nuget/nuget.go | 710 ++++ routers/api/packages/pub/pub.go | 284 ++ routers/api/packages/pypi/pypi.go | 194 + routers/api/packages/pypi/pypi_test.go | 38 + routers/api/packages/rpm/rpm.go | 318 ++ routers/api/packages/rubygems/rubygems.go | 451 +++ routers/api/packages/swift/swift.go | 465 +++ routers/api/packages/vagrant/vagrant.go | 242 ++ routers/api/shared/middleware.go | 152 + 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 + routers/common/auth.go | 45 + routers/common/compare.go | 21 + routers/common/db.go | 59 + routers/common/errpage.go | 56 + routers/common/errpage_test.go | 39 + routers/common/markup.go | 98 + routers/common/middleware.go | 116 + routers/common/middleware_test.go | 70 + routers/common/redirect.go | 26 + routers/common/serve.go | 43 + routers/init.go | 208 ++ routers/install/install.go | 617 ++++ routers/install/routes.go | 44 + routers/install/routes_test.go | 38 + routers/private/actions.go | 97 + routers/private/default_branch.go | 40 + routers/private/hook_post_receive.go | 368 ++ routers/private/hook_post_receive_test.go | 50 + routers/private/hook_pre_receive.go | 629 ++++ routers/private/hook_proc_receive.go | 43 + routers/private/hook_verification.go | 122 + routers/private/hook_verification_test.go | 46 + routers/private/internal.go | 84 + routers/private/internal_repo.go | 69 + routers/private/key.go | 61 + routers/private/mail.go | 91 + routers/private/main_test.go | 14 + routers/private/manager.go | 195 + routers/private/manager_process.go | 160 + routers/private/manager_unix.go | 25 + routers/private/manager_windows.go | 27 + routers/private/restore_repo.go | 53 + routers/private/serv.go | 397 ++ routers/private/ssh_log.go | 33 + .../tests/repos/repo1_hook_verification/HEAD | 1 + .../tests/repos/repo1_hook_verification/config | 6 + .../tests/repos/repo1_hook_verification/info/refs | 1 + .../tests/repos/repo1_hook_verification/logs/HEAD | 1 + .../repo1_hook_verification/logs/refs/heads/main | 1 + .../08/cbc8f0e903b0916025ae7577564b7ed39ecb2c | Bin 0 -> 86 bytes .../0b/5987362fe3fabdd4406babdc819642ee2f5a2a | Bin 0 -> 22 bytes .../13/b0f23f673b161f4b5cb66f051cb93c99729e1e | Bin 0 -> 86 bytes .../23/33a51fdb238b7023a62ae3dcc58994061a7c86 | Bin 0 -> 86 bytes .../2b/df04adb23d2b40b6085efb230856e5e2a775b7 | Bin 0 -> 55 bytes .../65/a457425a679cbe9adf0d2741785d3ceabb44a7 | Bin 0 -> 50 bytes .../72/920278f2f999e3005801e5d5b8ab8139d3641c | 2 + .../8b/903ede7c494725624bf842ec890f6342dababd | Bin 0 -> 86 bytes .../93/eac826f6188f34646cea81bf426aa5ba7d3bfe | Bin 0 -> 684 bytes .../97/79d17a04f1e2640583d35703c62460b2d86e0a | 2 + .../9c/e3f779ae33f31fce17fac3c512047b75d7498b | Bin 0 -> 153 bytes .../a9/f76e70a663e40091749a97eeac5f57a6fec141 | Bin 0 -> 24 bytes .../ba/0caedd359ebe310ef431335576e20f2b84e9b9 | Bin 0 -> 63 bytes .../bb/87653e0819460e79b5f075f2563f583cbbf18c | Bin 0 -> 682 bytes .../cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa | 3 + .../d7/66f2917716d45be24bfa968b8409544941be32 | 3 + .../e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 | Bin 0 -> 59 bytes .../e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 | Bin 0 -> 15 bytes .../e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb | Bin 0 -> 677 bytes .../repo1_hook_verification/objects/info/packs | 1 + .../08/cbc8f0e903b0916025ae7577564b7ed39ecb2c | Bin 0 -> 86 bytes .../0b/5987362fe3fabdd4406babdc819642ee2f5a2a | Bin 0 -> 22 bytes .../13/b0f23f673b161f4b5cb66f051cb93c99729e1e | Bin 0 -> 86 bytes .../23/33a51fdb238b7023a62ae3dcc58994061a7c86 | Bin 0 -> 86 bytes .../8b/903ede7c494725624bf842ec890f6342dababd | Bin 0 -> 86 bytes .../93/eac826f6188f34646cea81bf426aa5ba7d3bfe | Bin 0 -> 684 bytes .../9c/e3f779ae33f31fce17fac3c512047b75d7498b | Bin 0 -> 153 bytes .../a9/f76e70a663e40091749a97eeac5f57a6fec141 | Bin 0 -> 24 bytes .../bb/87653e0819460e79b5f075f2563f583cbbf18c | Bin 0 -> 682 bytes .../cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa | 3 + .../repos/repo1_hook_verification/refs/heads/main | 1 + .../repo1_hook_verification_dummy_gpg_key.txt | 127 + routers/utils/utils.go | 14 + routers/utils/utils_test.go | 40 + routers/web/admin/admin.go | 254 ++ routers/web/admin/admin_test.go | 117 + routers/web/admin/applications.go | 90 + routers/web/admin/auths.go | 465 +++ routers/web/admin/config.go | 255 ++ routers/web/admin/diagnosis.go | 68 + routers/web/admin/emails.go | 182 + routers/web/admin/hooks.go | 73 + routers/web/admin/main_test.go | 14 + routers/web/admin/notice.go | 78 + routers/web/admin/orgs.go | 39 + routers/web/admin/packages.go | 113 + routers/web/admin/queue.go | 89 + routers/web/admin/queue_tester.go | 77 + routers/web/admin/repos.go | 163 + routers/web/admin/runners.go | 13 + routers/web/admin/stacktrace.go | 46 + routers/web/admin/users.go | 557 +++ routers/web/admin/users_test.go | 200 + routers/web/auth/2fa.go | 163 + routers/web/auth/auth.go | 881 +++++ routers/web/auth/auth_test.go | 43 + routers/web/auth/linkaccount.go | 308 ++ routers/web/auth/main_test.go | 14 + routers/web/auth/oauth.go | 1422 ++++++++ routers/web/auth/oauth_test.go | 103 + routers/web/auth/openid.go | 391 ++ routers/web/auth/password.go | 317 ++ routers/web/auth/webauthn.go | 177 + routers/web/base.go | 98 + routers/web/devtest/devtest.go | 66 + routers/web/events/events.go | 122 + routers/web/explore/code.go | 144 + routers/web/explore/org.go | 48 + routers/web/explore/repo.go | 193 + routers/web/explore/topic.go | 41 + routers/web/explore/user.go | 163 + routers/web/feed/branch.go | 50 + routers/web/feed/convert.go | 332 ++ routers/web/feed/file.go | 62 + routers/web/feed/profile.go | 87 + routers/web/feed/release.go | 52 + routers/web/feed/render.go | 19 + routers/web/feed/repo.go | 44 + routers/web/githttp.go | 42 + routers/web/goget.go | 93 + routers/web/healthcheck/check.go | 140 + routers/web/home.go | 117 + routers/web/metrics.go | 33 + routers/web/misc/markup.go | 18 + routers/web/misc/misc.go | 49 + routers/web/misc/swagger-forgejo.go | 19 + routers/web/misc/swagger.go | 20 + routers/web/nodeinfo.go | 32 + routers/web/org/home.go | 189 + routers/web/org/main_test.go | 14 + routers/web/org/members.go | 144 + routers/web/org/org.go | 80 + routers/web/org/org_labels.go | 116 + routers/web/org/projects.go | 610 ++++ routers/web/org/projects_test.go | 28 + routers/web/org/setting.go | 258 ++ routers/web/org/setting/blocked_users.go | 85 + routers/web/org/setting/runners.go | 12 + routers/web/org/setting_oauth2.go | 102 + routers/web/org/setting_packages.go | 131 + routers/web/org/teams.go | 628 ++++ routers/web/repo/actions/actions.go | 247 ++ routers/web/repo/actions/manual.go | 62 + routers/web/repo/actions/view.go | 781 ++++ routers/web/repo/activity.go | 105 + routers/web/repo/attachment.go | 163 + routers/web/repo/badges/badges.go | 164 + routers/web/repo/blame.go | 298 ++ routers/web/repo/branch.go | 262 ++ routers/web/repo/cherry_pick.go | 192 + routers/web/repo/code_frequency.go | 41 + routers/web/repo/commit.go | 468 +++ routers/web/repo/compare.go | 972 +++++ routers/web/repo/contributors.go | 38 + routers/web/repo/download.go | 170 + routers/web/repo/editor.go | 962 +++++ routers/web/repo/editor_test.go | 73 + routers/web/repo/find.go | 24 + routers/web/repo/flags/manage.go | 49 + routers/web/repo/githttp.go | 599 +++ routers/web/repo/githttp_test.go | 42 + routers/web/repo/helper.go | 44 + routers/web/repo/helper_test.go | 26 + routers/web/repo/issue.go | 3822 ++++++++++++++++++++ routers/web/repo/issue_content_history.go | 237 ++ routers/web/repo/issue_dependency.go | 144 + routers/web/repo/issue_label.go | 229 ++ routers/web/repo/issue_label_test.go | 173 + routers/web/repo/issue_lock.go | 65 + routers/web/repo/issue_pin.go | 107 + routers/web/repo/issue_stopwatch.go | 113 + routers/web/repo/issue_test.go | 375 ++ routers/web/repo/issue_timetrack.go | 87 + routers/web/repo/issue_watch.go | 63 + routers/web/repo/main_test.go | 14 + routers/web/repo/middlewares.go | 120 + routers/web/repo/migrate.go | 310 ++ routers/web/repo/milestone.go | 304 ++ routers/web/repo/packages.go | 78 + routers/web/repo/patch.go | 124 + routers/web/repo/projects.go | 670 ++++ routers/web/repo/projects_test.go | 27 + routers/web/repo/pull.go | 1838 ++++++++++ routers/web/repo/pull_review.go | 316 ++ routers/web/repo/pull_review_test.go | 104 + routers/web/repo/recent_commits.go | 41 + routers/web/repo/release.go | 857 +++++ routers/web/repo/release_test.go | 124 + routers/web/repo/render.go | 76 + routers/web/repo/repo.go | 774 ++++ routers/web/repo/search.go | 105 + routers/web/repo/setting/avatar.go | 76 + routers/web/repo/setting/collaboration.go | 217 ++ routers/web/repo/setting/default_branch.go | 54 + routers/web/repo/setting/deploy_key.go | 109 + routers/web/repo/setting/git_hooks.go | 65 + routers/web/repo/setting/lfs.go | 562 +++ routers/web/repo/setting/main_test.go | 14 + routers/web/repo/setting/protected_branch.go | 347 ++ routers/web/repo/setting/protected_tag.go | 188 + routers/web/repo/setting/runners.go | 187 + routers/web/repo/setting/secrets.go | 127 + routers/web/repo/setting/setting.go | 1115 ++++++ routers/web/repo/setting/settings_test.go | 412 +++ routers/web/repo/setting/variables.go | 140 + routers/web/repo/setting/webhook.go | 485 +++ routers/web/repo/topic.go | 60 + routers/web/repo/treelist.go | 54 + routers/web/repo/view.go | 1258 +++++++ routers/web/repo/view_test.go | 62 + routers/web/repo/wiki.go | 816 +++++ routers/web/repo/wiki_test.go | 224 ++ routers/web/shared/actions/runners.go | 161 + routers/web/shared/actions/variables.go | 65 + routers/web/shared/packages/packages.go | 260 ++ routers/web/shared/project/column.go | 48 + routers/web/shared/secrets/secrets.go | 53 + routers/web/shared/user/header.go | 163 + routers/web/swagger_json.go | 13 + routers/web/user/avatar.go | 57 + routers/web/user/code.go | 129 + routers/web/user/home.go | 883 +++++ routers/web/user/home_test.go | 169 + routers/web/user/main_test.go | 14 + routers/web/user/notification.go | 485 +++ routers/web/user/package.go | 513 +++ routers/web/user/profile.go | 385 ++ routers/web/user/search.go | 44 + routers/web/user/setting/account.go | 344 ++ routers/web/user/setting/account_test.go | 101 + routers/web/user/setting/adopt.go | 64 + routers/web/user/setting/applications.go | 115 + routers/web/user/setting/blocked_users.go | 46 + routers/web/user/setting/keys.go | 338 ++ routers/web/user/setting/main_test.go | 14 + routers/web/user/setting/oauth2.go | 68 + routers/web/user/setting/oauth2_common.go | 163 + routers/web/user/setting/packages.go | 119 + routers/web/user/setting/profile.go | 433 +++ routers/web/user/setting/runner.go | 13 + routers/web/user/setting/security/2fa.go | 260 ++ routers/web/user/setting/security/openid.go | 126 + routers/web/user/setting/security/security.go | 148 + routers/web/user/setting/security/webauthn.go | 137 + routers/web/user/setting/webhooks.go | 49 + routers/web/user/stop_watch.go | 40 + routers/web/user/task.go | 53 + routers/web/web.go | 1658 +++++++++ routers/web/webfinger.go | 167 + 435 files changed, 95809 insertions(+) create mode 100644 routers/api/actions/actions.go create mode 100644 routers/api/actions/artifact.pb.go create mode 100644 routers/api/actions/artifact.proto create mode 100644 routers/api/actions/artifacts.go create mode 100644 routers/api/actions/artifacts_chunks.go create mode 100644 routers/api/actions/artifacts_utils.go create mode 100644 routers/api/actions/artifactsv4.go create mode 100644 routers/api/actions/ping/ping.go create mode 100644 routers/api/actions/ping/ping_test.go create mode 100644 routers/api/actions/runner/interceptor.go create mode 100644 routers/api/actions/runner/runner.go create mode 100644 routers/api/actions/runner/utils.go create mode 100644 routers/api/forgejo/v1/api.go create mode 100644 routers/api/forgejo/v1/forgejo.go create mode 100644 routers/api/forgejo/v1/generated.go create mode 100644 routers/api/forgejo/v1/root.go create mode 100644 routers/api/packages/README.md create mode 100644 routers/api/packages/alpine/alpine.go create mode 100644 routers/api/packages/api.go create mode 100644 routers/api/packages/arch/arch.go create mode 100644 routers/api/packages/cargo/cargo.go create mode 100644 routers/api/packages/chef/auth.go create mode 100644 routers/api/packages/chef/chef.go create mode 100644 routers/api/packages/composer/api.go create mode 100644 routers/api/packages/composer/composer.go create mode 100644 routers/api/packages/conan/auth.go create mode 100644 routers/api/packages/conan/conan.go create mode 100644 routers/api/packages/conan/search.go create mode 100644 routers/api/packages/conda/conda.go create mode 100644 routers/api/packages/container/auth.go create mode 100644 routers/api/packages/container/blob.go create mode 100644 routers/api/packages/container/container.go create mode 100644 routers/api/packages/container/errors.go create mode 100644 routers/api/packages/container/manifest.go create mode 100644 routers/api/packages/cran/cran.go create mode 100644 routers/api/packages/debian/debian.go create mode 100644 routers/api/packages/generic/generic.go create mode 100644 routers/api/packages/generic/generic_test.go create mode 100644 routers/api/packages/goproxy/goproxy.go create mode 100644 routers/api/packages/helm/helm.go create mode 100644 routers/api/packages/helper/helper.go create mode 100644 routers/api/packages/maven/api.go create mode 100644 routers/api/packages/maven/maven.go create mode 100644 routers/api/packages/npm/api.go create mode 100644 routers/api/packages/npm/npm.go create mode 100644 routers/api/packages/nuget/api_v2.go create mode 100644 routers/api/packages/nuget/api_v3.go create mode 100644 routers/api/packages/nuget/auth.go create mode 100644 routers/api/packages/nuget/links.go create mode 100644 routers/api/packages/nuget/nuget.go create mode 100644 routers/api/packages/pub/pub.go create mode 100644 routers/api/packages/pypi/pypi.go create mode 100644 routers/api/packages/pypi/pypi_test.go create mode 100644 routers/api/packages/rpm/rpm.go create mode 100644 routers/api/packages/rubygems/rubygems.go create mode 100644 routers/api/packages/swift/swift.go create mode 100644 routers/api/packages/vagrant/vagrant.go create mode 100644 routers/api/shared/middleware.go 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 create mode 100644 routers/common/auth.go create mode 100644 routers/common/compare.go create mode 100644 routers/common/db.go create mode 100644 routers/common/errpage.go create mode 100644 routers/common/errpage_test.go create mode 100644 routers/common/markup.go create mode 100644 routers/common/middleware.go create mode 100644 routers/common/middleware_test.go create mode 100644 routers/common/redirect.go create mode 100644 routers/common/serve.go create mode 100644 routers/init.go create mode 100644 routers/install/install.go create mode 100644 routers/install/routes.go create mode 100644 routers/install/routes_test.go create mode 100644 routers/private/actions.go create mode 100644 routers/private/default_branch.go create mode 100644 routers/private/hook_post_receive.go create mode 100644 routers/private/hook_post_receive_test.go create mode 100644 routers/private/hook_pre_receive.go create mode 100644 routers/private/hook_proc_receive.go create mode 100644 routers/private/hook_verification.go create mode 100644 routers/private/hook_verification_test.go create mode 100644 routers/private/internal.go create mode 100644 routers/private/internal_repo.go create mode 100644 routers/private/key.go create mode 100644 routers/private/mail.go create mode 100644 routers/private/main_test.go create mode 100644 routers/private/manager.go create mode 100644 routers/private/manager_process.go create mode 100644 routers/private/manager_unix.go create mode 100644 routers/private/manager_windows.go create mode 100644 routers/private/restore_repo.go create mode 100644 routers/private/serv.go create mode 100644 routers/private/ssh_log.go create mode 100644 routers/private/tests/repos/repo1_hook_verification/HEAD create mode 100644 routers/private/tests/repos/repo1_hook_verification/config create mode 100644 routers/private/tests/repos/repo1_hook_verification/info/refs create mode 100644 routers/private/tests/repos/repo1_hook_verification/logs/HEAD create mode 100644 routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/info/packs create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c create mode 100644 routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa create mode 100644 routers/private/tests/repos/repo1_hook_verification/refs/heads/main create mode 100644 routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt create mode 100644 routers/utils/utils.go create mode 100644 routers/utils/utils_test.go create mode 100644 routers/web/admin/admin.go create mode 100644 routers/web/admin/admin_test.go create mode 100644 routers/web/admin/applications.go create mode 100644 routers/web/admin/auths.go create mode 100644 routers/web/admin/config.go create mode 100644 routers/web/admin/diagnosis.go create mode 100644 routers/web/admin/emails.go create mode 100644 routers/web/admin/hooks.go create mode 100644 routers/web/admin/main_test.go create mode 100644 routers/web/admin/notice.go create mode 100644 routers/web/admin/orgs.go create mode 100644 routers/web/admin/packages.go create mode 100644 routers/web/admin/queue.go create mode 100644 routers/web/admin/queue_tester.go create mode 100644 routers/web/admin/repos.go create mode 100644 routers/web/admin/runners.go create mode 100644 routers/web/admin/stacktrace.go create mode 100644 routers/web/admin/users.go create mode 100644 routers/web/admin/users_test.go create mode 100644 routers/web/auth/2fa.go create mode 100644 routers/web/auth/auth.go create mode 100644 routers/web/auth/auth_test.go create mode 100644 routers/web/auth/linkaccount.go create mode 100644 routers/web/auth/main_test.go create mode 100644 routers/web/auth/oauth.go create mode 100644 routers/web/auth/oauth_test.go create mode 100644 routers/web/auth/openid.go create mode 100644 routers/web/auth/password.go create mode 100644 routers/web/auth/webauthn.go create mode 100644 routers/web/base.go create mode 100644 routers/web/devtest/devtest.go create mode 100644 routers/web/events/events.go create mode 100644 routers/web/explore/code.go create mode 100644 routers/web/explore/org.go create mode 100644 routers/web/explore/repo.go create mode 100644 routers/web/explore/topic.go create mode 100644 routers/web/explore/user.go create mode 100644 routers/web/feed/branch.go create mode 100644 routers/web/feed/convert.go create mode 100644 routers/web/feed/file.go create mode 100644 routers/web/feed/profile.go create mode 100644 routers/web/feed/release.go create mode 100644 routers/web/feed/render.go create mode 100644 routers/web/feed/repo.go create mode 100644 routers/web/githttp.go create mode 100644 routers/web/goget.go create mode 100644 routers/web/healthcheck/check.go create mode 100644 routers/web/home.go create mode 100644 routers/web/metrics.go create mode 100644 routers/web/misc/markup.go create mode 100644 routers/web/misc/misc.go create mode 100644 routers/web/misc/swagger-forgejo.go create mode 100644 routers/web/misc/swagger.go create mode 100644 routers/web/nodeinfo.go create mode 100644 routers/web/org/home.go create mode 100644 routers/web/org/main_test.go create mode 100644 routers/web/org/members.go create mode 100644 routers/web/org/org.go create mode 100644 routers/web/org/org_labels.go create mode 100644 routers/web/org/projects.go create mode 100644 routers/web/org/projects_test.go create mode 100644 routers/web/org/setting.go create mode 100644 routers/web/org/setting/blocked_users.go create mode 100644 routers/web/org/setting/runners.go create mode 100644 routers/web/org/setting_oauth2.go create mode 100644 routers/web/org/setting_packages.go create mode 100644 routers/web/org/teams.go create mode 100644 routers/web/repo/actions/actions.go create mode 100644 routers/web/repo/actions/manual.go create mode 100644 routers/web/repo/actions/view.go create mode 100644 routers/web/repo/activity.go create mode 100644 routers/web/repo/attachment.go create mode 100644 routers/web/repo/badges/badges.go create mode 100644 routers/web/repo/blame.go create mode 100644 routers/web/repo/branch.go create mode 100644 routers/web/repo/cherry_pick.go create mode 100644 routers/web/repo/code_frequency.go create mode 100644 routers/web/repo/commit.go create mode 100644 routers/web/repo/compare.go create mode 100644 routers/web/repo/contributors.go create mode 100644 routers/web/repo/download.go create mode 100644 routers/web/repo/editor.go create mode 100644 routers/web/repo/editor_test.go create mode 100644 routers/web/repo/find.go create mode 100644 routers/web/repo/flags/manage.go create mode 100644 routers/web/repo/githttp.go create mode 100644 routers/web/repo/githttp_test.go create mode 100644 routers/web/repo/helper.go create mode 100644 routers/web/repo/helper_test.go create mode 100644 routers/web/repo/issue.go create mode 100644 routers/web/repo/issue_content_history.go create mode 100644 routers/web/repo/issue_dependency.go create mode 100644 routers/web/repo/issue_label.go create mode 100644 routers/web/repo/issue_label_test.go create mode 100644 routers/web/repo/issue_lock.go create mode 100644 routers/web/repo/issue_pin.go create mode 100644 routers/web/repo/issue_stopwatch.go create mode 100644 routers/web/repo/issue_test.go create mode 100644 routers/web/repo/issue_timetrack.go create mode 100644 routers/web/repo/issue_watch.go create mode 100644 routers/web/repo/main_test.go create mode 100644 routers/web/repo/middlewares.go create mode 100644 routers/web/repo/migrate.go create mode 100644 routers/web/repo/milestone.go create mode 100644 routers/web/repo/packages.go create mode 100644 routers/web/repo/patch.go create mode 100644 routers/web/repo/projects.go create mode 100644 routers/web/repo/projects_test.go create mode 100644 routers/web/repo/pull.go create mode 100644 routers/web/repo/pull_review.go create mode 100644 routers/web/repo/pull_review_test.go create mode 100644 routers/web/repo/recent_commits.go create mode 100644 routers/web/repo/release.go create mode 100644 routers/web/repo/release_test.go create mode 100644 routers/web/repo/render.go create mode 100644 routers/web/repo/repo.go create mode 100644 routers/web/repo/search.go create mode 100644 routers/web/repo/setting/avatar.go create mode 100644 routers/web/repo/setting/collaboration.go create mode 100644 routers/web/repo/setting/default_branch.go create mode 100644 routers/web/repo/setting/deploy_key.go create mode 100644 routers/web/repo/setting/git_hooks.go create mode 100644 routers/web/repo/setting/lfs.go create mode 100644 routers/web/repo/setting/main_test.go create mode 100644 routers/web/repo/setting/protected_branch.go create mode 100644 routers/web/repo/setting/protected_tag.go create mode 100644 routers/web/repo/setting/runners.go create mode 100644 routers/web/repo/setting/secrets.go create mode 100644 routers/web/repo/setting/setting.go create mode 100644 routers/web/repo/setting/settings_test.go create mode 100644 routers/web/repo/setting/variables.go create mode 100644 routers/web/repo/setting/webhook.go create mode 100644 routers/web/repo/topic.go create mode 100644 routers/web/repo/treelist.go create mode 100644 routers/web/repo/view.go create mode 100644 routers/web/repo/view_test.go create mode 100644 routers/web/repo/wiki.go create mode 100644 routers/web/repo/wiki_test.go create mode 100644 routers/web/shared/actions/runners.go create mode 100644 routers/web/shared/actions/variables.go create mode 100644 routers/web/shared/packages/packages.go create mode 100644 routers/web/shared/project/column.go create mode 100644 routers/web/shared/secrets/secrets.go create mode 100644 routers/web/shared/user/header.go create mode 100644 routers/web/swagger_json.go create mode 100644 routers/web/user/avatar.go create mode 100644 routers/web/user/code.go create mode 100644 routers/web/user/home.go create mode 100644 routers/web/user/home_test.go create mode 100644 routers/web/user/main_test.go create mode 100644 routers/web/user/notification.go create mode 100644 routers/web/user/package.go create mode 100644 routers/web/user/profile.go create mode 100644 routers/web/user/search.go create mode 100644 routers/web/user/setting/account.go create mode 100644 routers/web/user/setting/account_test.go create mode 100644 routers/web/user/setting/adopt.go create mode 100644 routers/web/user/setting/applications.go create mode 100644 routers/web/user/setting/blocked_users.go create mode 100644 routers/web/user/setting/keys.go create mode 100644 routers/web/user/setting/main_test.go create mode 100644 routers/web/user/setting/oauth2.go create mode 100644 routers/web/user/setting/oauth2_common.go create mode 100644 routers/web/user/setting/packages.go create mode 100644 routers/web/user/setting/profile.go create mode 100644 routers/web/user/setting/runner.go create mode 100644 routers/web/user/setting/security/2fa.go create mode 100644 routers/web/user/setting/security/openid.go create mode 100644 routers/web/user/setting/security/security.go create mode 100644 routers/web/user/setting/security/webauthn.go create mode 100644 routers/web/user/setting/webhooks.go create mode 100644 routers/web/user/stop_watch.go create mode 100644 routers/web/user/task.go create mode 100644 routers/web/web.go create mode 100644 routers/web/webfinger.go (limited to 'routers') diff --git a/routers/api/actions/actions.go b/routers/api/actions/actions.go new file mode 100644 index 0000000..a418b3a --- /dev/null +++ b/routers/api/actions/actions.go @@ -0,0 +1,24 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/http" + + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/actions/ping" + "code.gitea.io/gitea/routers/api/actions/runner" +) + +func Routes(prefix string) *web.Route { + m := web.NewRoute() + + path, handler := ping.NewPingServiceHandler() + m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) + + path, handler = runner.NewRunnerServiceHandler() + m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) + + return m +} diff --git a/routers/api/actions/artifact.pb.go b/routers/api/actions/artifact.pb.go new file mode 100644 index 0000000..590eda9 --- /dev/null +++ b/routers/api/actions/artifact.pb.go @@ -0,0 +1,1058 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc v4.25.2 +// source: artifact.proto + +package actions + +import ( + reflect "reflect" + sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CreateArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + Version int32 `protobuf:"varint,5,opt,name=version,proto3" json:"version,omitempty"` +} + +func (x *CreateArtifactRequest) Reset() { + *x = CreateArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactRequest) ProtoMessage() {} + +func (x *CreateArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateArtifactRequest.ProtoReflect.Descriptor instead. +func (*CreateArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *CreateArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *CreateArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateArtifactRequest) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *CreateArtifactRequest) GetVersion() int32 { + if x != nil { + return x.Version + } + return 0 +} + +type CreateArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + SignedUploadUrl string `protobuf:"bytes,2,opt,name=signed_upload_url,json=signedUploadUrl,proto3" json:"signed_upload_url,omitempty"` +} + +func (x *CreateArtifactResponse) Reset() { + *x = CreateArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactResponse) ProtoMessage() {} + +func (x *CreateArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateArtifactResponse.ProtoReflect.Descriptor instead. +func (*CreateArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *CreateArtifactResponse) GetSignedUploadUrl() string { + if x != nil { + return x.SignedUploadUrl + } + return "" +} + +type FinalizeArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Hash *wrapperspb.StringValue `protobuf:"bytes,5,opt,name=hash,proto3" json:"hash,omitempty"` +} + +func (x *FinalizeArtifactRequest) Reset() { + *x = FinalizeArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeArtifactRequest) ProtoMessage() {} + +func (x *FinalizeArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeArtifactRequest.ProtoReflect.Descriptor instead. +func (*FinalizeArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{2} +} + +func (x *FinalizeArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *FinalizeArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *FinalizeArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FinalizeArtifactRequest) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FinalizeArtifactRequest) GetHash() *wrapperspb.StringValue { + if x != nil { + return x.Hash + } + return nil +} + +type FinalizeArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` +} + +func (x *FinalizeArtifactResponse) Reset() { + *x = FinalizeArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeArtifactResponse) ProtoMessage() {} + +func (x *FinalizeArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeArtifactResponse.ProtoReflect.Descriptor instead. +func (*FinalizeArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{3} +} + +func (x *FinalizeArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *FinalizeArtifactResponse) GetArtifactId() int64 { + if x != nil { + return x.ArtifactId + } + return 0 +} + +type ListArtifactsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + NameFilter *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=name_filter,json=nameFilter,proto3" json:"name_filter,omitempty"` + IdFilter *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=id_filter,json=idFilter,proto3" json:"id_filter,omitempty"` +} + +func (x *ListArtifactsRequest) Reset() { + *x = ListArtifactsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsRequest) ProtoMessage() {} + +func (x *ListArtifactsRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsRequest.ProtoReflect.Descriptor instead. +func (*ListArtifactsRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{4} +} + +func (x *ListArtifactsRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *ListArtifactsRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *ListArtifactsRequest) GetNameFilter() *wrapperspb.StringValue { + if x != nil { + return x.NameFilter + } + return nil +} + +func (x *ListArtifactsRequest) GetIdFilter() *wrapperspb.Int64Value { + if x != nil { + return x.IdFilter + } + return nil +} + +type ListArtifactsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Artifacts []*ListArtifactsResponse_MonolithArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` +} + +func (x *ListArtifactsResponse) Reset() { + *x = ListArtifactsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsResponse) ProtoMessage() {} + +func (x *ListArtifactsResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsResponse.ProtoReflect.Descriptor instead. +func (*ListArtifactsResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{5} +} + +func (x *ListArtifactsResponse) GetArtifacts() []*ListArtifactsResponse_MonolithArtifact { + if x != nil { + return x.Artifacts + } + return nil +} + +type ListArtifactsResponse_MonolithArtifact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + DatabaseId int64 `protobuf:"varint,3,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Size int64 `protobuf:"varint,5,opt,name=size,proto3" json:"size,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` +} + +func (x *ListArtifactsResponse_MonolithArtifact) Reset() { + *x = ListArtifactsResponse_MonolithArtifact{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsResponse_MonolithArtifact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsResponse_MonolithArtifact) ProtoMessage() {} + +func (x *ListArtifactsResponse_MonolithArtifact) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsResponse_MonolithArtifact.ProtoReflect.Descriptor instead. +func (*ListArtifactsResponse_MonolithArtifact) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{6} +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetDatabaseId() int64 { + if x != nil { + return x.DatabaseId + } + return 0 +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +type GetSignedArtifactURLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *GetSignedArtifactURLRequest) Reset() { + *x = GetSignedArtifactURLRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSignedArtifactURLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSignedArtifactURLRequest) ProtoMessage() {} + +func (x *GetSignedArtifactURLRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSignedArtifactURLRequest.ProtoReflect.Descriptor instead. +func (*GetSignedArtifactURLRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{7} +} + +func (x *GetSignedArtifactURLRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *GetSignedArtifactURLRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *GetSignedArtifactURLRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type GetSignedArtifactURLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SignedUrl string `protobuf:"bytes,1,opt,name=signed_url,json=signedUrl,proto3" json:"signed_url,omitempty"` +} + +func (x *GetSignedArtifactURLResponse) Reset() { + *x = GetSignedArtifactURLResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSignedArtifactURLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSignedArtifactURLResponse) ProtoMessage() {} + +func (x *GetSignedArtifactURLResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSignedArtifactURLResponse.ProtoReflect.Descriptor instead. +func (*GetSignedArtifactURLResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{8} +} + +func (x *GetSignedArtifactURLResponse) GetSignedUrl() string { + if x != nil { + return x.SignedUrl + } + return "" +} + +type DeleteArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *DeleteArtifactRequest) Reset() { + *x = DeleteArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactRequest) ProtoMessage() {} + +func (x *DeleteArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteArtifactRequest.ProtoReflect.Descriptor instead. +func (*DeleteArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *DeleteArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *DeleteArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DeleteArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` +} + +func (x *DeleteArtifactResponse) Reset() { + *x = DeleteArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactResponse) ProtoMessage() {} + +func (x *DeleteArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteArtifactResponse.ProtoReflect.Descriptor instead. +func (*DeleteArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{10} +} + +func (x *DeleteArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *DeleteArtifactResponse) GetArtifactId() int64 { + if x != nil { + return x.ArtifactId + } + return 0 +} + +var File_artifact_proto protoreflect.FileDescriptor + +var file_artifact_proto_rawDesc = []byte{ + 0x0a, 0x0e, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x1a, + 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2f, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0xf5, 0x01, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, + 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, + 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, + 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x54, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, + 0x6f, 0x6b, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, 0x70, 0x6c, + 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, + 0x69, 0x67, 0x6e, 0x65, 0x64, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xe8, + 0x01, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, + 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, + 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, + 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x30, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x22, 0x4b, 0x0a, 0x18, 0x46, 0x69, 0x6e, + 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, + 0x66, 0x61, 0x63, 0x74, 0x49, 0x64, 0x22, 0x84, 0x02, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x41, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, + 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3d, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x66, 0x69, 0x6c, + 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, + 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x46, 0x69, 0x6c, + 0x74, 0x65, 0x72, 0x12, 0x38, 0x0a, 0x09, 0x69, 0x64, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x08, 0x69, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x22, 0x7c, 0x0a, + 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, + 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x45, 0x2e, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, + 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x52, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x22, 0xa1, 0x02, 0x0a, 0x26, + 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, + 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, + 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, + 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, + 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, + 0x73, 0x69, 0x7a, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, + 0xa6, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, + 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, + 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3d, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x53, + 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, + 0x65, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x69, + 0x67, 0x6e, 0x65, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xa0, 0x01, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, + 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, + 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, + 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, + 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x49, 0x0a, 0x16, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x49, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_artifact_proto_rawDescOnce sync.Once + file_artifact_proto_rawDescData = file_artifact_proto_rawDesc +) + +func file_artifact_proto_rawDescGZIP() []byte { + file_artifact_proto_rawDescOnce.Do(func() { + file_artifact_proto_rawDescData = protoimpl.X.CompressGZIP(file_artifact_proto_rawDescData) + }) + return file_artifact_proto_rawDescData +} + +var ( + file_artifact_proto_msgTypes = make([]protoimpl.MessageInfo, 11) + file_artifact_proto_goTypes = []interface{}{ + (*CreateArtifactRequest)(nil), // 0: github.actions.results.api.v1.CreateArtifactRequest + (*CreateArtifactResponse)(nil), // 1: github.actions.results.api.v1.CreateArtifactResponse + (*FinalizeArtifactRequest)(nil), // 2: github.actions.results.api.v1.FinalizeArtifactRequest + (*FinalizeArtifactResponse)(nil), // 3: github.actions.results.api.v1.FinalizeArtifactResponse + (*ListArtifactsRequest)(nil), // 4: github.actions.results.api.v1.ListArtifactsRequest + (*ListArtifactsResponse)(nil), // 5: github.actions.results.api.v1.ListArtifactsResponse + (*ListArtifactsResponse_MonolithArtifact)(nil), // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + (*GetSignedArtifactURLRequest)(nil), // 7: github.actions.results.api.v1.GetSignedArtifactURLRequest + (*GetSignedArtifactURLResponse)(nil), // 8: github.actions.results.api.v1.GetSignedArtifactURLResponse + (*DeleteArtifactRequest)(nil), // 9: github.actions.results.api.v1.DeleteArtifactRequest + (*DeleteArtifactResponse)(nil), // 10: github.actions.results.api.v1.DeleteArtifactResponse + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*wrapperspb.StringValue)(nil), // 12: google.protobuf.StringValue + (*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value + } +) + +var file_artifact_proto_depIdxs = []int32{ + 11, // 0: github.actions.results.api.v1.CreateArtifactRequest.expires_at:type_name -> google.protobuf.Timestamp + 12, // 1: github.actions.results.api.v1.FinalizeArtifactRequest.hash:type_name -> google.protobuf.StringValue + 12, // 2: github.actions.results.api.v1.ListArtifactsRequest.name_filter:type_name -> google.protobuf.StringValue + 13, // 3: github.actions.results.api.v1.ListArtifactsRequest.id_filter:type_name -> google.protobuf.Int64Value + 6, // 4: github.actions.results.api.v1.ListArtifactsResponse.artifacts:type_name -> github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + 11, // 5: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact.created_at:type_name -> google.protobuf.Timestamp + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_artifact_proto_init() } +func file_artifact_proto_init() { + if File_artifact_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_artifact_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsResponse_MonolithArtifact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSignedArtifactURLRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSignedArtifactURLResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_artifact_proto_rawDesc, + NumEnums: 0, + NumMessages: 11, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_artifact_proto_goTypes, + DependencyIndexes: file_artifact_proto_depIdxs, + MessageInfos: file_artifact_proto_msgTypes, + }.Build() + File_artifact_proto = out.File + file_artifact_proto_rawDesc = nil + file_artifact_proto_goTypes = nil + file_artifact_proto_depIdxs = nil +} diff --git a/routers/api/actions/artifact.proto b/routers/api/actions/artifact.proto new file mode 100644 index 0000000..c68e5d0 --- /dev/null +++ b/routers/api/actions/artifact.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +package github.actions.results.api.v1; + +message CreateArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; + google.protobuf.Timestamp expires_at = 4; + int32 version = 5; +} + +message CreateArtifactResponse { + bool ok = 1; + string signed_upload_url = 2; +} + +message FinalizeArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; + int64 size = 4; + google.protobuf.StringValue hash = 5; +} + +message FinalizeArtifactResponse { + bool ok = 1; + int64 artifact_id = 2; +} + +message ListArtifactsRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + google.protobuf.StringValue name_filter = 3; + google.protobuf.Int64Value id_filter = 4; +} + +message ListArtifactsResponse { + repeated ListArtifactsResponse_MonolithArtifact artifacts = 1; +} + +message ListArtifactsResponse_MonolithArtifact { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + int64 database_id = 3; + string name = 4; + int64 size = 5; + google.protobuf.Timestamp created_at = 6; +} + +message GetSignedArtifactURLRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; +} + +message GetSignedArtifactURLResponse { + string signed_url = 1; +} + +message DeleteArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; +} + +message DeleteArtifactResponse { + bool ok = 1; + int64 artifact_id = 2; +} diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go new file mode 100644 index 0000000..bc29e44 --- /dev/null +++ b/routers/api/actions/artifacts.go @@ -0,0 +1,507 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +// GitHub Actions Artifacts API Simple Description +// +// 1. Upload artifact +// 1.1. Post upload url +// Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview +// Request: +// { +// "Type": "actions_storage", +// "Name": "artifact" +// } +// Response: +// { +// "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload" +// } +// it acquires an upload url for artifact upload +// 1.2. Upload artifact +// PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename +// it upload chunk with headers: +// x-tfs-filelength: 1024 // total file length +// content-length: 1024 // chunk length +// x-actions-results-md5: md5sum // md5sum of chunk +// content-range: bytes 0-1023/1024 // chunk range +// we save all chunks to one storage directory after md5sum check +// 1.3. Confirm upload +// PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename +// it confirm upload and merge all chunks to one file, save this file to storage +// +// 2. Download artifact +// 2.1 list artifacts +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview +// Response: +// { +// "count": 1, +// "value": [ +// { +// "name": "artifact", +// "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path" +// } +// ] +// } +// 2.2 download artifact +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview +// Response: +// { +// "value": [ +// { +// "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download", +// "path": "artifact/filename", +// "itemType": "file" +// } +// ] +// } +// 2.3 download artifact file +// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename +// Response: +// download file +// + +import ( + "crypto/md5" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + web_types "code.gitea.io/gitea/modules/web/types" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" +) + +const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" + +type artifactContextKeyType struct{} + +var artifactContextKey = artifactContextKeyType{} + +type ArtifactContext struct { + *context.Base + + ActionTask *actions.ActionTask +} + +func init() { + web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider { + return req.Context().Value(artifactContextKey).(*ArtifactContext) + }) +} + +func ArtifactsRoutes(prefix string) *web.Route { + m := web.NewRoute() + m.Use(ArtifactContexter()) + + r := artifactRoutes{ + prefix: prefix, + fs: storage.ActionsArtifacts, + } + + m.Group(artifactRouteBase, func() { + // retrieve, list and confirm artifacts + m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact) + // handle container artifacts list and download + m.Put("/{artifact_hash}/upload", r.uploadArtifact) + // handle artifacts download + m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL) + m.Get("/{artifact_id}/download", r.downloadArtifact) + }) + + return m +} + +func ArtifactContexter() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base, baseCleanUp := context.NewBaseContext(resp, req) + defer baseCleanUp() + + ctx := &ArtifactContext{Base: base} + ctx.AppendContextValue(artifactContextKey, ctx) + + // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN + // we should verify the ACTIONS_RUNTIME_TOKEN + authHeader := req.Header.Get("Authorization") + if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") { + ctx.Error(http.StatusUnauthorized, "Bad authorization header") + return + } + + // New act_runner uses jwt to authenticate + tID, err := actions_service.ParseAuthorizationToken(req) + + var task *actions.ActionTask + if err == nil { + task, err = actions.GetTaskByID(req.Context(), tID) + if err != nil { + log.Error("Error runner api getting task by ID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") + return + } + if task.Status != actions.StatusRunning { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + } else { + // Old act_runner uses GITEA_TOKEN to authenticate + authToken := strings.TrimPrefix(authHeader, "Bearer ") + + task, err = actions.GetRunningTaskByToken(req.Context(), authToken) + if err != nil { + log.Error("Error runner api getting task: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task") + return + } + } + + if err := task.LoadJob(req.Context()); err != nil { + log.Error("Error runner api getting job: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting job") + return + } + + ctx.ActionTask = task + next.ServeHTTP(ctx.Resp, ctx.Req) + }) + } +} + +type artifactRoutes struct { + prefix string + fs storage.ObjectStorage +} + +func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix string) string { + uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") + + strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) + + "/" + artifactHash + "/" + suffix + return uploadURL +} + +type getUploadArtifactRequest struct { + Type string + Name string + RetentionDays int64 +} + +type getUploadArtifactResponse struct { + FileContainerResourceURL string `json:"fileContainerResourceUrl"` +} + +// getUploadArtifactURL generates a URL for uploading an artifact +func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { + _, runID, ok := validateRunID(ctx) + if !ok { + return + } + + var req getUploadArtifactRequest + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return + } + + // set retention days + retentionQuery := "" + if req.RetentionDays > 0 { + retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays) + } + + // use md5(artifact_name) to create upload url + artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name))) + resp := getUploadArtifactResponse{ + FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery), + } + log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL) + ctx.JSON(http.StatusOK, resp) +} + +func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { + task, runID, ok := validateRunID(ctx) + if !ok { + return + } + artifactName, artifactPath, ok := parseArtifactItemPath(ctx) + if !ok { + return + } + + // check the owner's quota + ok, err := quota_model.EvaluateForUser(ctx, ctx.ActionTask.OwnerID, quota_model.LimitSubjectSizeAssetsArtifacts) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "Quota exceeded") + return + } + + // get upload file size + fileRealTotalSize, contentLength := getUploadFileSize(ctx) + + // get artifact retention days + expiredDays := setting.Actions.ArtifactRetentionDays + if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" { + var err error + expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64) + if err != nil { + log.Error("Error parse retention days: %v", err) + ctx.Error(http.StatusBadRequest, "Error parse retention days") + return + } + } + log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d", + artifactName, artifactPath, fileRealTotalSize, expiredDays) + + // create or get artifact with name and path + artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays) + if err != nil { + log.Error("Error create or get artifact: %v", err) + ctx.Error(http.StatusInternalServerError, "Error create or get artifact") + return + } + + // save chunk to storage, if success, return chunk stotal size + // if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize + // if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize + chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID) + if err != nil { + log.Error("Error save upload chunk: %v", err) + ctx.Error(http.StatusInternalServerError, "Error save upload chunk") + return + } + + // update artifact size if zero or not match, over write artifact size + if artifact.FileSize == 0 || + artifact.FileCompressedSize == 0 || + artifact.FileSize != fileRealTotalSize || + artifact.FileCompressedSize != chunksTotalSize { + artifact.FileSize = fileRealTotalSize + artifact.FileCompressedSize = chunksTotalSize + artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error update artifact: %v", err) + ctx.Error(http.StatusInternalServerError, "Error update artifact") + return + } + log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d", + artifact.ID, artifact.FileSize, artifact.FileCompressedSize) + } + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "success", + }) +} + +// comfirmUploadArtifact confirm upload artifact. +// if all chunks are uploaded, merge them to one file. +func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) { + _, runID, ok := validateRunID(ctx) + if !ok { + return + } + artifactName := ctx.Req.URL.Query().Get("artifactName") + if artifactName == "" { + log.Warn("Error artifact name is empty") + ctx.Error(http.StatusBadRequest, "Error artifact name is empty") + return + } + if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil { + log.Error("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + ctx.JSON(http.StatusOK, map[string]string{ + "message": "success", + }) +} + +type ( + listArtifactsResponse struct { + Count int64 `json:"count"` + Value []listArtifactsResponseItem `json:"value"` + } + listArtifactsResponseItem struct { + Name string `json:"name"` + FileContainerResourceURL string `json:"fileContainerResourceUrl"` + } +) + +func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { + _, runID, ok := validateRunID(ctx) + if !ok { + return + } + + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) + if err != nil { + log.Error("Error getting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + log.Debug("[artifact] handleListArtifacts, no artifacts") + ctx.Error(http.StatusNotFound) + return + } + + var ( + items []listArtifactsResponseItem + values = make(map[string]bool) + ) + + for _, art := range artifacts { + if values[art.ArtifactName] { + continue + } + artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName))) + item := listArtifactsResponseItem{ + Name: art.ArtifactName, + FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "download_url"), + } + items = append(items, item) + values[art.ArtifactName] = true + + log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL) + } + + respData := listArtifactsResponse{ + Count: int64(len(items)), + Value: items, + } + ctx.JSON(http.StatusOK, respData) +} + +type ( + downloadArtifactResponse struct { + Value []downloadArtifactResponseItem `json:"value"` + } + downloadArtifactResponseItem struct { + Path string `json:"path"` + ItemType string `json:"itemType"` + ContentLocation string `json:"contentLocation"` + } +) + +// getDownloadArtifactURL generates download url for each artifact +func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { + _, runID, ok := validateRunID(ctx) + if !ok { + return + } + + itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) + if !validateArtifactHash(ctx, itemPath) { + return + } + + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ + RunID: runID, + ArtifactName: itemPath, + }) + if err != nil { + log.Error("Error getting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + log.Debug("[artifact] getDownloadArtifactURL, no artifacts") + ctx.Error(http.StatusNotFound) + return + } + + if itemPath != artifacts[0].ArtifactName { + log.Error("Error mismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName) + ctx.Error(http.StatusBadRequest, "Error mismatch artifact name") + return + } + + var items []downloadArtifactResponseItem + for _, artifact := range artifacts { + var downloadURL string + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName) + if err != nil && !errors.Is(err, storage.ErrURLNotSupported) { + log.Error("Error getting serve direct url: %v", err) + } + if u != nil { + downloadURL = u.String() + } + } + if downloadURL == "" { + downloadURL = ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download") + } + item := downloadArtifactResponseItem{ + Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), + ItemType: "file", + ContentLocation: downloadURL, + } + log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation) + items = append(items, item) + } + respData := downloadArtifactResponse{ + Value: items, + } + ctx.JSON(http.StatusOK, respData) +} + +// downloadArtifact downloads artifact content +func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { + _, runID, ok := validateRunID(ctx) + if !ok { + return + } + + artifactID := ctx.ParamsInt64("artifact_id") + artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID) + if err != nil { + log.Error("Error getting artifact: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if !exist { + log.Error("artifact with ID %d does not exist", artifactID) + ctx.Error(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID)) + return + } + if artifact.RunID != runID { + log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) + ctx.Error(http.StatusBadRequest) + return + } + + fd, err := ar.fs.Open(artifact.StoragePath) + if err != nil { + log.Error("Error opening file: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + defer fd.Close() + + // if artifact is compressed, set content-encoding header to gzip + if artifact.ContentEncoding == "gzip" { + ctx.Resp.Header().Set("Content-Encoding", "gzip") + } + log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize) + ctx.ServeContent(fd, &context.ServeHeaderOptions{ + Filename: artifact.ArtifactName, + LastModified: artifact.CreatedUnix.AsLocalTime(), + }) +} diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go new file mode 100644 index 0000000..cdb5658 --- /dev/null +++ b/routers/api/actions/artifacts_chunks.go @@ -0,0 +1,301 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "path/filepath" + "sort" + "strings" + "time" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/storage" +) + +func saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + contentSize, runID, start, end, length int64, checkMd5 bool, +) (int64, error) { + // build chunk store path + storagePath := fmt.Sprintf("tmp%d/%d-%d-%d-%d.chunk", runID, runID, artifact.ID, start, end) + var r io.Reader = ctx.Req.Body + var hasher hash.Hash + if checkMd5 { + // use io.TeeReader to avoid reading all body to md5 sum. + // it writes data to hasher after reading end + // if hash is not matched, delete the read-end result + hasher = md5.New() + r = io.TeeReader(r, hasher) + } + // save chunk to storage + writtenSize, err := st.Save(storagePath, r, contentSize) + if err != nil { + return -1, fmt.Errorf("save chunk to storage error: %v", err) + } + var checkErr error + if checkMd5 { + // check md5 + reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header) + chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + log.Info("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String) + // if md5 not match, delete the chunk + if reqMd5String != chunkMd5String { + checkErr = fmt.Errorf("md5 not match") + } + } + if writtenSize != contentSize { + checkErr = errors.Join(checkErr, fmt.Errorf("contentSize not match body size")) + } + if checkErr != nil { + if err := st.Delete(storagePath); err != nil { + log.Error("Error deleting chunk: %s, %v", storagePath, err) + } + return -1, checkErr + } + log.Info("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d", + storagePath, contentSize, artifact.ID, start, end) + // return chunk total size + return length, nil +} + +func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + contentSize, runID int64, +) (int64, error) { + // parse content-range header, format: bytes 0-1023/146515 + contentRange := ctx.Req.Header.Get("Content-Range") + start, end, length := int64(0), int64(0), int64(0) + if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil { + log.Warn("parse content range error: %v, content-range: %s", err, contentRange) + return -1, fmt.Errorf("parse content range error: %v", err) + } + return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, length, true) +} + +func appendUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + start, contentSize, runID int64, +) (int64, error) { + end := start + contentSize - 1 + return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, contentSize, false) +} + +type chunkFileItem struct { + RunID int64 + ArtifactID int64 + Start int64 + End int64 + Path string +} + +func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chunkFileItem, error) { + storageDir := fmt.Sprintf("tmp%d", runID) + var chunks []*chunkFileItem + if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error { + baseName := filepath.Base(fpath) + // when read chunks from storage, it only contains storage dir and basename, + // no matter the subdirectory setting in storage config + item := chunkFileItem{Path: storageDir + "/" + baseName} + if _, err := fmt.Sscanf(baseName, "%d-%d-%d-%d.chunk", &item.RunID, &item.ArtifactID, &item.Start, &item.End); err != nil { + return fmt.Errorf("parse content range error: %v", err) + } + chunks = append(chunks, &item) + return nil + }); err != nil { + return nil, err + } + // chunks group by artifact id + chunksMap := make(map[int64][]*chunkFileItem) + for _, c := range chunks { + chunksMap[c.ArtifactID] = append(chunksMap[c.ArtifactID], c) + } + return chunksMap, nil +} + +func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blist *BlockList) ([]*chunkFileItem, error) { + storageDir := fmt.Sprintf("tmpv4%d", runID) + var chunks []*chunkFileItem + chunkMap := map[string]*chunkFileItem{} + dummy := &chunkFileItem{} + for _, name := range blist.Latest { + chunkMap[name] = dummy + } + if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error { + baseName := filepath.Base(fpath) + if !strings.HasPrefix(baseName, "block-") { + return nil + } + // when read chunks from storage, it only contains storage dir and basename, + // no matter the subdirectory setting in storage config + item := chunkFileItem{Path: storageDir + "/" + baseName, ArtifactID: artifactID} + var size int64 + var b64chunkName string + if _, err := fmt.Sscanf(baseName, "block-%d-%d-%s", &item.RunID, &size, &b64chunkName); err != nil { + return fmt.Errorf("parse content range error: %v", err) + } + rchunkName, err := base64.URLEncoding.DecodeString(b64chunkName) + if err != nil { + return fmt.Errorf("failed to parse chunkName: %v", err) + } + chunkName := string(rchunkName) + item.End = item.Start + size - 1 + if _, ok := chunkMap[chunkName]; ok { + chunkMap[chunkName] = &item + } + return nil + }); err != nil { + return nil, err + } + for i, name := range blist.Latest { + chunk, ok := chunkMap[name] + if !ok || chunk.Path == "" { + return nil, fmt.Errorf("missing Chunk (%d/%d): %s", i, len(blist.Latest), name) + } + chunks = append(chunks, chunk) + if i > 0 { + chunk.Start = chunkMap[blist.Latest[i-1]].End + 1 + chunk.End += chunk.Start + } + } + return chunks, nil +} + +func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error { + // read all db artifacts by name + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ + RunID: runID, + ArtifactName: artifactName, + }) + if err != nil { + return err + } + // read all uploading chunks from storage + chunksMap, err := listChunksByRunID(st, runID) + if err != nil { + return err + } + // range db artifacts to merge chunks + for _, art := range artifacts { + chunks, ok := chunksMap[art.ID] + if !ok { + log.Debug("artifact %d chunks not found", art.ID) + continue + } + if err := mergeChunksForArtifact(ctx, chunks, st, art, ""); err != nil { + return err + } + } + return nil +} + +func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st storage.ObjectStorage, artifact *actions.ActionArtifact, checksum string) error { + sort.Slice(chunks, func(i, j int) bool { + return chunks[i].Start < chunks[j].Start + }) + allChunks := make([]*chunkFileItem, 0) + startAt := int64(-1) + // check if all chunks are uploaded and in order and clean repeated chunks + for _, c := range chunks { + // startAt is -1 means this is the first chunk + // previous c.ChunkEnd + 1 == c.ChunkStart means this chunk is in order + // StartAt is not -1 and c.ChunkStart is not startAt + 1 means there is a chunk missing + if c.Start == (startAt + 1) { + allChunks = append(allChunks, c) + startAt = c.End + } + } + // if the last chunk.End + 1 is not equal to chunk.ChunkLength, means chunks are not uploaded completely + if startAt+1 != artifact.FileCompressedSize { + log.Debug("[artifact] chunks are not uploaded completely, artifact_id: %d", artifact.ID) + return nil + } + // use multiReader + readers := make([]io.Reader, 0, len(allChunks)) + closeReaders := func() { + for _, r := range readers { + _ = r.(io.Closer).Close() // it guarantees to be io.Closer by the following loop's Open function + } + readers = nil + } + defer closeReaders() + for _, c := range allChunks { + var readCloser io.ReadCloser + var err error + if readCloser, err = st.Open(c.Path); err != nil { + return fmt.Errorf("open chunk error: %v, %s", err, c.Path) + } + readers = append(readers, readCloser) + } + mergedReader := io.MultiReader(readers...) + shaPrefix := "sha256:" + var hash hash.Hash + if strings.HasPrefix(checksum, shaPrefix) { + hash = sha256.New() + } + if hash != nil { + mergedReader = io.TeeReader(mergedReader, hash) + } + + // if chunk is gzip, use gz as extension + // download-artifact action will use content-encoding header to decide if it should decompress the file + extension := "chunk" + if artifact.ContentEncoding == "gzip" { + extension = "chunk.gz" + } + + // save merged file + storagePath := fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), extension) + written, err := st.Save(storagePath, mergedReader, artifact.FileCompressedSize) + if err != nil { + return fmt.Errorf("save merged file error: %v", err) + } + if written != artifact.FileCompressedSize { + return fmt.Errorf("merged file size is not equal to chunk length") + } + + defer func() { + closeReaders() // close before delete + // drop chunks + for _, c := range chunks { + if err := st.Delete(c.Path); err != nil { + log.Warn("Error deleting chunk: %s, %v", c.Path, err) + } + } + }() + + if hash != nil { + rawChecksum := hash.Sum(nil) + actualChecksum := hex.EncodeToString(rawChecksum) + if !strings.HasSuffix(checksum, actualChecksum) { + return fmt.Errorf("update artifact error checksum is invalid %v vs %v", checksum, actualChecksum) + } + } + + // save storage path to artifact + log.Debug("[artifact] merge chunks to artifact: %d, %s, old:%s", artifact.ID, storagePath, artifact.StoragePath) + // if artifact is already uploaded, delete the old file + if artifact.StoragePath != "" { + if err := st.Delete(artifact.StoragePath); err != nil { + log.Warn("Error deleting old artifact: %s, %v", artifact.StoragePath, err) + } + } + + artifact.StoragePath = storagePath + artifact.Status = int64(actions.ArtifactStatusUploadConfirmed) + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + return fmt.Errorf("update artifact error: %v", err) + } + + return nil +} diff --git a/routers/api/actions/artifacts_utils.go b/routers/api/actions/artifacts_utils.go new file mode 100644 index 0000000..db602f1 --- /dev/null +++ b/routers/api/actions/artifacts_utils.go @@ -0,0 +1,94 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "crypto/md5" + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +const ( + artifactXTfsFileLengthHeader = "x-tfs-filelength" + artifactXActionsResultsMD5Header = "x-actions-results-md5" +) + +// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/path-and-artifact-name-validation.ts#L32 +var invalidArtifactNameChars = strings.Join([]string{"\\", "/", "\"", ":", "<", ">", "|", "*", "?", "\r", "\n"}, "") + +func validateArtifactName(ctx *ArtifactContext, artifactName string) bool { + if strings.ContainsAny(artifactName, invalidArtifactNameChars) { + log.Error("Error checking artifact name contains invalid character") + ctx.Error(http.StatusBadRequest, "Error checking artifact name contains invalid character") + return false + } + return true +} + +func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) { + task := ctx.ActionTask + runID := ctx.ParamsInt64("run_id") + if task.Job.RunID != runID { + log.Error("Error runID not match") + ctx.Error(http.StatusBadRequest, "run-id does not match") + return nil, 0, false + } + return task, runID, true +} + +func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam + task := ctx.ActionTask + runID, err := strconv.ParseInt(rawRunID, 10, 64) + if err != nil || task.Job.RunID != runID { + log.Error("Error runID not match") + ctx.Error(http.StatusBadRequest, "run-id does not match") + return nil, 0, false + } + return task, runID, true +} + +func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool { + paramHash := ctx.Params("artifact_hash") + // use artifact name to create upload url + artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(artifactName))) + if paramHash == artifactHash { + return true + } + log.Warn("Invalid artifact hash: %s", paramHash) + ctx.Error(http.StatusBadRequest, "Invalid artifact hash") + return false +} + +func parseArtifactItemPath(ctx *ArtifactContext) (string, string, bool) { + // itemPath is generated from upload-artifact action + // it's formatted as {artifact_name}/{artfict_path_in_runner} + // act_runner in host mode on Windows, itemPath is joined by Windows slash '\' + itemPath := util.PathJoinRelX(ctx.Req.URL.Query().Get("itemPath")) + artifactName := strings.Split(itemPath, "/")[0] + artifactPath := strings.TrimPrefix(itemPath, artifactName+"/") + if !validateArtifactHash(ctx, artifactName) { + return "", "", false + } + if !validateArtifactName(ctx, artifactName) { + return "", "", false + } + return artifactName, artifactPath, true +} + +// getUploadFileSize returns the size of the file to be uploaded. +// The raw size is the size of the file as reported by the header X-TFS-FileLength. +func getUploadFileSize(ctx *ArtifactContext) (int64, int64) { + contentLength := ctx.Req.ContentLength + xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64) + if xTfsLength > 0 { + return xTfsLength, contentLength + } + return contentLength, contentLength +} diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go new file mode 100644 index 0000000..677e89d --- /dev/null +++ b/routers/api/actions/artifactsv4.go @@ -0,0 +1,599 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +// GitHub Actions Artifacts V4 API Simple Description +// +// 1. Upload artifact +// 1.1. CreateArtifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact +// Request: +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test", +// "version": 4 +// } +// Response: +// { +// "ok": true, +// "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75" +// } +// 1.2. Upload Zip Content to Blobstorage (unauthenticated request) +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block +// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock +// 1.4. BlockList xml payload to Blobstorage (unauthenticated request) +// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to enshure the correct order +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList +// Request +// +// +// blockId1 +// blockId2 +// +// 1.5. FinalizeArtifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test", +// "size": "2097", +// "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4" +// } +// Response +// { +// "ok": true, +// "artifactId": "4" +// } +// 2. Download artifact +// 2.1. ListArtifacts and optionally filter by artifact exact name or id +// Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name_filter": "test" +// } +// Response +// { +// "artifacts": [ +// { +// "workflowRunBackendId": "21", +// "workflowJobRunBackendId": "49", +// "databaseId": "4", +// "name": "test", +// "size": "2093", +// "createdAt": "2024-01-23T00:13:28Z" +// } +// ] +// } +// 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test" +// } +// Response +// { +// "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76" +// } +// 2.3. Download Zip from Blobstorage (unauthenticated request) +// GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76 + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + + "google.golang.org/protobuf/encoding/protojson" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService" + ArtifactV4ContentEncoding = "application/zip" +) + +type artifactV4Routes struct { + prefix string + fs storage.ObjectStorage +} + +func ArtifactV4Contexter() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base, baseCleanUp := context.NewBaseContext(resp, req) + defer baseCleanUp() + + ctx := &ArtifactContext{Base: base} + ctx.AppendContextValue(artifactContextKey, ctx) + + next.ServeHTTP(ctx.Resp, ctx.Req) + }) + } +} + +func ArtifactsV4Routes(prefix string) *web.Route { + m := web.NewRoute() + + r := artifactV4Routes{ + prefix: prefix, + fs: storage.ActionsArtifacts, + } + + m.Group("", func() { + m.Post("CreateArtifact", r.createArtifact) + m.Post("FinalizeArtifact", r.finalizeArtifact) + m.Post("ListArtifacts", r.listArtifacts) + m.Post("GetSignedArtifactURL", r.getSignedArtifactURL) + m.Post("DeleteArtifact", r.deleteArtifact) + }, ArtifactContexter()) + m.Group("", func() { + m.Put("UploadArtifact", r.uploadArtifact) + m.Get("DownloadArtifact", r.downloadArtifact) + }, ArtifactV4Contexter()) + + return m +} + +func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID, artifactID int64) []byte { + mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) + mac.Write([]byte(endp)) + mac.Write([]byte(expires)) + mac.Write([]byte(artifactName)) + mac.Write([]byte(fmt.Sprint(taskID))) + mac.Write([]byte(fmt.Sprint(artifactID))) + return mac.Sum(nil) +} + +func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID, artifactID int64) string { + expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST") + uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") + + "/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID, artifactID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID) + "&artifactID=" + fmt.Sprint(artifactID) + return uploadURL +} + +func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) { + rawTaskID := ctx.Req.URL.Query().Get("taskID") + rawArtifactID := ctx.Req.URL.Query().Get("artifactID") + sig := ctx.Req.URL.Query().Get("sig") + expires := ctx.Req.URL.Query().Get("expires") + artifactName := ctx.Req.URL.Query().Get("artifactName") + dsig, _ := base64.URLEncoding.DecodeString(sig) + taskID, _ := strconv.ParseInt(rawTaskID, 10, 64) + artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64) + + expecedsig := r.buildSignature(endp, expires, artifactName, taskID, artifactID) + if !hmac.Equal(dsig, expecedsig) { + log.Error("Error unauthorized") + ctx.Error(http.StatusUnauthorized, "Error unauthorized") + return nil, "", false + } + t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires) + if err != nil || t.Before(time.Now()) { + log.Error("Error link expired") + ctx.Error(http.StatusUnauthorized, "Error link expired") + return nil, "", false + } + task, err := actions.GetTaskByID(ctx, taskID) + if err != nil { + log.Error("Error runner api getting task by ID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") + return nil, "", false + } + if task.Status != actions.StatusRunning { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return nil, "", false + } + if err := task.LoadJob(ctx); err != nil { + log.Error("Error runner api getting job: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting job") + return nil, "", false + } + return task, artifactName, true +} + +func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { + var art actions.ActionArtifact + has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ? AND content_encoding = ?", runID, name, name+".zip", ArtifactV4ContentEncoding).Get(&art) + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + return &art, nil +} + +func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool { + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return false + } + err = protojson.Unmarshal(body, req) + if err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return false + } + return true +} + +func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) { + resp, err := protojson.Marshal(req) + if err != nil { + log.Error("Error encode response body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error encode response body") + return + } + ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(resp) +} + +func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { + var req CreateArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifactName := req.Name + + rententionDays := setting.Actions.ArtifactRetentionDays + if req.ExpiresAt != nil { + rententionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24) + } + // create or get artifact with name and path + artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, artifactName+".zip", rententionDays) + if err != nil { + log.Error("Error create or get artifact: %v", err) + ctx.Error(http.StatusInternalServerError, "Error create or get artifact") + return + } + artifact.ContentEncoding = ArtifactV4ContentEncoding + artifact.FileSize = 0 + artifact.FileCompressedSize = 0 + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") + return + } + + respData := CreateArtifactResponse{ + Ok: true, + SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID), + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { + task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") + if !ok { + return + } + + // check the owner's quota + ok, err := quota_model.EvaluateForUser(ctx, task.OwnerID, quota_model.LimitSubjectSizeAssetsArtifacts) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "Quota exceeded") + return + } + + comp := ctx.Req.URL.Query().Get("comp") + switch comp { + case "block", "appendBlock": + blockid := ctx.Req.URL.Query().Get("blockid") + if blockid == "" { + // get artifact by name + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + _, err = appendUploadChunk(r.fs, ctx, artifact, artifact.FileSize, ctx.Req.ContentLength, artifact.RunID) + if err != nil { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + artifact.FileCompressedSize += ctx.Req.ContentLength + artifact.FileSize += ctx.Req.ContentLength + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") + return + } + } else { + _, err := r.fs.Save(fmt.Sprintf("tmpv4%d/block-%d-%d-%s", task.Job.RunID, task.Job.RunID, ctx.Req.ContentLength, base64.URLEncoding.EncodeToString([]byte(blockid))), ctx.Req.Body, -1) + if err != nil { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + } + ctx.JSON(http.StatusCreated, "appended") + case "blocklist": + rawArtifactID := ctx.Req.URL.Query().Get("artifactID") + artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64) + _, err := r.fs.Save(fmt.Sprintf("tmpv4%d/%d-%d-blocklist", task.Job.RunID, task.Job.RunID, artifactID), ctx.Req.Body, -1) + if err != nil { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + ctx.JSON(http.StatusCreated, "created") + } +} + +type BlockList struct { + Latest []string `xml:"Latest"` +} + +type Latest struct { + Value string `xml:",chardata"` +} + +func (r *artifactV4Routes) readBlockList(runID, artifactID int64) (*BlockList, error) { + blockListName := fmt.Sprintf("tmpv4%d/%d-%d-blocklist", runID, runID, artifactID) + s, err := r.fs.Open(blockListName) + if err != nil { + return nil, err + } + + xdec := xml.NewDecoder(s) + blockList := &BlockList{} + err = xdec.Decode(blockList) + + delerr := r.fs.Delete(blockListName) + if delerr != nil { + log.Warn("Failed to delete blockList %s: %v", blockListName, delerr) + } + return blockList, err +} + +func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { + var req FinalizeArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, req.Name) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + var chunks []*chunkFileItem + blockList, err := r.readBlockList(runID, artifact.ID) + if err != nil { + log.Warn("Failed to read BlockList, fallback to old behavior: %v", err) + chunkMap, err := listChunksByRunID(r.fs, runID) + if err != nil { + log.Error("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + chunks, ok = chunkMap[artifact.ID] + if !ok { + log.Error("Error merge chunks") + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + } else { + chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, blockList) + if err != nil { + log.Error("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + artifact.FileSize = chunks[len(chunks)-1].End + 1 + artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 + } + + checksum := "" + if req.Hash != nil { + checksum = req.Hash.Value + } + if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, checksum); err != nil { + log.Warn("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + + respData := FinalizeArtifactResponse{ + Ok: true, + ArtifactId: artifact.ID, + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { + var req ListArtifactsRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) + if err != nil { + log.Error("Error getting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + log.Debug("[artifact] handleListArtifacts, no artifacts") + ctx.Error(http.StatusNotFound) + return + } + + list := []*ListArtifactsResponse_MonolithArtifact{} + + table := map[string]*ListArtifactsResponse_MonolithArtifact{} + for _, artifact := range artifacts { + if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value || artifact.ArtifactName+".zip" != artifact.ArtifactPath || artifact.ContentEncoding != ArtifactV4ContentEncoding { + table[artifact.ArtifactName] = nil + continue + } + + table[artifact.ArtifactName] = &ListArtifactsResponse_MonolithArtifact{ + Name: artifact.ArtifactName, + CreatedAt: timestamppb.New(artifact.CreatedUnix.AsTime()), + DatabaseId: artifact.ID, + WorkflowRunBackendId: req.WorkflowRunBackendId, + WorkflowJobRunBackendId: req.WorkflowJobRunBackendId, + Size: artifact.FileSize, + } + } + for _, artifact := range table { + if artifact != nil { + list = append(list, artifact) + } + } + + respData := ListArtifactsResponse{ + Artifacts: list, + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { + var req GetSignedArtifactURLRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifactName := req.Name + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + respData := GetSignedArtifactURLResponse{} + + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath) + if u != nil && err == nil { + respData.SignedUrl = u.String() + } + } + if respData.SignedUrl == "" { + respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID) + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { + task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + file, err := r.fs.Open(artifact.StoragePath) + if err != nil { + log.Error("Error artifact could not be opened: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + common.ServeContentByReadSeeker(ctx.Base, artifactName, util.ToPointer(artifact.UpdatedUnix.AsTime()), file) +} + +func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { + var req DeleteArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, req.Name) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + err = actions.SetArtifactNeedDelete(ctx, runID, req.Name) + if err != nil { + log.Error("Error deleting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + respData := DeleteArtifactResponse{ + Ok: true, + ArtifactId: artifact.ID, + } + r.sendProtbufBody(ctx, &respData) +} diff --git a/routers/api/actions/ping/ping.go b/routers/api/actions/ping/ping.go new file mode 100644 index 0000000..13985c9 --- /dev/null +++ b/routers/api/actions/ping/ping.go @@ -0,0 +1,38 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ping + +import ( + "context" + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/log" + + pingv1 "code.gitea.io/actions-proto-go/ping/v1" + "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" + "connectrpc.com/connect" +) + +func NewPingServiceHandler() (string, http.Handler) { + return pingv1connect.NewPingServiceHandler(&Service{}) +} + +var _ pingv1connect.PingServiceHandler = (*Service)(nil) + +type Service struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *Service) Ping( + ctx context.Context, + req *connect.Request[pingv1.PingRequest], +) (*connect.Response[pingv1.PingResponse], error) { + log.Trace("Content-Type: %s", req.Header().Get("Content-Type")) + log.Trace("User-Agent: %s", req.Header().Get("User-Agent")) + res := connect.NewResponse(&pingv1.PingResponse{ + Data: fmt.Sprintf("Hello, %s!", req.Msg.Data), + }) + return res, nil +} diff --git a/routers/api/actions/ping/ping_test.go b/routers/api/actions/ping/ping_test.go new file mode 100644 index 0000000..098b003 --- /dev/null +++ b/routers/api/actions/ping/ping_test.go @@ -0,0 +1,61 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ping + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + pingv1 "code.gitea.io/actions-proto-go/ping/v1" + "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestService(t *testing.T) { + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler( + &Service{}, + )) + MainServiceTest(t, mux) +} + +func MainServiceTest(t *testing.T, h http.Handler) { + t.Parallel() + server := httptest.NewUnstartedServer(h) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + connectClient := pingv1connect.NewPingServiceClient( + server.Client(), + server.URL, + ) + + grpcClient := pingv1connect.NewPingServiceClient( + server.Client(), + server.URL, + connect.WithGRPC(), + ) + + grpcWebClient := pingv1connect.NewPingServiceClient( + server.Client(), + server.URL, + connect.WithGRPCWeb(), + ) + + clients := []pingv1connect.PingServiceClient{connectClient, grpcClient, grpcWebClient} + t.Run("ping request", func(t *testing.T) { + for _, client := range clients { + result, err := client.Ping(context.Background(), connect.NewRequest(&pingv1.PingRequest{ + Data: "foobar", + })) + require.NoError(t, err) + assert.Equal(t, "Hello, foobar!", result.Msg.Data) + } + }) +} diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go new file mode 100644 index 0000000..521ba91 --- /dev/null +++ b/routers/api/actions/runner/interceptor.go @@ -0,0 +1,80 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + "crypto/subtle" + "errors" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "connectrpc.com/connect" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + uuidHeaderKey = "x-runner-uuid" + tokenHeaderKey = "x-runner-token" +) + +var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { + methodName := getMethodName(request) + if methodName == "Register" { + return unaryFunc(ctx, request) + } + uuid := request.Header().Get(uuidHeaderKey) + token := request.Header().Get(tokenHeaderKey) + + runner, err := actions_model.GetRunnerByUUID(ctx, uuid) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil, status.Error(codes.Unauthenticated, "unregistered runner") + } + return nil, status.Error(codes.Internal, err.Error()) + } + if subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))) != 1 { + return nil, status.Error(codes.Unauthenticated, "unregistered runner") + } + + cols := []string{"last_online"} + runner.LastOnline = timeutil.TimeStampNow() + if methodName == "UpdateTask" || methodName == "UpdateLog" { + runner.LastActive = timeutil.TimeStampNow() + cols = append(cols, "last_active") + } + if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil { + log.Error("can't update runner status: %v", err) + } + + ctx = context.WithValue(ctx, runnerCtxKey{}, runner) + return unaryFunc(ctx, request) + } +})) + +func getMethodName(req connect.AnyRequest) string { + splits := strings.Split(req.Spec().Procedure, "/") + if len(splits) > 0 { + return splits[len(splits)-1] + } + return "" +} + +type runnerCtxKey struct{} + +func GetRunner(ctx context.Context) *actions_model.ActionRunner { + if v := ctx.Value(runnerCtxKey{}); v != nil { + if r, ok := v.(*actions_model.ActionRunner); ok { + return r + } + } + return nil +} diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go new file mode 100644 index 0000000..017bdf6 --- /dev/null +++ b/routers/api/actions/runner/runner.go @@ -0,0 +1,289 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + actions_service "code.gitea.io/gitea/services/actions" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" + "connectrpc.com/connect" + gouuid "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func NewRunnerServiceHandler() (string, http.Handler) { + return runnerv1connect.NewRunnerServiceHandler( + &Service{}, + connect.WithCompressMinBytes(1024), + withRunner, + ) +} + +var _ runnerv1connect.RunnerServiceClient = (*Service)(nil) + +type Service struct { + runnerv1connect.UnimplementedRunnerServiceHandler +} + +// Register for new runner. +func (s *Service) Register( + ctx context.Context, + req *connect.Request[runnerv1.RegisterRequest], +) (*connect.Response[runnerv1.RegisterResponse], error) { + if req.Msg.Token == "" || req.Msg.Name == "" { + return nil, errors.New("missing runner token, name") + } + + runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token) + if err != nil { + return nil, errors.New("runner registration token not found") + } + + if !runnerToken.IsActive { + return nil, errors.New("runner registration token has been invalidated, please use the latest one") + } + + if runnerToken.OwnerID > 0 { + if _, err := user_model.GetUserByID(ctx, runnerToken.OwnerID); err != nil { + return nil, errors.New("owner of the token not found") + } + } + + if runnerToken.RepoID > 0 { + if _, err := repo_model.GetRepositoryByID(ctx, runnerToken.RepoID); err != nil { + return nil, errors.New("repository of the token not found") + } + } + + labels := req.Msg.Labels + + // create new runner + name, _ := util.SplitStringAtByteN(req.Msg.Name, 255) + runner := &actions_model.ActionRunner{ + UUID: gouuid.New().String(), + Name: name, + OwnerID: runnerToken.OwnerID, + RepoID: runnerToken.RepoID, + Version: req.Msg.Version, + AgentLabels: labels, + } + if err := runner.GenerateToken(); err != nil { + return nil, errors.New("can't generate token") + } + + // create new runner + if err := actions_model.CreateRunner(ctx, runner); err != nil { + return nil, errors.New("can't create new runner") + } + + // update token status + runnerToken.IsActive = true + if err := actions_model.UpdateRunnerToken(ctx, runnerToken, "is_active"); err != nil { + return nil, errors.New("can't update runner token status") + } + + res := connect.NewResponse(&runnerv1.RegisterResponse{ + Runner: &runnerv1.Runner{ + Id: runner.ID, + Uuid: runner.UUID, + Token: runner.Token, + Name: runner.Name, + Version: runner.Version, + Labels: runner.AgentLabels, + }, + }) + + return res, nil +} + +func (s *Service) Declare( + ctx context.Context, + req *connect.Request[runnerv1.DeclareRequest], +) (*connect.Response[runnerv1.DeclareResponse], error) { + runner := GetRunner(ctx) + runner.AgentLabels = req.Msg.Labels + runner.Version = req.Msg.Version + if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version"); err != nil { + return nil, status.Errorf(codes.Internal, "update runner: %v", err) + } + + return connect.NewResponse(&runnerv1.DeclareResponse{ + Runner: &runnerv1.Runner{ + Id: runner.ID, + Uuid: runner.UUID, + Token: runner.Token, + Name: runner.Name, + Version: runner.Version, + Labels: runner.AgentLabels, + }, + }), nil +} + +// FetchTask assigns a task to the runner +func (s *Service) FetchTask( + ctx context.Context, + req *connect.Request[runnerv1.FetchTaskRequest], +) (*connect.Response[runnerv1.FetchTaskResponse], error) { + runner := GetRunner(ctx) + + var task *runnerv1.Task + tasksVersion := req.Msg.TasksVersion // task version from runner + latestVersion, err := actions_model.GetTasksVersionByScope(ctx, runner.OwnerID, runner.RepoID) + if err != nil { + return nil, status.Errorf(codes.Internal, "query tasks version failed: %v", err) + } else if latestVersion == 0 { + if err := actions_model.IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID); err != nil { + return nil, status.Errorf(codes.Internal, "fail to increase task version: %v", err) + } + // if we don't increase the value of `latestVersion` here, + // the response of FetchTask will return tasksVersion as zero. + // and the runner will treat it as an old version of Gitea. + latestVersion++ + } + + if tasksVersion != latestVersion { + // if the task version in request is not equal to the version in db, + // it means there may still be some tasks not be assigned. + // try to pick a task for the runner that send the request. + if t, ok, err := pickTask(ctx, runner); err != nil { + log.Error("pick task failed: %v", err) + return nil, status.Errorf(codes.Internal, "pick task: %v", err) + } else if ok { + task = t + } + } + res := connect.NewResponse(&runnerv1.FetchTaskResponse{ + Task: task, + TasksVersion: latestVersion, + }) + return res, nil +} + +// UpdateTask updates the task status. +func (s *Service) UpdateTask( + ctx context.Context, + req *connect.Request[runnerv1.UpdateTaskRequest], +) (*connect.Response[runnerv1.UpdateTaskResponse], error) { + task, err := actions_model.UpdateTaskByState(ctx, req.Msg.State) + if err != nil { + return nil, status.Errorf(codes.Internal, "update task: %v", err) + } + + for k, v := range req.Msg.Outputs { + if len(k) > 255 { + log.Warn("Ignore the output of task %d because the key is too long: %q", task.ID, k) + continue + } + // The value can be a maximum of 1 MB + if l := len(v); l > 1024*1024 { + log.Warn("Ignore the output %q of task %d because the value is too long: %v", k, task.ID, l) + continue + } + // There's another limitation on GitHub that the total of all outputs in a workflow run can be a maximum of 50 MB. + // We don't check the total size here because it's not easy to do, and it doesn't really worth it. + // See https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs + + if err := actions_model.InsertTaskOutputIfNotExist(ctx, task.ID, k, v); err != nil { + log.Warn("Failed to insert the output %q of task %d: %v", k, task.ID, err) + // It's ok not to return errors, the runner will resend the outputs. + } + } + sentOutputs, err := actions_model.FindTaskOutputKeyByTaskID(ctx, task.ID) + if err != nil { + log.Warn("Failed to find the sent outputs of task %d: %v", task.ID, err) + // It's not to return errors, it can be handled when the runner resends sent outputs. + } + + if err := task.LoadJob(ctx); err != nil { + return nil, status.Errorf(codes.Internal, "load job: %v", err) + } + if err := task.Job.LoadRun(ctx); err != nil { + return nil, status.Errorf(codes.Internal, "load run: %v", err) + } + + // don't create commit status for cron job + if task.Job.Run.ScheduleID == 0 { + actions_service.CreateCommitStatus(ctx, task.Job) + } + + if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { + if err := actions_service.EmitJobsIfReady(task.Job.RunID); err != nil { + log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err) + } + } + + return connect.NewResponse(&runnerv1.UpdateTaskResponse{ + State: &runnerv1.TaskState{ + Id: req.Msg.State.Id, + Result: task.Status.AsResult(), + }, + SentOutputs: sentOutputs, + }), nil +} + +// UpdateLog uploads log of the task. +func (s *Service) UpdateLog( + ctx context.Context, + req *connect.Request[runnerv1.UpdateLogRequest], +) (*connect.Response[runnerv1.UpdateLogResponse], error) { + res := connect.NewResponse(&runnerv1.UpdateLogResponse{}) + + task, err := actions_model.GetTaskByID(ctx, req.Msg.TaskId) + if err != nil { + return nil, status.Errorf(codes.Internal, "get task: %v", err) + } + ack := task.LogLength + + if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack { + res.Msg.AckIndex = ack + return res, nil + } + + if task.LogInStorage { + return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") + } + + rows := req.Msg.Rows[ack-req.Msg.Index:] + ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows) + if err != nil { + return nil, status.Errorf(codes.Internal, "write logs: %v", err) + } + task.LogLength += int64(len(rows)) + for _, n := range ns { + task.LogIndexes = append(task.LogIndexes, task.LogSize) + task.LogSize += int64(n) + } + + res.Msg.AckIndex = task.LogLength + + var remove func() + if req.Msg.NoMore { + task.LogInStorage = true + remove, err = actions.TransferLogs(ctx, task.LogFilename) + if err != nil { + return nil, status.Errorf(codes.Internal, "transfer logs: %v", err) + } + } + + if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_length", "log_size", "log_in_storage"); err != nil { + return nil, status.Errorf(codes.Internal, "update task: %v", err) + } + if remove != nil { + remove() + } + + return res, nil +} diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go new file mode 100644 index 0000000..ff6ec5b --- /dev/null +++ b/routers/api/actions/runner/utils.go @@ -0,0 +1,189 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + "fmt" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" + actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/actions" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "google.golang.org/protobuf/types/known/structpb" +) + +func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { + t, ok, err := actions_model.CreateTaskForRunner(ctx, runner) + if err != nil { + return nil, false, fmt.Errorf("CreateTaskForRunner: %w", err) + } + if !ok { + return nil, false, nil + } + + secrets, err := secret_model.GetSecretsOfTask(ctx, t) + if err != nil { + return nil, false, fmt.Errorf("GetSecretsOfTask: %w", err) + } + + vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run) + if err != nil { + return nil, false, fmt.Errorf("GetVariablesOfRun: %w", err) + } + + actions.CreateCommitStatus(ctx, t.Job) + + task := &runnerv1.Task{ + Id: t.ID, + WorkflowPayload: t.Job.WorkflowPayload, + Context: generateTaskContext(t), + Secrets: secrets, + Vars: vars, + } + + if needs, err := findTaskNeeds(ctx, t); err != nil { + log.Error("Cannot find needs for task %v: %v", t.ID, err) + // Go on with empty needs. + // If return error, the task will be wild, which means the runner will never get it when it has been assigned to the runner. + // In contrast, missing needs is less serious. + // And the task will fail and the runner will report the error in the logs. + } else { + task.Needs = needs + } + + return task, true, nil +} + +func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { + event := map[string]any{} + _ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) + + // TriggerEvent is added in https://github.com/go-gitea/gitea/pull/25229 + // This fallback is for the old ActionRun that doesn't have the TriggerEvent field + // and should be removed in 1.22 + eventName := t.Job.Run.TriggerEvent + if eventName == "" { + eventName = t.Job.Run.Event.Event() + } + + baseRef := "" + headRef := "" + ref := t.Job.Run.Ref + sha := t.Job.Run.CommitSHA + if pullPayload, err := t.Job.Run.GetPullRequestEventPayload(); err == nil && pullPayload.PullRequest != nil && pullPayload.PullRequest.Base != nil && pullPayload.PullRequest.Head != nil { + baseRef = pullPayload.PullRequest.Base.Ref + headRef = pullPayload.PullRequest.Head.Ref + + // if the TriggerEvent is pull_request_target, ref and sha need to be set according to the base of pull request + // In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target, + // the ref will be the base branch. + if t.Job.Run.TriggerEvent == actions_module.GithubEventPullRequestTarget { + ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name + sha = pullPayload.PullRequest.Base.Sha + } + } + + refName := git.RefName(ref) + + giteaRuntimeToken, err := actions.CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) + if err != nil { + log.Error("actions.CreateAuthorizationToken failed: %v", err) + } + + taskContext, err := structpb.NewStruct(map[string]any{ + // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context + "action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2. + "action_path": "", // string, The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action. + "action_ref": "", // string, For a step executing an action, this is the ref of the action being executed. For example, v2. + "action_repository": "", // string, For a step executing an action, this is the owner and repository name of the action. For example, actions/checkout. + "action_status": "", // string, For a composite action, the current result of the composite action. + "actor": t.Job.Run.TriggerUser.Name, // string, The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. + "api_url": setting.AppURL + "api/v1", // string, The URL of the GitHub REST API. + "base_ref": baseRef, // string, The base_ref or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target. + "env": "", // string, Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." + "event": event, // object, The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in "Events that trigger workflows." For example, for a workflow run triggered by the push event, this object contains the contents of the push webhook payload. + "event_name": eventName, // string, The name of the event that triggered the workflow run. + "event_path": "", // string, The path to the file on the runner that contains the full event webhook payload. + "graphql_url": "", // string, The URL of the GitHub GraphQL API. + "head_ref": headRef, // string, The head_ref or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target. + "job": fmt.Sprint(t.JobID), // string, The job_id of the current job. + "ref": ref, // string, The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/, for pull requests it is refs/pull//merge, and for tags it is refs/tags/. For example, refs/heads/feature-branch-1. + "ref_name": refName.ShortName(), // string, The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1. + "ref_protected": false, // boolean, true if branch protections are configured for the ref that triggered the workflow run. + "ref_type": refName.RefType(), // string, The type of ref that triggered the workflow run. Valid values are branch or tag. + "path": "", // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." + "repository": t.Job.Run.Repo.OwnerName + "/" + t.Job.Run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World. + "repository_owner": t.Job.Run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. + "repositoryUrl": t.Job.Run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. + "retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. + "run_id": fmt.Sprint(t.Job.RunID), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. + "run_number": fmt.Sprint(t.Job.Run.Index), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. + "run_attempt": fmt.Sprint(t.Job.Attempt), // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. + "secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. + "server_url": setting.AppURL, // string, The URL of the GitHub server. For example: https://github.com. + "sha": sha, // string, The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see "Events that trigger workflows." For example, ffac537e6cbbf934b08745a378932722df287a53. + "token": t.Token, // string, A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the GITHUB_TOKEN secret. For more information, see "Automatic token authentication." + "triggering_actor": "", // string, The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. + "workflow": t.Job.Run.WorkflowID, // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository. + "workspace": "", // string, The default working directory on the runner for steps, and the default location of your repository when using the checkout action. + + // additional contexts + "gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(), + "gitea_runtime_token": giteaRuntimeToken, + }) + if err != nil { + log.Error("structpb.NewStruct failed: %v", err) + } + + return taskContext +} + +func findTaskNeeds(ctx context.Context, task *actions_model.ActionTask) (map[string]*runnerv1.TaskNeed, error) { + if err := task.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("LoadAttributes: %w", err) + } + if len(task.Job.Needs) == 0 { + return nil, nil + } + needs := container.SetOf(task.Job.Needs...) + + jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: task.Job.RunID}) + if err != nil { + return nil, fmt.Errorf("FindRunJobs: %w", err) + } + + ret := make(map[string]*runnerv1.TaskNeed, len(needs)) + for _, job := range jobs { + if !needs.Contains(job.JobID) { + continue + } + if job.TaskID == 0 || !job.Status.IsDone() { + // it shouldn't happen, or the job has been rerun + continue + } + outputs := make(map[string]string) + got, err := actions_model.FindTaskOutputByTaskID(ctx, job.TaskID) + if err != nil { + return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) + } + for _, v := range got { + outputs[v.OutputKey] = v.OutputValue + } + ret[job.JobID] = &runnerv1.TaskNeed{ + Outputs: outputs, + Result: runnerv1.Result(job.Status), + } + } + + return ret, nil +} diff --git a/routers/api/forgejo/v1/api.go b/routers/api/forgejo/v1/api.go new file mode 100644 index 0000000..88c7502 --- /dev/null +++ b/routers/api/forgejo/v1/api.go @@ -0,0 +1,20 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/shared" +) + +func Routes() *web.Route { + m := web.NewRoute() + + m.Use(shared.Middlewares()...) + + forgejo := NewForgejo() + m.Get("", Root) + m.Get("/version", forgejo.GetVersion) + return m +} diff --git a/routers/api/forgejo/v1/forgejo.go b/routers/api/forgejo/v1/forgejo.go new file mode 100644 index 0000000..0f1f4f1 --- /dev/null +++ b/routers/api/forgejo/v1/forgejo.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "net/http" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" +) + +type Forgejo struct{} + +var _ ServerInterface = &Forgejo{} + +func NewForgejo() *Forgejo { + return &Forgejo{} +} + +func (f *Forgejo) GetVersion(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Version{&setting.ForgejoVersion}) +} diff --git a/routers/api/forgejo/v1/generated.go b/routers/api/forgejo/v1/generated.go new file mode 100644 index 0000000..725ddf5 --- /dev/null +++ b/routers/api/forgejo/v1/generated.go @@ -0,0 +1,167 @@ +// Package v1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.12.4 DO NOT EDIT. +package v1 + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Version defines model for Version. +type Version struct { + Version *string `json:"version,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // API version + // (GET /version) + GetVersion(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetVersion operation middleware +func (siw *ServerInterfaceWrapper) GetVersion(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetVersion(w, r) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshallingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshallingParamError) Error() string { + return fmt.Sprintf("Error unmarshalling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshallingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/version", wrapper.GetVersion) + }) + + return r +} diff --git a/routers/api/forgejo/v1/root.go b/routers/api/forgejo/v1/root.go new file mode 100644 index 0000000..b976c51 --- /dev/null +++ b/routers/api/forgejo/v1/root.go @@ -0,0 +1,14 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "net/http" +) + +func Root(w http.ResponseWriter, r *http.Request) { + // https://www.rfc-editor.org/rfc/rfc8631 + w.Header().Set("Link", "; rel=\"service-desc\"") + w.WriteHeader(http.StatusNoContent) +} diff --git a/routers/api/packages/README.md b/routers/api/packages/README.md new file mode 100644 index 0000000..74d1492 --- /dev/null +++ b/routers/api/packages/README.md @@ -0,0 +1,50 @@ +# Gitea Package Registry + +This document gives a brief overview how the package registry is organized in code. + +## Structure + +The package registry code is divided into multiple modules to split the functionality and make code reuse possible. + +| Module | Description | +| - | - | +| `models/packages` | Common methods and models used by all registry types | +| `models/packages/` | Methods used by specific registry type. There should be no need to use type specific models. | +| `modules/packages` | Common methods and types used by multiple registry types | +| `modules/packages/` | Registry type specific methods and types (e.g. metadata extraction of package files) | +| `routers/api/packages` | Route definitions for all registry types | +| `routers/api/packages/` | Route implementation for a specific registry type | +| `services/packages` | Helper methods used by registry types to handle common tasks like package creation and deletion in `routers` | +| `services/packages/` | Registry type specific methods used by `routers` and `services` | + +## Models + +Every package registry implementation uses the same underlying models: + +| Model | Description | +| - | - | +| `Package` | The root of a package providing values fixed for every version (e.g. the package name) | +| `PackageVersion` | A version of a package containing metadata (e.g. the package description) | +| `PackageFile` | A file of a package describing its content (e.g. file name) | +| `PackageBlob` | The content of a file (may be shared by multiple files) | +| `PackageProperty` | Additional properties attached to `Package`, `PackageVersion` or `PackageFile` (e.g. used if metadata is needed for routing) | + +The following diagram shows the relationship between the models: +``` +Package <1---*> PackageVersion <1---*> PackageFile <*---1> PackageBlob +``` + +## Adding a new package registry type + +Before adding a new package registry type have a look at the existing implementation to get an impression of how it could work. +Most registry types offer endpoints to retrieve the metadata, upload and download package files. +The upload endpoint is often the heavy part because it must validate the uploaded blob, extract metadata and create the models. +The methods to validate and extract the metadata should be added in the `modules/packages/` package. +If the upload is valid the methods in `services/packages` allow to store the upload and create the corresponding models. +It depends if the registry type allows multiple files per package version which method should be called: +- `CreatePackageAndAddFile`: error if package version already exists +- `CreatePackageOrAddFileToExisting`: error if file already exists +- `AddFileToExistingPackage`: error if package version does not exist or file already exists + +`services/packages` also contains helper methods to download a file or to remove a package version. +There are no helper methods for metadata endpoints because they are very type specific. diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go new file mode 100644 index 0000000..831a910 --- /dev/null +++ b/routers/api/packages/alpine/alpine.go @@ -0,0 +1,287 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package alpine + +import ( + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + alpine_model "code.gitea.io/gitea/models/packages/alpine" + "code.gitea.io/gitea/modules/json" + packages_module "code.gitea.io/gitea/modules/packages" + alpine_module "code.gitea.io/gitea/modules/packages/alpine" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + alpine_service "code.gitea.io/gitea/services/packages/alpine" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func createOrAddToExisting(ctx *context.Context, pck *alpine_module.Package, branch, repository, architecture string, buf packages_module.HashedSizeReader, fileMetadataRaw []byte) { + _, _, err := packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeAlpine, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + Metadata: pck.VersionMetadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s.apk", pck.Name, pck.Version), + CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: map[string]string{ + alpine_module.PropertyBranch: branch, + alpine_module.PropertyRepository: repository, + alpine_module.PropertyArchitecture: architecture, + alpine_module.PropertyMetadata: string(fileMetadataRaw), + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, pck.FileMetadata.Architecture); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } +} + +func GetRepositoryKey(ctx *context.Context) { + _, pub, err := alpine_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pubPem, _ := pem.Decode([]byte(pub)) + if pubPem == nil { + apiError(ctx, http.StatusInternalServerError, "failed to decode private key pem") + return + } + + pubKey, err := x509.ParsePKIXPublicKey(pubPem.Bytes) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + fingerprint, err := util.CreatePublicKeyFingerprint(pubKey) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{ + ContentType: "application/x-pem-file", + Filename: fmt.Sprintf("%s@%s.rsa.pub", ctx.Package.Owner.LowerName, hex.EncodeToString(fingerprint)), + }) +} + +func GetRepositoryFile(ctx *context.Context) { + pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pv, + &packages_service.PackageFileInfo{ + Filename: alpine_service.IndexArchiveFilename, + CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")), + }, + ) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func UploadPackageFile(ctx *context.Context) { + branch := strings.TrimSpace(ctx.Params("branch")) + repository := strings.TrimSpace(ctx.Params("repository")) + if branch == "" || repository == "" { + apiError(ctx, http.StatusBadRequest, "invalid branch or repository") + return + } + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := alpine_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) || err == io.EOF { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + fileMetadataRaw, err := json.Marshal(pck.FileMetadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // Check whether the package being uploaded has no architecture defined. + // If true, loop through the available architectures in the repo and create + // the package file for the each architecture. If there are no architectures + // available on the repository, fallback to x86_64 + if pck.FileMetadata.Architecture == "noarch" { + architectures, err := alpine_model.GetArchitectures(ctx, ctx.Package.Owner.ID, repository) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(architectures) == 0 { + architectures = []string{ + "x86_64", + } + } + + for _, arch := range architectures { + pck.FileMetadata.Architecture = arch + + fileMetadataRaw, err := json.Marshal(pck.FileMetadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + createOrAddToExisting(ctx, pck, branch, repository, pck.FileMetadata.Architecture, buf, fileMetadataRaw) + } + } else { + createOrAddToExisting(ctx, pck, branch, repository, pck.FileMetadata.Architecture, buf, fileMetadataRaw) + } + + ctx.Status(http.StatusCreated) +} + +func DownloadPackageFile(ctx *context.Context) { + branch := ctx.Params("branch") + repository := ctx.Params("repository") + architecture := ctx.Params("architecture") + + opts := &packages_model.PackageFileSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + PackageType: packages_model.TypeAlpine, + Query: ctx.Params("filename"), + CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), + } + + pfs, _, err := packages_model.SearchFiles(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func DeletePackageFile(ctx *context.Context) { + branch, repository, architecture := ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture") + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + PackageType: packages_model.TypeAlpine, + Query: ctx.Params("filename"), + CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.Doer, pfs[0]); err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, architecture); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go new file mode 100644 index 0000000..1337ce4 --- /dev/null +++ b/routers/api/packages/api.go @@ -0,0 +1,916 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "net/http" + "regexp" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/packages/alpine" + "code.gitea.io/gitea/routers/api/packages/arch" + "code.gitea.io/gitea/routers/api/packages/cargo" + "code.gitea.io/gitea/routers/api/packages/chef" + "code.gitea.io/gitea/routers/api/packages/composer" + "code.gitea.io/gitea/routers/api/packages/conan" + "code.gitea.io/gitea/routers/api/packages/conda" + "code.gitea.io/gitea/routers/api/packages/container" + "code.gitea.io/gitea/routers/api/packages/cran" + "code.gitea.io/gitea/routers/api/packages/debian" + "code.gitea.io/gitea/routers/api/packages/generic" + "code.gitea.io/gitea/routers/api/packages/goproxy" + "code.gitea.io/gitea/routers/api/packages/helm" + "code.gitea.io/gitea/routers/api/packages/maven" + "code.gitea.io/gitea/routers/api/packages/npm" + "code.gitea.io/gitea/routers/api/packages/nuget" + "code.gitea.io/gitea/routers/api/packages/pub" + "code.gitea.io/gitea/routers/api/packages/pypi" + "code.gitea.io/gitea/routers/api/packages/rpm" + "code.gitea.io/gitea/routers/api/packages/rubygems" + "code.gitea.io/gitea/routers/api/packages/swift" + "code.gitea.io/gitea/routers/api/packages/vagrant" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" +) + +func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.Data["IsApiToken"] == true { + scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) + if ok { // it's a personal access token but not oauth2 token + scopeMatched := false + var err error + if accessMode == perm.AccessModeRead { + scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeReadPackage) + if err != nil { + ctx.Error(http.StatusInternalServerError, "HasScope", err.Error()) + return + } + } else if accessMode == perm.AccessModeWrite { + scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeWritePackage) + if err != nil { + ctx.Error(http.StatusInternalServerError, "HasScope", err.Error()) + return + } + } + if !scopeMatched { + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`) + ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin") + return + } + + // 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 + } + + if publicOnly { + if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages") + return + } + } + } + } + + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`) + ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin") + return + } + } +} + +func enforcePackagesQuota() func(ctx *context.Context) { + return func(ctx *context.Context) { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeAssetsPackagesAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "enforcePackagesQuota", "quota exceeded") + return + } + } +} + +func verifyAuth(r *web.Route, authMethods []auth.Method) { + if setting.Service.EnableReverseProxyAuth { + authMethods = append(authMethods, &auth.ReverseProxy{}) + } + authGroup := auth.NewGroup(authMethods...) + + r.Use(func(ctx *context.Context) { + var err error + ctx.Doer, err = authGroup.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) + if err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, "authGroup.Verify") + return + } + ctx.IsSigned = ctx.Doer != nil + }) +} + +// CommonRoutes provide endpoints for most package managers (except containers - see below) +// These are mounted on `/api/packages` (not `/api/v1/packages`) +func CommonRoutes() *web.Route { + r := web.NewRoute() + + r.Use(context.PackageContexter()) + + verifyAuth(r, []auth.Method{ + &auth.OAuth2{}, + &auth.Basic{}, + &nuget.Auth{}, + &conan.Auth{}, + &chef.Auth{}, + }) + + r.Group("/{username}", func() { + r.Group("/alpine", func() { + r.Get("/key", alpine.GetRepositoryKey) + r.Group("/{branch}/{repository}", func() { + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), alpine.UploadPackageFile) + r.Group("/{architecture}", func() { + r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile) + r.Group("/{filename}", func() { + r.Get("", alpine.DownloadPackageFile) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile) + }) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/arch", func() { + r.Group("/repository.key", func() { + r.Head("", arch.GetRepositoryKey) + r.Get("", arch.GetRepositoryKey) + }) + + r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) { + pathGroups := strings.Split(strings.Trim(ctx.Params("*"), "/"), "/") + groupLen := len(pathGroups) + isGetHead := ctx.Req.Method == "HEAD" || ctx.Req.Method == "GET" + isPut := ctx.Req.Method == "PUT" + isDelete := ctx.Req.Method == "DELETE" + if isGetHead { + if groupLen < 2 { + ctx.Status(http.StatusNotFound) + return + } + if groupLen == 2 { + ctx.SetParams("group", "") + ctx.SetParams("arch", pathGroups[0]) + ctx.SetParams("file", pathGroups[1]) + } else { + ctx.SetParams("group", strings.Join(pathGroups[:groupLen-2], "/")) + ctx.SetParams("arch", pathGroups[groupLen-2]) + ctx.SetParams("file", pathGroups[groupLen-1]) + } + arch.GetPackageOrDB(ctx) + return + } else if isPut { + ctx.SetParams("group", strings.Join(pathGroups, "/")) + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + arch.PushPackage(ctx) + return + } else if isDelete { + if groupLen < 3 { + ctx.Status(http.StatusBadRequest) + return + } + if groupLen == 3 { + ctx.SetParams("group", "") + ctx.SetParams("package", pathGroups[0]) + ctx.SetParams("version", pathGroups[1]) + ctx.SetParams("arch", pathGroups[2]) + } else { + ctx.SetParams("group", strings.Join(pathGroups[:groupLen-3], "/")) + ctx.SetParams("package", pathGroups[groupLen-3]) + ctx.SetParams("version", pathGroups[groupLen-2]) + ctx.SetParams("arch", pathGroups[groupLen-1]) + } + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + arch.RemovePackage(ctx) + return + } + ctx.Status(http.StatusNotFound) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/cargo", func() { + r.Group("/api/v1/crates", func() { + r.Get("", cargo.SearchPackages) + r.Put("/new", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UploadPackage) + r.Group("/{package}", func() { + r.Group("/{version}", func() { + r.Get("/download", cargo.DownloadPackageFile) + r.Delete("/yank", reqPackageAccess(perm.AccessModeWrite), cargo.YankPackage) + r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UnyankPackage) + }) + r.Get("/owners", cargo.ListOwners) + }) + }) + r.Get("/config.json", cargo.RepositoryConfig) + r.Get("/1/{package}", cargo.EnumeratePackageVersions) + r.Get("/2/{package}", cargo.EnumeratePackageVersions) + // Use dummy placeholders because these parts are not of interest + r.Get("/3/{_}/{package}", cargo.EnumeratePackageVersions) + r.Get("/{_}/{__}/{package}", cargo.EnumeratePackageVersions) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/chef", func() { + r.Group("/api/v1", func() { + r.Get("/universe", chef.PackagesUniverse) + r.Get("/search", chef.EnumeratePackages) + r.Group("/cookbooks", func() { + r.Get("", chef.EnumeratePackages) + r.Post("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), chef.UploadPackage) + r.Group("/{name}", func() { + r.Get("", chef.PackageMetadata) + r.Group("/versions/{version}", func() { + r.Get("", chef.PackageVersionMetadata) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackageVersion) + r.Get("/download", chef.DownloadPackage) + }) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackage) + }) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/composer", func() { + r.Get("/packages.json", composer.ServiceIndex) + r.Get("/search.json", composer.SearchPackages) + r.Get("/list.json", composer.EnumeratePackages) + r.Get("/p2/{vendorname}/{projectname}~dev.json", composer.PackageMetadata) + r.Get("/p2/{vendorname}/{projectname}.json", composer.PackageMetadata) + r.Get("/files/{package}/{version}/{filename}", composer.DownloadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), composer.UploadPackage) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/conan", func() { + r.Group("/v1", func() { + r.Get("/ping", conan.Ping) + r.Group("/users", func() { + r.Get("/authenticate", conan.Authenticate) + r.Get("/check_credentials", conan.CheckCredentials) + }) + r.Group("/conans", func() { + r.Get("/search", conan.SearchRecipes) + r.Group("/{name}/{version}/{user}/{channel}", func() { + r.Get("", conan.RecipeSnapshot) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV1) + r.Get("/search", conan.SearchPackagesV1) + r.Get("/digest", conan.RecipeDownloadURLs) + r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.RecipeUploadURLs) + r.Get("/download_urls", conan.RecipeDownloadURLs) + r.Group("/packages", func() { + r.Post("/delete", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV1) + r.Group("/{package_reference}", func() { + r.Get("", conan.PackageSnapshot) + r.Get("/digest", conan.PackageDownloadURLs) + r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.PackageUploadURLs) + r.Get("/download_urls", conan.PackageDownloadURLs) + }) + }) + }, conan.ExtractPathParameters) + }) + r.Group("/files/{name}/{version}/{user}/{channel}/{recipe_revision}", func() { + r.Group("/recipe/{filename}", func() { + r.Get("", conan.DownloadRecipeFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile) + }) + r.Group("/package/{package_reference}/{package_revision}/{filename}", func() { + r.Get("", conan.DownloadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile) + }) + }, conan.ExtractPathParameters) + }) + r.Group("/v2", func() { + r.Get("/ping", conan.Ping) + r.Group("/users", func() { + r.Get("/authenticate", conan.Authenticate) + r.Get("/check_credentials", conan.CheckCredentials) + }) + r.Group("/conans", func() { + r.Get("/search", conan.SearchRecipes) + r.Group("/{name}/{version}/{user}/{channel}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2) + r.Get("/search", conan.SearchPackagesV2) + r.Get("/latest", conan.LatestRecipeRevision) + r.Group("/revisions", func() { + r.Get("", conan.ListRecipeRevisions) + r.Group("/{recipe_revision}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2) + r.Get("/search", conan.SearchPackagesV2) + r.Group("/files", func() { + r.Get("", conan.ListRecipeRevisionFiles) + r.Group("/{filename}", func() { + r.Get("", conan.DownloadRecipeFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile) + }) + }) + r.Group("/packages", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2) + r.Group("/{package_reference}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2) + r.Get("/latest", conan.LatestPackageRevision) + r.Group("/revisions", func() { + r.Get("", conan.ListPackageRevisions) + r.Group("/{package_revision}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2) + r.Group("/files", func() { + r.Get("", conan.ListPackageRevisionFiles) + r.Group("/{filename}", func() { + r.Get("", conan.DownloadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile) + }) + }) + }) + }) + }) + }) + }) + }) + }, conan.ExtractPathParameters) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/conda", func() { + var ( + downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`) + uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`) + ) + + r.Get("/*", func(ctx *context.Context) { + m := downloadPattern.FindStringSubmatch(ctx.Params("*")) + if len(m) == 0 { + ctx.Status(http.StatusNotFound) + return + } + + ctx.SetParams("channel", strings.TrimSuffix(m[1], "/")) + ctx.SetParams("architecture", m[2]) + ctx.SetParams("filename", m[3]) + + switch m[3] { + case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2": + conda.EnumeratePackages(ctx) + default: + conda.DownloadPackageFile(ctx) + } + }) + r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), func(ctx *context.Context) { + m := uploadPattern.FindStringSubmatch(ctx.Params("*")) + if len(m) == 0 { + ctx.Status(http.StatusNotFound) + return + } + + ctx.SetParams("channel", strings.TrimSuffix(m[1], "/")) + ctx.SetParams("filename", m[2]) + + conda.UploadPackageFile(ctx) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/cran", func() { + r.Group("/src", func() { + r.Group("/contrib", func() { + r.Get("/PACKAGES", cran.EnumerateSourcePackages) + r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages) + r.Get("/{filename}", cran.DownloadSourcePackageFile) + }) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadSourcePackageFile) + }) + r.Group("/bin", func() { + r.Group("/{platform}/contrib/{rversion}", func() { + r.Get("/PACKAGES", cran.EnumerateBinaryPackages) + r.Get("/PACKAGES{format}", cran.EnumerateBinaryPackages) + r.Get("/{filename}", cran.DownloadBinaryPackageFile) + }) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadBinaryPackageFile) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/debian", func() { + r.Get("/repository.key", debian.GetRepositoryKey) + r.Group("/dists/{distribution}", func() { + r.Get("/{filename}", debian.GetRepositoryFile) + r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash) + r.Group("/{component}/{architecture}", func() { + r.Get("/{filename}", debian.GetRepositoryFile) + r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash) + }) + }) + r.Group("/pool/{distribution}/{component}", func() { + r.Get("/{name}_{version}_{architecture}.deb", debian.DownloadPackageFile) + r.Group("", func() { + r.Put("/upload", enforcePackagesQuota(), debian.UploadPackageFile) + r.Delete("/{name}/{version}/{architecture}", debian.DeletePackageFile) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/go", func() { + r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), goproxy.UploadPackage) + r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) { + ctx.Status(http.StatusNotFound) + }) + + // Manual mapping of routes because the package name contains slashes which chi does not support + // https://go.dev/ref/mod#goproxy-protocol + r.Get("/*", func(ctx *context.Context) { + path := ctx.Params("*") + + if strings.HasSuffix(path, "/@latest") { + ctx.SetParams("name", path[:len(path)-len("/@latest")]) + ctx.SetParams("version", "latest") + + goproxy.PackageVersionMetadata(ctx) + return + } + + parts := strings.SplitN(path, "/@v/", 2) + if len(parts) != 2 { + ctx.Status(http.StatusNotFound) + return + } + + ctx.SetParams("name", parts[0]) + + // /@v/list + if parts[1] == "list" { + goproxy.EnumeratePackageVersions(ctx) + return + } + + // /@v/.zip + if strings.HasSuffix(parts[1], ".zip") { + ctx.SetParams("version", parts[1][:len(parts[1])-len(".zip")]) + + goproxy.DownloadPackageFile(ctx) + return + } + // /@v/.info + if strings.HasSuffix(parts[1], ".info") { + ctx.SetParams("version", parts[1][:len(parts[1])-len(".info")]) + + goproxy.PackageVersionMetadata(ctx) + return + } + // /@v/.mod + if strings.HasSuffix(parts[1], ".mod") { + ctx.SetParams("version", parts[1][:len(parts[1])-len(".mod")]) + + goproxy.PackageVersionGoModContent(ctx) + return + } + + ctx.Status(http.StatusNotFound) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/generic", func() { + r.Group("/{packagename}/{packageversion}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage) + r.Group("/{filename}", func() { + r.Get("", generic.DownloadPackageFile) + r.Group("", func() { + r.Put("", enforcePackagesQuota(), generic.UploadPackage) + r.Delete("", generic.DeletePackageFile) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/helm", func() { + r.Get("/index.yaml", helm.Index) + r.Get("/{filename}", helm.DownloadPackageFile) + r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), helm.UploadPackage) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/maven", func() { + r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), maven.UploadPackageFile) + r.Get("/*", maven.DownloadPackageFile) + r.Head("/*", maven.ProvidePackageFileHeader) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/nuget", func() { + r.Group("", func() { // Needs to be unauthenticated for the NuGet client. + r.Get("/", nuget.ServiceIndexV2) + r.Get("/index.json", nuget.ServiceIndexV3) + r.Get("/$metadata", nuget.FeedCapabilityResource) + }) + r.Group("", func() { + r.Get("/query", nuget.SearchServiceV3) + r.Group("/registration/{id}", func() { + r.Get("/index.json", nuget.RegistrationIndex) + r.Get("/{version}", nuget.RegistrationLeafV3) + }) + r.Group("/package/{id}", func() { + r.Get("/index.json", nuget.EnumeratePackageVersionsV3) + r.Get("/{version}/{filename}", nuget.DownloadPackageFile) + }) + r.Group("", func() { + r.Put("/", enforcePackagesQuota(), nuget.UploadPackage) + r.Put("/symbolpackage", enforcePackagesQuota(), nuget.UploadSymbolPackage) + r.Delete("/{id}/{version}", nuget.DeletePackage) + }, reqPackageAccess(perm.AccessModeWrite)) + r.Get("/symbols/{filename}/{guid:[0-9a-fA-F]{32}[fF]{8}}/{filename2}", nuget.DownloadSymbolFile) + r.Get("/Packages(Id='{id:[^']+}',Version='{version:[^']+}')", nuget.RegistrationLeafV2) + r.Group("/Packages()", func() { + r.Get("", nuget.SearchServiceV2) + r.Get("/$count", nuget.SearchServiceV2Count) + }) + r.Group("/FindPackagesById()", func() { + r.Get("", nuget.EnumeratePackageVersionsV2) + r.Get("/$count", nuget.EnumeratePackageVersionsV2Count) + }) + r.Group("/Search()", func() { + r.Get("", nuget.SearchServiceV2) + r.Get("/$count", nuget.SearchServiceV2Count) + }) + }, reqPackageAccess(perm.AccessModeRead)) + }) + r.Group("/npm", func() { + r.Group("/@{scope}/{id}", func() { + r.Get("", npm.PackageMetadata) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage) + r.Group("/-/{version}/{filename}", func() { + r.Get("", npm.DownloadPackageFile) + r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) + }) + r.Get("/-/{filename}", npm.DownloadPackageFileByName) + r.Group("/-rev/{revision}", func() { + r.Delete("", npm.DeletePackage) + r.Put("", npm.DeletePreview) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + r.Group("/{id}", func() { + r.Get("", npm.PackageMetadata) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage) + r.Group("/-/{version}/{filename}", func() { + r.Get("", npm.DownloadPackageFile) + r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) + }) + r.Get("/-/{filename}", npm.DownloadPackageFileByName) + r.Group("/-rev/{revision}", func() { + r.Delete("", npm.DeletePackage) + r.Put("", npm.DeletePreview) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + r.Group("/-/package/@{scope}/{id}/dist-tags", func() { + r.Get("", npm.ListPackageTags) + r.Group("/{tag}", func() { + r.Put("", npm.AddPackageTag) + r.Delete("", npm.DeletePackageTag) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + r.Group("/-/package/{id}/dist-tags", func() { + r.Get("", npm.ListPackageTags) + r.Group("/{tag}", func() { + r.Put("", npm.AddPackageTag) + r.Delete("", npm.DeletePackageTag) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + r.Group("/-/v1/search", func() { + r.Get("", npm.PackageSearch) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/pub", func() { + r.Group("/api/packages", func() { + r.Group("/versions/new", func() { + r.Get("", pub.RequestUpload) + r.Post("/upload", enforcePackagesQuota(), pub.UploadPackageFile) + r.Get("/finalize/{id}/{version}", pub.FinalizePackage) + }, reqPackageAccess(perm.AccessModeWrite)) + r.Group("/{id}", func() { + r.Get("", pub.EnumeratePackageVersions) + r.Get("/files/{version}", pub.DownloadPackageFile) + r.Get("/{version}", pub.PackageVersionMetadata) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/pypi", func() { + r.Post("/", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), pypi.UploadPackageFile) + r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile) + r.Get("/simple/{id}", pypi.PackageMetadata) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/rpm", func() { + r.Group("/repository.key", func() { + r.Head("", rpm.GetRepositoryKey) + r.Get("", rpm.GetRepositoryKey) + }) + + var ( + repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`) + uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`) + filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`) + repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`) + ) + + r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) { + path := ctx.Params("*") + isHead := ctx.Req.Method == "HEAD" + isGetHead := ctx.Req.Method == "HEAD" || ctx.Req.Method == "GET" + isPut := ctx.Req.Method == "PUT" + isDelete := ctx.Req.Method == "DELETE" + + m := repoPattern.FindStringSubmatch(path) + if len(m) == 2 && isGetHead { + ctx.SetParams("group", strings.Trim(m[1], "/")) + rpm.GetRepositoryConfig(ctx) + return + } + + m = repoFilePattern.FindStringSubmatch(path) + if len(m) == 3 && isGetHead { + ctx.SetParams("group", strings.Trim(m[1], "/")) + ctx.SetParams("filename", m[2]) + if isHead { + rpm.CheckRepositoryFileExistence(ctx) + } else { + rpm.GetRepositoryFile(ctx) + } + return + } + + m = uploadPattern.FindStringSubmatch(path) + if len(m) == 2 && isPut { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + enforcePackagesQuota()(ctx) + if ctx.Written() { + return + } + ctx.SetParams("group", strings.Trim(m[1], "/")) + rpm.UploadPackageFile(ctx) + return + } + + m = filePattern.FindStringSubmatch(path) + if len(m) == 6 && (isGetHead || isDelete) { + ctx.SetParams("group", strings.Trim(m[1], "/")) + ctx.SetParams("name", m[2]) + ctx.SetParams("version", m[3]) + ctx.SetParams("architecture", m[4]) + if isGetHead { + rpm.DownloadPackageFile(ctx) + } else { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + rpm.DeletePackageFile(ctx) + } + return + } + + ctx.Status(http.StatusNotFound) + }) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/rubygems", func() { + r.Get("/specs.4.8.gz", rubygems.EnumeratePackages) + r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest) + r.Get("/prerelease_specs.4.8.gz", rubygems.EnumeratePackagesPreRelease) + r.Get("/info/{package}", rubygems.ServePackageInfo) + r.Get("/versions", rubygems.ServeVersionsFile) + r.Get("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification) + r.Get("/gems/{filename}", rubygems.DownloadPackageFile) + r.Group("/api/v1/gems", func() { + r.Post("/", enforcePackagesQuota(), rubygems.UploadPackageFile) + r.Delete("/yank", rubygems.DeletePackage) + }, reqPackageAccess(perm.AccessModeWrite)) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/swift", func() { + r.Group("/{scope}/{name}", func() { + r.Group("", func() { + r.Get("", swift.EnumeratePackageVersions) + r.Get(".json", swift.EnumeratePackageVersions) + }, swift.CheckAcceptMediaType(swift.AcceptJSON)) + r.Group("/{version}", func() { + r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) + r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), enforcePackagesQuota(), swift.UploadPackageFile) + r.Get("", func(ctx *context.Context) { + // Can't use normal routes here: https://github.com/go-chi/chi/issues/781 + + version := ctx.Params("version") + if strings.HasSuffix(version, ".zip") { + swift.CheckAcceptMediaType(swift.AcceptZip)(ctx) + if ctx.Written() { + return + } + ctx.SetParams("version", version[:len(version)-4]) + swift.DownloadPackageFile(ctx) + } else { + swift.CheckAcceptMediaType(swift.AcceptJSON)(ctx) + if ctx.Written() { + return + } + if strings.HasSuffix(version, ".json") { + ctx.SetParams("version", version[:len(version)-5]) + } + swift.PackageVersionMetadata(ctx) + } + }) + }) + }) + r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) + }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/vagrant", func() { + r.Group("/authenticate", func() { + r.Get("", vagrant.CheckAuthenticate) + }) + r.Group("/{name}", func() { + r.Head("", vagrant.CheckBoxAvailable) + r.Get("", vagrant.EnumeratePackageVersions) + r.Group("/{version}/{provider}", func() { + r.Get("", vagrant.DownloadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), vagrant.UploadPackageFile) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) + }, context.UserAssignmentWeb(), context.PackageAssignment()) + + return r +} + +// ContainerRoutes provides endpoints that implement the OCI API to serve containers +// These have to be mounted on `/v2/...` to comply with the OCI spec: +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md +func ContainerRoutes() *web.Route { + r := web.NewRoute() + + r.Use(context.PackageContexter()) + + verifyAuth(r, []auth.Method{ + &auth.Basic{}, + &container.Auth{}, + }) + + r.Get("", container.ReqContainerAccess, container.DetermineSupport) + r.Group("/token", func() { + r.Get("", container.Authenticate) + r.Post("", container.AuthenticateNotImplemented) + }) + r.Get("/_catalog", container.ReqContainerAccess, container.GetRepositoryList) + r.Group("/{username}", func() { + r.Group("/{image}", func() { + r.Group("/blobs/uploads", func() { + r.Post("", container.InitiateUploadBlob) + r.Group("/{uuid}", func() { + r.Get("", container.GetUploadBlob) + r.Patch("", container.UploadBlob) + r.Put("", container.EndUploadBlob) + r.Delete("", container.CancelUploadBlob) + }) + }, reqPackageAccess(perm.AccessModeWrite)) + r.Group("/blobs/{digest}", func() { + r.Head("", container.HeadBlob) + r.Get("", container.GetBlob) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob) + }) + r.Group("/manifests/{reference}", func() { + r.Put("", reqPackageAccess(perm.AccessModeWrite), container.UploadManifest) + r.Head("", container.HeadManifest) + r.Get("", container.GetManifest) + r.Delete("", reqPackageAccess(perm.AccessModeWrite), container.DeleteManifest) + }) + r.Get("/tags/list", container.GetTagList) + }, container.VerifyImageName) + + var ( + blobsUploadsPattern = regexp.MustCompile(`\A(.+)/blobs/uploads/([a-zA-Z0-9-_.=]+)\z`) + blobsPattern = regexp.MustCompile(`\A(.+)/blobs/([^/]+)\z`) + manifestsPattern = regexp.MustCompile(`\A(.+)/manifests/([^/]+)\z`) + ) + + // Manual mapping of routes because {image} can contain slashes which chi does not support + r.Methods("HEAD,GET,POST,PUT,PATCH,DELETE", "/*", func(ctx *context.Context) { + path := ctx.Params("*") + isHead := ctx.Req.Method == "HEAD" + isGet := ctx.Req.Method == "GET" + isPost := ctx.Req.Method == "POST" + isPut := ctx.Req.Method == "PUT" + isPatch := ctx.Req.Method == "PATCH" + isDelete := ctx.Req.Method == "DELETE" + + if isPost && strings.HasSuffix(path, "/blobs/uploads") { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + + ctx.SetParams("image", path[:len(path)-14]) + container.VerifyImageName(ctx) + if ctx.Written() { + return + } + + container.InitiateUploadBlob(ctx) + return + } + if isGet && strings.HasSuffix(path, "/tags/list") { + ctx.SetParams("image", path[:len(path)-10]) + container.VerifyImageName(ctx) + if ctx.Written() { + return + } + + container.GetTagList(ctx) + return + } + + m := blobsUploadsPattern.FindStringSubmatch(path) + if len(m) == 3 && (isGet || isPut || isPatch || isDelete) { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + + ctx.SetParams("image", m[1]) + container.VerifyImageName(ctx) + if ctx.Written() { + return + } + + ctx.SetParams("uuid", m[2]) + + if isGet { + container.GetUploadBlob(ctx) + } else if isPatch { + container.UploadBlob(ctx) + } else if isPut { + container.EndUploadBlob(ctx) + } else { + container.CancelUploadBlob(ctx) + } + return + } + m = blobsPattern.FindStringSubmatch(path) + if len(m) == 3 && (isHead || isGet || isDelete) { + ctx.SetParams("image", m[1]) + container.VerifyImageName(ctx) + if ctx.Written() { + return + } + + ctx.SetParams("digest", m[2]) + + if isHead { + container.HeadBlob(ctx) + } else if isGet { + container.GetBlob(ctx) + } else { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + container.DeleteBlob(ctx) + } + return + } + m = manifestsPattern.FindStringSubmatch(path) + if len(m) == 3 && (isHead || isGet || isPut || isDelete) { + ctx.SetParams("image", m[1]) + container.VerifyImageName(ctx) + if ctx.Written() { + return + } + + ctx.SetParams("reference", m[2]) + + if isHead { + container.HeadManifest(ctx) + } else if isGet { + container.GetManifest(ctx) + } else { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + if isPut { + container.UploadManifest(ctx) + } else { + container.DeleteManifest(ctx) + } + } + return + } + + ctx.Status(http.StatusNotFound) + }) + }, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + + return r +} diff --git a/routers/api/packages/arch/arch.go b/routers/api/packages/arch/arch.go new file mode 100644 index 0000000..15fcc37 --- /dev/null +++ b/routers/api/packages/arch/arch.go @@ -0,0 +1,269 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package arch + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "regexp" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + packages_module "code.gitea.io/gitea/modules/packages" + arch_module "code.gitea.io/gitea/modules/packages/arch" + "code.gitea.io/gitea/modules/sync" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + arch_service "code.gitea.io/gitea/services/packages/arch" +) + +var ( + archPkgOrSig = regexp.MustCompile(`^.*\.pkg\.tar\.\w+(\.sig)*$`) + archDBOrSig = regexp.MustCompile(`^.*.db(\.tar\.gz)*(\.sig)*$`) + + locker = sync.NewExclusivePool() +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func refreshLocker(ctx *context.Context, group string) func() { + key := fmt.Sprintf("pkg_%d_arch_pkg_%s", ctx.Package.Owner.ID, group) + locker.CheckIn(key) + return func() { + locker.CheckOut(key) + } +} + +func GetRepositoryKey(ctx *context.Context) { + _, pub, err := arch_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{ + ContentType: "application/pgp-keys", + Filename: "repository.key", + }) +} + +func PushPackage(ctx *context.Context) { + group := ctx.Params("group") + releaser := refreshLocker(ctx, group) + defer releaser() + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + p, err := arch_module.ParsePackage(buf) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + _, err = buf.Seek(0, io.SeekStart) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + sign, err := arch_service.NewFileSign(ctx, ctx.Package.Owner.ID, buf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer sign.Close() + _, err = buf.Seek(0, io.SeekStart) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + // update gpg sign + pgp, err := io.ReadAll(sign) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + p.FileMetadata.PgpSigned = base64.StdEncoding.EncodeToString(pgp) + _, err = sign.Seek(0, io.SeekStart) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + properties := map[string]string{ + arch_module.PropertyDescription: p.Desc(), + arch_module.PropertyArch: p.FileMetadata.Arch, + arch_module.PropertyDistribution: group, + } + + version, _, err := packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeArch, + Name: p.Name, + Version: p.Version, + }, + Creator: ctx.Doer, + Metadata: p.VersionMetadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s-%s.pkg.tar.%s", p.Name, p.Version, p.FileMetadata.Arch, p.CompressType), + CompositeKey: group, + }, + OverwriteExisting: false, + IsLead: true, + Creator: ctx.ContextUser, + Data: buf, + Properties: properties, + }, + ) + if err != nil { + switch { + case errors.Is(err, packages_model.ErrDuplicatePackageVersion), errors.Is(err, packages_model.ErrDuplicatePackageFile): + apiError(ctx, http.StatusConflict, err) + case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize): + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + // add sign file + _, err = packages_service.AddFileToPackageVersionInternal(ctx, version, &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + CompositeKey: group, + Filename: fmt.Sprintf("%s-%s-%s.pkg.tar.%s.sig", p.Name, p.Version, p.FileMetadata.Arch, p.CompressType), + }, + OverwriteExisting: true, + IsLead: false, + Creator: ctx.Doer, + Data: sign, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if err = arch_service.BuildPacmanDB(ctx, ctx.Package.Owner.ID, group, p.FileMetadata.Arch); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + ctx.Status(http.StatusCreated) +} + +func GetPackageOrDB(ctx *context.Context) { + var ( + file = ctx.Params("file") + group = ctx.Params("group") + arch = ctx.Params("arch") + ) + if archPkgOrSig.MatchString(file) { + pkg, u, pf, err := arch_service.GetPackageFile(ctx, group, file, ctx.Package.Owner.ID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + helper.ServePackageFile(ctx, pkg, u, pf) + return + } + + if archDBOrSig.MatchString(file) { + pkg, u, pf, err := arch_service.GetPackageDBFile(ctx, group, arch, ctx.Package.Owner.ID, + strings.HasSuffix(file, ".sig")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + helper.ServePackageFile(ctx, pkg, u, pf) + return + } + + ctx.Status(http.StatusNotFound) +} + +func RemovePackage(ctx *context.Context) { + var ( + group = ctx.Params("group") + pkg = ctx.Params("package") + ver = ctx.Params("version") + pkgArch = ctx.Params("arch") + ) + releaser := refreshLocker(ctx, group) + defer releaser() + pv, err := packages_model.GetVersionByNameAndVersion( + ctx, ctx.Package.Owner.ID, packages_model.TypeArch, pkg, ver, + ) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + files, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + deleted := false + for _, file := range files { + extName := fmt.Sprintf("-%s.pkg.tar%s", pkgArch, filepath.Ext(file.LowerName)) + if strings.HasSuffix(file.LowerName, ".sig") { + extName = fmt.Sprintf("-%s.pkg.tar%s.sig", pkgArch, + filepath.Ext(strings.TrimSuffix(file.LowerName, filepath.Ext(file.LowerName)))) + } + if file.CompositeKey == group && + strings.HasSuffix(file.LowerName, extName) { + deleted = true + err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.ContextUser, file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + } + if deleted { + err = arch_service.BuildCustomRepositoryFiles(ctx, ctx.Package.Owner.ID, group) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + ctx.Status(http.StatusNoContent) + } else { + ctx.Error(http.StatusNotFound) + } +} diff --git a/routers/api/packages/cargo/cargo.go b/routers/api/packages/cargo/cargo.go new file mode 100644 index 0000000..140e532 --- /dev/null +++ b/routers/api/packages/cargo/cargo.go @@ -0,0 +1,311 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cargo + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + cargo_module "code.gitea.io/gitea/modules/packages/cargo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + packages_service "code.gitea.io/gitea/services/packages" + cargo_service "code.gitea.io/gitea/services/packages/cargo" +) + +// https://doc.rust-lang.org/cargo/reference/registries.html#web-api +type StatusResponse struct { + OK bool `json:"ok"` + Errors []StatusMessage `json:"errors,omitempty"` +} + +type StatusMessage struct { + Message string `json:"detail"` +} + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, StatusResponse{ + OK: false, + Errors: []StatusMessage{ + { + Message: message, + }, + }, + }) + }) +} + +// https://rust-lang.github.io/rfcs/2789-sparse-index.html +func RepositoryConfig(ctx *context.Context) { + ctx.JSON(http.StatusOK, cargo_service.BuildConfig(ctx.Package.Owner, setting.Service.RequireSignInView || ctx.Package.Owner.Visibility != structs.VisibleTypePublic)) +} + +func EnumeratePackageVersions(ctx *context.Context) { + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.Params("package")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + b, err := cargo_service.BuildPackageIndex(ctx, p) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if b == nil { + apiError(ctx, http.StatusNotFound, nil) + return + } + + ctx.PlainTextBytes(http.StatusOK, b.Bytes()) +} + +type SearchResult struct { + Crates []*SearchResultCrate `json:"crates"` + Meta SearchResultMeta `json:"meta"` +} + +type SearchResultCrate struct { + Name string `json:"name"` + LatestVersion string `json:"max_version"` + Description string `json:"description"` +} + +type SearchResultMeta struct { + Total int64 `json:"total"` +} + +// https://doc.rust-lang.org/cargo/reference/registries.html#search +func SearchPackages(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + perPage := ctx.FormInt("per_page") + paginator := db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(perPage), + } + + pvs, total, err := packages_model.SearchLatestVersions( + ctx, + &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeCargo, + Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, + IsInternal: optional.Some(false), + Paginator: &paginator, + }, + ) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + crates := make([]*SearchResultCrate, 0, len(pvs)) + for _, pd := range pds { + crates = append(crates, &SearchResultCrate{ + Name: pd.Package.Name, + LatestVersion: pd.Version.Version, + Description: pd.Metadata.(*cargo_module.Metadata).Description, + }) + } + + ctx.JSON(http.StatusOK, SearchResult{ + Crates: crates, + Meta: SearchResultMeta{ + Total: total, + }, + }) +} + +type Owners struct { + Users []OwnerUser `json:"users"` +} + +type OwnerUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` +} + +// https://doc.rust-lang.org/cargo/reference/registries.html#owners-list +func ListOwners(ctx *context.Context) { + ctx.JSON(http.StatusOK, Owners{ + Users: []OwnerUser{ + { + ID: ctx.Package.Owner.ID, + Login: ctx.Package.Owner.Name, + Name: ctx.Package.Owner.DisplayName(), + }, + }, + }) +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeCargo, + Name: ctx.Params("package"), + Version: ctx.Params("version"), + }, + &packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", ctx.Params("package"), ctx.Params("version"))), + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// https://doc.rust-lang.org/cargo/reference/registries.html#publish +func UploadPackage(ctx *context.Context) { + defer ctx.Req.Body.Close() + + cp, err := cargo_module.ParsePackage(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + buf, err := packages_module.CreateHashedBufferFromReader(cp.Content) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + if buf.Size() != cp.ContentSize { + apiError(ctx, http.StatusBadRequest, "invalid content size") + return + } + + pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeCargo, + Name: cp.Name, + Version: cp.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: cp.Metadata, + VersionProperties: map[string]string{ + cargo_module.PropertyYanked: strconv.FormatBool(false), + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", cp.Name, cp.Version)), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { + if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { + log.Error("Rollback creation of package version: %v", err) + } + + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.JSON(http.StatusOK, StatusResponse{OK: true}) +} + +// https://doc.rust-lang.org/cargo/reference/registries.html#yank +func YankPackage(ctx *context.Context) { + yankPackage(ctx, true) +} + +// https://doc.rust-lang.org/cargo/reference/registries.html#unyank +func UnyankPackage(ctx *context.Context) { + yankPackage(ctx, false) +} + +func yankPackage(ctx *context.Context, yank bool) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.Params("package"), ctx.Params("version")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, cargo_module.PropertyYanked) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pps) == 0 { + apiError(ctx, http.StatusInternalServerError, "Property not found") + return + } + + pp := pps[0] + pp.Value = strconv.FormatBool(yank) + + if err := packages_model.UpdateProperty(ctx, pp); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.JSON(http.StatusOK, StatusResponse{OK: true}) +} diff --git a/routers/api/packages/chef/auth.go b/routers/api/packages/chef/auth.go new file mode 100644 index 0000000..a790e9a --- /dev/null +++ b/routers/api/packages/chef/auth.go @@ -0,0 +1,274 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "math/big" + "net/http" + "path" + "regexp" + "slices" + "strconv" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/auth" +) + +const ( + maxTimeDifference = 10 * time.Minute +) + +var ( + algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`) + versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`) + authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`) + + _ auth.Method = &Auth{} +) + +// Documentation: +// https://docs.chef.io/server/api_chef_server/#required-headers +// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md +// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb + +type Auth struct{} + +func (a *Auth) Name() string { + return "chef" +} + +// Verify extracts the user from the signed request +// If the request is signed with the user private key the user is verified. +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + u, err := getUserFromRequest(req) + if err != nil { + return nil, err + } + if u == nil { + return nil, nil + } + + pub, err := getUserPublicKey(req.Context(), u) + if err != nil { + return nil, err + } + + if err := verifyTimestamp(req); err != nil { + return nil, err + } + + version, err := getSignVersion(req) + if err != nil { + return nil, err + } + + if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil { + return nil, err + } + + return u, nil +} + +func getUserFromRequest(req *http.Request) (*user_model.User, error) { + username := req.Header.Get("X-Ops-Userid") + if username == "" { + return nil, nil + } + + return user_model.GetUserByName(req.Context(), username) +} + +func getUserPublicKey(ctx context.Context, u *user_model.User) (crypto.PublicKey, error) { + pubKey, err := user_model.GetSetting(ctx, u.ID, chef_module.SettingPublicPem) + if err != nil { + return nil, err + } + + pubPem, _ := pem.Decode([]byte(pubKey)) + + return x509.ParsePKIXPublicKey(pubPem.Bytes) +} + +func verifyTimestamp(req *http.Request) error { + hdr := req.Header.Get("X-Ops-Timestamp") + if hdr == "" { + return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing") + } + + ts, err := time.Parse(time.RFC3339, hdr) + if err != nil { + return err + } + + diff := time.Now().UTC().Sub(ts) + if diff < 0 { + diff = -diff + } + + if diff > maxTimeDifference { + return fmt.Errorf("time difference") + } + + return nil +} + +func getSignVersion(req *http.Request) (string, error) { + hdr := req.Header.Get("X-Ops-Sign") + if hdr == "" { + return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing") + } + + m := versionPattern.FindStringSubmatch(hdr) + if len(m) != 2 { + return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header") + } + + switch m[1] { + case "1.0", "1.1", "1.2", "1.3": + default: + return "", util.NewInvalidArgumentErrorf("unsupported version") + } + + version := m[1] + + m = algorithmPattern.FindStringSubmatch(hdr) + if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") { + return "", util.NewInvalidArgumentErrorf("unsupported algorithm") + } + + return version, nil +} + +func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error { + authorizationData, err := getAuthorizationData(req) + if err != nil { + return err + } + + checkData := buildCheckData(req, version) + + switch version { + case "1.3": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256) + case "1.2": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1) + default: + return verifyDataOld(authorizationData, checkData, pub) + } +} + +func getAuthorizationData(req *http.Request) ([]byte, error) { + valueList := make(map[int]string) + for k, vs := range req.Header { + if m := authorizationPattern.FindStringSubmatch(k); m != nil { + index, _ := strconv.Atoi(m[1]) + var v string + if len(vs) == 0 { + v = "" + } else { + v = vs[0] + } + valueList[index] = v + } + } + + tmp := make([]string, len(valueList)) + for k, v := range valueList { + if k > len(tmp) { + return nil, fmt.Errorf("invalid X-Ops-Authorization headers") + } + tmp[k-1] = v + } + + return base64.StdEncoding.DecodeString(strings.Join(tmp, "")) +} + +func buildCheckData(req *http.Request, version string) []byte { + username := req.Header.Get("X-Ops-Userid") + if version != "1.0" && version != "1.3" { + sum := sha1.Sum([]byte(username)) + username = base64.StdEncoding.EncodeToString(sum[:]) + } + + var data string + if version == "1.3" { + data = fmt.Sprintf( + "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", + req.Method, + path.Clean(req.URL.Path), + req.Header.Get("X-Ops-Content-Hash"), + version, + req.Header.Get("X-Ops-Timestamp"), + username, + req.Header.Get("X-Ops-Server-Api-Version"), + ) + } else { + sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) + data = fmt.Sprintf( + "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", + req.Method, + base64.StdEncoding.EncodeToString(sum[:]), + req.Header.Get("X-Ops-Content-Hash"), + req.Header.Get("X-Ops-Timestamp"), + username, + ) + } + + return []byte(data) +} + +func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error { + var h hash.Hash + if algo == crypto.SHA256 { + h = sha256.New() + } else { + h = sha1.New() + } + if _, err := h.Write(data); err != nil { + return err + } + + return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature) +} + +func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error { + c := new(big.Int) + m := new(big.Int) + m.SetBytes(signature) + e := big.NewInt(int64(pub.E)) + c.Exp(m, e, pub.N) + + out := c.Bytes() + + skip := 0 + for i := 2; i < len(out); i++ { + if i+1 >= len(out) { + break + } + if out[i] == 0xFF && out[i+1] == 0 { + skip = i + 2 + break + } + } + + if !slices.Equal(out[skip:], data) { + return fmt.Errorf("could not verify signature") + } + + return nil +} diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go new file mode 100644 index 0000000..b49f4e9 --- /dev/null +++ b/routers/api/packages/chef/chef.go @@ -0,0 +1,403 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj any) { + type Error struct { + ErrorMessages []string `json:"error_messages"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, Error{ + ErrorMessages: []string{message}, + }) + }) +} + +func PackagesUniverse(ctx *context.Context) { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeChef, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type VersionInfo struct { + LocationType string `json:"location_type"` + LocationPath string `json:"location_path"` + DownloadURL string `json:"download_url"` + Dependencies map[string]string `json:"dependencies"` + } + + baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1" + + universe := make(map[string]map[string]*VersionInfo) + for _, pd := range pds { + if _, ok := universe[pd.Package.Name]; !ok { + universe[pd.Package.Name] = make(map[string]*VersionInfo) + } + universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{ + LocationType: "opscode", + LocationPath: baseURL, + DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version), + Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies, + } + } + + ctx.JSON(http.StatusOK, universe) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb +func EnumeratePackages(ctx *context.Context) { + opts := &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeChef, + Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, + IsInternal: optional.Some(false), + Paginator: db.NewAbsoluteListOptions( + ctx.FormInt("start"), + ctx.FormInt("items"), + ), + } + + switch strings.ToLower(ctx.FormTrim("order")) { + case "recently_updated", "recently_added": + opts.Sort = packages_model.SortCreatedDesc + default: + opts.Sort = packages_model.SortNameAsc + } + + pvs, total, err := packages_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Item struct { + CookbookName string `json:"cookbook_name"` + CookbookMaintainer string `json:"cookbook_maintainer"` + CookbookDescription string `json:"cookbook_description"` + Cookbook string `json:"cookbook"` + } + + type Result struct { + Start int `json:"start"` + Total int `json:"total"` + Items []*Item `json:"items"` + } + + baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/" + + items := make([]*Item, 0, len(pds)) + for _, pd := range pds { + metadata := pd.Metadata.(*chef_module.Metadata) + + items = append(items, &Item{ + CookbookName: pd.Package.Name, + CookbookMaintainer: metadata.Author, + CookbookDescription: metadata.Description, + Cookbook: baseURL + url.PathEscape(pd.Package.Name), + }) + } + + skip, _ := opts.Paginator.GetSkipTake() + + ctx.JSON(http.StatusOK, &Result{ + Start: skip, + Total: int(total), + Items: items, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + type Result struct { + Name string `json:"name"` + Maintainer string `json:"maintainer"` + Description string `json:"description"` + Category string `json:"category"` + LatestVersion string `json:"latest_version"` + SourceURL string `json:"source_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Deprecated bool `json:"deprecated"` + Versions []string `json:"versions"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName)) + + versions := make([]string, 0, len(pds)) + for _, pd := range pds { + versions = append(versions, baseURL+pd.Version.Version) + } + + latest := pds[len(pds)-1] + + metadata := latest.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Name: latest.Package.Name, + Maintainer: metadata.Author, + Description: metadata.Description, + LatestVersion: baseURL + latest.Version.Version, + SourceURL: metadata.RepositoryURL, + CreatedAt: latest.Version.CreatedUnix.AsLocalTime(), + UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(), + Deprecated: false, + Versions: versions, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageVersionMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?! + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Result struct { + Version string `json:"version"` + TarballFileSize int64 `json:"tarball_file_size"` + PublishedAt time.Time `json:"published_at"` + Cookbook string `json:"cookbook"` + File string `json:"file"` + License string `json:"license"` + Dependencies map[string]string `json:"dependencies"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name)) + + metadata := pd.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Version: pd.Version.Version, + TarballFileSize: pd.Files[0].Blob.Size, + PublishedAt: pd.Version.CreatedUnix.AsLocalTime(), + Cookbook: baseURL, + File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version), + License: metadata.License, + Dependencies: metadata.Dependencies, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb +func UploadPackage(ctx *context.Context) { + file, _, err := ctx.Req.FormFile("tarball") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := chef_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeChef, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + SemverCompatible: true, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(pck.Version + ".tar.gz"), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.JSON(http.StatusCreated, make(map[any]any)) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb +func DownloadPackage(ctx *context.Context) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackageVersion(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := ctx.Params("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeChef, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusOK) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackage(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + for _, pv := range pvs { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusOK) +} diff --git a/routers/api/packages/composer/api.go b/routers/api/packages/composer/api.go new file mode 100644 index 0000000..a3bcf80 --- /dev/null +++ b/routers/api/packages/composer/api.go @@ -0,0 +1,117 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package composer + +import ( + "fmt" + "net/url" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + composer_module "code.gitea.io/gitea/modules/packages/composer" +) + +// ServiceIndexResponse contains registry endpoints +type ServiceIndexResponse struct { + SearchTemplate string `json:"search"` + MetadataTemplate string `json:"metadata-url"` + PackageList string `json:"list"` +} + +func createServiceIndexResponse(registryURL string) *ServiceIndexResponse { + return &ServiceIndexResponse{ + SearchTemplate: registryURL + "/search.json?q=%query%&type=%type%", + MetadataTemplate: registryURL + "/p2/%package%.json", + PackageList: registryURL + "/list.json", + } +} + +// SearchResultResponse contains search results +type SearchResultResponse struct { + Total int64 `json:"total"` + Results []*SearchResult `json:"results"` + NextLink string `json:"next,omitempty"` +} + +// SearchResult contains a search result +type SearchResult struct { + Name string `json:"name"` + Description string `json:"description"` + Downloads int64 `json:"downloads"` +} + +func createSearchResultResponse(total int64, pds []*packages_model.PackageDescriptor, nextLink string) *SearchResultResponse { + results := make([]*SearchResult, 0, len(pds)) + + for _, pd := range pds { + results = append(results, &SearchResult{ + Name: pd.Package.Name, + Description: pd.Metadata.(*composer_module.Metadata).Description, + Downloads: pd.Version.DownloadCount, + }) + } + + return &SearchResultResponse{ + Total: total, + Results: results, + NextLink: nextLink, + } +} + +// PackageMetadataResponse contains packages metadata +type PackageMetadataResponse struct { + Minified string `json:"minified"` + Packages map[string][]*PackageVersionMetadata `json:"packages"` +} + +// PackageVersionMetadata contains package metadata +type PackageVersionMetadata struct { + *composer_module.Metadata + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Created time.Time `json:"time"` + Dist Dist `json:"dist"` +} + +// Dist contains package download information +type Dist struct { + Type string `json:"type"` + URL string `json:"url"` + Checksum string `json:"shasum"` +} + +func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *PackageMetadataResponse { + versions := make([]*PackageVersionMetadata, 0, len(pds)) + + for _, pd := range pds { + packageType := "" + for _, pvp := range pd.VersionProperties { + if pvp.Name == composer_module.TypeProperty { + packageType = pvp.Value + break + } + } + + versions = append(versions, &PackageVersionMetadata{ + Name: pd.Package.Name, + Version: pd.Version.Version, + Type: packageType, + Created: pd.Version.CreatedUnix.AsLocalTime(), + Metadata: pd.Metadata.(*composer_module.Metadata), + Dist: Dist{ + Type: "zip", + URL: fmt.Sprintf("%s/files/%s/%s/%s", registryURL, url.PathEscape(pd.Package.LowerName), url.PathEscape(pd.Version.LowerVersion), url.PathEscape(pd.Files[0].File.LowerName)), + Checksum: pd.Files[0].Blob.HashSHA1, + }, + }) + } + + return &PackageMetadataResponse{ + Minified: "composer/2.0", + Packages: map[string][]*PackageVersionMetadata{ + pds[0].Package.Name: versions, + }, + } +} diff --git a/routers/api/packages/composer/composer.go b/routers/api/packages/composer/composer.go new file mode 100644 index 0000000..a045da4 --- /dev/null +++ b/routers/api/packages/composer/composer.go @@ -0,0 +1,261 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package composer + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + composer_module "code.gitea.io/gitea/modules/packages/composer" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/hashicorp/go-version" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + type Error struct { + Status int `json:"status"` + Message string `json:"message"` + } + ctx.JSON(status, struct { + Errors []Error `json:"errors"` + }{ + Errors: []Error{ + {Status: status, Message: message}, + }, + }) + }) +} + +// ServiceIndex displays registry endpoints +func ServiceIndex(ctx *context.Context) { + resp := createServiceIndexResponse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer") + + ctx.JSON(http.StatusOK, resp) +} + +// SearchPackages searches packages, only "q" is supported +// https://packagist.org/apidoc#search-packages +func SearchPackages(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + perPage := ctx.FormInt("per_page") + paginator := db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(perPage), + } + + opts := &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeComposer, + Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, + IsInternal: optional.Some(false), + Paginator: &paginator, + } + if ctx.FormTrim("type") != "" { + opts.Properties = map[string]string{ + composer_module.TypeProperty: ctx.FormTrim("type"), + } + } + + pvs, total, err := packages_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + nextLink := "" + if len(pvs) == paginator.PageSize { + u, err := url.Parse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer/search.json") + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + q := u.Query() + q.Set("q", ctx.FormTrim("q")) + q.Set("type", ctx.FormTrim("type")) + q.Set("page", strconv.Itoa(page+1)) + if perPage != 0 { + q.Set("per_page", strconv.Itoa(perPage)) + } + u.RawQuery = q.Encode() + + nextLink = u.String() + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createSearchResultResponse(total, pds, nextLink) + + ctx.JSON(http.StatusOK, resp) +} + +// EnumeratePackages lists all package names +// https://packagist.org/apidoc#list-packages +func EnumeratePackages(ctx *context.Context) { + ps, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + names := make([]string, 0, len(ps)) + for _, p := range ps { + names = append(names, p.Name) + } + + ctx.JSON(http.StatusOK, map[string][]string{ + "packageNames": names, + }) +} + +// PackageMetadata returns the metadata for a single package +// https://packagist.org/apidoc#get-package-data +func PackageMetadata(ctx *context.Context) { + vendorName := ctx.Params("vendorname") + projectName := ctx.Params("projectname") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer, vendorName+"/"+projectName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createPackageMetadataResponse( + setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/composer", + pds, + ) + + ctx.JSON(http.StatusOK, resp) +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeComposer, + Name: ctx.Params("package"), + Version: ctx.Params("version"), + }, + &packages_service.PackageFileInfo{ + Filename: ctx.Params("filename"), + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackage creates a new package +func UploadPackage(ctx *context.Context) { + buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + cp, err := composer_module.ParsePackage(buf, buf.Size()) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if cp.Version == "" { + v, err := version.NewVersion(ctx.FormTrim("version")) + if err != nil { + apiError(ctx, http.StatusBadRequest, composer_module.ErrInvalidVersion) + return + } + cp.Version = v.String() + } + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeComposer, + Name: cp.Name, + Version: cp.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: cp.Metadata, + VersionProperties: map[string]string{ + composer_module.TypeProperty: cp.Type, + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.%s.zip", strings.ReplaceAll(cp.Name, "/", "-"), cp.Version)), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} diff --git a/routers/api/packages/conan/auth.go b/routers/api/packages/conan/auth.go new file mode 100644 index 0000000..e2e1901 --- /dev/null +++ b/routers/api/packages/conan/auth.go @@ -0,0 +1,48 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conan + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/packages" +) + +var _ auth.Method = &Auth{} + +type Auth struct{} + +func (a *Auth) Name() string { + return "conan" +} + +// Verify extracts the user from the Bearer token +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + uid, scope, err := packages.ParseAuthorizationToken(req) + if err != nil { + log.Trace("ParseAuthorizationToken: %v", err) + return nil, err + } + + if uid == 0 { + return nil, nil + } + + // Propagate scope of the authorization token. + if scope != "" { + store.GetData()["IsApiToken"] = true + store.GetData()["ApiTokenScope"] = scope + } + + u, err := user_model.GetUserByID(req.Context(), uid) + if err != nil { + log.Error("GetUserByID: %v", err) + return nil, err + } + + return u, nil +} diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go new file mode 100644 index 0000000..e07907a --- /dev/null +++ b/routers/api/packages/conan/conan.go @@ -0,0 +1,807 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conan + +import ( + std_ctx "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + conan_model "code.gitea.io/gitea/models/packages/conan" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + conan_module "code.gitea.io/gitea/modules/packages/conan" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + notify_service "code.gitea.io/gitea/services/notify" + packages_service "code.gitea.io/gitea/services/packages" +) + +const ( + conanfileFile = "conanfile.py" + conaninfoFile = "conaninfo.txt" + + recipeReferenceKey = "RecipeReference" + packageReferenceKey = "PackageReference" +) + +var ( + recipeFileList = container.SetOf( + conanfileFile, + "conanmanifest.txt", + "conan_sources.tgz", + "conan_export.tgz", + ) + packageFileList = container.SetOf( + conaninfoFile, + "conanmanifest.txt", + "conan_package.tgz", + ) +) + +func jsonResponse(ctx *context.Context, status int, obj any) { + // https://github.com/conan-io/conan/issues/6613 + ctx.Resp.Header().Set("Content-Type", "application/json") + ctx.Status(status) + if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { + log.Error("JSON encode: %v", err) + } +} + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + jsonResponse(ctx, status, map[string]string{ + "message": message, + }) + }) +} + +func baseURL(ctx *context.Context) string { + return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/conan" +} + +// ExtractPathParameters is a middleware to extract common parameters from path +func ExtractPathParameters(ctx *context.Context) { + rref, err := conan_module.NewRecipeReference( + ctx.Params("name"), + ctx.Params("version"), + ctx.Params("user"), + ctx.Params("channel"), + ctx.Params("recipe_revision"), + ) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + ctx.Data[recipeReferenceKey] = rref + + reference := ctx.Params("package_reference") + + var pref *conan_module.PackageReference + if reference != "" { + pref, err = conan_module.NewPackageReference( + rref, + reference, + ctx.Params("package_revision"), + ) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + } + + ctx.Data[packageReferenceKey] = pref +} + +// Ping reports the server capabilities +func Ping(ctx *context.Context) { + ctx.RespHeader().Add("X-Conan-Server-Capabilities", "revisions") // complex_search,checksum_deploy,matrix_params + + ctx.Status(http.StatusOK) +} + +// Authenticate creates an authentication token for the user +func Authenticate(ctx *context.Context) { + if ctx.Doer == nil { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + // If there's an API scope, ensure it propagates. + scope, _ := ctx.Data.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope) + + token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.PlainText(http.StatusOK, token) +} + +// CheckCredentials tests if the provided authentication token is valid +func CheckCredentials(ctx *context.Context) { + if ctx.Doer == nil { + ctx.Status(http.StatusUnauthorized) + } else { + ctx.Status(http.StatusOK) + } +} + +// RecipeSnapshot displays the recipe files with their md5 hash +func RecipeSnapshot(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + serveSnapshot(ctx, rref.AsKey()) +} + +// RecipeSnapshot displays the package files with their md5 hash +func PackageSnapshot(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + serveSnapshot(ctx, pref.AsKey()) +} + +func serveSnapshot(ctx *context.Context, fileKey string) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pv.ID, + CompositeKey: fileKey, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + files := make(map[string]string) + for _, pf := range pfs { + pb, err := packages_model.GetBlobByID(ctx, pf.BlobID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + files[pf.Name] = pb.HashMD5 + } + + jsonResponse(ctx, http.StatusOK, files) +} + +// RecipeDownloadURLs displays the recipe files with their download url +func RecipeDownloadURLs(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + serveDownloadURLs( + ctx, + rref.AsKey(), + fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()), + ) +} + +// PackageDownloadURLs displays the package files with their download url +func PackageDownloadURLs(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + serveDownloadURLs( + ctx, + pref.AsKey(), + fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()), + ) +} + +func serveDownloadURLs(ctx *context.Context, fileKey, downloadURL string) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pv.ID, + CompositeKey: fileKey, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + urls := make(map[string]string) + for _, pf := range pfs { + urls[pf.Name] = fmt.Sprintf("%s/%s", downloadURL, pf.Name) + } + + jsonResponse(ctx, http.StatusOK, urls) +} + +// RecipeUploadURLs displays the upload urls for the provided recipe files +func RecipeUploadURLs(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + serveUploadURLs( + ctx, + recipeFileList, + fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()), + ) +} + +// PackageUploadURLs displays the upload urls for the provided package files +func PackageUploadURLs(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + serveUploadURLs( + ctx, + packageFileList, + fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()), + ) +} + +func serveUploadURLs(ctx *context.Context, fileFilter container.Set[string], uploadURL string) { + defer ctx.Req.Body.Close() + + var files map[string]int64 + if err := json.NewDecoder(ctx.Req.Body).Decode(&files); err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + urls := make(map[string]string) + for file := range files { + if fileFilter.Contains(file) { + urls[file] = fmt.Sprintf("%s/%s", uploadURL, file) + } + } + + jsonResponse(ctx, http.StatusOK, urls) +} + +// UploadRecipeFile handles the upload of a recipe file +func UploadRecipeFile(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + uploadFile(ctx, recipeFileList, rref.AsKey()) +} + +// UploadPackageFile handles the upload of a package file +func UploadPackageFile(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + uploadFile(ctx, packageFileList, pref.AsKey()) +} + +func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + filename := ctx.Params("filename") + if !fileFilter.Contains(filename) { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + isConanfileFile := filename == conanfileFile + isConaninfoFile := filename == conaninfoFile + + pci := &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeConan, + Name: rref.Name, + Version: rref.Version, + }, + Creator: ctx.Doer, + } + pfci := &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(filename), + CompositeKey: fileKey, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: isConanfileFile, + Properties: map[string]string{ + conan_module.PropertyRecipeUser: rref.User, + conan_module.PropertyRecipeChannel: rref.Channel, + conan_module.PropertyRecipeRevision: rref.RevisionOrDefault(), + }, + OverwriteExisting: true, + } + + if pref != nil { + pfci.Properties[conan_module.PropertyPackageReference] = pref.Reference + pfci.Properties[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault() + } + + if isConanfileFile || isConaninfoFile { + if isConanfileFile { + metadata, err := conan_module.ParseConanfile(buf) + if err != nil { + log.Error("Error parsing package metadata: %v", err) + apiError(ctx, http.StatusInternalServerError, err) + return + } + pv, err := packages_model.GetVersionByNameAndVersion(ctx, pci.Owner.ID, pci.PackageType, pci.Name, pci.Version) + if err != nil && err != packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if pv != nil { + raw, err := json.Marshal(metadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + pv.MetadataJSON = string(raw) + if err := packages_model.UpdateVersion(ctx, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } else { + pci.Metadata = metadata + } + } else { + info, err := conan_module.ParseConaninfo(buf) + if err != nil { + log.Error("Error parsing conan info: %v", err) + apiError(ctx, http.StatusInternalServerError, err) + return + } + raw, err := json.Marshal(info) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + pfci.Properties[conan_module.PropertyPackageInfo] = string(raw) + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + pci, + pfci, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +// DownloadRecipeFile serves the content of the requested recipe file +func DownloadRecipeFile(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + downloadFile(ctx, recipeFileList, rref.AsKey()) +} + +// DownloadPackageFile serves the content of the requested package file +func DownloadPackageFile(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + downloadFile(ctx, packageFileList, pref.AsKey()) +} + +func downloadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + filename := ctx.Params("filename") + if !fileFilter.Contains(filename) { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeConan, + Name: rref.Name, + Version: rref.Version, + }, + &packages_service.PackageFileInfo{ + Filename: filename, + CompositeKey: fileKey, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// DeleteRecipeV1 deletes the requested recipe(s) +func DeleteRecipeV1(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + if err := deleteRecipeOrPackage(ctx, rref, true, nil, false); err != nil { + if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + ctx.Status(http.StatusOK) +} + +// DeleteRecipeV2 deletes the requested recipe(s) respecting its revisions +func DeleteRecipeV2(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + if err := deleteRecipeOrPackage(ctx, rref, rref.Revision == "", nil, false); err != nil { + if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + ctx.Status(http.StatusOK) +} + +// DeletePackageV1 deletes the requested package(s) +func DeletePackageV1(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + type PackageReferences struct { + References []string `json:"package_ids"` + } + + var ids *PackageReferences + if err := json.NewDecoder(ctx.Req.Body).Decode(&ids); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + for _, revision := range revisions { + currentRref := rref.WithRevision(revision.Value) + + var references []*conan_model.PropertyValue + if len(ids.References) == 0 { + if references, err = conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRref); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } else { + for _, reference := range ids.References { + references = append(references, &conan_model.PropertyValue{Value: reference}) + } + } + + for _, reference := range references { + pref, _ := conan_module.NewPackageReference(currentRref, reference.Value, conan_module.DefaultRevision) + if err := deleteRecipeOrPackage(ctx, currentRref, true, pref, true); err != nil { + if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + } + } + ctx.Status(http.StatusOK) +} + +// DeletePackageV2 deletes the requested package(s) respecting its revisions +func DeletePackageV2(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + if pref != nil { // has package reference + if err := deleteRecipeOrPackage(ctx, rref, false, pref, pref.Revision == ""); err != nil { + if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + } else { + ctx.Status(http.StatusOK) + } + return + } + + references, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(references) == 0 { + apiError(ctx, http.StatusNotFound, conan_model.ErrPackageReferenceNotExist) + return + } + + for _, reference := range references { + pref, _ := conan_module.NewPackageReference(rref, reference.Value, conan_module.DefaultRevision) + + if err := deleteRecipeOrPackage(ctx, rref, false, pref, true); err != nil { + if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + } + + ctx.Status(http.StatusOK) +} + +func deleteRecipeOrPackage(apictx *context.Context, rref *conan_module.RecipeReference, ignoreRecipeRevision bool, pref *conan_module.PackageReference, ignorePackageRevision bool) error { + var pd *packages_model.PackageDescriptor + versionDeleted := false + + err := db.WithTx(apictx, func(ctx std_ctx.Context) error { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, apictx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version) + if err != nil { + return err + } + + pd, err = packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + return err + } + + filter := map[string]string{ + conan_module.PropertyRecipeUser: rref.User, + conan_module.PropertyRecipeChannel: rref.Channel, + } + if !ignoreRecipeRevision { + filter[conan_module.PropertyRecipeRevision] = rref.RevisionOrDefault() + } + if pref != nil { + filter[conan_module.PropertyPackageReference] = pref.Reference + if !ignorePackageRevision { + filter[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault() + } + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pv.ID, + Properties: filter, + }) + if err != nil { + return err + } + if len(pfs) == 0 { + return conan_model.ErrPackageReferenceNotExist + } + + for _, pf := range pfs { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + return err + } + } + has, err := packages_model.HasVersionFileReferences(ctx, pv.ID) + if err != nil { + return err + } + if !has { + versionDeleted = true + + return packages_service.DeletePackageVersionAndReferences(ctx, pv) + } + return nil + }) + if err != nil { + return err + } + + if versionDeleted { + notify_service.PackageDelete(apictx, apictx.Doer, pd) + } + + return nil +} + +// ListRecipeRevisions gets a list of all recipe revisions +func ListRecipeRevisions(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + listRevisions(ctx, revisions) +} + +// ListPackageRevisions gets a list of all package revisions +func ListPackageRevisions(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + revisions, err := conan_model.GetPackageRevisions(ctx, ctx.Package.Owner.ID, pref) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + listRevisions(ctx, revisions) +} + +type revisionInfo struct { + Revision string `json:"revision"` + Time time.Time `json:"time"` +} + +func listRevisions(ctx *context.Context, revisions []*conan_model.PropertyValue) { + if len(revisions) == 0 { + apiError(ctx, http.StatusNotFound, conan_model.ErrRecipeReferenceNotExist) + return + } + + type RevisionList struct { + Revisions []*revisionInfo `json:"revisions"` + } + + revs := make([]*revisionInfo, 0, len(revisions)) + for _, rev := range revisions { + revs = append(revs, &revisionInfo{Revision: rev.Value, Time: rev.CreatedUnix.AsLocalTime()}) + } + + jsonResponse(ctx, http.StatusOK, &RevisionList{revs}) +} + +// LatestRecipeRevision gets the latest recipe revision +func LatestRecipeRevision(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + revision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + if err == conan_model.ErrRecipeReferenceNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()}) +} + +// LatestPackageRevision gets the latest package revision +func LatestPackageRevision(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + revision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref) + if err != nil { + if err == conan_model.ErrRecipeReferenceNotExist || err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()}) +} + +// ListRecipeRevisionFiles gets a list of all recipe revision files +func ListRecipeRevisionFiles(ctx *context.Context) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + listRevisionFiles(ctx, rref.AsKey()) +} + +// ListPackageRevisionFiles gets a list of all package revision files +func ListPackageRevisionFiles(ctx *context.Context) { + pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference) + + listRevisionFiles(ctx, pref.AsKey()) +} + +func listRevisionFiles(ctx *context.Context, fileKey string) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pv.ID, + CompositeKey: fileKey, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + files := make(map[string]any) + for _, pf := range pfs { + files[pf.Name] = nil + } + + type FileList struct { + Files map[string]any `json:"files"` + } + + jsonResponse(ctx, http.StatusOK, &FileList{ + Files: files, + }) +} diff --git a/routers/api/packages/conan/search.go b/routers/api/packages/conan/search.go new file mode 100644 index 0000000..7370c70 --- /dev/null +++ b/routers/api/packages/conan/search.go @@ -0,0 +1,163 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conan + +import ( + "net/http" + "strings" + + conan_model "code.gitea.io/gitea/models/packages/conan" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + conan_module "code.gitea.io/gitea/modules/packages/conan" + "code.gitea.io/gitea/services/context" +) + +// SearchResult contains the found recipe names +type SearchResult struct { + Results []string `json:"results"` +} + +// SearchRecipes searches all recipes matching the query +func SearchRecipes(ctx *context.Context) { + q := ctx.FormTrim("q") + + opts := parseQuery(ctx.Package.Owner, q) + + results, err := conan_model.SearchRecipes(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + jsonResponse(ctx, http.StatusOK, &SearchResult{ + Results: results, + }) +} + +// parseQuery creates search options for the given query +func parseQuery(owner *user_model.User, query string) *conan_model.RecipeSearchOptions { + opts := &conan_model.RecipeSearchOptions{ + OwnerID: owner.ID, + } + + if query != "" { + parts := strings.Split(strings.ReplaceAll(query, "@", "/"), "/") + + opts.Name = parts[0] + if len(parts) > 1 && parts[1] != "*" { + opts.Version = parts[1] + } + if len(parts) > 2 && parts[2] != "*" { + opts.User = parts[2] + } + if len(parts) > 3 && parts[3] != "*" { + opts.Channel = parts[3] + } + } + + return opts +} + +// SearchPackagesV1 searches all packages of a recipe (Conan v1 endpoint) +func SearchPackagesV1(ctx *context.Context) { + searchPackages(ctx, true) +} + +// SearchPackagesV2 searches all packages of a recipe (Conan v2 endpoint) +func SearchPackagesV2(ctx *context.Context) { + searchPackages(ctx, false) +} + +func searchPackages(ctx *context.Context, searchAllRevisions bool) { + rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference) + + if !searchAllRevisions && rref.Revision == "" { + lastRevision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + if err == conan_model.ErrRecipeReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + rref = rref.WithRevision(lastRevision.Value) + } else { + has, err := conan_model.RecipeExists(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + if err == conan_model.ErrRecipeReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + if !has { + apiError(ctx, http.StatusNotFound, nil) + return + } + } + + recipeRevisions := []*conan_model.PropertyValue{{Value: rref.Revision}} + if searchAllRevisions { + var err error + recipeRevisions, err = conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + result := make(map[string]*conan_module.Conaninfo) + + for _, recipeRevision := range recipeRevisions { + currentRef := rref + if recipeRevision.Value != "" { + currentRef = rref.WithRevision(recipeRevision.Value) + } + packageReferences, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRef) + if err != nil { + if err == conan_model.ErrRecipeReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + for _, packageReference := range packageReferences { + if _, ok := result[packageReference.Value]; ok { + continue + } + pref, _ := conan_module.NewPackageReference(currentRef, packageReference.Value, "") + lastPackageRevision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref) + if err != nil { + if err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + pref = pref.WithRevision(lastPackageRevision.Value) + infoRaw, err := conan_model.GetPackageInfo(ctx, ctx.Package.Owner.ID, pref) + if err != nil { + if err == conan_model.ErrPackageReferenceNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + var info *conan_module.Conaninfo + if err := json.Unmarshal([]byte(infoRaw), &info); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + result[pref.Reference] = info + } + } + + jsonResponse(ctx, http.StatusOK, result) +} diff --git a/routers/api/packages/conda/conda.go b/routers/api/packages/conda/conda.go new file mode 100644 index 0000000..c7e4544 --- /dev/null +++ b/routers/api/packages/conda/conda.go @@ -0,0 +1,303 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conda + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + conda_model "code.gitea.io/gitea/models/packages/conda" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + conda_module "code.gitea.io/gitea/modules/packages/conda" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/dsnet/compress/bzip2" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, struct { + Reason string `json:"reason"` + Message string `json:"message"` + }{ + Reason: http.StatusText(status), + Message: message, + }) + }) +} + +func EnumeratePackages(ctx *context.Context) { + type Info struct { + Subdir string `json:"subdir"` + } + + type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + NoArch string `json:"noarch"` + Subdir string `json:"subdir"` + Timestamp int64 `json:"timestamp"` + Build string `json:"build"` + BuildNumber int64 `json:"build_number"` + Dependencies []string `json:"depends"` + License string `json:"license"` + LicenseFamily string `json:"license_family"` + HashMD5 string `json:"md5"` + HashSHA256 string `json:"sha256"` + Size int64 `json:"size"` + } + + type RepoData struct { + Info Info `json:"info"` + Packages map[string]*PackageInfo `json:"packages"` + PackagesConda map[string]*PackageInfo `json:"packages.conda"` + Removed map[string]*PackageInfo `json:"removed"` + } + + repoData := &RepoData{ + Info: Info{ + Subdir: ctx.Params("architecture"), + }, + Packages: make(map[string]*PackageInfo), + PackagesConda: make(map[string]*PackageInfo), + Removed: make(map[string]*PackageInfo), + } + + pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Channel: ctx.Params("channel"), + Subdir: repoData.Info.Subdir, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds := make(map[int64]*packages_model.PackageDescriptor) + + for _, pf := range pfs { + pd, exists := pds[pf.VersionID] + if !exists { + pv, err := packages_model.GetVersionByID(ctx, pf.VersionID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err = packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds[pf.VersionID] = pd + } + + var pfd *packages_model.PackageFileDescriptor + for _, d := range pd.Files { + if d.File.ID == pf.ID { + pfd = d + break + } + } + + var fileMetadata *conda_module.FileMetadata + if err := json.Unmarshal([]byte(pfd.Properties.GetByName(conda_module.PropertyMetadata)), &fileMetadata); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + versionMetadata := pd.Metadata.(*conda_module.VersionMetadata) + + pi := &PackageInfo{ + Name: pd.PackageProperties.GetByName(conda_module.PropertyName), + Version: pd.Version.Version, + NoArch: fileMetadata.NoArch, + Subdir: repoData.Info.Subdir, + Timestamp: fileMetadata.Timestamp, + Build: fileMetadata.Build, + BuildNumber: fileMetadata.BuildNumber, + Dependencies: fileMetadata.Dependencies, + License: versionMetadata.License, + LicenseFamily: versionMetadata.LicenseFamily, + HashMD5: pfd.Blob.HashMD5, + HashSHA256: pfd.Blob.HashSHA256, + Size: pfd.Blob.Size, + } + + if fileMetadata.IsCondaPackage { + repoData.PackagesConda[pfd.File.Name] = pi + } else { + repoData.Packages[pfd.File.Name] = pi + } + } + + resp := ctx.Resp + + var w io.Writer = resp + + if strings.HasSuffix(ctx.Params("filename"), ".json") { + resp.Header().Set("Content-Type", "application/json") + } else { + resp.Header().Set("Content-Type", "application/x-bzip2") + + zw, err := bzip2.NewWriter(w, nil) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer zw.Close() + + w = zw + } + + resp.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(repoData); err != nil { + log.Error("JSON encode: %v", err) + } +} + +func UploadPackageFile(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + var pck *conda_module.Package + if strings.HasSuffix(strings.ToLower(ctx.Params("filename")), ".tar.bz2") { + pck, err = conda_module.ParsePackageBZ2(buf) + } else { + pck, err = conda_module.ParsePackageConda(buf, buf.Size()) + } + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + fullName := pck.Name + + channel := ctx.Params("channel") + if channel != "" { + fullName = channel + "/" + pck.Name + } + + extension := ".tar.bz2" + if pck.FileMetadata.IsCondaPackage { + extension = ".conda" + } + + fileMetadataRaw, err := json.Marshal(pck.FileMetadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeConda, + Name: fullName, + Version: pck.Version, + }, + SemverCompatible: false, + Creator: ctx.Doer, + Metadata: pck.VersionMetadata, + PackageProperties: map[string]string{ + conda_module.PropertyName: pck.Name, + conda_module.PropertyChannel: channel, + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s-%s%s", pck.Name, pck.Version, pck.FileMetadata.Build, extension), + CompositeKey: pck.Subdir, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: map[string]string{ + conda_module.PropertySubdir: pck.Subdir, + conda_module.PropertyMetadata: string(fileMetadataRaw), + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadPackageFile(ctx *context.Context) { + pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Channel: ctx.Params("channel"), + Subdir: ctx.Params("architecture"), + Filename: ctx.Params("filename"), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pf := pfs[0] + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/api/packages/container/auth.go b/routers/api/packages/container/auth.go new file mode 100644 index 0000000..a8b3ec1 --- /dev/null +++ b/routers/api/packages/container/auth.go @@ -0,0 +1,49 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/packages" +) + +var _ auth.Method = &Auth{} + +type Auth struct{} + +func (a *Auth) Name() string { + return "container" +} + +// Verify extracts the user from the Bearer token +// If it's an anonymous session a ghost user is returned +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + uid, scope, err := packages.ParseAuthorizationToken(req) + if err != nil { + log.Trace("ParseAuthorizationToken: %v", err) + return nil, err + } + + if uid == 0 { + return nil, nil + } + + // Propagate scope of the authorization token. + if scope != "" { + store.GetData()["IsApiToken"] = true + store.GetData()["ApiTokenScope"] = scope + } + + u, err := user_model.GetPossibleUserByID(req.Context(), uid) + if err != nil { + log.Error("GetPossibleUserByID: %v", err) + return nil, err + } + + return u, nil +} diff --git a/routers/api/packages/container/blob.go b/routers/api/packages/container/blob.go new file mode 100644 index 0000000..9e3a470 --- /dev/null +++ b/routers/api/packages/container/blob.go @@ -0,0 +1,202 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "sync" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + container_module "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/util" + packages_service "code.gitea.io/gitea/services/packages" +) + +var uploadVersionMutex sync.Mutex + +// saveAsPackageBlob creates a package blob from an upload +// The uploaded blob gets stored in a special upload version to link them to the package/image +func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam + pb := packages_service.NewPackageBlob(hsr) + + exists := false + + contentStore := packages_module.NewContentStore() + + uploadVersion, err := getOrCreateUploadVersion(ctx, &pci.PackageInfo) + if err != nil { + return nil, err + } + + err = db.WithTx(ctx, func(ctx context.Context) error { + if err := packages_service.CheckSizeQuotaExceeded(ctx, pci.Creator, pci.Owner, packages_model.TypeContainer, hsr.Size()); err != nil { + return err + } + + pb, exists, err = packages_model.GetOrInsertBlob(ctx, pb) + if err != nil { + log.Error("Error inserting package blob: %v", err) + return err + } + // FIXME: Workaround to be removed in v1.20 + // https://github.com/go-gitea/gitea/issues/19586 + if exists { + err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256)) + if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) { + log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256) + exists = false + } + } + if !exists { + if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), hsr, hsr.Size()); err != nil { + log.Error("Error saving package blob in content store: %v", err) + return err + } + } + + return createFileForBlob(ctx, uploadVersion, pb) + }) + if err != nil { + if !exists { + if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil { + log.Error("Error deleting package blob from content store: %v", err) + } + } + return nil, err + } + + return pb, nil +} + +// mountBlob mounts the specific blob to a different package +func mountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packages_model.PackageBlob) error { + uploadVersion, err := getOrCreateUploadVersion(ctx, pi) + if err != nil { + return err + } + + return db.WithTx(ctx, func(ctx context.Context) error { + return createFileForBlob(ctx, uploadVersion, pb) + }) +} + +func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) { + var uploadVersion *packages_model.PackageVersion + + // FIXME: Replace usage of mutex with database transaction + // https://github.com/go-gitea/gitea/pull/21862 + uploadVersionMutex.Lock() + err := db.WithTx(ctx, func(ctx context.Context) error { + created := true + p := &packages_model.Package{ + OwnerID: pi.Owner.ID, + Type: packages_model.TypeContainer, + Name: strings.ToLower(pi.Name), + LowerName: strings.ToLower(pi.Name), + } + var err error + if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { + if err == packages_model.ErrDuplicatePackage { + created = false + } else { + log.Error("Error inserting package: %v", err) + return err + } + } + + if created { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil { + log.Error("Error setting package property: %v", err) + return err + } + } + + pv := &packages_model.PackageVersion{ + PackageID: p.ID, + CreatorID: pi.Owner.ID, + Version: container_model.UploadVersion, + LowerVersion: container_model.UploadVersion, + IsInternal: true, + MetadataJSON: "null", + } + if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil { + if err != packages_model.ErrDuplicatePackageVersion { + log.Error("Error inserting package: %v", err) + return err + } + } + + uploadVersion = pv + + return nil + }) + uploadVersionMutex.Unlock() + + return uploadVersion, err +} + +func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error { + filename := strings.ToLower(fmt.Sprintf("sha256_%s", pb.HashSHA256)) + + pf := &packages_model.PackageFile{ + VersionID: pv.ID, + BlobID: pb.ID, + Name: filename, + LowerName: filename, + CompositeKey: packages_model.EmptyFileKey, + } + var err error + if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil { + if err == packages_model.ErrDuplicatePackageFile { + return nil + } + log.Error("Error inserting package file: %v", err) + return err + } + + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, container_module.PropertyDigest, digestFromPackageBlob(pb)); err != nil { + log.Error("Error setting package file property: %v", err) + return err + } + + return nil +} + +func deleteBlob(ctx context.Context, ownerID int64, image, digest string) error { + return db.WithTx(ctx, func(ctx context.Context) error { + pfds, err := container_model.GetContainerBlobs(ctx, &container_model.BlobSearchOptions{ + OwnerID: ownerID, + Image: image, + Digest: digest, + }) + if err != nil { + return err + } + + for _, file := range pfds { + if err := packages_service.DeletePackageFile(ctx, file.File); err != nil { + return err + } + } + return nil + }) +} + +func digestFromHashSummer(h packages_module.HashSummer) string { + _, _, hashSHA256, _ := h.Sums() + return "sha256:" + hex.EncodeToString(hashSHA256) +} + +func digestFromPackageBlob(pb *packages_model.PackageBlob) string { + return "sha256:" + pb.HashSHA256 +} diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go new file mode 100644 index 0000000..f376e7b --- /dev/null +++ b/routers/api/packages/container/container.go @@ -0,0 +1,785 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + container_module "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + container_service "code.gitea.io/gitea/services/packages/container" + + digest "github.com/opencontainers/go-digest" +) + +// maximum size of a container manifest +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests +const maxManifestSize = 10 * 1024 * 1024 + +var ( + imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) + referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`) +) + +type containerHeaders struct { + Status int + ContentDigest string + UploadUUID string + Range string + Location string + ContentType string + ContentLength int64 +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers +func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { + if h.Location != "" { + resp.Header().Set("Location", h.Location) + } + if h.Range != "" { + resp.Header().Set("Range", h.Range) + } + if h.ContentType != "" { + resp.Header().Set("Content-Type", h.ContentType) + } + if h.ContentLength != 0 { + resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) + } + if h.UploadUUID != "" { + resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID) + } + if h.ContentDigest != "" { + resp.Header().Set("Docker-Content-Digest", h.ContentDigest) + resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest)) + } + resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + resp.WriteHeader(h.Status) +} + +func jsonResponse(ctx *context.Context, status int, obj any) { + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: status, + ContentType: "application/json", + }) + if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { + log.Error("JSON encode: %v", err) + } +} + +func apiError(ctx *context.Context, status int, err error) { + helper.LogAndProcessError(ctx, status, err, func(message string) { + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: status, + }) + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes +func apiErrorDefined(ctx *context.Context, err *namedError) { + type ContainerError struct { + Code string `json:"code"` + Message string `json:"message"` + } + + type ContainerErrors struct { + Errors []ContainerError `json:"errors"` + } + + jsonResponse(ctx, err.StatusCode, ContainerErrors{ + Errors: []ContainerError{ + { + Code: err.Code, + Message: err.Message, + }, + }, + }) +} + +func apiUnauthorizedError(ctx *context.Context) { + ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`) + apiErrorDefined(ctx, errUnauthorized) +} + +// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled) +func ReqContainerAccess(ctx *context.Context) { + if ctx.Doer == nil || (setting.Service.RequireSignInView && ctx.Doer.IsGhost()) { + apiUnauthorizedError(ctx) + } +} + +// VerifyImageName is a middleware which checks if the image name is allowed +func VerifyImageName(ctx *context.Context) { + if !imageNamePattern.MatchString(ctx.Params("image")) { + apiErrorDefined(ctx, errNameInvalid) + } +} + +// DetermineSupport is used to test if the registry supports OCI +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support +func DetermineSupport(ctx *context.Context) { + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: http.StatusOK, + }) +} + +// Authenticate creates a token for the current user +// If the current user is anonymous, the ghost user is used unless RequireSignInView is enabled. +func Authenticate(ctx *context.Context) { + u := ctx.Doer + if u == nil { + if setting.Service.RequireSignInView { + apiUnauthorizedError(ctx) + return + } + + u = user_model.NewGhostUser() + } + + // If there's an API scope, ensure it propagates. + scope, _ := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) + + token, err := packages_service.CreateAuthorizationToken(u, scope) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "token": token, + }) +} + +// https://distribution.github.io/distribution/spec/auth/oauth/ +func AuthenticateNotImplemented(ctx *context.Context) { + // This optional endpoint can be used to authenticate a client. + // It must implement the specification described in: + // https://datatracker.ietf.org/doc/html/rfc6749 + // https://distribution.github.io/distribution/spec/auth/oauth/ + // Purpose of this stub is to respond with 404 Not Found instead of 405 Method Not Allowed. + + ctx.Status(http.StatusNotFound) +} + +// https://docs.docker.com/registry/spec/api/#listing-repositories +func GetRepositoryList(ctx *context.Context) { + n := ctx.FormInt("n") + if n <= 0 || n > 100 { + n = 100 + } + last := ctx.FormTrim("last") + + repositories, err := container_model.GetRepositories(ctx, ctx.Doer, n, last) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type RepositoryList struct { + Repositories []string `json:"repositories"` + } + + if len(repositories) == n { + v := url.Values{} + if n > 0 { + v.Add("n", strconv.Itoa(n)) + } + v.Add("last", repositories[len(repositories)-1]) + + ctx.Resp.Header().Set("Link", fmt.Sprintf(`; rel="next"`, v.Encode())) + } + + jsonResponse(ctx, http.StatusOK, RepositoryList{ + Repositories: repositories, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks +func InitiateUploadBlob(ctx *context.Context) { + image := ctx.Params("image") + + mount := ctx.FormTrim("mount") + from := ctx.FormTrim("from") + if mount != "" { + blob, _ := workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ + Repository: from, + Digest: mount, + }) + if blob != nil { + accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if accessible { + if err := mountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount), + ContentDigest: mount, + Status: http.StatusCreated, + }) + return + } + } + } + + digest := ctx.FormTrim("digest") + if digest != "" { + buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + if digest != digestFromHashSummer(buf) { + apiErrorDefined(ctx, errDigestInvalid) + return + } + + if _, err := saveAsPackageBlob(ctx, + buf, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + Name: image, + }, + Creator: ctx.Doer, + }, + ); err != nil { + switch err { + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest), + ContentDigest: digest, + Status: http.StatusCreated, + }) + return + } + + upload, err := packages_model.CreateBlobUpload(ctx) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID), + Range: "0-0", + UploadUUID: upload.ID, + Status: http.StatusAccepted, + }) +} + +// https://docs.docker.com/registry/spec/api/#get-blob-upload +func GetUploadBlob(ctx *context.Context) { + uuid := ctx.Params("uuid") + + upload, err := packages_model.GetBlobUploadByID(ctx, uuid) + if err != nil { + if err == packages_model.ErrPackageBlobUploadNotExist { + apiErrorDefined(ctx, errBlobUploadUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Range: fmt.Sprintf("0-%d", upload.BytesReceived), + UploadUUID: upload.ID, + Status: http.StatusNoContent, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks +func UploadBlob(ctx *context.Context) { + image := ctx.Params("image") + + uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) + if err != nil { + if err == packages_model.ErrPackageBlobUploadNotExist { + apiErrorDefined(ctx, errBlobUploadUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + defer uploader.Close() + + contentRange := ctx.Req.Header.Get("Content-Range") + if contentRange != "" { + start, end := 0, 0 + if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil { + apiErrorDefined(ctx, errBlobUploadInvalid) + return + } + + if int64(start) != uploader.Size() { + apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable)) + return + } + } else if uploader.Size() != 0 { + apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed")) + return + } + + if err := uploader.Append(ctx, ctx.Req.Body); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID), + Range: fmt.Sprintf("0-%d", uploader.Size()-1), + UploadUUID: uploader.ID, + Status: http.StatusAccepted, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks +func EndUploadBlob(ctx *context.Context) { + image := ctx.Params("image") + + digest := ctx.FormTrim("digest") + if digest == "" { + apiErrorDefined(ctx, errDigestInvalid) + return + } + + uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) + if err != nil { + if err == packages_model.ErrPackageBlobUploadNotExist { + apiErrorDefined(ctx, errBlobUploadUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + doClose := true + defer func() { + if doClose { + uploader.Close() + } + }() + + if ctx.Req.Body != nil { + if err := uploader.Append(ctx, ctx.Req.Body); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + if digest != digestFromHashSummer(uploader) { + apiErrorDefined(ctx, errDigestInvalid) + return + } + + if _, err := saveAsPackageBlob(ctx, + uploader, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + Name: image, + }, + Creator: ctx.Doer, + }, + ); err != nil { + switch err { + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := uploader.Close(); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + doClose = false + + if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest), + ContentDigest: digest, + Status: http.StatusCreated, + }) +} + +// https://docs.docker.com/registry/spec/api/#delete-blob-upload +func CancelUploadBlob(ctx *context.Context) { + uuid := ctx.Params("uuid") + + _, err := packages_model.GetBlobUploadByID(ctx, uuid) + if err != nil { + if err == packages_model.ErrPackageBlobUploadNotExist { + apiErrorDefined(ctx, errBlobUploadUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := container_service.RemoveBlobUploadByID(ctx, uuid); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: http.StatusNoContent, + }) +} + +func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { + d := ctx.Params("digest") + + if digest.Digest(d).Validate() != nil { + return nil, container_model.ErrContainerBlobNotExist + } + + return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Image: ctx.Params("image"), + Digest: d, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry +func HeadBlob(ctx *context.Context) { + blob, err := getBlobFromContext(ctx) + if err != nil { + if err == container_model.ErrContainerBlobNotExist { + apiErrorDefined(ctx, errBlobUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest), + ContentLength: blob.Blob.Size, + Status: http.StatusOK, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs +func GetBlob(ctx *context.Context) { + blob, err := getBlobFromContext(ctx) + if err != nil { + if err == container_model.ErrContainerBlobNotExist { + apiErrorDefined(ctx, errBlobUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + serveBlob(ctx, blob) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs +func DeleteBlob(ctx *context.Context) { + d := ctx.Params("digest") + + if digest.Digest(d).Validate() != nil { + apiErrorDefined(ctx, errBlobUnknown) + return + } + + if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.Params("image"), d); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: http.StatusAccepted, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests +func UploadManifest(ctx *context.Context) { + reference := ctx.Params("reference") + + mci := &manifestCreationInfo{ + MediaType: ctx.Req.Header.Get("Content-Type"), + Owner: ctx.Package.Owner, + Creator: ctx.Doer, + Image: ctx.Params("image"), + Reference: reference, + IsTagged: digest.Digest(reference).Validate() != nil, + } + + if mci.IsTagged && !referencePattern.MatchString(reference) { + apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid")) + return + } + + maxSize := maxManifestSize + 1 + buf, err := packages_module.CreateHashedBufferFromReaderWithSize(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + if buf.Size() > maxManifestSize { + apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge)) + return + } + + digest, err := processManifest(ctx, mci, buf) + if err != nil { + var namedError *namedError + if errors.As(err, &namedError) { + apiErrorDefined(ctx, namedError) + } else if errors.Is(err, container_model.ErrContainerBlobNotExist) { + apiErrorDefined(ctx, errBlobUnknown) + } else { + switch err { + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + } + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference), + ContentDigest: digest, + Status: http.StatusCreated, + }) +} + +func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.BlobSearchOptions, error) { + reference := ctx.Params("reference") + + opts := &container_model.BlobSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Image: ctx.Params("image"), + IsManifest: true, + } + + if digest.Digest(reference).Validate() == nil { + opts.Digest = reference + } else if referencePattern.MatchString(reference) { + opts.Tag = reference + } else { + return nil, container_model.ErrContainerBlobNotExist + } + + return opts, nil +} + +func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { + opts, err := getBlobSearchOptionsFromContext(ctx) + if err != nil { + return nil, err + } + + return workaroundGetContainerBlob(ctx, opts) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry +func HeadManifest(ctx *context.Context) { + manifest, err := getManifestFromContext(ctx) + if err != nil { + if err == container_model.ErrContainerBlobNotExist { + apiErrorDefined(ctx, errManifestUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest), + ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType), + ContentLength: manifest.Blob.Size, + Status: http.StatusOK, + }) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests +func GetManifest(ctx *context.Context) { + manifest, err := getManifestFromContext(ctx) + if err != nil { + if err == container_model.ErrContainerBlobNotExist { + apiErrorDefined(ctx, errManifestUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + serveBlob(ctx, manifest) +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests +func DeleteManifest(ctx *context.Context) { + opts, err := getBlobSearchOptionsFromContext(ctx) + if err != nil { + apiErrorDefined(ctx, errManifestUnknown) + return + } + + pvs, err := container_model.GetManifestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiErrorDefined(ctx, errManifestUnknown) + return + } + + for _, pv := range pvs { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: http.StatusAccepted, + }) +} + +func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { + s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + headers := &containerHeaders{ + ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest), + ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), + ContentLength: pfd.Blob.Size, + Status: http.StatusOK, + } + + if u != nil { + headers.Status = http.StatusTemporaryRedirect + headers.Location = u.String() + + setResponseHeaders(ctx.Resp, headers) + return + } + + defer s.Close() + + setResponseHeaders(ctx.Resp, headers) + if _, err := io.Copy(ctx.Resp, s); err != nil { + log.Error("Error whilst copying content to response: %v", err) + } +} + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery +func GetTagList(ctx *context.Context) { + image := ctx.Params("image") + + if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { + if err == packages_model.ErrPackageNotExist { + apiErrorDefined(ctx, errNameUnknown) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + n := -1 + if ctx.FormTrim("n") != "" { + n = ctx.FormInt("n") + } + last := ctx.FormTrim("last") + + tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type TagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + + if len(tags) > 0 { + v := url.Values{} + if n > 0 { + v.Add("n", strconv.Itoa(n)) + } + v.Add("last", tags[len(tags)-1]) + + ctx.Resp.Header().Set("Link", fmt.Sprintf(`; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode())) + } + + jsonResponse(ctx, http.StatusOK, TagList{ + Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image), + Tags: tags, + }) +} + +// FIXME: Workaround to be removed in v1.20 +// https://github.com/go-gitea/gitea/issues/19586 +func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) { + blob, err := container_model.GetContainerBlob(ctx, opts) + if err != nil { + return nil, err + } + + err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(blob.Blob.HashSHA256)) + if err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + log.Debug("Package registry inconsistent: blob %s does not exist on file system", blob.Blob.HashSHA256) + return nil, container_model.ErrContainerBlobNotExist + } + return nil, err + } + + return blob, nil +} diff --git a/routers/api/packages/container/errors.go b/routers/api/packages/container/errors.go new file mode 100644 index 0000000..1a9b0f3 --- /dev/null +++ b/routers/api/packages/container/errors.go @@ -0,0 +1,52 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "net/http" +) + +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes +var ( + errBlobUnknown = &namedError{Code: "BLOB_UNKNOWN", StatusCode: http.StatusNotFound} + errBlobUploadInvalid = &namedError{Code: "BLOB_UPLOAD_INVALID", StatusCode: http.StatusBadRequest} + errBlobUploadUnknown = &namedError{Code: "BLOB_UPLOAD_UNKNOWN", StatusCode: http.StatusNotFound} + errDigestInvalid = &namedError{Code: "DIGEST_INVALID", StatusCode: http.StatusBadRequest} + errManifestBlobUnknown = &namedError{Code: "MANIFEST_BLOB_UNKNOWN", StatusCode: http.StatusNotFound} + errManifestInvalid = &namedError{Code: "MANIFEST_INVALID", StatusCode: http.StatusBadRequest} + errManifestUnknown = &namedError{Code: "MANIFEST_UNKNOWN", StatusCode: http.StatusNotFound} + errNameInvalid = &namedError{Code: "NAME_INVALID", StatusCode: http.StatusBadRequest} + errNameUnknown = &namedError{Code: "NAME_UNKNOWN", StatusCode: http.StatusNotFound} + errSizeInvalid = &namedError{Code: "SIZE_INVALID", StatusCode: http.StatusBadRequest} + errUnauthorized = &namedError{Code: "UNAUTHORIZED", StatusCode: http.StatusUnauthorized} + errUnsupported = &namedError{Code: "UNSUPPORTED", StatusCode: http.StatusNotImplemented} +) + +type namedError struct { + Code string + StatusCode int + Message string +} + +func (e *namedError) Error() string { + return e.Message +} + +// WithMessage creates a new instance of the error with a different message +func (e *namedError) WithMessage(message string) *namedError { + return &namedError{ + Code: e.Code, + StatusCode: e.StatusCode, + Message: message, + } +} + +// WithStatusCode creates a new instance of the error with a different status code +func (e *namedError) WithStatusCode(statusCode int) *namedError { + return &namedError{ + Code: e.Code, + StatusCode: statusCode, + Message: e.Message, + } +} diff --git a/routers/api/packages/container/manifest.go b/routers/api/packages/container/manifest.go new file mode 100644 index 0000000..4a79a58 --- /dev/null +++ b/routers/api/packages/container/manifest.go @@ -0,0 +1,483 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + container_module "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" + packages_service "code.gitea.io/gitea/services/packages" + + digest "github.com/opencontainers/go-digest" + oci "github.com/opencontainers/image-spec/specs-go/v1" +) + +func isValidMediaType(mt string) bool { + return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.") +} + +func isImageManifestMediaType(mt string) bool { + return strings.EqualFold(mt, oci.MediaTypeImageManifest) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.v2+json") +} + +func isImageIndexMediaType(mt string) bool { + return strings.EqualFold(mt, oci.MediaTypeImageIndex) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.list.v2+json") +} + +// manifestCreationInfo describes a manifest to create +type manifestCreationInfo struct { + MediaType string + Owner *user_model.User + Creator *user_model.User + Image string + Reference string + IsTagged bool + Properties map[string]string +} + +func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { + var index oci.Index + if err := json.NewDecoder(buf).Decode(&index); err != nil { + return "", err + } + + if index.SchemaVersion != 2 { + return "", errUnsupported.WithMessage("Schema version is not supported") + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + return "", err + } + + if !isValidMediaType(mci.MediaType) { + mci.MediaType = index.MediaType + if !isValidMediaType(mci.MediaType) { + return "", errManifestInvalid.WithMessage("MediaType not recognized") + } + } + + if isImageManifestMediaType(mci.MediaType) { + return processImageManifest(ctx, mci, buf) + } else if isImageIndexMediaType(mci.MediaType) { + return processImageManifestIndex(ctx, mci, buf) + } + return "", errManifestInvalid +} + +func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { + manifestDigest := "" + + err := func() error { + var manifest oci.Manifest + if err := json.NewDecoder(buf).Decode(&manifest); err != nil { + return err + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + return err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + configDescriptor, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ + OwnerID: mci.Owner.ID, + Image: mci.Image, + Digest: string(manifest.Config.Digest), + }) + if err != nil { + return err + } + + configReader, err := packages_module.NewContentStore().Get(packages_module.BlobHash256Key(configDescriptor.Blob.HashSHA256)) + if err != nil { + return err + } + defer configReader.Close() + + metadata, err := container_module.ParseImageConfig(manifest.Config.MediaType, configReader) + if err != nil { + return err + } + + blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers)) + + blobReferences = append(blobReferences, &blobReference{ + Digest: manifest.Config.Digest, + MediaType: manifest.Config.MediaType, + File: configDescriptor, + ExpectedSize: manifest.Config.Size, + }) + + for _, layer := range manifest.Layers { + pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ + OwnerID: mci.Owner.ID, + Image: mci.Image, + Digest: string(layer.Digest), + }) + if err != nil { + return err + } + + blobReferences = append(blobReferences, &blobReference{ + Digest: layer.Digest, + MediaType: layer.MediaType, + File: pfd, + ExpectedSize: layer.Size, + }) + } + + pv, err := createPackageAndVersion(ctx, mci, metadata) + if err != nil { + return err + } + + uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_model.UploadVersion) + if err != nil && err != packages_model.ErrPackageNotExist { + return err + } + + for _, ref := range blobReferences { + if err := createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil { + return err + } + } + + pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf) + removeBlob := false + defer func() { + if removeBlob { + contentStore := packages_module.NewContentStore() + if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil { + log.Error("Error deleting package blob from content store: %v", err) + } + } + }() + if err != nil { + removeBlob = created + return err + } + + if err := committer.Commit(); err != nil { + removeBlob = created + return err + } + + if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil { + return err + } + + manifestDigest = digest + + return nil + }() + if err != nil { + return "", err + } + + return manifestDigest, nil +} + +func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { + manifestDigest := "" + + err := func() error { + var index oci.Index + if err := json.NewDecoder(buf).Decode(&index); err != nil { + return err + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + return err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + metadata := &container_module.Metadata{ + Type: container_module.TypeOCI, + Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)), + } + + for _, manifest := range index.Manifests { + if !isImageManifestMediaType(manifest.MediaType) { + return errManifestInvalid + } + + platform := container_module.DefaultPlatform + if manifest.Platform != nil { + platform = fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture) + if manifest.Platform.Variant != "" { + platform = fmt.Sprintf("%s/%s", platform, manifest.Platform.Variant) + } + } + + pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ + OwnerID: mci.Owner.ID, + Image: mci.Image, + Digest: string(manifest.Digest), + IsManifest: true, + }) + if err != nil { + if err == container_model.ErrContainerBlobNotExist { + return errManifestBlobUnknown + } + return err + } + + size, err := packages_model.CalculateFileSize(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pfd.File.VersionID, + }) + if err != nil { + return err + } + + metadata.Manifests = append(metadata.Manifests, &container_module.Manifest{ + Platform: platform, + Digest: string(manifest.Digest), + Size: size, + }) + } + + pv, err := createPackageAndVersion(ctx, mci, metadata) + if err != nil { + return err + } + + pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf) + removeBlob := false + defer func() { + if removeBlob { + contentStore := packages_module.NewContentStore() + if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil { + log.Error("Error deleting package blob from content store: %v", err) + } + } + }() + if err != nil { + removeBlob = created + return err + } + + if err := committer.Commit(); err != nil { + removeBlob = created + return err + } + + if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil { + return err + } + + manifestDigest = digest + + return nil + }() + if err != nil { + return "", err + } + + return manifestDigest, nil +} + +func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *packages_model.PackageVersion) error { + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + return err + } + + notify_service.PackageCreate(ctx, doer, pd) + + return nil +} + +func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) { + created := true + p := &packages_model.Package{ + OwnerID: mci.Owner.ID, + Type: packages_model.TypeContainer, + Name: strings.ToLower(mci.Image), + LowerName: strings.ToLower(mci.Image), + } + var err error + if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { + if err == packages_model.ErrDuplicatePackage { + created = false + } else { + log.Error("Error inserting package: %v", err) + return nil, err + } + } + + if created { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(mci.Owner.LowerName+"/"+mci.Image)); err != nil { + log.Error("Error setting package property: %v", err) + return nil, err + } + } + + metadata.IsTagged = mci.IsTagged + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + + _pv := &packages_model.PackageVersion{ + PackageID: p.ID, + CreatorID: mci.Creator.ID, + Version: strings.ToLower(mci.Reference), + LowerVersion: strings.ToLower(mci.Reference), + MetadataJSON: string(metadataJSON), + } + var pv *packages_model.PackageVersion + if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil { + if err == packages_model.ErrDuplicatePackageVersion { + if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { + return nil, err + } + + // keep download count on overwrite + _pv.DownloadCount = pv.DownloadCount + + if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil { + log.Error("Error inserting package: %v", err) + return nil, err + } + } else { + log.Error("Error inserting package: %v", err) + return nil, err + } + } + + if err := packages_service.CheckCountQuotaExceeded(ctx, mci.Creator, mci.Owner); err != nil { + return nil, err + } + + if mci.IsTagged { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil { + log.Error("Error setting package version property: %v", err) + return nil, err + } + } + for _, manifest := range metadata.Manifests { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil { + log.Error("Error setting package version property: %v", err) + return nil, err + } + } + + return pv, nil +} + +type blobReference struct { + Digest digest.Digest + MediaType string + Name string + File *packages_model.PackageFileDescriptor + ExpectedSize int64 + IsLead bool +} + +func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) error { + if ref.File.Blob.Size != ref.ExpectedSize { + return errSizeInvalid + } + + if ref.Name == "" { + ref.Name = strings.ToLower(fmt.Sprintf("sha256_%s", ref.File.Blob.HashSHA256)) + } + + pf := &packages_model.PackageFile{ + VersionID: pv.ID, + BlobID: ref.File.Blob.ID, + Name: ref.Name, + LowerName: ref.Name, + IsLead: ref.IsLead, + } + var err error + if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil { + if err == packages_model.ErrDuplicatePackageFile { + // Skip this blob because the manifest contains the same filesystem layer multiple times. + return nil + } + log.Error("Error inserting package file: %v", err) + return err + } + + props := map[string]string{ + container_module.PropertyMediaType: ref.MediaType, + container_module.PropertyDigest: string(ref.Digest), + } + for name, value := range props { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, name, value); err != nil { + log.Error("Error setting package file property: %v", err) + return err + } + } + + // Remove the file from the blob upload version + if uploadVersion != nil && ref.File.File != nil && uploadVersion.ID == ref.File.File.VersionID { + if err := packages_service.DeletePackageFile(ctx, ref.File.File); err != nil { + return err + } + } + + return nil +} + +func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (*packages_model.PackageBlob, bool, string, error) { + pb, exists, err := packages_model.GetOrInsertBlob(ctx, packages_service.NewPackageBlob(buf)) + if err != nil { + log.Error("Error inserting package blob: %v", err) + return nil, false, "", err + } + // FIXME: Workaround to be removed in v1.20 + // https://github.com/go-gitea/gitea/issues/19586 + if exists { + err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(pb.HashSHA256)) + if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) { + log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256) + exists = false + } + } + if !exists { + contentStore := packages_module.NewContentStore() + if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), buf, buf.Size()); err != nil { + log.Error("Error saving package blob in content store: %v", err) + return nil, false, "", err + } + } + + manifestDigest := digestFromHashSummer(buf) + err = createFileFromBlobReference(ctx, pv, nil, &blobReference{ + Digest: digest.Digest(manifestDigest), + MediaType: mci.MediaType, + Name: container_model.ManifestFilename, + File: &packages_model.PackageFileDescriptor{Blob: pb}, + ExpectedSize: pb.Size, + IsLead: true, + }) + + return pb, !exists, manifestDigest, err +} diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go new file mode 100644 index 0000000..f1d6167 --- /dev/null +++ b/routers/api/packages/cran/cran.go @@ -0,0 +1,264 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cran + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + cran_model "code.gitea.io/gitea/models/packages/cran" + packages_module "code.gitea.io/gitea/modules/packages" + cran_module "code.gitea.io/gitea/modules/packages/cran" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func EnumerateSourcePackages(ctx *context.Context) { + enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeSource, + }) +} + +func EnumerateBinaryPackages(ctx *context.Context) { + enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeBinary, + Platform: ctx.Params("platform"), + RVersion: ctx.Params("rversion"), + }) +} + +func enumeratePackages(ctx *context.Context, format string, opts *cran_model.SearchOptions) { + if format != "" && format != ".gz" { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pvs, err := cran_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + var w io.Writer = ctx.Resp + + if format == ".gz" { + ctx.Resp.Header().Set("Content-Type", "application/x-gzip") + + gzw := gzip.NewWriter(w) + defer gzw.Close() + + w = gzw + } else { + ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") + } + ctx.Resp.WriteHeader(http.StatusOK) + + for i, pd := range pds { + if i > 0 { + fmt.Fprintln(w) + } + + var pfd *packages_model.PackageFileDescriptor + for _, d := range pd.Files { + if d.Properties.GetByName(cran_module.PropertyType) == opts.FileType && + d.Properties.GetByName(cran_module.PropertyPlatform) == opts.Platform && + d.Properties.GetByName(cran_module.PropertyRVersion) == opts.RVersion { + pfd = d + break + } + } + + metadata := pd.Metadata.(*cran_module.Metadata) + + fmt.Fprintln(w, "Package:", pd.Package.Name) + fmt.Fprintln(w, "Version:", pd.Version.Version) + if metadata.License != "" { + fmt.Fprintln(w, "License:", metadata.License) + } + if len(metadata.Depends) > 0 { + fmt.Fprintln(w, "Depends:", strings.Join(metadata.Depends, ", ")) + } + if len(metadata.Imports) > 0 { + fmt.Fprintln(w, "Imports:", strings.Join(metadata.Imports, ", ")) + } + if len(metadata.LinkingTo) > 0 { + fmt.Fprintln(w, "LinkingTo:", strings.Join(metadata.LinkingTo, ", ")) + } + if len(metadata.Suggests) > 0 { + fmt.Fprintln(w, "Suggests:", strings.Join(metadata.Suggests, ", ")) + } + needsCompilation := "no" + if metadata.NeedsCompilation { + needsCompilation = "yes" + } + fmt.Fprintln(w, "NeedsCompilation:", needsCompilation) + fmt.Fprintln(w, "MD5sum:", pfd.Blob.HashMD5) + } +} + +func UploadSourcePackageFile(ctx *context.Context) { + uploadPackageFile( + ctx, + packages_model.EmptyFileKey, + map[string]string{ + cran_module.PropertyType: cran_module.TypeSource, + }, + ) +} + +func UploadBinaryPackageFile(ctx *context.Context) { + platform, rversion := ctx.FormTrim("platform"), ctx.FormTrim("rversion") + if platform == "" || rversion == "" { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + uploadPackageFile( + ctx, + platform+"|"+rversion, + map[string]string{ + cran_module.PropertyType: cran_module.TypeBinary, + cran_module.PropertyPlatform: platform, + cran_module.PropertyRVersion: rversion, + }, + ) +} + +func uploadPackageFile(ctx *context.Context, compositeKey string, properties map[string]string) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := cran_module.ParsePackage(buf, buf.Size()) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeCran, + Name: pck.Name, + Version: pck.Version, + }, + SemverCompatible: false, + Creator: ctx.Doer, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s_%s%s", pck.Name, pck.Version, pck.FileExtension), + CompositeKey: compositeKey, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: properties, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadSourcePackageFile(ctx *context.Context) { + downloadPackageFile(ctx, &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeSource, + Filename: ctx.Params("filename"), + }) +} + +func DownloadBinaryPackageFile(ctx *context.Context) { + downloadPackageFile(ctx, &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeBinary, + Platform: ctx.Params("platform"), + RVersion: ctx.Params("rversion"), + Filename: ctx.Params("filename"), + }) +} + +func downloadPackageFile(ctx *context.Context, opts *cran_model.SearchOptions) { + pf, err := cran_model.SearchFile(ctx, opts) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/api/packages/debian/debian.go b/routers/api/packages/debian/debian.go new file mode 100644 index 0000000..8c05476 --- /dev/null +++ b/routers/api/packages/debian/debian.go @@ -0,0 +1,309 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package debian + +import ( + stdctx "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + packages_module "code.gitea.io/gitea/modules/packages" + debian_module "code.gitea.io/gitea/modules/packages/debian" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + notify_service "code.gitea.io/gitea/services/notify" + packages_service "code.gitea.io/gitea/services/packages" + debian_service "code.gitea.io/gitea/services/packages/debian" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func GetRepositoryKey(ctx *context.Context) { + _, pub, err := debian_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{ + ContentType: "application/pgp-keys", + Filename: "repository.key", + }) +} + +// https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files +// https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices +func GetRepositoryFile(ctx *context.Context) { + pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + key := ctx.Params("distribution") + + component := ctx.Params("component") + architecture := strings.TrimPrefix(ctx.Params("architecture"), "binary-") + if component != "" && architecture != "" { + key += "|" + component + "|" + architecture + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pv, + &packages_service.PackageFileInfo{ + Filename: ctx.Params("filename"), + CompositeKey: key, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// https://wiki.debian.org/DebianRepository/Format#indices_acquisition_via_hashsums_.28by-hash.29 +func GetRepositoryFileByHash(ctx *context.Context) { + pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + algorithm := strings.ToLower(ctx.Params("algorithm")) + if algorithm == "md5sum" { + algorithm = "md5" + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + VersionID: pv.ID, + Hash: strings.ToLower(ctx.Params("hash")), + HashAlgorithm: algorithm, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func UploadPackageFile(ctx *context.Context) { + distribution := strings.TrimSpace(ctx.Params("distribution")) + component := strings.TrimSpace(ctx.Params("component")) + if distribution == "" || component == "" { + apiError(ctx, http.StatusBadRequest, "invalid distribution or component") + return + } + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := debian_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeDebian, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s_%s_%s.deb", pck.Name, pck.Version, pck.Architecture), + CompositeKey: fmt.Sprintf("%s|%s", distribution, component), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: map[string]string{ + debian_module.PropertyDistribution: distribution, + debian_module.PropertyComponent: component, + debian_module.PropertyArchitecture: pck.Architecture, + debian_module.PropertyControl: pck.Control, + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, pck.Architecture); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadPackageFile(ctx *context.Context) { + name := ctx.Params("name") + version := ctx.Params("version") + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeDebian, + Name: name, + Version: version, + }, + &packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s_%s_%s.deb", name, version, ctx.Params("architecture")), + CompositeKey: fmt.Sprintf("%s|%s", ctx.Params("distribution"), ctx.Params("component")), + }, + ) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{ + ContentType: "application/vnd.debian.binary-package", + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + }) +} + +func DeletePackageFile(ctx *context.Context) { + distribution := ctx.Params("distribution") + component := ctx.Params("component") + name := ctx.Params("name") + version := ctx.Params("version") + architecture := ctx.Params("architecture") + + owner := ctx.Package.Owner + + var pd *packages_model.PackageDescriptor + + err := db.WithTx(ctx, func(ctx stdctx.Context) error { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, owner.ID, packages_model.TypeDebian, name, version) + if err != nil { + return err + } + + pf, err := packages_model.GetFileForVersionByName( + ctx, + pv.ID, + fmt.Sprintf("%s_%s_%s.deb", name, version, architecture), + fmt.Sprintf("%s|%s", distribution, component), + ) + if err != nil { + return err + } + + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + return err + } + + has, err := packages_model.HasVersionFileReferences(ctx, pv.ID) + if err != nil { + return err + } + if !has { + pd, err = packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + return err + } + + if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { + return err + } + } + + return nil + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if pd != nil { + notify_service.PackageDelete(ctx, ctx.Doer, pd) + } + + if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, architecture); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/generic/generic.go b/routers/api/packages/generic/generic.go new file mode 100644 index 0000000..e66f3ee --- /dev/null +++ b/routers/api/packages/generic/generic.go @@ -0,0 +1,212 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package generic + +import ( + "errors" + "net/http" + "regexp" + "strings" + "unicode" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +var ( + packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) + filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +// DownloadPackageFile serves the specific generic package. +func DownloadPackageFile(ctx *context.Context) { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeGeneric, + Name: ctx.Params("packagename"), + Version: ctx.Params("packageversion"), + }, + &packages_service.PackageFileInfo{ + Filename: ctx.Params("filename"), + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func isValidPackageName(packageName string) bool { + if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) { + return false + } + return packageNameRegex.MatchString(packageName) && packageName != ".." +} + +func isValidFileName(filename string) bool { + return filenameRegex.MatchString(filename) && + strings.TrimSpace(filename) == filename && + filename != "." && filename != ".." +} + +// UploadPackage uploads the specific generic package. +// Duplicated packages get rejected. +func UploadPackage(ctx *context.Context) { + packageName := ctx.Params("packagename") + filename := ctx.Params("filename") + + if !isValidPackageName(packageName) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) + return + } + + if !isValidFileName(filename) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid filename")) + return + } + + packageVersion := ctx.Params("packageversion") + if packageVersion != strings.TrimSpace(packageVersion) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package version")) + return + } + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + log.Error("Error creating hashed buffer: %v", err) + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeGeneric, + Name: packageName, + Version: packageVersion, + }, + Creator: ctx.Doer, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: filename, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +// DeletePackage deletes the specific generic package. +func DeletePackage(ctx *context.Context) { + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeGeneric, + Name: ctx.Params("packagename"), + Version: ctx.Params("packageversion"), + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeletePackageFile deletes the specific file of a generic package. +func DeletePackageFile(ctx *context.Context) { + pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeGeneric, ctx.Params("packagename"), ctx.Params("packageversion")) + if err != nil { + return nil, nil, err + } + + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.Params("filename"), packages_model.EmptyFileKey) + if err != nil { + return nil, nil, err + } + + return pv, pf, nil + }() + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) == 1 { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } else { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/generic/generic_test.go b/routers/api/packages/generic/generic_test.go new file mode 100644 index 0000000..1acaafe --- /dev/null +++ b/routers/api/packages/generic/generic_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package generic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatePackageName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "-", + "a?b", + "a b", + "a/b", + } + for _, name := range bad { + assert.False(t, isValidPackageName(name), "bad=%q", name) + } + + good := []string{ + "a", + "1", + "a-", + "a_b", + "c.d+", + } + for _, name := range good { + assert.True(t, isValidPackageName(name), "good=%q", name) + } +} + +func TestValidateFileName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "a?b", + "a/b", + " a", + "a ", + } + for _, name := range bad { + assert.False(t, isValidFileName(name), "bad=%q", name) + } + + good := []string{ + "-", + "a", + "1", + "a-", + "a_b", + "a b", + "c.d+", + `-_+=:;.()[]{}~!@#$%^& aA1`, + } + for _, name := range good { + assert.True(t, isValidFileName(name), "good=%q", name) + } +} diff --git a/routers/api/packages/goproxy/goproxy.go b/routers/api/packages/goproxy/goproxy.go new file mode 100644 index 0000000..56a07db --- /dev/null +++ b/routers/api/packages/goproxy/goproxy.go @@ -0,0 +1,224 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package goproxy + +import ( + "errors" + "fmt" + "io" + "net/http" + "sort" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + goproxy_module "code.gitea.io/gitea/modules/packages/goproxy" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func EnumeratePackageVersions(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeGo, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + sort.Slice(pvs, func(i, j int) bool { + return pvs[i].CreatedUnix < pvs[j].CreatedUnix + }) + + ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") + + for _, pv := range pvs { + fmt.Fprintln(ctx.Resp, pv.Version) + } +} + +func PackageVersionMetadata(ctx *context.Context) { + pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.JSON(http.StatusOK, struct { + Version string `json:"Version"` + Time time.Time `json:"Time"` + }{ + Version: pv.Version, + Time: pv.CreatedUnix.AsLocalTime(), + }) +} + +func PackageVersionGoModContent(ctx *context.Context) { + pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, goproxy_module.PropertyGoMod) + if err != nil || len(pps) != 1 { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.PlainText(http.StatusOK, pps[0].Value) +} + +func DownloadPackageFile(ctx *context.Context) { + pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + if err != nil || len(pfs) != 1 { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pfs[0]) +} + +func resolvePackage(ctx *context.Context, ownerID int64, name, version string) (*packages_model.PackageVersion, error) { + var pv *packages_model.PackageVersion + + if version == "latest" { + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ownerID, + Type: packages_model.TypeGo, + Name: packages_model.SearchValue{ + Value: name, + ExactMatch: true, + }, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) + if err != nil { + return nil, err + } + + if len(pvs) != 1 { + return nil, packages_model.ErrPackageNotExist + } + + pv = pvs[0] + } else { + var err error + pv, err = packages_model.GetVersionByNameAndVersion(ctx, ownerID, packages_model.TypeGo, name, version) + if err != nil { + return nil, err + } + } + + return pv, nil +} + +func UploadPackage(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := goproxy_module.ParsePackage(buf, buf.Size()) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeGo, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + VersionProperties: map[string]string{ + goproxy_module.PropertyGoMod: pck.GoMod, + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%v.zip", pck.Version), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go new file mode 100644 index 0000000..efdb83e --- /dev/null +++ b/routers/api/packages/helm/helm.go @@ -0,0 +1,217 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package helm + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + helm_module "code.gitea.io/gitea/modules/packages/helm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + + "gopkg.in/yaml.v3" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + type Error struct { + Error string `json:"error"` + } + ctx.JSON(status, Error{ + Error: message, + }) + }) +} + +// Index generates the Helm charts index +func Index(ctx *context.Context) { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeHelm, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + baseURL := setting.AppURL + "api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm" + + type ChartVersion struct { + helm_module.Metadata `yaml:",inline"` + URLs []string `yaml:"urls"` + Created time.Time `yaml:"created,omitempty"` + Removed bool `yaml:"removed,omitempty"` + Digest string `yaml:"digest,omitempty"` + } + + type ServerInfo struct { + ContextPath string `yaml:"contextPath,omitempty"` + } + + type Index struct { + APIVersion string `yaml:"apiVersion"` + Entries map[string][]*ChartVersion `yaml:"entries"` + Generated time.Time `yaml:"generated,omitempty"` + ServerInfo *ServerInfo `yaml:"serverInfo,omitempty"` + } + + entries := make(map[string][]*ChartVersion) + for _, pv := range pvs { + metadata := &helm_module.Metadata{} + if err := json.Unmarshal([]byte(pv.MetadataJSON), &metadata); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + entries[metadata.Name] = append(entries[metadata.Name], &ChartVersion{ + Metadata: *metadata, + Created: pv.CreatedUnix.AsTime(), + URLs: []string{fmt.Sprintf("%s/%s", baseURL, url.PathEscape(createFilename(metadata)))}, + }) + } + + ctx.Resp.WriteHeader(http.StatusOK) + if err := yaml.NewEncoder(ctx.Resp).Encode(&Index{ + APIVersion: "v1", + Entries: entries, + Generated: time.Now(), + ServerInfo: &ServerInfo{ + ContextPath: setting.AppSubURL + "/api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm", + }, + }); err != nil { + log.Error("YAML encode failed: %v", err) + } +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + filename := ctx.Params("filename") + + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeHelm, + Name: packages_model.SearchValue{ + ExactMatch: true, + Value: ctx.Params("package"), + }, + HasFileWithName: filename, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pvs[0], + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackage creates a new package +func UploadPackage(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + metadata, err := helm_module.ParseChartArchive(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeHelm, + Name: metadata.Name, + Version: metadata.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: createFilename(metadata), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + OverwriteExisting: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func createFilename(metadata *helm_module.Metadata) string { + return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)) +} diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go new file mode 100644 index 0000000..cdb6410 --- /dev/null +++ b/routers/api/packages/helper/helper.go @@ -0,0 +1,63 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package helper + +import ( + "fmt" + "io" + "net/http" + "net/url" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// LogAndProcessError logs an error and calls a custom callback with the processed error message. +// If the error is an InternalServerError the message is stripped if the user is not an admin. +func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(string)) { + var message string + if err, ok := obj.(error); ok { + message = err.Error() + } else if obj != nil { + message = fmt.Sprintf("%s", obj) + } + if status == http.StatusInternalServerError { + log.ErrorWithSkip(1, message) + + if setting.IsProd && (ctx.Doer == nil || !ctx.Doer.IsAdmin) { + message = "" + } + } else { + log.Debug(message) + } + + if cb != nil { + cb(message) + } +} + +// Serves the content of the package file +// If the url is set it will redirect the request, otherwise the content is copied to the response. +func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) { + if u != nil { + ctx.Redirect(u.String()) + return + } + + defer s.Close() + + var opts *context.ServeHeaderOptions + if len(forceOpts) > 0 { + opts = forceOpts[0] + } else { + opts = &context.ServeHeaderOptions{ + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + } + } + + ctx.ServeContent(s, opts) +} diff --git a/routers/api/packages/maven/api.go b/routers/api/packages/maven/api.go new file mode 100644 index 0000000..167fe42 --- /dev/null +++ b/routers/api/packages/maven/api.go @@ -0,0 +1,50 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package maven + +import ( + "encoding/xml" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + maven_module "code.gitea.io/gitea/modules/packages/maven" +) + +// MetadataResponse https://maven.apache.org/ref/3.2.5/maven-repository-metadata/repository-metadata.html +type MetadataResponse struct { + XMLName xml.Name `xml:"metadata"` + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Release string `xml:"versioning>release,omitempty"` + Latest string `xml:"versioning>latest"` + Version []string `xml:"versioning>versions>version"` +} + +// pds is expected to be sorted ascending by CreatedUnix +func createMetadataResponse(pds []*packages_model.PackageDescriptor) *MetadataResponse { + var release *packages_model.PackageDescriptor + + versions := make([]string, 0, len(pds)) + for _, pd := range pds { + if !strings.HasSuffix(pd.Version.Version, "-SNAPSHOT") { + release = pd + } + versions = append(versions, pd.Version.Version) + } + + latest := pds[len(pds)-1] + + metadata := latest.Metadata.(*maven_module.Metadata) + + resp := &MetadataResponse{ + GroupID: metadata.GroupID, + ArtifactID: metadata.ArtifactID, + Latest: latest.Version.Version, + Version: versions, + } + if release != nil { + resp.Release = release.Version.Version + } + return resp +} diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go new file mode 100644 index 0000000..4181577 --- /dev/null +++ b/routers/api/packages/maven/maven.go @@ -0,0 +1,433 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package maven + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "encoding/xml" + "errors" + "io" + "net/http" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + maven_module "code.gitea.io/gitea/modules/packages/maven" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +const ( + mavenMetadataFile = "maven-metadata.xml" + extensionMD5 = ".md5" + extensionSHA1 = ".sha1" + extensionSHA256 = ".sha256" + extensionSHA512 = ".sha512" + extensionPom = ".pom" + extensionJar = ".jar" + contentTypeJar = "application/java-archive" + contentTypeXML = "text/xml" +) + +var ( + errInvalidParameters = errors.New("request parameters are invalid") + illegalCharacters = regexp.MustCompile(`[\\/:"<>|?\*]`) +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + // The maven client does not present the error message to the user. Log it for users with access to server logs. + switch status { + case http.StatusBadRequest: + log.Warn(message) + case http.StatusInternalServerError: + log.Error(message) + } + + ctx.PlainText(status, message) + }) +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + handlePackageFile(ctx, true) +} + +// ProvidePackageFileHeader provides only the headers describing a package +func ProvidePackageFileHeader(ctx *context.Context) { + handlePackageFile(ctx, false) +} + +func handlePackageFile(ctx *context.Context, serveContent bool) { + params, err := extractPathParameters(ctx) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + if params.IsMeta && params.Version == "" { + serveMavenMetadata(ctx, params) + } else { + servePackageFile(ctx, params, serveContent) + } +} + +func serveMavenMetadata(ctx *context.Context, params parameters) { + // /com/foo/project/maven-metadata.xml[.md5/.sha1/.sha256/.sha512] + + packageName := params.GroupID + "-" + params.ArtifactID + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + // Maven and Gradle order packages by their creation timestamp and not by their version string + return pds[i].Version.CreatedUnix < pds[j].Version.CreatedUnix + }) + + xmlMetadata, err := xml.Marshal(createMetadataResponse(pds)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + xmlMetadataWithHeader := append([]byte(xml.Header), xmlMetadata...) + + latest := pds[len(pds)-1] + // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat + lastModifed := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat) + ctx.Resp.Header().Set("Last-Modified", lastModifed) + + ext := strings.ToLower(filepath.Ext(params.Filename)) + if isChecksumExtension(ext) { + var hash []byte + switch ext { + case extensionMD5: + tmp := md5.Sum(xmlMetadataWithHeader) + hash = tmp[:] + case extensionSHA1: + tmp := sha1.Sum(xmlMetadataWithHeader) + hash = tmp[:] + case extensionSHA256: + tmp := sha256.Sum256(xmlMetadataWithHeader) + hash = tmp[:] + case extensionSHA512: + tmp := sha512.Sum512(xmlMetadataWithHeader) + hash = tmp[:] + } + ctx.PlainText(http.StatusOK, hex.EncodeToString(hash)) + return + } + + ctx.Resp.Header().Set("Content-Length", strconv.Itoa(len(xmlMetadataWithHeader))) + ctx.Resp.Header().Set("Content-Type", contentTypeXML) + + _, _ = ctx.Resp.Write(xmlMetadataWithHeader) +} + +func servePackageFile(ctx *context.Context, params parameters, serveContent bool) { + packageName := params.GroupID + "-" + params.ArtifactID + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName, params.Version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + filename := params.Filename + + ext := strings.ToLower(filepath.Ext(filename)) + if isChecksumExtension(ext) { + filename = filename[:len(filename)-len(ext)] + } + + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, filename, packages_model.EmptyFileKey) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pb, err := packages_model.GetBlobByID(ctx, pf.BlobID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if isChecksumExtension(ext) { + var hash string + switch ext { + case extensionMD5: + hash = pb.HashMD5 + case extensionSHA1: + hash = pb.HashSHA1 + case extensionSHA256: + hash = pb.HashSHA256 + case extensionSHA512: + hash = pb.HashSHA512 + } + ctx.PlainText(http.StatusOK, hash) + return + } + + opts := &context.ServeHeaderOptions{ + ContentLength: &pb.Size, + LastModified: pf.CreatedUnix.AsLocalTime(), + } + switch ext { + case extensionJar: + opts.ContentType = contentTypeJar + case extensionPom: + opts.ContentType = contentTypeXML + } + + if !serveContent { + ctx.SetServeHeaders(opts) + ctx.Status(http.StatusOK) + return + } + + s, u, _, err := packages_service.GetPackageBlobStream(ctx, pf, pb) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + opts.Filename = pf.Name + + helper.ServePackageFile(ctx, s, u, pf, opts) +} + +// UploadPackageFile adds a file to the package. If the package does not exist, it gets created. +func UploadPackageFile(ctx *context.Context) { + params, err := extractPathParameters(ctx) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + log.Trace("Parameters: %+v", params) + + // Ignore the package index //maven-metadata.xml + if params.IsMeta && params.Version == "" { + ctx.Status(http.StatusOK) + return + } + + packageName := params.GroupID + "-" + params.ArtifactID + + buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pvci := &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeMaven, + Name: packageName, + Version: params.Version, + }, + SemverCompatible: false, + Creator: ctx.Doer, + } + + ext := filepath.Ext(params.Filename) + + // Do not upload checksum files but compare the hashes. + if isChecksumExtension(ext) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, params.Filename[:len(params.Filename)-len(ext)], packages_model.EmptyFileKey) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + pb, err := packages_model.GetBlobByID(ctx, pf.BlobID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + hash, err := io.ReadAll(buf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if (ext == extensionMD5 && pb.HashMD5 != string(hash)) || + (ext == extensionSHA1 && pb.HashSHA1 != string(hash)) || + (ext == extensionSHA256 && pb.HashSHA256 != string(hash)) || + (ext == extensionSHA512 && pb.HashSHA512 != string(hash)) { + apiError(ctx, http.StatusBadRequest, "hash mismatch") + return + } + + ctx.Status(http.StatusOK) + return + } + + pfci := &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: params.Filename, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: false, + OverwriteExisting: params.IsMeta, + } + + // If it's the package pom file extract the metadata + if ext == extensionPom { + pfci.IsLead = true + + var err error + pvci.Metadata, err = maven_module.ParsePackageMetaData(buf) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + if pvci.Metadata != nil { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version) + if err != nil && err != packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if pv != nil { + raw, err := json.Marshal(pvci.Metadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + pv.MetadataJSON = string(raw) + if err := packages_model.UpdateVersion(ctx, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + pvci, + pfci, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func isChecksumExtension(ext string) bool { + return ext == extensionMD5 || ext == extensionSHA1 || ext == extensionSHA256 || ext == extensionSHA512 +} + +type parameters struct { + GroupID string + ArtifactID string + Version string + Filename string + IsMeta bool +} + +func extractPathParameters(ctx *context.Context) (parameters, error) { + parts := strings.Split(ctx.Params("*"), "/") + + p := parameters{ + Filename: parts[len(parts)-1], + } + + p.IsMeta = p.Filename == mavenMetadataFile || + p.Filename == mavenMetadataFile+extensionMD5 || + p.Filename == mavenMetadataFile+extensionSHA1 || + p.Filename == mavenMetadataFile+extensionSHA256 || + p.Filename == mavenMetadataFile+extensionSHA512 + + parts = parts[:len(parts)-1] + if len(parts) == 0 { + return p, errInvalidParameters + } + + p.Version = parts[len(parts)-1] + if p.IsMeta && !strings.HasSuffix(p.Version, "-SNAPSHOT") { + p.Version = "" + } else { + parts = parts[:len(parts)-1] + } + + if illegalCharacters.MatchString(p.Version) { + return p, errInvalidParameters + } + + if len(parts) < 2 { + return p, errInvalidParameters + } + + p.ArtifactID = parts[len(parts)-1] + p.GroupID = strings.Join(parts[:len(parts)-1], ".") + + if illegalCharacters.MatchString(p.GroupID) || illegalCharacters.MatchString(p.ArtifactID) { + return p, errInvalidParameters + } + + return p, nil +} diff --git a/routers/api/packages/npm/api.go b/routers/api/packages/npm/api.go new file mode 100644 index 0000000..b4379f3 --- /dev/null +++ b/routers/api/packages/npm/api.go @@ -0,0 +1,114 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package npm + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "sort" + + packages_model "code.gitea.io/gitea/models/packages" + npm_module "code.gitea.io/gitea/modules/packages/npm" + "code.gitea.io/gitea/modules/setting" +) + +func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *npm_module.PackageMetadata { + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + versions := make(map[string]*npm_module.PackageMetadataVersion) + distTags := make(map[string]string) + for _, pd := range pds { + versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd) + + for _, pvp := range pd.VersionProperties { + if pvp.Name == npm_module.TagProperty { + distTags[pvp.Value] = pd.Version.Version + } + } + } + + latest := pds[len(pds)-1] + + metadata := latest.Metadata.(*npm_module.Metadata) + + return &npm_module.PackageMetadata{ + ID: latest.Package.Name, + Name: latest.Package.Name, + DistTags: distTags, + Description: metadata.Description, + Readme: metadata.Readme, + Homepage: metadata.ProjectURL, + Author: npm_module.User{Name: metadata.Author}, + License: metadata.License, + Versions: versions, + Repository: metadata.Repository, + } +} + +func createPackageMetadataVersion(registryURL string, pd *packages_model.PackageDescriptor) *npm_module.PackageMetadataVersion { + hashBytes, _ := hex.DecodeString(pd.Files[0].Blob.HashSHA512) + + metadata := pd.Metadata.(*npm_module.Metadata) + + return &npm_module.PackageMetadataVersion{ + ID: fmt.Sprintf("%s@%s", pd.Package.Name, pd.Version.Version), + Name: pd.Package.Name, + Version: pd.Version.Version, + Description: metadata.Description, + Author: npm_module.User{Name: metadata.Author}, + Homepage: metadata.ProjectURL, + License: metadata.License, + Dependencies: metadata.Dependencies, + BundleDependencies: metadata.BundleDependencies, + DevDependencies: metadata.DevelopmentDependencies, + PeerDependencies: metadata.PeerDependencies, + OptionalDependencies: metadata.OptionalDependencies, + Readme: metadata.Readme, + Bin: metadata.Bin, + Dist: npm_module.PackageDistribution{ + Shasum: pd.Files[0].Blob.HashSHA1, + Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes), + Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.QueryEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)), + }, + } +} + +func createPackageSearchResponse(pds []*packages_model.PackageDescriptor, total int64) *npm_module.PackageSearch { + objects := make([]*npm_module.PackageSearchObject, 0, len(pds)) + for _, pd := range pds { + metadata := pd.Metadata.(*npm_module.Metadata) + + scope := metadata.Scope + if scope == "" { + scope = "unscoped" + } + + objects = append(objects, &npm_module.PackageSearchObject{ + Package: &npm_module.PackageSearchPackage{ + Scope: scope, + Name: metadata.Name, + Version: pd.Version.Version, + Date: pd.Version.CreatedUnix.AsLocalTime(), + Description: metadata.Description, + Author: npm_module.User{Name: metadata.Author}, + Publisher: npm_module.User{Name: pd.Owner.Name}, + Maintainers: []npm_module.User{}, // npm cli needs this field + Keywords: metadata.Keywords, + Links: &npm_module.PackageSearchPackageLinks{ + Registry: setting.AppURL + "api/packages/" + pd.Owner.Name + "/npm", + Homepage: metadata.ProjectURL, + }, + }, + }) + } + + return &npm_module.PackageSearch{ + Objects: objects, + Total: total, + } +} diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go new file mode 100644 index 0000000..84acfff --- /dev/null +++ b/routers/api/packages/npm/npm.go @@ -0,0 +1,462 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package npm + +import ( + "bytes" + std_ctx "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + npm_module "code.gitea.io/gitea/modules/packages/npm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/hashicorp/go-version" +) + +// errInvalidTagName indicates an invalid tag name +var errInvalidTagName = errors.New("The tag name is invalid") + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, map[string]string{ + "error": message, + }) + }) +} + +// packageNameFromParams gets the package name from the url parameters +// Variations: /name/, /@scope/name/, /@scope%2Fname/ +func packageNameFromParams(ctx *context.Context) string { + scope := ctx.Params("scope") + id := ctx.Params("id") + if scope != "" { + return fmt.Sprintf("@%s/%s", scope, id) + } + return id +} + +// PackageMetadata returns the metadata for a single package +func PackageMetadata(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createPackageMetadataResponse( + setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/npm", + pds, + ) + + ctx.JSON(http.StatusOK, resp) +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + packageVersion := ctx.Params("version") + filename := ctx.Params("filename") + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNpm, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// DownloadPackageFileByName finds the version and serves the contents of a package +func DownloadPackageFileByName(ctx *context.Context) { + filename := ctx.Params("filename") + + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeNpm, + Name: packages_model.SearchValue{ + ExactMatch: true, + Value: packageNameFromParams(ctx), + }, + HasFileWithName: filename, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pvs[0], + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackage creates a new package +func UploadPackage(ctx *context.Context) { + npmPackage, err := npm_module.ParsePackage(ctx.Req.Body) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + repo, err := repo_model.GetRepositoryByURL(ctx, npmPackage.Metadata.Repository.URL) + if err == nil { + canWrite := repo.OwnerID == ctx.Doer.ID + + if !canWrite { + perms, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + canWrite = perms.CanWrite(unit.TypePackages) + } + + if !canWrite { + apiError(ctx, http.StatusForbidden, "no permission to upload this package") + return + } + } + + buf, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(npmPackage.Data)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNpm, + Name: npmPackage.Name, + Version: npmPackage.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: npmPackage.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: npmPackage.Filename, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + for _, tag := range npmPackage.DistTags { + if err := setPackageTag(ctx, tag, pv, false); err != nil { + if err == errInvalidTagName { + apiError(ctx, http.StatusBadRequest, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + if repo != nil { + if err := packages_model.SetRepositoryLink(ctx, pv.PackageID, repo.ID); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusCreated) +} + +// DeletePreview does nothing +// The client tells the server what package version it knows about after deleting a version. +func DeletePreview(ctx *context.Context) { + ctx.Status(http.StatusOK) +} + +// DeletePackageVersion deletes the package version +func DeletePackageVersion(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + packageVersion := ctx.Params("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNpm, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusOK) +} + +// DeletePackage deletes the package and all versions +func DeletePackage(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + for _, pv := range pvs { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusOK) +} + +// ListPackageTags returns all tags for a package +func ListPackageTags(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + tags := make(map[string]string) + for _, pv := range pvs { + pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + for _, pvp := range pvps { + tags[pvp.Value] = pv.Version + } + } + + ctx.JSON(http.StatusOK, tags) +} + +// AddPackageTag adds a tag to the package +func AddPackageTag(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + version := strings.Trim(string(body), "\"") // is as "version" in the body + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName, version) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if err := setPackageTag(ctx, ctx.Params("tag"), pv, false); err != nil { + if err == errInvalidTagName { + apiError(ctx, http.StatusBadRequest, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } +} + +// DeletePackageTag deletes a package tag +func DeletePackageTag(ctx *context.Context) { + packageName := packageNameFromParams(ctx) + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) != 0 { + if err := setPackageTag(ctx, ctx.Params("tag"), pvs[0], true); err != nil { + if err == errInvalidTagName { + apiError(ctx, http.StatusBadRequest, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + } +} + +func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVersion, deleteOnly bool) error { + if tag == "" { + return errInvalidTagName + } + _, err := version.NewVersion(tag) + if err == nil { + return errInvalidTagName + } + + return db.WithTx(ctx, func(ctx std_ctx.Context) error { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: pv.PackageID, + Properties: map[string]string{ + npm_module.TagProperty: tag, + }, + IsInternal: optional.Some(false), + }) + if err != nil { + return err + } + + if len(pvs) == 1 { + pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pvs[0].ID, npm_module.TagProperty) + if err != nil { + return err + } + + for _, pvp := range pvps { + if pvp.Value == tag { + if err := packages_model.DeletePropertyByID(ctx, pvp.ID); err != nil { + return err + } + break + } + } + } + + if !deleteOnly { + _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty, tag) + if err != nil { + return err + } + } + return nil + }) +} + +func PackageSearch(ctx *context.Context) { + pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeNpm, + IsInternal: optional.Some(false), + Name: packages_model.SearchValue{ + ExactMatch: false, + Value: ctx.FormTrim("text"), + }, + Paginator: db.NewAbsoluteListOptions( + ctx.FormInt("from"), + ctx.FormInt("size"), + ), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createPackageSearchResponse( + pds, + total, + ) + + ctx.JSON(http.StatusOK, resp) +} diff --git a/routers/api/packages/nuget/api_v2.go b/routers/api/packages/nuget/api_v2.go new file mode 100644 index 0000000..a726065 --- /dev/null +++ b/routers/api/packages/nuget/api_v2.go @@ -0,0 +1,402 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package nuget + +import ( + "encoding/xml" + "strings" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + nuget_module "code.gitea.io/gitea/modules/packages/nuget" +) + +type AtomTitle struct { + Type string `xml:"type,attr"` + Text string `xml:",chardata"` +} + +type ServiceCollection struct { + Href string `xml:"href,attr"` + Title AtomTitle `xml:"atom:title"` +} + +type ServiceWorkspace struct { + Title AtomTitle `xml:"atom:title"` + Collection ServiceCollection `xml:"collection"` +} + +type ServiceIndexResponseV2 struct { + XMLName xml.Name `xml:"service"` + Base string `xml:"base,attr"` + Xmlns string `xml:"xmlns,attr"` + XmlnsAtom string `xml:"xmlns:atom,attr"` + Workspace ServiceWorkspace `xml:"workspace"` +} + +type EdmxPropertyRef struct { + Name string `xml:"Name,attr"` +} + +type EdmxProperty struct { + Name string `xml:"Name,attr"` + Type string `xml:"Type,attr"` + Nullable bool `xml:"Nullable,attr"` +} + +type EdmxEntityType struct { + Name string `xml:"Name,attr"` + HasStream bool `xml:"m:HasStream,attr"` + Keys []EdmxPropertyRef `xml:"Key>PropertyRef"` + Properties []EdmxProperty `xml:"Property"` +} + +type EdmxFunctionParameter struct { + Name string `xml:"Name,attr"` + Type string `xml:"Type,attr"` +} + +type EdmxFunctionImport struct { + Name string `xml:"Name,attr"` + ReturnType string `xml:"ReturnType,attr"` + EntitySet string `xml:"EntitySet,attr"` + Parameter []EdmxFunctionParameter `xml:"Parameter"` +} + +type EdmxEntitySet struct { + Name string `xml:"Name,attr"` + EntityType string `xml:"EntityType,attr"` +} + +type EdmxEntityContainer struct { + Name string `xml:"Name,attr"` + IsDefaultEntityContainer bool `xml:"m:IsDefaultEntityContainer,attr"` + EntitySet EdmxEntitySet `xml:"EntitySet"` + FunctionImports []EdmxFunctionImport `xml:"FunctionImport"` +} + +type EdmxSchema struct { + Xmlns string `xml:"xmlns,attr"` + Namespace string `xml:"Namespace,attr"` + EntityType *EdmxEntityType `xml:"EntityType,omitempty"` + EntityContainer *EdmxEntityContainer `xml:"EntityContainer,omitempty"` +} + +type EdmxDataServices struct { + XmlnsM string `xml:"xmlns:m,attr"` + DataServiceVersion string `xml:"m:DataServiceVersion,attr"` + MaxDataServiceVersion string `xml:"m:MaxDataServiceVersion,attr"` + Schema []EdmxSchema `xml:"Schema"` +} + +type EdmxMetadata struct { + XMLName xml.Name `xml:"edmx:Edmx"` + XmlnsEdmx string `xml:"xmlns:edmx,attr"` + Version string `xml:"Version,attr"` + DataServices EdmxDataServices `xml:"edmx:DataServices"` +} + +var Metadata = &EdmxMetadata{ + XmlnsEdmx: "http://schemas.microsoft.com/ado/2007/06/edmx", + Version: "1.0", + DataServices: EdmxDataServices{ + XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata", + DataServiceVersion: "2.0", + MaxDataServiceVersion: "2.0", + Schema: []EdmxSchema{ + { + Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm", + Namespace: "NuGetGallery.OData", + EntityType: &EdmxEntityType{ + Name: "V2FeedPackage", + HasStream: true, + Keys: []EdmxPropertyRef{ + {Name: "Id"}, + {Name: "Version"}, + }, + Properties: []EdmxProperty{ + { + Name: "Id", + Type: "Edm.String", + }, + { + Name: "Version", + Type: "Edm.String", + }, + { + Name: "NormalizedVersion", + Type: "Edm.String", + Nullable: true, + }, + { + Name: "Authors", + Type: "Edm.String", + Nullable: true, + }, + { + Name: "Created", + Type: "Edm.DateTime", + }, + { + Name: "Dependencies", + Type: "Edm.String", + }, + { + Name: "Description", + Type: "Edm.String", + }, + { + Name: "DownloadCount", + Type: "Edm.Int64", + }, + { + Name: "LastUpdated", + Type: "Edm.DateTime", + }, + { + Name: "Published", + Type: "Edm.DateTime", + }, + { + Name: "PackageSize", + Type: "Edm.Int64", + }, + { + Name: "ProjectUrl", + Type: "Edm.String", + Nullable: true, + }, + { + Name: "ReleaseNotes", + Type: "Edm.String", + Nullable: true, + }, + { + Name: "RequireLicenseAcceptance", + Type: "Edm.Boolean", + Nullable: false, + }, + { + Name: "Title", + Type: "Edm.String", + Nullable: true, + }, + { + Name: "VersionDownloadCount", + Type: "Edm.Int64", + Nullable: false, + }, + }, + }, + }, + { + Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm", + Namespace: "NuGetGallery", + EntityContainer: &EdmxEntityContainer{ + Name: "V2FeedContext", + IsDefaultEntityContainer: true, + EntitySet: EdmxEntitySet{ + Name: "Packages", + EntityType: "NuGetGallery.OData.V2FeedPackage", + }, + FunctionImports: []EdmxFunctionImport{ + { + Name: "Search", + ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)", + EntitySet: "Packages", + Parameter: []EdmxFunctionParameter{ + { + Name: "searchTerm", + Type: "Edm.String", + }, + }, + }, + { + Name: "FindPackagesById", + ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)", + EntitySet: "Packages", + Parameter: []EdmxFunctionParameter{ + { + Name: "id", + Type: "Edm.String", + }, + }, + }, + }, + }, + }, + }, + }, +} + +type FeedEntryCategory struct { + Term string `xml:"term,attr"` + Scheme string `xml:"scheme,attr"` +} + +type FeedEntryLink struct { + Rel string `xml:"rel,attr"` + Href string `xml:"href,attr"` +} + +type TypedValue[T any] struct { + Type string `xml:"type,attr,omitempty"` + Value T `xml:",chardata"` +} + +type FeedEntryProperties struct { + Version string `xml:"d:Version"` + NormalizedVersion string `xml:"d:NormalizedVersion"` + Authors string `xml:"d:Authors"` + Dependencies string `xml:"d:Dependencies"` + Description string `xml:"d:Description"` + VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"` + DownloadCount TypedValue[int64] `xml:"d:DownloadCount"` + PackageSize TypedValue[int64] `xml:"d:PackageSize"` + Created TypedValue[time.Time] `xml:"d:Created"` + LastUpdated TypedValue[time.Time] `xml:"d:LastUpdated"` + Published TypedValue[time.Time] `xml:"d:Published"` + ProjectURL string `xml:"d:ProjectUrl,omitempty"` + ReleaseNotes string `xml:"d:ReleaseNotes,omitempty"` + RequireLicenseAcceptance TypedValue[bool] `xml:"d:RequireLicenseAcceptance"` + Title string `xml:"d:Title"` +} + +type FeedEntry struct { + XMLName xml.Name `xml:"entry"` + Xmlns string `xml:"xmlns,attr,omitempty"` + XmlnsD string `xml:"xmlns:d,attr,omitempty"` + XmlnsM string `xml:"xmlns:m,attr,omitempty"` + Base string `xml:"xml:base,attr,omitempty"` + ID string `xml:"id"` + Category FeedEntryCategory `xml:"category"` + Links []FeedEntryLink `xml:"link"` + Title TypedValue[string] `xml:"title"` + Updated time.Time `xml:"updated"` + Author string `xml:"author>name"` + Summary string `xml:"summary"` + Properties *FeedEntryProperties `xml:"m:properties"` + Content string `xml:",innerxml"` +} + +type FeedResponse struct { + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr,omitempty"` + XmlnsD string `xml:"xmlns:d,attr,omitempty"` + XmlnsM string `xml:"xmlns:m,attr,omitempty"` + Base string `xml:"xml:base,attr,omitempty"` + ID string `xml:"id"` + Title TypedValue[string] `xml:"title"` + Updated time.Time `xml:"updated"` + Links []FeedEntryLink `xml:"link"` + Entries []*FeedEntry `xml:"entry"` + Count int64 `xml:"m:count"` +} + +func createFeedResponse(l *linkBuilder, totalEntries int64, pds []*packages_model.PackageDescriptor) *FeedResponse { + entries := make([]*FeedEntry, 0, len(pds)) + for _, pd := range pds { + entries = append(entries, createEntry(l, pd, false)) + } + + links := []FeedEntryLink{ + {Rel: "self", Href: l.Base}, + } + if l.Next != nil { + links = append(links, FeedEntryLink{ + Rel: "next", + Href: l.GetNextURL(), + }) + } + + return &FeedResponse{ + Xmlns: "http://www.w3.org/2005/Atom", + Base: l.Base, + XmlnsD: "http://schemas.microsoft.com/ado/2007/08/dataservices", + XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata", + ID: "http://schemas.datacontract.org/2004/07/", + Updated: time.Now(), + Links: links, + Count: totalEntries, + Entries: entries, + } +} + +func createEntryResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *FeedEntry { + return createEntry(l, pd, true) +} + +func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNamespace bool) *FeedEntry { + metadata := pd.Metadata.(*nuget_module.Metadata) + + id := l.GetPackageMetadataURL(pd.Package.Name, pd.Version.Version) + + // Workaround to force a self-closing tag to satisfy XmlReader.IsEmptyElement used by the NuGet client. + // https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlreader.isemptyelement + content := `` + + createdValue := TypedValue[time.Time]{ + Type: "Edm.DateTime", + Value: pd.Version.CreatedUnix.AsLocalTime(), + } + + entry := &FeedEntry{ + ID: id, + Category: FeedEntryCategory{Term: "NuGetGallery.OData.V2FeedPackage", Scheme: "http://schemas.microsoft.com/ado/2007/08/dataservices/scheme"}, + Links: []FeedEntryLink{ + {Rel: "self", Href: id}, + {Rel: "edit", Href: id}, + }, + Title: TypedValue[string]{Type: "text", Value: pd.Package.Name}, + Updated: pd.Version.CreatedUnix.AsLocalTime(), + Author: metadata.Authors, + Content: content, + Properties: &FeedEntryProperties{ + Version: pd.Version.Version, + NormalizedVersion: pd.Version.Version, + Authors: metadata.Authors, + Dependencies: buildDependencyString(metadata), + Description: metadata.Description, + VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount}, + DownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount}, + PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()}, + Created: createdValue, + LastUpdated: createdValue, + Published: createdValue, + ProjectURL: metadata.ProjectURL, + ReleaseNotes: metadata.ReleaseNotes, + RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance}, + Title: pd.Package.Name, + }, + } + + if withNamespace { + entry.Xmlns = "http://www.w3.org/2005/Atom" + entry.Base = l.Base + entry.XmlnsD = "http://schemas.microsoft.com/ado/2007/08/dataservices" + entry.XmlnsM = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" + } + + return entry +} + +func buildDependencyString(metadata *nuget_module.Metadata) string { + var b strings.Builder + first := true + for group, deps := range metadata.Dependencies { + for _, dep := range deps { + if !first { + b.WriteByte('|') + } + first = false + + b.WriteString(dep.ID) + b.WriteByte(':') + b.WriteString(dep.Version) + b.WriteByte(':') + b.WriteString(group) + } + } + return b.String() +} diff --git a/routers/api/packages/nuget/api_v3.go b/routers/api/packages/nuget/api_v3.go new file mode 100644 index 0000000..2fe25dc --- /dev/null +++ b/routers/api/packages/nuget/api_v3.go @@ -0,0 +1,255 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package nuget + +import ( + "sort" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + nuget_module "code.gitea.io/gitea/modules/packages/nuget" + + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +// https://docs.microsoft.com/en-us/nuget/api/service-index#resources +type ServiceIndexResponseV3 struct { + Version string `json:"version"` + Resources []ServiceResource `json:"resources"` +} + +// https://docs.microsoft.com/en-us/nuget/api/service-index#resource +type ServiceResource struct { + ID string `json:"@id"` + Type string `json:"@type"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#response +type RegistrationIndexResponse struct { + RegistrationIndexURL string `json:"@id"` + Type []string `json:"@type"` + Count int `json:"count"` + Pages []*RegistrationIndexPage `json:"items"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-page-object +type RegistrationIndexPage struct { + RegistrationPageURL string `json:"@id"` + Lower string `json:"lower"` + Upper string `json:"upper"` + Count int `json:"count"` + Items []*RegistrationIndexPageItem `json:"items"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf-object-in-a-page +type RegistrationIndexPageItem struct { + RegistrationLeafURL string `json:"@id"` + PackageContentURL string `json:"packageContent"` + CatalogEntry *CatalogEntry `json:"catalogEntry"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry +type CatalogEntry struct { + CatalogLeafURL string `json:"@id"` + PackageContentURL string `json:"packageContent"` + ID string `json:"id"` + Version string `json:"version"` + Description string `json:"description"` + ReleaseNotes string `json:"releaseNotes"` + Authors string `json:"authors"` + RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` + ProjectURL string `json:"projectURL"` + DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group +type PackageDependencyGroup struct { + TargetFramework string `json:"targetFramework"` + Dependencies []*PackageDependency `json:"dependencies"` +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency +type PackageDependency struct { + ID string `json:"id"` + Range string `json:"range"` +} + +func createRegistrationIndexResponse(l *linkBuilder, pds []*packages_model.PackageDescriptor) *RegistrationIndexResponse { + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + items := make([]*RegistrationIndexPageItem, 0, len(pds)) + for _, p := range pds { + items = append(items, createRegistrationIndexPageItem(l, p)) + } + + return &RegistrationIndexResponse{ + RegistrationIndexURL: l.GetRegistrationIndexURL(pds[0].Package.Name), + Type: []string{"catalog:CatalogRoot", "PackageRegistration", "catalog:Permalink"}, + Count: 1, + Pages: []*RegistrationIndexPage{ + { + RegistrationPageURL: l.GetRegistrationIndexURL(pds[0].Package.Name), + Count: len(pds), + Lower: pds[0].Version.Version, + Upper: pds[len(pds)-1].Version.Version, + Items: items, + }, + }, + } +} + +func createRegistrationIndexPageItem(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationIndexPageItem { + metadata := pd.Metadata.(*nuget_module.Metadata) + + return &RegistrationIndexPageItem{ + RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + CatalogEntry: &CatalogEntry{ + CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + ID: pd.Package.Name, + Version: pd.Version.Version, + Description: metadata.Description, + ReleaseNotes: metadata.ReleaseNotes, + Authors: metadata.Authors, + ProjectURL: metadata.ProjectURL, + DependencyGroups: createDependencyGroups(pd), + }, + } +} + +func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDependencyGroup { + metadata := pd.Metadata.(*nuget_module.Metadata) + + dependencyGroups := make([]*PackageDependencyGroup, 0, len(metadata.Dependencies)) + for k, v := range metadata.Dependencies { + dependencies := make([]*PackageDependency, 0, len(v)) + for _, dep := range v { + dependencies = append(dependencies, &PackageDependency{ + ID: dep.ID, + Range: dep.Version, + }) + } + + dependencyGroups = append(dependencyGroups, &PackageDependencyGroup{ + TargetFramework: k, + Dependencies: dependencies, + }) + } + return dependencyGroups +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf +type RegistrationLeafResponse struct { + RegistrationLeafURL string `json:"@id"` + Type []string `json:"@type"` + Listed bool `json:"listed"` + PackageContentURL string `json:"packageContent"` + Published time.Time `json:"published"` + RegistrationIndexURL string `json:"registration"` +} + +func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse { + return &RegistrationLeafResponse{ + Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"}, + Listed: true, + Published: pd.Version.CreatedUnix.AsLocalTime(), + RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name), + } +} + +// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#response +type PackageVersionsResponse struct { + Versions []string `json:"versions"` +} + +func createPackageVersionsResponse(pvs []*packages_model.PackageVersion) *PackageVersionsResponse { + versions := make([]string, 0, len(pvs)) + for _, pv := range pvs { + versions = append(versions, pv.Version) + } + + return &PackageVersionsResponse{ + Versions: versions, + } +} + +// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#response +type SearchResultResponse struct { + TotalHits int64 `json:"totalHits"` + Data []*SearchResult `json:"data"` +} + +// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result +type SearchResult struct { + ID string `json:"id"` + Version string `json:"version"` + Versions []*SearchResultVersion `json:"versions"` + Description string `json:"description"` + Authors string `json:"authors"` + ProjectURL string `json:"projectURL"` + RegistrationIndexURL string `json:"registration"` +} + +// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result +type SearchResultVersion struct { + RegistrationLeafURL string `json:"@id"` + Version string `json:"version"` + Downloads int64 `json:"downloads"` +} + +func createSearchResultResponse(l *linkBuilder, totalHits int64, pds []*packages_model.PackageDescriptor) *SearchResultResponse { + grouped := make(map[string][]*packages_model.PackageDescriptor) + for _, pd := range pds { + grouped[pd.Package.Name] = append(grouped[pd.Package.Name], pd) + } + + keys := make([]string, 0, len(grouped)) + for key := range grouped { + keys = append(keys, key) + } + collate.New(language.English, collate.IgnoreCase).SortStrings(keys) + + data := make([]*SearchResult, 0, len(pds)) + for _, key := range keys { + data = append(data, createSearchResult(l, grouped[key])) + } + + return &SearchResultResponse{ + TotalHits: totalHits, + Data: data, + } +} + +func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) *SearchResult { + latest := pds[0] + versions := make([]*SearchResultVersion, 0, len(pds)) + for _, pd := range pds { + if latest.SemVer.LessThan(pd.SemVer) { + latest = pd + } + + versions = append(versions, &SearchResultVersion{ + RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + Version: pd.Version.Version, + }) + } + + metadata := latest.Metadata.(*nuget_module.Metadata) + + return &SearchResult{ + ID: latest.Package.Name, + Version: latest.Version.Version, + Versions: versions, + Description: metadata.Description, + Authors: metadata.Authors, + ProjectURL: metadata.ProjectURL, + RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name), + } +} diff --git a/routers/api/packages/nuget/auth.go b/routers/api/packages/nuget/auth.go new file mode 100644 index 0000000..1bb68d0 --- /dev/null +++ b/routers/api/packages/nuget/auth.go @@ -0,0 +1,47 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package nuget + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/auth" +) + +var _ auth.Method = &Auth{} + +type Auth struct{} + +func (a *Auth) Name() string { + return "nuget" +} + +// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#request-parameters +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + token, err := auth_model.GetAccessTokenBySHA(req.Context(), req.Header.Get("X-NuGet-ApiKey")) + if err != nil { + if !(auth_model.IsErrAccessTokenNotExist(err) || auth_model.IsErrAccessTokenEmpty(err)) { + log.Error("GetAccessTokenBySHA: %v", err) + return nil, err + } + return nil, nil + } + + u, err := user_model.GetUserByID(req.Context(), token.UID) + if err != nil { + log.Error("GetUserByID: %v", err) + return nil, err + } + + token.UpdatedUnix = timeutil.TimeStampNow() + if err := auth_model.UpdateAccessToken(req.Context(), token); err != nil { + log.Error("UpdateAccessToken: %v", err) + } + + return u, nil +} diff --git a/routers/api/packages/nuget/links.go b/routers/api/packages/nuget/links.go new file mode 100644 index 0000000..4c573fe --- /dev/null +++ b/routers/api/packages/nuget/links.go @@ -0,0 +1,52 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package nuget + +import ( + "fmt" + "net/url" +) + +type nextOptions struct { + Path string + Query url.Values +} + +type linkBuilder struct { + Base string + Next *nextOptions +} + +// GetRegistrationIndexURL builds the registration index url +func (l *linkBuilder) GetRegistrationIndexURL(id string) string { + return fmt.Sprintf("%s/registration/%s/index.json", l.Base, id) +} + +// GetRegistrationLeafURL builds the registration leaf url +func (l *linkBuilder) GetRegistrationLeafURL(id, version string) string { + return fmt.Sprintf("%s/registration/%s/%s.json", l.Base, id, version) +} + +// GetPackageDownloadURL builds the download url +func (l *linkBuilder) GetPackageDownloadURL(id, version string) string { + return fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", l.Base, id, version, id, version) +} + +// GetPackageMetadataURL builds the package metadata url +func (l *linkBuilder) GetPackageMetadataURL(id, version string) string { + return fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", l.Base, id, version) +} + +func (l *linkBuilder) GetNextURL() string { + u, _ := url.Parse(l.Base) + u = u.JoinPath(l.Next.Path) + q := u.Query() + for k, vs := range l.Next.Query { + for _, v := range vs { + q.Add(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() +} diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go new file mode 100644 index 0000000..0d7212d --- /dev/null +++ b/routers/api/packages/nuget/nuget.go @@ -0,0 +1,710 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package nuget + +import ( + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + nuget_model "code.gitea.io/gitea/models/packages/nuget" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + nuget_module "code.gitea.io/gitea/modules/packages/nuget" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, map[string]string{ + "Message": message, + }) + }) +} + +func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam + ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") + ctx.Resp.WriteHeader(status) + if _, err := ctx.Resp.Write([]byte(xml.Header)); err != nil { + log.Error("Write failed: %v", err) + } + if err := xml.NewEncoder(ctx.Resp).Encode(obj); err != nil { + log.Error("XML encode failed: %v", err) + } +} + +// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs +func ServiceIndexV2(ctx *context.Context) { + base := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget" + + xmlResponse(ctx, http.StatusOK, &ServiceIndexResponseV2{ + Base: base, + Xmlns: "http://www.w3.org/2007/app", + XmlnsAtom: "http://www.w3.org/2005/Atom", + Workspace: ServiceWorkspace{ + Title: AtomTitle{ + Type: "text", + Text: "Default", + }, + Collection: ServiceCollection{ + Href: "Packages", + Title: AtomTitle{ + Type: "text", + Text: "Packages", + }, + }, + }, + }) +} + +// https://docs.microsoft.com/en-us/nuget/api/service-index +func ServiceIndexV3(ctx *context.Context) { + root := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget" + + ctx.JSON(http.StatusOK, &ServiceIndexResponseV3{ + Version: "3.0.0", + Resources: []ServiceResource{ + {ID: root + "/query", Type: "SearchQueryService"}, + {ID: root + "/query", Type: "SearchQueryService/3.0.0-beta"}, + {ID: root + "/query", Type: "SearchQueryService/3.0.0-rc"}, + {ID: root + "/registration", Type: "RegistrationsBaseUrl"}, + {ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-beta"}, + {ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-rc"}, + {ID: root + "/package", Type: "PackageBaseAddress/3.0.0"}, + {ID: root, Type: "PackagePublish/2.0.0"}, + {ID: root + "/symbolpackage", Type: "SymbolPackagePublish/4.9.0"}, + }, + }) +} + +// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/LegacyFeedCapabilityResourceV2Feed.cs +func FeedCapabilityResource(ctx *context.Context) { + xmlResponse(ctx, http.StatusOK, Metadata) +} + +var ( + searchTermExtract = regexp.MustCompile(`'([^']+)'`) + searchTermExact = regexp.MustCompile(`\s+eq\s+'`) +) + +func getSearchTerm(ctx *context.Context) packages_model.SearchValue { + searchTerm := strings.Trim(ctx.FormTrim("searchTerm"), "'") + if searchTerm != "" { + return packages_model.SearchValue{ + Value: searchTerm, + ExactMatch: false, + } + } + + // $filter contains a query like: + // (((Id ne null) and substringof('microsoft',tolower(Id))) + // https://www.odata.org/documentation/odata-version-2-0/uri-conventions/ section 4.5 + // We don't support these queries, just extract the search term. + filter := ctx.FormTrim("$filter") + match := searchTermExtract.FindStringSubmatch(filter) + if len(match) == 2 { + return packages_model.SearchValue{ + Value: strings.TrimSpace(match[1]), + ExactMatch: searchTermExact.MatchString(filter), + } + } + + return packages_model.SearchValue{} +} + +// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs +func SearchServiceV2(ctx *context.Context) { + skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top") + paginator := db.NewAbsoluteListOptions(skip, take) + + pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeNuGet, + Name: getSearchTerm(ctx), + IsInternal: optional.Some(false), + Paginator: paginator, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + skip, take = paginator.GetSkipTake() + + var next *nextOptions + if len(pvs) == take { + next = &nextOptions{ + Path: "Search()", + Query: url.Values{}, + } + searchTerm := ctx.FormTrim("searchTerm") + if searchTerm != "" { + next.Query.Set("searchTerm", searchTerm) + } + filter := ctx.FormTrim("$filter") + if filter != "" { + next.Query.Set("$filter", filter) + } + next.Query.Set("$skip", strconv.Itoa(skip+take)) + next.Query.Set("$top", strconv.Itoa(take)) + } + + resp := createFeedResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next}, + total, + pds, + ) + + xmlResponse(ctx, http.StatusOK, resp) +} + +// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351 +func SearchServiceV2Count(ctx *context.Context) { + count, err := nuget_model.CountPackages(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Name: getSearchTerm(ctx), + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10)) +} + +// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages +func SearchServiceV3(ctx *context.Context) { + pvs, count, err := nuget_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, + IsInternal: optional.Some(false), + Paginator: db.NewAbsoluteListOptions( + ctx.FormInt("skip"), + ctx.FormInt("take"), + ), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createSearchResultResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"}, + count, + pds, + ) + + ctx.JSON(http.StatusOK, resp) +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-index +func RegistrationIndex(ctx *context.Context) { + packageName := ctx.Params("id") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createRegistrationIndexResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"}, + pds, + ) + + ctx.JSON(http.StatusOK, resp) +} + +// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs +func RegistrationLeafV2(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := ctx.Params("version") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createEntryResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"}, + pd, + ) + + xmlResponse(ctx, http.StatusOK, resp) +} + +// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf +func RegistrationLeafV3(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := strings.TrimSuffix(ctx.Params("version"), ".json") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + resp := createRegistrationLeafResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"}, + pd, + ) + + ctx.JSON(http.StatusOK, resp) +} + +// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs +func EnumeratePackageVersionsV2(ctx *context.Context) { + packageName := strings.Trim(ctx.FormTrim("id"), "'") + + skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top") + paginator := db.NewAbsoluteListOptions(skip, take) + + pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeNuGet, + Name: packages_model.SearchValue{ + ExactMatch: true, + Value: packageName, + }, + IsInternal: optional.Some(false), + Paginator: paginator, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + skip, take = paginator.GetSkipTake() + + var next *nextOptions + if len(pvs) == take { + next = &nextOptions{ + Path: "FindPackagesById()", + Query: url.Values{}, + } + next.Query.Set("id", packageName) + next.Query.Set("$skip", strconv.Itoa(skip+take)) + next.Query.Set("$top", strconv.Itoa(take)) + } + + resp := createFeedResponse( + &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next}, + total, + pds, + ) + + xmlResponse(ctx, http.StatusOK, resp) +} + +// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351 +func EnumeratePackageVersionsV2Count(ctx *context.Context) { + count, err := packages_model.CountVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeNuGet, + Name: packages_model.SearchValue{ + ExactMatch: true, + Value: strings.Trim(ctx.FormTrim("id"), "'"), + }, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10)) +} + +// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#enumerate-package-versions +func EnumeratePackageVersionsV3(ctx *context.Context) { + packageName := ctx.Params("id") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + resp := createPackageVersionsResponse(pvs) + + ctx.JSON(http.StatusOK, resp) +} + +// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec +// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg +func DownloadPackageFile(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := ctx.Params("version") + filename := ctx.Params("filename") + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackage creates a new package with the metadata contained in the uploaded nupgk file +// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#push-a-package +func UploadPackage(ctx *context.Context) { + np, buf, closables := processUploadedFile(ctx, nuget_module.DependencyPackage) + defer func() { + for _, c := range closables { + c.Close() + } + }() + if np == nil { + return + } + + pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNuGet, + Name: np.ID, + Version: np.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: np.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", np.ID, np.Version)), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len()) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer nuspecBuf.Close() + + _, err = packages_service.AddFileToPackageVersionInternal( + ctx, + pv, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", np.ID)), + }, + Data: nuspecBuf, + }, + ) + if err != nil { + switch err { + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +// UploadSymbolPackage adds a symbol package to an existing package +// https://docs.microsoft.com/en-us/nuget/api/symbol-package-publish-resource +func UploadSymbolPackage(ctx *context.Context) { + np, buf, closables := processUploadedFile(ctx, nuget_module.SymbolsPackage) + defer func() { + for _, c := range closables { + c.Close() + } + }() + if np == nil { + return + } + + pdbs, err := nuget_module.ExtractPortablePdb(buf, buf.Size()) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + defer pdbs.Close() + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pi := &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNuGet, + Name: np.ID, + Version: np.Version, + } + + _, err = packages_service.AddFileToExistingPackage( + ctx, + pi, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.%s.snupkg", np.ID, np.Version)), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: false, + }, + ) + if err != nil { + switch err { + case packages_model.ErrPackageNotExist: + apiError(ctx, http.StatusNotFound, err) + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + for _, pdb := range pdbs { + _, err := packages_service.AddFileToExistingPackage( + ctx, + pi, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(pdb.Name), + CompositeKey: strings.ToLower(pdb.ID), + }, + Creator: ctx.Doer, + Data: pdb.Content, + IsLead: false, + Properties: map[string]string{ + nuget_module.PropertySymbolID: strings.ToLower(pdb.ID), + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + } + + ctx.Status(http.StatusCreated) +} + +func processUploadedFile(ctx *context.Context, expectedType nuget_module.PackageType) (*nuget_module.Package, *packages_module.HashedBuffer, []io.Closer) { + closables := make([]io.Closer, 0, 2) + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return nil, nil, closables + } + + if needToClose { + closables = append(closables, upload) + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return nil, nil, closables + } + closables = append(closables, buf) + + np, err := nuget_module.ParsePackageMetaData(buf, buf.Size()) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return nil, nil, closables + } + if np.PackageType != expectedType { + apiError(ctx, http.StatusBadRequest, errors.New("unexpected package type")) + return nil, nil, closables + } + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return nil, nil, closables + } + return np, buf, closables +} + +// https://github.com/dotnet/symstore/blob/main/docs/specs/Simple_Symbol_Query_Protocol.md#request +func DownloadSymbolFile(ctx *context.Context) { + filename := ctx.Params("filename") + guid := ctx.Params("guid")[:32] + filename2 := ctx.Params("filename2") + + if filename != filename2 { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + PackageType: packages_model.TypeNuGet, + Query: filename, + Properties: map[string]string{ + nuget_module.PropertySymbolID: strings.ToLower(guid), + }, + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pfs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// DeletePackage hard deletes the package +// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#delete-a-package +func DeletePackage(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := ctx.Params("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/pub/pub.go b/routers/api/packages/pub/pub.go new file mode 100644 index 0000000..f87df52 --- /dev/null +++ b/routers/api/packages/pub/pub.go @@ -0,0 +1,284 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pub + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + pub_module "code.gitea.io/gitea/modules/packages/pub" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +func jsonResponse(ctx *context.Context, status int, obj any) { + resp := ctx.Resp + resp.Header().Set("Content-Type", "application/vnd.pub.v2+json") + resp.WriteHeader(status) + if err := json.NewEncoder(resp).Encode(obj); err != nil { + log.Error("JSON encode: %v", err) + } +} + +func apiError(ctx *context.Context, status int, obj any) { + type Error struct { + Code string `json:"code"` + Message string `json:"message"` + } + type ErrorWrapper struct { + Error Error `json:"error"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + jsonResponse(ctx, status, ErrorWrapper{ + Error: Error{ + Code: http.StatusText(status), + Message: message, + }, + }) + }) +} + +type packageVersions struct { + Name string `json:"name"` + Latest *versionMetadata `json:"latest"` + Versions []*versionMetadata `json:"versions"` +} + +type versionMetadata struct { + Version string `json:"version"` + ArchiveURL string `json:"archive_url"` + Published time.Time `json:"published"` + Pubspec any `json:"pubspec,omitempty"` +} + +func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata { + return &versionMetadata{ + Version: pd.Version.Version, + ArchiveURL: fmt.Sprintf("%s/files/%s.tar.gz", baseURL, url.PathEscape(pd.Version.Version)), + Published: pd.Version.CreatedUnix.AsLocalTime(), + Pubspec: pd.Metadata.(*pub_module.Metadata).Pubspec, + } +} + +func baseURL(ctx *context.Context) string { + return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pub/api/packages" +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#list-all-versions-of-a-package +func EnumeratePackageVersions(ctx *context.Context) { + packageName := ctx.Params("id") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + baseURL := fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pds[0].Package.Name)) + + versions := make([]*versionMetadata, 0, len(pds)) + for _, pd := range pds { + versions = append(versions, packageDescriptorToMetadata(baseURL, pd)) + } + + jsonResponse(ctx, http.StatusOK, &packageVersions{ + Name: pds[0].Package.Name, + Latest: packageDescriptorToMetadata(baseURL, pds[0]), + Versions: versions, + }) +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-inspect-a-specific-version-of-a-package +func PackageVersionMetadata(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := ctx.Params("version") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + jsonResponse(ctx, http.StatusOK, packageDescriptorToMetadata( + fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pd.Package.Name)), + pd, + )) +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages +func RequestUpload(ctx *context.Context) { + type UploadRequest struct { + URL string `json:"url"` + Fields map[string]string `json:"fields"` + } + + jsonResponse(ctx, http.StatusOK, UploadRequest{ + URL: baseURL(ctx) + "/versions/new/upload", + Fields: make(map[string]string), + }) +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages +func UploadPackageFile(ctx *context.Context) { + file, _, err := ctx.Req.FormFile("file") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + pck, err := pub_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypePub, + Name: pck.Name, + Version: pck.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(pck.Version + ".tar.gz"), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Resp.Header().Set("Location", fmt.Sprintf("%s/versions/new/finalize/%s/%s", baseURL(ctx), url.PathEscape(pck.Name), url.PathEscape(pck.Version))) + ctx.Status(http.StatusNoContent) +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages +func FinalizePackage(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := ctx.Params("version") + + _, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Success struct { + Message string `json:"message"` + } + type SuccessWrapper struct { + Success Success `json:"success"` + } + + jsonResponse(ctx, http.StatusOK, SuccessWrapper{Success{}}) +} + +// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-download-a-specific-version-of-a-package +func DownloadPackageFile(ctx *context.Context) { + packageName := ctx.Params("id") + packageVersion := strings.TrimSuffix(ctx.Params("version"), ".tar.gz") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go new file mode 100644 index 0000000..7824db1 --- /dev/null +++ b/routers/api/packages/pypi/pypi.go @@ -0,0 +1,194 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pypi + +import ( + "encoding/hex" + "io" + "net/http" + "regexp" + "sort" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + packages_module "code.gitea.io/gitea/modules/packages" + pypi_module "code.gitea.io/gitea/modules/packages/pypi" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +// https://peps.python.org/pep-0426/#name +var ( + normalizer = strings.NewReplacer(".", "-", "_", "-") + nameMatcher = regexp.MustCompile(`\A(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\.\-_]*[a-zA-Z0-9])\z`) +) + +// https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions +var versionMatcher = regexp.MustCompile(`\Av?` + + `(?:[0-9]+!)?` + // epoch + `[0-9]+(?:\.[0-9]+)*` + // release segment + `(?:[-_\.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_\.]?[0-9]*)?` + // pre-release + `(?:-[0-9]+|[-_\.]?(?:post|rev|r)[-_\.]?[0-9]*)?` + // post release + `(?:[-_\.]?dev[-_\.]?[0-9]*)?` + // dev release + `(?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)?` + // local version + `\z`) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +// PackageMetadata returns the metadata for a single package +func PackageMetadata(ctx *context.Context) { + packageName := normalizer.Replace(ctx.Params("id")) + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePyPI, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // sort package descriptors by version to mimic PyPI format + sort.Slice(pds, func(i, j int) bool { + return strings.Compare(pds[i].Version.Version, pds[j].Version.Version) < 0 + }) + + ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi" + ctx.Data["PackageDescriptor"] = pds[0] + ctx.Data["PackageDescriptors"] = pds + ctx.HTML(http.StatusOK, "api/packages/pypi/simple") +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + packageName := normalizer.Replace(ctx.Params("id")) + packageVersion := ctx.Params("version") + filename := ctx.Params("filename") + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypePyPI, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackageFile adds a file to the package. If the package does not exist, it gets created. +func UploadPackageFile(ctx *context.Context) { + file, fileHeader, err := ctx.Req.FormFile("content") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + _, _, hashSHA256, _ := buf.Sums() + + if !strings.EqualFold(ctx.Req.FormValue("sha256_digest"), hex.EncodeToString(hashSHA256)) { + apiError(ctx, http.StatusBadRequest, "hash mismatch") + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + packageName := normalizer.Replace(ctx.Req.FormValue("name")) + packageVersion := ctx.Req.FormValue("version") + if !isValidNameAndVersion(packageName, packageVersion) { + apiError(ctx, http.StatusBadRequest, "invalid name or version") + return + } + + projectURL := ctx.Req.FormValue("home_page") + if !validation.IsValidURL(projectURL) { + projectURL = "" + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypePyPI, + Name: packageName, + Version: packageVersion, + }, + SemverCompatible: false, + Creator: ctx.Doer, + Metadata: &pypi_module.Metadata{ + Author: ctx.Req.FormValue("author"), + Description: ctx.Req.FormValue("description"), + LongDescription: ctx.Req.FormValue("long_description"), + Summary: ctx.Req.FormValue("summary"), + ProjectURL: projectURL, + License: ctx.Req.FormValue("license"), + RequiresPython: ctx.Req.FormValue("requires_python"), + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fileHeader.Filename, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func isValidNameAndVersion(packageName, packageVersion string) bool { + return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion) +} diff --git a/routers/api/packages/pypi/pypi_test.go b/routers/api/packages/pypi/pypi_test.go new file mode 100644 index 0000000..3023692 --- /dev/null +++ b/routers/api/packages/pypi/pypi_test.go @@ -0,0 +1,38 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pypi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidNameAndVersion(t *testing.T) { + // The test cases below were created from the following Python PEPs: + // https://peps.python.org/pep-0426/#name + // https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions + + // Valid Cases + assert.True(t, isValidNameAndVersion("A", "1.0.1")) + assert.True(t, isValidNameAndVersion("Test.Name.1234", "1.0.1")) + assert.True(t, isValidNameAndVersion("test_name", "1.0.1")) + assert.True(t, isValidNameAndVersion("test-name", "1.0.1")) + assert.True(t, isValidNameAndVersion("test-name", "v1.0.1")) + assert.True(t, isValidNameAndVersion("test-name", "2012.4")) + assert.True(t, isValidNameAndVersion("test-name", "1.0.1-alpha")) + assert.True(t, isValidNameAndVersion("test-name", "1.0.1a1")) + assert.True(t, isValidNameAndVersion("test-name", "1.0b2.r345.dev456")) + assert.True(t, isValidNameAndVersion("test-name", "1!1.0.1")) + assert.True(t, isValidNameAndVersion("test-name", "1.0.1+local.1")) + + // Invalid Cases + assert.False(t, isValidNameAndVersion(".test-name", "1.0.1")) + assert.False(t, isValidNameAndVersion("test!name", "1.0.1")) + assert.False(t, isValidNameAndVersion("-test-name", "1.0.1")) + assert.False(t, isValidNameAndVersion("test-name-", "1.0.1")) + assert.False(t, isValidNameAndVersion("test-name", "a1.0.1")) + assert.False(t, isValidNameAndVersion("test-name", "1.0.1aa")) + assert.False(t, isValidNameAndVersion("test-name", "1.0.0-alpha.beta")) +} diff --git a/routers/api/packages/rpm/rpm.go b/routers/api/packages/rpm/rpm.go new file mode 100644 index 0000000..54fb01c --- /dev/null +++ b/routers/api/packages/rpm/rpm.go @@ -0,0 +1,318 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package rpm + +import ( + stdctx "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + packages_module "code.gitea.io/gitea/modules/packages" + rpm_module "code.gitea.io/gitea/modules/packages/rpm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + notify_service "code.gitea.io/gitea/services/notify" + packages_service "code.gitea.io/gitea/services/packages" + rpm_service "code.gitea.io/gitea/services/packages/rpm" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +// https://dnf.readthedocs.io/en/latest/conf_ref.html +func GetRepositoryConfig(ctx *context.Context) { + group := ctx.Params("group") + + var groupParts []string + if group != "" { + groupParts = strings.Split(group, "/") + } + + url := fmt.Sprintf("%sapi/packages/%s/rpm", setting.AppURL, ctx.Package.Owner.Name) + + ctx.PlainText(http.StatusOK, `[gitea-`+strings.Join(append([]string{ctx.Package.Owner.LowerName}, groupParts...), "-")+`] +name=`+strings.Join(append([]string{ctx.Package.Owner.Name, setting.AppName}, groupParts...), " - ")+` +baseurl=`+strings.Join(append([]string{url}, groupParts...), "/")+` +enabled=1 +gpgcheck=1 +gpgkey=`+url+`/repository.key`) +} + +// Gets or creates the PGP public key used to sign repository metadata files +func GetRepositoryKey(ctx *context.Context) { + _, pub, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{ + ContentType: "application/pgp-keys", + Filename: "repository.key", + }) +} + +func CheckRepositoryFileExistence(ctx *context.Context) { + pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.Params("filename"), ctx.Params("group")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Status(http.StatusNotFound) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.SetServeHeaders(&context.ServeHeaderOptions{ + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + }) + ctx.Status(http.StatusOK) +} + +// Gets a pre-generated repository metadata file +func GetRepositoryFile(ctx *context.Context) { + pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pv, + &packages_service.PackageFileInfo{ + Filename: ctx.Params("filename"), + CompositeKey: ctx.Params("group"), + }, + ) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func UploadPackageFile(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + // if rpm sign enabled + if setting.Packages.DefaultRPMSignEnabled || ctx.FormBool("sign") { + pri, _, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + signedBuf, err := rpm_service.NewSignedRPMBuffer(buf, pri) + if err != nil { + // Not in rpm format, parsing failed. + apiError(ctx, http.StatusBadRequest, err) + return + } + defer signedBuf.Close() + buf = signedBuf + } + + pck, err := rpm_module.ParsePackage(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + fileMetadataRaw, err := json.Marshal(pck.FileMetadata) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + group := ctx.Params("group") + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeRpm, + Name: pck.Name, + Version: pck.Version, + }, + Creator: ctx.Doer, + Metadata: pck.VersionMetadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s.%s.rpm", pck.Name, pck.Version, pck.FileMetadata.Architecture), + CompositeKey: group, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: map[string]string{ + rpm_module.PropertyGroup: group, + rpm_module.PropertyArchitecture: pck.FileMetadata.Architecture, + rpm_module.PropertyMetadata: string(fileMetadataRaw), + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if err := rpm_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, group); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadPackageFile(ctx *context.Context) { + name := ctx.Params("name") + version := ctx.Params("version") + + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeRpm, + Name: name, + Version: version, + }, + &packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.Params("architecture")), + CompositeKey: ctx.Params("group"), + }, + ) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func DeletePackageFile(webctx *context.Context) { + group := webctx.Params("group") + name := webctx.Params("name") + version := webctx.Params("version") + architecture := webctx.Params("architecture") + + var pd *packages_model.PackageDescriptor + + err := db.WithTx(webctx, func(ctx stdctx.Context) error { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, + webctx.Package.Owner.ID, + packages_model.TypeRpm, + name, + version, + ) + if err != nil { + return err + } + + pf, err := packages_model.GetFileForVersionByName( + ctx, + pv.ID, + fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture), + group, + ) + if err != nil { + return err + } + + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + return err + } + + has, err := packages_model.HasVersionFileReferences(ctx, pv.ID) + if err != nil { + return err + } + if !has { + pd, err = packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + return err + } + + if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { + return err + } + } + + return nil + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(webctx, http.StatusNotFound, err) + } else { + apiError(webctx, http.StatusInternalServerError, err) + } + return + } + + if pd != nil { + notify_service.PackageDelete(webctx, webctx.Doer, pd) + } + + if err := rpm_service.BuildSpecificRepositoryFiles(webctx, webctx.Package.Owner.ID, group); err != nil { + apiError(webctx, http.StatusInternalServerError, err) + return + } + + webctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go new file mode 100644 index 0000000..dfefe2c --- /dev/null +++ b/routers/api/packages/rubygems/rubygems.go @@ -0,0 +1,451 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package rubygems + +import ( + "compress/gzip" + "compress/zlib" + "crypto/md5" + "errors" + "fmt" + "io" + "net/http" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + rubygems_module "code.gitea.io/gitea/modules/packages/rubygems" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +const ( + Sep = "---\n" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +// EnumeratePackages serves the package list +func EnumeratePackages(ctx *context.Context) { + packages, err := packages_model.GetVersionsByPackageType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + enumeratePackages(ctx, "specs.4.8", packages) +} + +// EnumeratePackagesLatest serves the list of the latest version of every package +func EnumeratePackagesLatest(ctx *context.Context) { + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeRubyGems, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + enumeratePackages(ctx, "latest_specs.4.8", pvs) +} + +// EnumeratePackagesPreRelease is not supported and serves an empty list +func EnumeratePackagesPreRelease(ctx *context.Context) { + enumeratePackages(ctx, "prerelease_specs.4.8", []*packages_model.PackageVersion{}) +} + +func enumeratePackages(ctx *context.Context, filename string, pvs []*packages_model.PackageVersion) { + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + specs := make([]any, 0, len(pds)) + for _, p := range pds { + specs = append(specs, []any{ + p.Package.Name, + &rubygems_module.RubyUserMarshal{ + Name: "Gem::Version", + Value: []string{p.Version.Version}, + }, + p.Metadata.(*rubygems_module.Metadata).Platform, + }) + } + + ctx.SetServeHeaders(&context.ServeHeaderOptions{ + Filename: filename + ".gz", + }) + + zw := gzip.NewWriter(ctx.Resp) + defer zw.Close() + + zw.Name = filename + + if err := rubygems_module.NewMarshalEncoder(zw).Encode(specs); err != nil { + ctx.ServerError("Download file failed", err) + } +} + +// Serves info file for rubygems.org compatible /info/{gem} file. +// See also https://guides.rubygems.org/rubygems-org-compact-index-api/. +func ServePackageInfo(ctx *context.Context) { + packageName := ctx.Params("package") + versions, err := packages_model.GetVersionsByPackageName( + ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + } + if len(versions) == 0 { + apiError(ctx, http.StatusNotFound, fmt.Sprintf("Could not find package %s", packageName)) + } + + result, err := buildInfoFileForPackage(ctx, versions) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.PlainText(http.StatusOK, *result) +} + +// ServeVersionsFile creates rubygems.org compatible /versions file. +// See also https://guides.rubygems.org/rubygems-org-compact-index-api/. +func ServeVersionsFile(ctx *context.Context) { + packages, err := packages_model.GetPackagesByType( + ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + result := new(strings.Builder) + result.WriteString(Sep) + for _, pack := range packages { + versions, err := packages_model.GetVersionsByPackageName( + ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, pack.Name) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + } + if len(versions) == 0 { + // No versions left for this package, we should continue. + continue + } + + fmt.Fprintf(result, "%s ", pack.Name) + for i, v := range versions { + result.WriteString(v.Version) + if i != len(versions)-1 { + result.WriteString(",") + } + } + + info, err := buildInfoFileForPackage(ctx, versions) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + } + + checksum := md5.Sum([]byte(*info)) + fmt.Fprintf(result, " %x\n", checksum) + } + ctx.PlainText(http.StatusOK, result.String()) +} + +// ServePackageSpecification serves the compressed Gemspec file of a package +func ServePackageSpecification(ctx *context.Context) { + filename := ctx.Params("filename") + + if !strings.HasSuffix(filename, ".gemspec.rz") { + apiError(ctx, http.StatusNotImplemented, nil) + return + } + + pvs, err := getVersionsByFilename(ctx, filename[:len(filename)-10]+"gem") + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0]) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.SetServeHeaders(&context.ServeHeaderOptions{ + Filename: filename, + }) + + zw := zlib.NewWriter(ctx.Resp) + defer zw.Close() + + metadata := pd.Metadata.(*rubygems_module.Metadata) + + // create a Ruby Gem::Specification object + spec := &rubygems_module.RubyUserDef{ + Name: "Gem::Specification", + Value: []any{ + "3.2.3", // @rubygems_version + 4, // @specification_version, + pd.Package.Name, + &rubygems_module.RubyUserMarshal{ + Name: "Gem::Version", + Value: []string{pd.Version.Version}, + }, + nil, // date + metadata.Summary, // @summary + nil, // @required_ruby_version + nil, // @required_rubygems_version + metadata.Platform, // @original_platform + []any{}, // @dependencies + nil, // rubyforge_project + "", // @email + metadata.Authors, + metadata.Description, + metadata.ProjectURL, + true, // has_rdoc + metadata.Platform, // @new_platform + nil, + metadata.Licenses, + }, + } + + if err := rubygems_module.NewMarshalEncoder(zw).Encode(spec); err != nil { + ctx.ServerError("Download file failed", err) + } +} + +// DownloadPackageFile serves the content of a package +func DownloadPackageFile(ctx *context.Context) { + filename := ctx.Params("filename") + + pvs, err := getVersionsByFilename(ctx, filename) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) != 1 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + ctx, + pvs[0], + &packages_service.PackageFileInfo{ + Filename: filename, + }, + ) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +// UploadPackageFile adds a file to the package. If the package does not exist, it gets created. +func UploadPackageFile(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + rp, err := rubygems_module.ParsePackageMetaData(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + filename := getFullFilename(rp.Name, rp.Version, rp.Metadata.Platform) + + _, _, err = packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeRubyGems, + Name: rp.Name, + Version: rp.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: rp.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: filename, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +// DeletePackage deletes a package +func DeletePackage(ctx *context.Context) { + // Go populates the form only for POST, PUT and PATCH requests + if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + packageName := ctx.FormString("gem_name") + packageVersion := ctx.FormString("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeRubyGems, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + } +} + +func writeRequirements(reqs []rubygems_module.VersionRequirement, result *strings.Builder) { + if len(reqs) == 0 { + reqs = []rubygems_module.VersionRequirement{{Restriction: ">=", Version: "0"}} + } + for i, req := range reqs { + if i != 0 { + result.WriteString("&") + } + result.WriteString(req.Restriction) + result.WriteString(" ") + result.WriteString(req.Version) + } +} + +func buildRequirementStringFromVersion(ctx *context.Context, version *packages_model.PackageVersion) (string, error) { + pd, err := packages_model.GetPackageDescriptor(ctx, version) + if err != nil { + return "", err + } + metadata := pd.Metadata.(*rubygems_module.Metadata) + dependencyRequirements := new(strings.Builder) + for i, dep := range metadata.RuntimeDependencies { + if i != 0 { + dependencyRequirements.WriteString(",") + } + + dependencyRequirements.WriteString(dep.Name) + dependencyRequirements.WriteString(":") + reqs := dep.Version + writeRequirements(reqs, dependencyRequirements) + } + fullname := getFullFilename(pd.Package.Name, version.Version, metadata.Platform) + file, err := packages_model.GetFileForVersionByName(ctx, version.ID, fullname, "") + if err != nil { + return "", err + } + blob, err := packages_model.GetBlobByID(ctx, file.BlobID) + if err != nil { + return "", err + } + additionalRequirements := new(strings.Builder) + fmt.Fprintf(additionalRequirements, "checksum:%s", blob.HashSHA256) + if len(metadata.RequiredRubyVersion) != 0 { + additionalRequirements.WriteString(",ruby:") + writeRequirements(metadata.RequiredRubyVersion, additionalRequirements) + } + if len(metadata.RequiredRubygemsVersion) != 0 { + additionalRequirements.WriteString(",rubygems:") + writeRequirements(metadata.RequiredRubygemsVersion, additionalRequirements) + } + return fmt.Sprintf("%s %s|%s", version.Version, dependencyRequirements, additionalRequirements), nil +} + +func buildInfoFileForPackage(ctx *context.Context, versions []*packages_model.PackageVersion) (*string, error) { + result := "---\n" + for _, v := range versions { + str, err := buildRequirementStringFromVersion(ctx, v) + if err != nil { + return nil, err + } + result += str + result += "\n" + } + return &result, nil +} + +func getFullFilename(gemName, version, platform string) string { + return strings.ToLower(getFullName(gemName, version, platform)) + ".gem" +} + +func getFullName(gemName, version, platform string) string { + if platform == "" || platform == "ruby" { + return fmt.Sprintf("%s-%s", gemName, version) + } + return fmt.Sprintf("%s-%s-%s", gemName, version, platform) +} + +func getVersionsByFilename(ctx *context.Context, filename string) ([]*packages_model.PackageVersion, error) { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeRubyGems, + HasFileWithName: filename, + IsInternal: optional.Some(false), + }) + return pvs, err +} diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go new file mode 100644 index 0000000..a9da3ea --- /dev/null +++ b/routers/api/packages/swift/swift.go @@ -0,0 +1,465 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swift + +import ( + "errors" + "fmt" + "io" + "net/http" + "regexp" + "sort" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + packages_module "code.gitea.io/gitea/modules/packages" + swift_module "code.gitea.io/gitea/modules/packages/swift" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/hashicorp/go-version" +) + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +const ( + AcceptJSON = "application/vnd.swift.registry.v1+json" + AcceptSwift = "application/vnd.swift.registry.v1+swift" + AcceptZip = "application/vnd.swift.registry.v1+zip" +) + +var ( + // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#361-package-scope + scopePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\z`) + // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#362-package-name + namePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-_]{0,99}\z`) +) + +type headers struct { + Status int + ContentType string + Digest string + Location string + Link string +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +func setResponseHeaders(resp http.ResponseWriter, h *headers) { + if h.ContentType != "" { + resp.Header().Set("Content-Type", h.ContentType) + } + if h.Digest != "" { + resp.Header().Set("Digest", "sha256="+h.Digest) + } + if h.Location != "" { + resp.Header().Set("Location", h.Location) + } + if h.Link != "" { + resp.Header().Set("Link", h.Link) + } + resp.Header().Set("Content-Version", "1") + if h.Status != 0 { + resp.WriteHeader(h.Status) + } +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#33-error-handling +func apiError(ctx *context.Context, status int, obj any) { + // https://www.rfc-editor.org/rfc/rfc7807 + type Problem struct { + Status int `json:"status"` + Detail string `json:"detail"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + setResponseHeaders(ctx.Resp, &headers{ + Status: status, + ContentType: "application/problem+json", + }) + if err := json.NewEncoder(ctx.Resp).Encode(Problem{ + Status: status, + Detail: message, + }); err != nil { + log.Error("JSON encode: %v", err) + } + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning +func CheckAcceptMediaType(requiredAcceptHeader string) func(ctx *context.Context) { + return func(ctx *context.Context) { + accept := ctx.Req.Header.Get("Accept") + if accept != "" && accept != requiredAcceptHeader { + apiError(ctx, http.StatusBadRequest, fmt.Sprintf("Unexpected accept header. Should be '%s'.", requiredAcceptHeader)) + } + } +} + +func buildPackageID(scope, name string) string { + return scope + "." + name +} + +type Release struct { + URL string `json:"url"` +} + +type EnumeratePackageVersionsResponse struct { + Releases map[string]Release `json:"releases"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#41-list-package-releases +func EnumeratePackageVersions(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + baseURL := fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName) + + releases := make(map[string]Release) + for _, pd := range pds { + version := pd.SemVer.String() + releases[version] = Release{ + URL: baseURL + version, + } + } + + setResponseHeaders(ctx.Resp, &headers{ + Link: fmt.Sprintf(`<%s%s>; rel="latest-version"`, baseURL, pds[len(pds)-1].Version.Version), + }) + + ctx.JSON(http.StatusOK, EnumeratePackageVersionsResponse{ + Releases: releases, + }) +} + +type Resource struct { + Name string `json:"name"` + Type string `json:"type"` + Checksum string `json:"checksum"` +} + +type PackageVersionMetadataResponse struct { + ID string `json:"id"` + Version string `json:"version"` + Resources []Resource `json:"resources"` + Metadata *swift_module.SoftwareSourceCode `json:"metadata"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-2 +func PackageVersionMetadata(ctx *context.Context) { + id := buildPackageID(ctx.Params("scope"), ctx.Params("name")) + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, id, ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + metadata := pd.Metadata.(*swift_module.Metadata) + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.JSON(http.StatusOK, PackageVersionMetadataResponse{ + ID: id, + Version: pd.Version.Version, + Resources: []Resource{ + { + Name: "source-archive", + Type: "application/zip", + Checksum: pd.Files[0].Blob.HashSHA256, + }, + }, + Metadata: &swift_module.SoftwareSourceCode{ + Context: []string{"http://schema.org/"}, + Type: "SoftwareSourceCode", + Name: pd.PackageProperties.GetByName(swift_module.PropertyName), + Version: pd.Version.Version, + Description: metadata.Description, + Keywords: metadata.Keywords, + CodeRepository: metadata.RepositoryURL, + License: metadata.License, + ProgrammingLanguage: swift_module.ProgrammingLanguage{ + Type: "ComputerLanguage", + Name: "Swift", + URL: "https://swift.org", + }, + Author: swift_module.Person{ + Type: "Person", + GivenName: metadata.Author.GivenName, + MiddleName: metadata.Author.MiddleName, + FamilyName: metadata.Author.FamilyName, + }, + }, + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#43-fetch-manifest-for-a-package-release +func DownloadManifest(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + packageVersion := ctx.Params("version") + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName), packageVersion) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + swiftVersion := ctx.FormTrim("swift-version") + if swiftVersion != "" { + v, err := version.NewVersion(swiftVersion) + if err == nil { + swiftVersion = swift_module.TrimmedVersionString(v) + } + } + m, ok := pd.Metadata.(*swift_module.Metadata).Manifests[swiftVersion] + if !ok { + setResponseHeaders(ctx.Resp, &headers{ + Status: http.StatusSeeOther, + Location: fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/%s/Package.swift", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName, packageVersion), + }) + return + } + + setResponseHeaders(ctx.Resp, &headers{}) + + filename := "Package.swift" + if swiftVersion != "" { + filename = fmt.Sprintf("Package@swift-%s.swift", swiftVersion) + } + + ctx.ServeContent(strings.NewReader(m.Content), &context.ServeHeaderOptions{ + ContentType: "text/x-swift", + Filename: filename, + LastModified: pv.CreatedUnix.AsLocalTime(), + }) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-6 +func UploadPackageFile(ctx *context.Context) { + packageScope := ctx.Params("scope") + packageName := ctx.Params("name") + + v, err := version.NewVersion(ctx.Params("version")) + + if !scopePattern.MatchString(packageScope) || !namePattern.MatchString(packageName) || err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + packageVersion := v.Core().String() + + file, _, err := ctx.Req.FormFile("source-archive") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + var mr io.Reader + metadata := ctx.Req.FormValue("metadata") + if metadata != "" { + mr = strings.NewReader(metadata) + } + + pck, err := swift_module.ParsePackage(buf, buf.Size(), mr) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeSwift, + Name: buildPackageID(packageScope, packageName), + Version: packageVersion, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: pck.Metadata, + PackageProperties: map[string]string{ + swift_module.PropertyScope: packageScope, + swift_module.PropertyName: packageName, + }, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s-%s.zip", packageName, packageVersion), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + for _, url := range pck.RepositoryURLs { + _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, swift_module.PropertyRepositoryURL, url) + if err != nil { + log.Error("InsertProperty failed: %v", err) + } + } + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.Status(http.StatusCreated) +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-4 +func DownloadPackageFile(ctx *context.Context) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(ctx.Params("scope"), ctx.Params("name")), ctx.Params("version")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + setResponseHeaders(ctx.Resp, &headers{ + Digest: pd.Files[0].Blob.HashSHA256, + }) + + helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{ + Filename: pf.Name, + ContentType: "application/zip", + LastModified: pf.CreatedUnix.AsLocalTime(), + }) +} + +type LookupPackageIdentifiersResponse struct { + Identifiers []string `json:"identifiers"` +} + +// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-5 +func LookupPackageIdentifiers(ctx *context.Context) { + url := ctx.FormTrim("url") + if url == "" { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeSwift, + Properties: map[string]string{ + swift_module.PropertyRepositoryURL: url, + }, + IsInternal: optional.Some(false), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + identifiers := make([]string, 0, len(pds)) + for _, pd := range pds { + identifiers = append(identifiers, pd.Package.Name) + } + + setResponseHeaders(ctx.Resp, &headers{}) + + ctx.JSON(http.StatusOK, LookupPackageIdentifiersResponse{ + Identifiers: identifiers, + }) +} diff --git a/routers/api/packages/vagrant/vagrant.go b/routers/api/packages/vagrant/vagrant.go new file mode 100644 index 0000000..98a81da --- /dev/null +++ b/routers/api/packages/vagrant/vagrant.go @@ -0,0 +1,242 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package vagrant + +import ( + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + packages_module "code.gitea.io/gitea/modules/packages" + vagrant_module "code.gitea.io/gitea/modules/packages/vagrant" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + + "github.com/hashicorp/go-version" +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, struct { + Errors []string `json:"errors"` + }{ + Errors: []string{ + message, + }, + }) + }) +} + +func CheckAuthenticate(ctx *context.Context) { + if ctx.Doer == nil { + apiError(ctx, http.StatusUnauthorized, "Invalid access token") + return + } + + ctx.Status(http.StatusOK) +} + +func CheckBoxAvailable(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + ctx.JSON(http.StatusOK, nil) // needs to be Content-Type: application/json +} + +type packageMetadata struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ShortDescription string `json:"short_description,omitempty"` + Versions []*versionMetadata `json:"versions"` +} + +type versionMetadata struct { + Version string `json:"version"` + Status string `json:"status"` + DescriptionHTML string `json:"description_html,omitempty"` + DescriptionMarkdown string `json:"description_markdown,omitempty"` + Providers []*providerData `json:"providers"` +} + +type providerData struct { + Name string `json:"name"` + URL string `json:"url"` + Checksum string `json:"checksum"` + ChecksumType string `json:"checksum_type"` +} + +func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata { + versionURL := baseURL + "/" + url.PathEscape(pd.Version.Version) + + providers := make([]*providerData, 0, len(pd.Files)) + + for _, f := range pd.Files { + providers = append(providers, &providerData{ + Name: f.Properties.GetByName(vagrant_module.PropertyProvider), + URL: versionURL + "/" + url.PathEscape(f.File.Name), + Checksum: f.Blob.HashSHA512, + ChecksumType: "sha512", + }) + } + + return &versionMetadata{ + Status: "active", + Version: pd.Version.Version, + Providers: providers, + } +} + +func EnumeratePackageVersions(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + baseURL := fmt.Sprintf("%sapi/packages/%s/vagrant/%s", setting.AppURL, url.PathEscape(ctx.Package.Owner.Name), url.PathEscape(pds[0].Package.Name)) + + versions := make([]*versionMetadata, 0, len(pds)) + for _, pd := range pds { + versions = append(versions, packageDescriptorToMetadata(baseURL, pd)) + } + + ctx.JSON(http.StatusOK, &packageMetadata{ + Name: pds[0].Package.Name, + Description: pds[len(pds)-1].Metadata.(*vagrant_module.Metadata).Description, + Versions: versions, + }) +} + +func UploadPackageFile(ctx *context.Context) { + boxName := ctx.Params("name") + boxVersion := ctx.Params("version") + _, err := version.NewSemver(boxVersion) + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + boxProvider := ctx.Params("provider") + if !strings.HasSuffix(boxProvider, ".box") { + apiError(ctx, http.StatusBadRequest, err) + return + } + + upload, needsClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needsClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + metadata, err := vagrant_module.ParseMetadataFromBox(buf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeVagrant, + Name: boxName, + Version: boxVersion, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(boxProvider), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: map[string]string{ + vagrant_module.PropertyProvider: strings.TrimSuffix(boxProvider, ".box"), + }, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadPackageFile(ctx *context.Context) { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeVagrant, + Name: ctx.Params("name"), + Version: ctx.Params("version"), + }, + &packages_service.PackageFileInfo{ + Filename: ctx.Params("provider"), + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go new file mode 100644 index 0000000..e2ff004 --- /dev/null +++ b/routers/api/shared/middleware.go @@ -0,0 +1,152 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + + "github.com/go-chi/cors" +) + +func Middlewares() (stack []any) { + stack = append(stack, securityHeaders()) + + if setting.CORSConfig.Enabled { + stack = append(stack, cors.Handler(cors.Options{ + AllowedOrigins: setting.CORSConfig.AllowDomain, + AllowedMethods: setting.CORSConfig.Methods, + AllowCredentials: setting.CORSConfig.AllowCredentials, + AllowedHeaders: append([]string{"Authorization", "X-Gitea-OTP", "X-Forgejo-OTP"}, setting.CORSConfig.Headers...), + MaxAge: int(setting.CORSConfig.MaxAge.Seconds()), + })) + } + return append(stack, + context.APIContexter(), + + checkDeprecatedAuthMethods, + // Get user from session if logged in. + apiAuth(buildAuthGroup()), + verifyAuthWithOptions(&common.VerifyOptions{ + SignInRequired: setting.Service.RequireSignInView, + }), + ) +} + +func buildAuthGroup() *auth.Group { + group := auth.NewGroup( + &auth.OAuth2{}, + &auth.HTTPSign{}, + &auth.Basic{}, // FIXME: this should be removed once we don't allow basic auth in API + ) + if setting.Service.EnableReverseProxyAuthAPI { + group.Add(&auth.ReverseProxy{}) + } + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth.SSPI{}) // it MUST be the last, see the comment of SSPI + } + + return group +} + +func apiAuth(authMethod auth.Method) func(*context.APIContext) { + return func(ctx *context.APIContext) { + ar, err := common.AuthShared(ctx.Base, nil, authMethod) + if err != nil { + ctx.Error(http.StatusUnauthorized, "APIAuth", err) + return + } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth + } +} + +// verifyAuthWithOptions checks authentication according to options +func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + // Check prohibit login users. + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is not activated.", + }) + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is prohibited from signing in, please contact your site administrator.", + }) + return + } + + if ctx.Doer.MustChangePassword { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password", + }) + return + } + } + + // Redirect to dashboard if user tries to visit any non-login page. + if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { + ctx.Redirect(setting.AppSubURL + "/") + return + } + + if options.SignInRequired { + if !ctx.IsSigned { + // Restrict API calls with error message. + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in user is allowed to call APIs.", + }) + return + } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is not activated.", + }) + return + } + } + + if options.AdminRequired { + if !ctx.Doer.IsAdmin { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "You have no permission to request for this.", + }) + return + } + } + } +} + +// check for and warn against deprecated authentication options +func checkDeprecatedAuthMethods(ctx *context.APIContext) { + if ctx.FormString("token") != "" || ctx.FormString("access_token") != "" { + ctx.Resp.Header().Set("Warning", "token and access_token API authentication is deprecated and will be removed in gitea 1.23. Please use AuthorizationHeaderToken instead. Existing queries will continue to work but without authorization.") + } +} + +func securityHeaders() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + // CORB: https://www.chromium.org/Home/chromium-security/corb-for-developers + // http://stackoverflow.com/a/3146618/244009 + resp.Header().Set("x-content-type-options", "nosniff") + next.ServeHTTP(resp, req) + }) + } +} 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")), + } +} diff --git a/routers/common/auth.go b/routers/common/auth.go new file mode 100644 index 0000000..115d65e --- /dev/null +++ b/routers/common/auth.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" +) + +type AuthResult struct { + Doer *user_model.User + IsBasicAuth bool +} + +func AuthShared(ctx *context.Base, sessionStore auth_service.SessionStore, authMethod auth_service.Method) (ar AuthResult, err error) { + ar.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, sessionStore) + if err != nil { + return ar, err + } + if ar.Doer != nil { + if ctx.Locale.Language() != ar.Doer.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + ar.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth_service.BasicMethodName + + ctx.Data["IsSigned"] = true + ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer + ctx.Data["SignedUserID"] = ar.Doer.ID + ctx.Data["IsAdmin"] = ar.Doer.IsAdmin + } else { + ctx.Data["SignedUserID"] = int64(0) + } + return ar, nil +} + +// VerifyOptions contains required or check options +type VerifyOptions struct { + SignInRequired bool + SignOutRequired bool + AdminRequired bool + DisableCSRF bool +} diff --git a/routers/common/compare.go b/routers/common/compare.go new file mode 100644 index 0000000..4d1cc2f --- /dev/null +++ b/routers/common/compare.go @@ -0,0 +1,21 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" +) + +// CompareInfo represents the collected results from ParseCompareInfo +type CompareInfo struct { + HeadUser *user_model.User + HeadRepo *repo_model.Repository + HeadGitRepo *git.Repository + CompareInfo *git.CompareInfo + BaseBranch string + HeadBranch string + DirectComparison bool +} diff --git a/routers/common/db.go b/routers/common/db.go new file mode 100644 index 0000000..d7dcfa0 --- /dev/null +++ b/routers/common/db.go @@ -0,0 +1,59 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/migrations" + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + + "xorm.io/xorm" +) + +// InitDBEngine In case of problems connecting to DB, retry connection. Eg, PGSQL in Docker Container on Synology +func InitDBEngine(ctx context.Context) (err error) { + log.Info("Beginning ORM engine initialization.") + for i := 0; i < setting.Database.DBConnectRetries; i++ { + select { + case <-ctx.Done(): + return fmt.Errorf("Aborted due to shutdown:\nin retry ORM engine initialization") + default: + } + log.Info("ORM engine initialization attempt #%d/%d...", i+1, setting.Database.DBConnectRetries) + if err = db.InitEngineWithMigration(ctx, migrateWithSetting); err == nil { + break + } else if i == setting.Database.DBConnectRetries-1 { + return err + } + log.Error("ORM engine initialization attempt #%d/%d failed. Error: %v", i+1, setting.Database.DBConnectRetries, err) + log.Info("Backing off for %d seconds", int64(setting.Database.DBConnectBackoff/time.Second)) + time.Sleep(setting.Database.DBConnectBackoff) + } + config.SetDynGetter(system_model.NewDatabaseDynKeyGetter()) + return nil +} + +func migrateWithSetting(x *xorm.Engine) error { + if setting.Database.AutoMigration { + return migrations.Migrate(x) + } + + if current, err := migrations.GetCurrentDBVersion(x); err != nil { + return err + } else if current < 0 { + // execute migrations when the database isn't initialized even if AutoMigration is false + return migrations.Migrate(x) + } else if expected := migrations.ExpectedVersion(); current != expected { + log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+ + `You can set "database.AUTO_MIGRATION" to true or migrate manually by running "forgejo [--config /path/to/app.ini] migrate"`, current, expected) + } + return nil +} diff --git a/routers/common/errpage.go b/routers/common/errpage.go new file mode 100644 index 0000000..402ca44 --- /dev/null +++ b/routers/common/errpage.go @@ -0,0 +1,56 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/services/context" +) + +const tplStatus500 base.TplName = "status/500" + +// RenderPanicErrorPage renders a 500 page, and it never panics +func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { + combinedErr := fmt.Sprintf("%v\n%s", err, log.Stack(2)) + log.Error("PANIC: %s", combinedErr) + + defer func() { + if err := recover(); err != nil { + log.Error("Panic occurs again when rendering error page: %v. Stack:\n%s", err, log.Stack(2)) + } + }() + + routing.UpdatePanicError(req.Context(), err) + + httpcache.SetCacheControlInHeader(w.Header(), 0, "no-transform") + w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) + + tmplCtx := context.TemplateContext{} + tmplCtx["Locale"] = middleware.Locale(w, req) + ctxData := middleware.GetContextData(req.Context()) + + // This recovery handler could be called without Gitea's web context, so we shouldn't touch that context too much. + // Otherwise, the 500-page may cause new panics, eg: cache.GetContextWithData, it makes the developer&users couldn't find the original panic. + user, _ := ctxData[middleware.ContextDataKeySignedUser].(*user_model.User) + if !setting.IsProd || (user != nil && user.IsAdmin) { + ctxData["ErrorMsg"] = "PANIC: " + combinedErr + } + + err = templates.HTMLRenderer().HTML(w, http.StatusInternalServerError, string(tplStatus500), ctxData, tmplCtx) + if err != nil { + log.Error("Error occurs again when rendering error page: %v", err) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker")) + } +} diff --git a/routers/common/errpage_test.go b/routers/common/errpage_test.go new file mode 100644 index 0000000..4fd63ba --- /dev/null +++ b/routers/common/errpage_test.go @@ -0,0 +1,39 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web/middleware" + + "github.com/stretchr/testify/assert" +) + +func TestRenderPanicErrorPage(t *testing.T) { + w := httptest.NewRecorder() + req := &http.Request{URL: &url.URL{}} + req = req.WithContext(middleware.WithContextData(context.Background())) + RenderPanicErrorPage(w, req, errors.New("fake panic error (for test only)")) + respContent := w.Body.String() + assert.Contains(t, respContent, `class="page-content status-page-500"`) + assert.Contains(t, respContent, ``) + assert.Contains(t, respContent, `lang="en-US"`) // make sure the locale work + + // the 500 page doesn't have normal pages footer, it makes it easier to distinguish a normal page and a failed page. + // especially when a sub-template causes page error, the HTTP response code is still 200, + // the different "footer" is the only way to know whether a page is fully rendered without error. + assert.False(t, test.IsNormalPageCompleted(respContent)) +} + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/common/markup.go b/routers/common/markup.go new file mode 100644 index 0000000..2d5638e --- /dev/null +++ b/routers/common/markup.go @@ -0,0 +1,98 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + + "mvdan.cc/xurls/v2" +) + +// RenderMarkup renders markup text for the /markup and /markdown endpoints +func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPrefix, filePath string, wiki bool) { + var markupType string + relativePath := "" + + if len(text) == 0 { + _, _ = ctx.Write([]byte("")) + return + } + + switch mode { + case "markdown": + // Raw markdown + if err := markdown.RenderRaw(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: urlPrefix, + }, + }, strings.NewReader(text), ctx.Resp); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + return + case "comment": + // Comment as markdown + markupType = markdown.MarkupName + case "gfm": + // Github Flavored Markdown as document + markupType = markdown.MarkupName + case "file": + // File as document based on file extension + markupType = "" + relativePath = filePath + default: + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode)) + return + } + + if !strings.HasPrefix(setting.AppSubURL+"/", urlPrefix) { + // check if urlPrefix is already set to a URL + linkRegex, _ := xurls.StrictMatchingScheme("https?://") + m := linkRegex.FindStringIndex(urlPrefix) + if m == nil { + urlPrefix = util.URLJoin(setting.AppURL, urlPrefix) + } + } + + meta := map[string]string{} + if repo != nil && repo.Repository != nil { + if mode == "comment" { + meta = repo.Repository.ComposeMetas(ctx) + } else { + meta = repo.Repository.ComposeDocumentMetas(ctx) + } + } + if mode != "comment" { + meta["mode"] = "document" + } + + if err := markup.Render(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: urlPrefix, + }, + Metas: meta, + IsWiki: wiki, + Type: markupType, + RelativePath: relativePath, + }, strings.NewReader(text), ctx.Resp); err != nil { + if markup.IsErrUnsupportedRenderExtension(err) { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + return + } +} diff --git a/routers/common/middleware.go b/routers/common/middleware.go new file mode 100644 index 0000000..59e59b8 --- /dev/null +++ b/routers/common/middleware.go @@ -0,0 +1,116 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/services/context" + + "code.forgejo.org/go-chi/session" + "github.com/chi-middleware/proxy" + chi "github.com/go-chi/chi/v5" +) + +// ProtocolMiddlewares returns HTTP protocol related middlewares, and it provides a global panic recovery +func ProtocolMiddlewares() (handlers []any) { + // first, normalize the URL path + handlers = append(handlers, stripSlashesMiddleware) + + // prepare the ContextData and panic recovery + handlers = append(handlers, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + defer func() { + if err := recover(); err != nil { + RenderPanicErrorPage(resp, req, err) // it should never panic + } + }() + req = req.WithContext(middleware.WithContextData(req.Context())) + next.ServeHTTP(resp, req) + }) + }) + + // wrap the request and response, use the process context and add it to the process manager + handlers = append(handlers, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + ctx, _, finished := process.GetManager().AddTypedContext(req.Context(), fmt.Sprintf("%s: %s", req.Method, req.RequestURI), process.RequestProcessType, true) + defer finished() + next.ServeHTTP(context.WrapResponseWriter(resp), req.WithContext(cache.WithCacheContext(ctx))) + }) + }) + + if setting.ReverseProxyLimit > 0 { + opt := proxy.NewForwardedHeadersOptions(). + WithForwardLimit(setting.ReverseProxyLimit). + ClearTrustedProxies() + for _, n := range setting.ReverseProxyTrustedProxies { + if !strings.Contains(n, "/") { + opt.AddTrustedProxy(n) + } else { + opt.AddTrustedNetwork(n) + } + } + handlers = append(handlers, proxy.ForwardedHeaders(opt)) + } + + if setting.IsRouteLogEnabled() { + handlers = append(handlers, routing.NewLoggerHandler()) + } + + if setting.IsAccessLogEnabled() { + handlers = append(handlers, context.AccessLogger()) + } + + return handlers +} + +func stripSlashesMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + // First of all escape the URL RawPath to ensure that all routing is done using a correctly escaped URL + req.URL.RawPath = req.URL.EscapedPath() + + urlPath := req.URL.RawPath + rctx := chi.RouteContext(req.Context()) + if rctx != nil && rctx.RoutePath != "" { + urlPath = rctx.RoutePath + } + + sanitizedPath := &strings.Builder{} + prevWasSlash := false + for _, chr := range strings.TrimRight(urlPath, "/") { + if chr != '/' || !prevWasSlash { + sanitizedPath.WriteRune(chr) + } + prevWasSlash = chr == '/' + } + + if rctx == nil { + req.URL.Path = sanitizedPath.String() + } else { + rctx.RoutePath = sanitizedPath.String() + } + next.ServeHTTP(resp, req) + }) +} + +func Sessioner() func(next http.Handler) http.Handler { + return session.Sessioner(session.Options{ + Provider: setting.SessionConfig.Provider, + ProviderConfig: setting.SessionConfig.ProviderConfig, + CookieName: setting.SessionConfig.CookieName, + CookiePath: setting.SessionConfig.CookiePath, + Gclifetime: setting.SessionConfig.Gclifetime, + Maxlifetime: setting.SessionConfig.Maxlifetime, + Secure: setting.SessionConfig.Secure, + SameSite: setting.SessionConfig.SameSite, + Domain: setting.SessionConfig.Domain, + }) +} diff --git a/routers/common/middleware_test.go b/routers/common/middleware_test.go new file mode 100644 index 0000000..f16b937 --- /dev/null +++ b/routers/common/middleware_test.go @@ -0,0 +1,70 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package common + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripSlashesMiddleware(t *testing.T) { + type test struct { + name string + expectedPath string + inputPath string + } + + tests := []test{ + { + name: "path with multiple slashes", + inputPath: "https://github.com///go-gitea//gitea.git", + expectedPath: "/go-gitea/gitea.git", + }, + { + name: "path with no slashes", + inputPath: "https://github.com/go-gitea/gitea.git", + expectedPath: "/go-gitea/gitea.git", + }, + { + name: "path with slashes in the middle", + inputPath: "https://git.data.coop//halfd/new-website.git", + expectedPath: "/halfd/new-website.git", + }, + { + name: "path with slashes in the middle", + inputPath: "https://git.data.coop//halfd/new-website.git", + expectedPath: "/halfd/new-website.git", + }, + { + name: "path with slashes in the end", + inputPath: "/user2//repo1/", + expectedPath: "/user2/repo1", + }, + { + name: "path with slashes and query params", + inputPath: "/repo//migrate?service_type=3", + expectedPath: "/repo/migrate", + }, + { + name: "path with encoded slash", + inputPath: "/user2/%2F%2Frepo1", + expectedPath: "/user2/%2F%2Frepo1", + }, + } + + for _, tt := range tests { + testMiddleware := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.expectedPath, r.URL.Path) + }) + + // pass the test middleware to validate the changes + handlerToTest := stripSlashesMiddleware(testMiddleware) + // create a mock request to use + req := httptest.NewRequest("GET", tt.inputPath, nil) + // call the handler using a mock response recorder + handlerToTest.ServeHTTP(httptest.NewRecorder(), req) + } +} diff --git a/routers/common/redirect.go b/routers/common/redirect.go new file mode 100644 index 0000000..9bf2025 --- /dev/null +++ b/routers/common/redirect.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net/http" + + "code.gitea.io/gitea/modules/httplib" +) + +// FetchRedirectDelegate helps the "fetch" requests to redirect to the correct location +func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) { + // When use "fetch" to post requests and the response is a redirect, browser's "location.href = uri" has limitations. + // 1. change "location" from old "/foo" to new "/foo#hash", the browser will not reload the page. + // 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target. + // The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2", + // then frontend needs this delegate to redirect to the new location with hash correctly. + redirect := req.PostFormValue("redirect") + if httplib.IsRiskyRedirectURL(redirect) { + resp.WriteHeader(http.StatusBadRequest) + return + } + resp.Header().Add("Location", redirect) + resp.WriteHeader(http.StatusSeeOther) +} diff --git a/routers/common/serve.go b/routers/common/serve.go new file mode 100644 index 0000000..446908d --- /dev/null +++ b/routers/common/serve.go @@ -0,0 +1,43 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "io" + "time" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// ServeBlob download a git.Blob +func ServeBlob(ctx *context.Base, filePath string, blob *git.Blob, lastModified *time.Time) error { + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + return nil + } + + dataRc, err := blob.DataAsync() + if err != nil { + return err + } + defer func() { + if err = dataRc.Close(); err != nil { + log.Error("ServeBlob: Close: %v", err) + } + }() + + httplib.ServeContentByReader(ctx.Req, ctx.Resp, filePath, blob.Size(), dataRc) + return nil +} + +func ServeContentByReader(ctx *context.Base, filePath string, size int64, reader io.Reader) { + httplib.ServeContentByReader(ctx.Req, ctx.Resp, filePath, size, reader) +} + +func ServeContentByReadSeeker(ctx *context.Base, filePath string, modTime *time.Time, reader io.ReadSeeker) { + httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, filePath, modTime, reader) +} diff --git a/routers/init.go b/routers/init.go new file mode 100644 index 0000000..821a0ef --- /dev/null +++ b/routers/init.go @@ -0,0 +1,208 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package routers + +import ( + "context" + "reflect" + "runtime" + + "code.gitea.io/gitea/models" + asymkey_model "code.gitea.io/gitea/models/asymkey" + authmodel "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/external" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/ssh" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/system" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/web" + actions_router "code.gitea.io/gitea/routers/api/actions" + forgejo "code.gitea.io/gitea/routers/api/forgejo/v1" + packages_router "code.gitea.io/gitea/routers/api/packages" + apiv1 "code.gitea.io/gitea/routers/api/v1" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/routers/private" + web_routers "code.gitea.io/gitea/routers/web" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/automerge" + "code.gitea.io/gitea/services/cron" + feed_service "code.gitea.io/gitea/services/feed" + indexer_service "code.gitea.io/gitea/services/indexer" + "code.gitea.io/gitea/services/mailer" + mailer_incoming "code.gitea.io/gitea/services/mailer/incoming" + markup_service "code.gitea.io/gitea/services/markup" + repo_migrations "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" + pull_service "code.gitea.io/gitea/services/pull" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/services/repository/archiver" + "code.gitea.io/gitea/services/task" + "code.gitea.io/gitea/services/uinotification" + "code.gitea.io/gitea/services/webhook" +) + +func mustInit(fn func() error) { + err := fn() + if err != nil { + ptr := reflect.ValueOf(fn).Pointer() + fi := runtime.FuncForPC(ptr) + log.Fatal("%s failed: %v", fi.Name(), err) + } +} + +func mustInitCtx(ctx context.Context, fn func(ctx context.Context) error) { + err := fn(ctx) + if err != nil { + ptr := reflect.ValueOf(fn).Pointer() + fi := runtime.FuncForPC(ptr) + log.Fatal("%s(ctx) failed: %v", fi.Name(), err) + } +} + +func syncAppConfForGit(ctx context.Context) error { + runtimeState := new(system.RuntimeState) + if err := system.AppState.Get(ctx, runtimeState); err != nil { + return err + } + + updated := false + if runtimeState.LastAppPath != setting.AppPath { + log.Info("AppPath changed from '%s' to '%s'", runtimeState.LastAppPath, setting.AppPath) + runtimeState.LastAppPath = setting.AppPath + updated = true + } + if runtimeState.LastCustomConf != setting.CustomConf { + log.Info("CustomConf changed from '%s' to '%s'", runtimeState.LastCustomConf, setting.CustomConf) + runtimeState.LastCustomConf = setting.CustomConf + updated = true + } + + if updated { + log.Info("re-sync repository hooks ...") + mustInitCtx(ctx, repo_service.SyncRepositoryHooks) + + log.Info("re-write ssh public keys ...") + mustInitCtx(ctx, asymkey_model.RewriteAllPublicKeys) + + return system.AppState.Set(ctx, runtimeState) + } + return nil +} + +func InitWebInstallPage(ctx context.Context) { + translation.InitLocales(ctx) + setting.LoadSettingsForInstall() + mustInit(svg.Init) +} + +// InitWebInstalled is for global installed configuration. +func InitWebInstalled(ctx context.Context) { + mustInitCtx(ctx, git.InitFull) + log.Info("Git version: %s (home: %s)", git.VersionInfo(), git.HomeDir()) + + // Setup i18n + translation.InitLocales(ctx) + + setting.LoadSettings() + mustInit(storage.Init) + + mailer.NewContext(ctx) + mustInit(cache.Init) + mustInit(feed_service.Init) + mustInit(uinotification.Init) + mustInitCtx(ctx, archiver.Init) + + highlight.NewContext() + external.RegisterRenderers() + markup.Init(markup_service.ProcessorHelper()) + + if setting.EnableSQLite3 { + log.Info("SQLite3 support is enabled") + } else if setting.Database.Type.IsSQLite3() { + log.Fatal("SQLite3 support is disabled, but it is used for database setting. Please get or build a Forgejo release with SQLite3 support.") + } + + mustInitCtx(ctx, common.InitDBEngine) + log.Info("ORM engine initialization successful!") + mustInit(system.Init) + mustInitCtx(ctx, oauth2.Init) + + mustInit(release_service.Init) + + mustInitCtx(ctx, models.Init) + mustInitCtx(ctx, authmodel.Init) + mustInitCtx(ctx, repo_service.Init) + + // Booting long running goroutines. + mustInit(indexer_service.Init) + + mirror_service.InitSyncMirrors() + mustInit(webhook.Init) + mustInit(pull_service.Init) + mustInit(automerge.Init) + mustInit(task.Init) + mustInit(repo_migrations.Init) + eventsource.GetManager().Init() + mustInitCtx(ctx, mailer_incoming.Init) + + mustInitCtx(ctx, syncAppConfForGit) + + mustInit(ssh.Init) + + auth.Init() + mustInit(svg.Init) + + actions_service.Init() + + // Finally start up the cron + cron.NewContext(ctx) +} + +// NormalRoutes represents non install routes +func NormalRoutes() *web.Route { + _ = templates.HTMLRenderer() + r := web.NewRoute() + r.Use(common.ProtocolMiddlewares()...) + + r.Mount("/", web_routers.Routes()) + r.Mount("/api/v1", apiv1.Routes()) + r.Mount("/api/forgejo/v1", forgejo.Routes()) + r.Mount("/api/internal", private.Routes()) + + r.Post("/-/fetch-redirect", common.FetchRedirectDelegate) + + if setting.Packages.Enabled { + // This implements package support for most package managers + r.Mount("/api/packages", packages_router.CommonRoutes()) + // This implements the OCI API (Note this is not preceded by /api but is instead /v2) + r.Mount("/v2", packages_router.ContainerRoutes()) + } + + if setting.Actions.Enabled { + prefix := "/api/actions" + r.Mount(prefix, actions_router.Routes(prefix)) + + // TODO: Pipeline api used for runner internal communication with gitea server. but only artifact is used for now. + // In Github, it uses ACTIONS_RUNTIME_URL=https://pipelines.actions.githubusercontent.com/fLgcSHkPGySXeIFrg8W8OBSfeg3b5Fls1A1CwX566g8PayEGlg/ + // TODO: this prefix should be generated with a token string with runner ? + prefix = "/api/actions_pipeline" + r.Mount(prefix, actions_router.ArtifactsRoutes(prefix)) + prefix = actions_router.ArtifactV4RouteBase + r.Mount(prefix, actions_router.ArtifactsV4Routes(prefix)) + } + + return r +} diff --git a/routers/install/install.go b/routers/install/install.go new file mode 100644 index 0000000..24db25f --- /dev/null +++ b/routers/install/install.go @@ -0,0 +1,617 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package install + +import ( + "fmt" + "net/http" + "net/mail" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + db_install "code.gitea.io/gitea/models/db/install" + "code.gitea.io/gitea/models/migrations" + system_model "code.gitea.io/gitea/models/system" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password/hash" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/user" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + + "code.forgejo.org/go-chi/session" +) + +const ( + // tplInstall template for installation page + tplInstall base.TplName = "install" + tplPostInstall base.TplName = "post-install" +) + +// getSupportedDbTypeNames returns a slice for supported database types and names. The slice is used to keep the order +func getSupportedDbTypeNames() (dbTypeNames []map[string]string) { + for _, t := range setting.SupportedDatabaseTypes { + dbTypeNames = append(dbTypeNames, map[string]string{"type": t, "name": setting.DatabaseTypeNames[t]}) + } + return dbTypeNames +} + +// Contexter prepare for rendering installation page +func Contexter() func(next http.Handler) http.Handler { + rnd := templates.HTMLRenderer() + dbTypeNames := getSupportedDbTypeNames() + envConfigKeys := setting.CollectEnvConfigKeys() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base, baseCleanUp := context.NewBaseContext(resp, req) + defer baseCleanUp() + + ctx := context.NewWebContext(base, rnd, session.GetSession(req)) + ctx.AppendContextValue(context.WebContextKey, ctx) + ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) + ctx.Data.MergeFrom(middleware.ContextData{ + "Context": ctx, // TODO: use "ctx" in template and remove this + "locale": ctx.Locale, + "Title": ctx.Locale.Tr("install.install"), + "PageIsInstall": true, + "DbTypeNames": dbTypeNames, + "EnvConfigKeys": envConfigKeys, + "CustomConfFile": setting.CustomConf, + "AllLangs": translation.AllLangs(), + + "PasswordHashAlgorithms": hash.RecommendedHashAlgorithms, + }) + next.ServeHTTP(resp, ctx.Req) + }) + } +} + +// Install render installation page +func Install(ctx *context.Context) { + if setting.InstallLock { + InstallDone(ctx) + return + } + + form := forms.InstallForm{} + + // Database settings + form.DbHost = setting.Database.Host + form.DbUser = setting.Database.User + form.DbPasswd = setting.Database.Passwd + form.DbName = setting.Database.Name + form.DbPath = setting.Database.Path + form.DbSchema = setting.Database.Schema + form.SSLMode = setting.Database.SSLMode + + curDBType := setting.Database.Type.String() + var isCurDBTypeSupported bool + for _, dbType := range setting.SupportedDatabaseTypes { + if dbType == curDBType { + isCurDBTypeSupported = true + break + } + } + if !isCurDBTypeSupported { + curDBType = "mysql" + } + ctx.Data["CurDbType"] = curDBType + + // Application general settings + form.AppName = "Forgejo" + form.AppSlogan = "Beyond coding. We Forge." + form.RepoRootPath = setting.RepoRootPath + form.LFSRootPath = setting.LFS.Storage.Path + + // Note(unknown): it's hard for Windows users change a running user, + // so just use current one if config says default. + if setting.IsWindows && setting.RunUser == "git" { + form.RunUser = user.CurrentUsername() + } else { + form.RunUser = setting.RunUser + } + + form.Domain = setting.Domain + form.SSHPort = setting.SSH.Port + form.HTTPPort = setting.HTTPPort + form.AppURL = setting.AppURL + form.LogRootPath = setting.Log.RootPath + + // E-mail service settings + if setting.MailService != nil { + form.SMTPAddr = setting.MailService.SMTPAddr + form.SMTPPort = setting.MailService.SMTPPort + form.SMTPFrom = setting.MailService.From + form.SMTPUser = setting.MailService.User + form.SMTPPasswd = setting.MailService.Passwd + } + form.RegisterConfirm = setting.Service.RegisterEmailConfirm + form.MailNotify = setting.Service.EnableNotifyMail + + // Server and other services settings + form.OfflineMode = setting.OfflineMode + form.DisableGravatar = setting.DisableGravatar // when installing, there is no database connection so that given a default value + form.EnableFederatedAvatar = setting.EnableFederatedAvatar // when installing, there is no database connection so that given a default value + + form.EnableOpenIDSignIn = setting.Service.EnableOpenIDSignIn + form.EnableOpenIDSignUp = setting.Service.EnableOpenIDSignUp + form.DisableRegistration = true // Force it to true, for the installation, to discourage creating instances with open registration, which invite all kinds of spam. + form.AllowOnlyExternalRegistration = setting.Service.AllowOnlyExternalRegistration + form.EnableCaptcha = setting.Service.EnableCaptcha + form.RequireSignInView = setting.Service.RequireSignInView + form.DefaultKeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate + form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization + form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking + form.NoReplyAddress = setting.Service.NoReplyAddress + form.EnableUpdateChecker = true + form.PasswordAlgorithm = hash.ConfigHashAlgorithm(setting.PasswordHashAlgo) + + middleware.AssignForm(form, ctx.Data) + ctx.HTML(http.StatusOK, tplInstall) +} + +func checkDatabase(ctx *context.Context, form *forms.InstallForm) bool { + var err error + + if (setting.Database.Type == "sqlite3") && + len(setting.Database.Path) == 0 { + ctx.Data["Err_DbPath"] = true + ctx.RenderWithErr(ctx.Tr("install.err_empty_db_path"), tplInstall, form) + return false + } + + // Check if the user is trying to re-install in an installed database + db.UnsetDefaultEngine() + defer db.UnsetDefaultEngine() + + if err = db.InitEngine(ctx); err != nil { + if strings.Contains(err.Error(), `Unknown database type: sqlite3`) { + ctx.Data["Err_DbType"] = true + ctx.RenderWithErr(ctx.Tr("install.sqlite3_not_available", "https://forgejo.org/download#installation-from-binary"), tplInstall, form) + } else { + ctx.Data["Err_DbSetting"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_db_setting", err), tplInstall, form) + } + return false + } + + err = db_install.CheckDatabaseConnection() + if err != nil { + ctx.Data["Err_DbSetting"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_db_setting", err), tplInstall, form) + return false + } + + hasPostInstallationUser, err := db_install.HasPostInstallationUsers() + if err != nil { + ctx.Data["Err_DbSetting"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_db_table", "user", err), tplInstall, form) + return false + } + dbMigrationVersion, err := db_install.GetMigrationVersion() + if err != nil { + ctx.Data["Err_DbSetting"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_db_table", "version", err), tplInstall, form) + return false + } + + if hasPostInstallationUser && dbMigrationVersion > 0 { + log.Error("The database is likely to have been used by Forgejo before, database migration version=%d", dbMigrationVersion) + confirmed := form.ReinstallConfirmFirst && form.ReinstallConfirmSecond && form.ReinstallConfirmThird + if !confirmed { + ctx.Data["Err_DbInstalledBefore"] = true + ctx.RenderWithErr(ctx.Tr("install.reinstall_error"), tplInstall, form) + return false + } + + log.Info("User confirmed re-installation of Forgejo into a pre-existing database") + } + + if hasPostInstallationUser || dbMigrationVersion > 0 { + log.Info("Forgejo will be installed in a database with: hasPostInstallationUser=%v, dbMigrationVersion=%v", hasPostInstallationUser, dbMigrationVersion) + } + + return true +} + +// SubmitInstall response for submit install items +func SubmitInstall(ctx *context.Context) { + if setting.InstallLock { + InstallDone(ctx) + return + } + + var err error + + form := *web.GetForm(ctx).(*forms.InstallForm) + + // fix form values + if form.AppURL != "" && form.AppURL[len(form.AppURL)-1] != '/' { + form.AppURL += "/" + } + + ctx.Data["CurDbType"] = form.DbType + + if ctx.HasError() { + ctx.Data["Err_SMTP"] = ctx.Data["Err_SMTPUser"] != nil + ctx.Data["Err_Admin"] = ctx.Data["Err_AdminName"] != nil || ctx.Data["Err_AdminPasswd"] != nil || ctx.Data["Err_AdminEmail"] != nil + ctx.HTML(http.StatusOK, tplInstall) + return + } + + if _, err = exec.LookPath("git"); err != nil { + ctx.RenderWithErr(ctx.Tr("install.test_git_failed", err), tplInstall, &form) + return + } + + // ---- Basic checks are passed, now test configuration. + + // Test database setting. + setting.Database.Type = setting.DatabaseType(form.DbType) + setting.Database.Host = form.DbHost + setting.Database.User = form.DbUser + setting.Database.Passwd = form.DbPasswd + setting.Database.Name = form.DbName + setting.Database.Schema = form.DbSchema + setting.Database.SSLMode = form.SSLMode + setting.Database.Path = form.DbPath + setting.Database.LogSQL = !setting.IsProd + + if !checkDatabase(ctx, &form) { + return + } + + // Prepare AppDataPath, it is very important for Gitea + if err = setting.PrepareAppDataPath(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.invalid_app_data_path", err), tplInstall, &form) + return + } + + // Test repository root path. + form.RepoRootPath = strings.ReplaceAll(form.RepoRootPath, "\\", "/") + if err = os.MkdirAll(form.RepoRootPath, os.ModePerm); err != nil { + ctx.Data["Err_RepoRootPath"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_repo_path", err), tplInstall, &form) + return + } + + // Test LFS root path if not empty, empty meaning disable LFS + if form.LFSRootPath != "" { + form.LFSRootPath = strings.ReplaceAll(form.LFSRootPath, "\\", "/") + if err := os.MkdirAll(form.LFSRootPath, os.ModePerm); err != nil { + ctx.Data["Err_LFSRootPath"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_lfs_path", err), tplInstall, &form) + return + } + } + + // Test log root path. + form.LogRootPath = strings.ReplaceAll(form.LogRootPath, "\\", "/") + if err = os.MkdirAll(form.LogRootPath, os.ModePerm); err != nil { + ctx.Data["Err_LogRootPath"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_log_root_path", err), tplInstall, &form) + return + } + + currentUser, match := setting.IsRunUserMatchCurrentUser(form.RunUser) + if !match { + ctx.Data["Err_RunUser"] = true + ctx.RenderWithErr(ctx.Tr("install.run_user_not_match", form.RunUser, currentUser), tplInstall, &form) + return + } + + // Check logic loophole between disable self-registration and no admin account. + if form.DisableRegistration && len(form.AdminName) == 0 { + ctx.Data["Err_DisabledRegistration"] = true + ctx.Data["Err_Admin"] = true + ctx.RenderWithErr(ctx.Tr("install.no_admin_and_disable_registration"), tplInstall, form) + return + } + + // Check admin user creation + if len(form.AdminName) > 0 { + // Ensure AdminName is valid + if err := user_model.IsUsableUsername(form.AdminName); err != nil { + ctx.Data["Err_Admin"] = true + ctx.Data["Err_AdminName"] = true + if db.IsErrNameReserved(err) { + ctx.RenderWithErr(ctx.Tr("install.err_admin_name_is_reserved"), tplInstall, form) + return + } else if db.IsErrNamePatternNotAllowed(err) { + ctx.RenderWithErr(ctx.Tr("install.err_admin_name_pattern_not_allowed"), tplInstall, form) + return + } + ctx.RenderWithErr(ctx.Tr("install.err_admin_name_is_invalid"), tplInstall, form) + return + } + // Check Admin email + if len(form.AdminEmail) == 0 { + ctx.Data["Err_Admin"] = true + ctx.Data["Err_AdminEmail"] = true + ctx.RenderWithErr(ctx.Tr("install.err_empty_admin_email"), tplInstall, form) + return + } + // Check admin password. + if len(form.AdminPasswd) == 0 { + ctx.Data["Err_Admin"] = true + ctx.Data["Err_AdminPasswd"] = true + ctx.RenderWithErr(ctx.Tr("install.err_empty_admin_password"), tplInstall, form) + return + } + if form.AdminPasswd != form.AdminConfirmPasswd { + ctx.Data["Err_Admin"] = true + ctx.Data["Err_AdminPasswd"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplInstall, form) + return + } + if len(form.AdminPasswd) < setting.MinPasswordLength { + ctx.Data["Err_Admin"] = true + ctx.Data["Err_AdminPasswd"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplInstall, form) + return + } + } + + // Init the engine with migration + if err = db.InitEngineWithMigration(ctx, migrations.Migrate); err != nil { + db.UnsetDefaultEngine() + ctx.Data["Err_DbSetting"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_db_setting", err), tplInstall, &form) + return + } + + // Save settings. + cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf) + if err != nil { + log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) + } + + cfg.Section("").Key("APP_NAME").SetValue(form.AppName) + cfg.Section("").Key("APP_SLOGAN").SetValue(form.AppSlogan) + cfg.Section("").Key("RUN_USER").SetValue(form.RunUser) + cfg.Section("").Key("WORK_PATH").SetValue(setting.AppWorkPath) + cfg.Section("").Key("RUN_MODE").SetValue("prod") + + cfg.Section("database").Key("DB_TYPE").SetValue(setting.Database.Type.String()) + cfg.Section("database").Key("HOST").SetValue(setting.Database.Host) + cfg.Section("database").Key("NAME").SetValue(setting.Database.Name) + cfg.Section("database").Key("USER").SetValue(setting.Database.User) + cfg.Section("database").Key("PASSWD").SetValue(setting.Database.Passwd) + cfg.Section("database").Key("SCHEMA").SetValue(setting.Database.Schema) + cfg.Section("database").Key("SSL_MODE").SetValue(setting.Database.SSLMode) + cfg.Section("database").Key("PATH").SetValue(setting.Database.Path) + cfg.Section("database").Key("LOG_SQL").SetValue("false") // LOG_SQL is rarely helpful + + cfg.Section("repository").Key("ROOT").SetValue(form.RepoRootPath) + cfg.Section("server").Key("SSH_DOMAIN").SetValue(form.Domain) + cfg.Section("server").Key("DOMAIN").SetValue(form.Domain) + cfg.Section("server").Key("HTTP_PORT").SetValue(form.HTTPPort) + cfg.Section("server").Key("ROOT_URL").SetValue(form.AppURL) + cfg.Section("server").Key("APP_DATA_PATH").SetValue(setting.AppDataPath) + + if form.SSHPort == 0 { + cfg.Section("server").Key("DISABLE_SSH").SetValue("true") + } else { + cfg.Section("server").Key("DISABLE_SSH").SetValue("false") + cfg.Section("server").Key("SSH_PORT").SetValue(fmt.Sprint(form.SSHPort)) + } + + if form.LFSRootPath != "" { + cfg.Section("server").Key("LFS_START_SERVER").SetValue("true") + cfg.Section("lfs").Key("PATH").SetValue(form.LFSRootPath) + var lfsJwtSecret string + if _, lfsJwtSecret, err = generate.NewJwtSecret(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.lfs_jwt_secret_failed", err), tplInstall, &form) + return + } + cfg.Section("server").Key("LFS_JWT_SECRET").SetValue(lfsJwtSecret) + } else { + cfg.Section("server").Key("LFS_START_SERVER").SetValue("false") + } + + if len(strings.TrimSpace(form.SMTPAddr)) > 0 { + if _, err := mail.ParseAddress(form.SMTPFrom); err != nil { + ctx.RenderWithErr(ctx.Tr("install.smtp_from_invalid"), tplInstall, &form) + return + } + + cfg.Section("mailer").Key("ENABLED").SetValue("true") + cfg.Section("mailer").Key("SMTP_ADDR").SetValue(form.SMTPAddr) + cfg.Section("mailer").Key("SMTP_PORT").SetValue(form.SMTPPort) + cfg.Section("mailer").Key("FROM").SetValue(form.SMTPFrom) + cfg.Section("mailer").Key("USER").SetValue(form.SMTPUser) + cfg.Section("mailer").Key("PASSWD").SetValue(form.SMTPPasswd) + } else { + cfg.Section("mailer").Key("ENABLED").SetValue("false") + } + cfg.Section("service").Key("REGISTER_EMAIL_CONFIRM").SetValue(fmt.Sprint(form.RegisterConfirm)) + cfg.Section("service").Key("ENABLE_NOTIFY_MAIL").SetValue(fmt.Sprint(form.MailNotify)) + + cfg.Section("server").Key("OFFLINE_MODE").SetValue(fmt.Sprint(form.OfflineMode)) + if err := system_model.SetSettings(ctx, map[string]string{ + setting.Config().Picture.DisableGravatar.DynKey(): strconv.FormatBool(form.DisableGravatar), + setting.Config().Picture.EnableFederatedAvatar.DynKey(): strconv.FormatBool(form.EnableFederatedAvatar), + }); err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + + cfg.Section("openid").Key("ENABLE_OPENID_SIGNIN").SetValue(fmt.Sprint(form.EnableOpenIDSignIn)) + cfg.Section("openid").Key("ENABLE_OPENID_SIGNUP").SetValue(fmt.Sprint(form.EnableOpenIDSignUp)) + cfg.Section("service").Key("DISABLE_REGISTRATION").SetValue(fmt.Sprint(form.DisableRegistration)) + cfg.Section("service").Key("ALLOW_ONLY_EXTERNAL_REGISTRATION").SetValue(fmt.Sprint(form.AllowOnlyExternalRegistration)) + cfg.Section("service").Key("ENABLE_CAPTCHA").SetValue(fmt.Sprint(form.EnableCaptcha)) + cfg.Section("service").Key("REQUIRE_SIGNIN_VIEW").SetValue(fmt.Sprint(form.RequireSignInView)) + cfg.Section("service").Key("DEFAULT_KEEP_EMAIL_PRIVATE").SetValue(fmt.Sprint(form.DefaultKeepEmailPrivate)) + cfg.Section("service").Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").SetValue(fmt.Sprint(form.DefaultAllowCreateOrganization)) + cfg.Section("service").Key("DEFAULT_ENABLE_TIMETRACKING").SetValue(fmt.Sprint(form.DefaultEnableTimetracking)) + cfg.Section("service").Key("NO_REPLY_ADDRESS").SetValue(fmt.Sprint(form.NoReplyAddress)) + cfg.Section("cron.update_checker").Key("ENABLED").SetValue(fmt.Sprint(form.EnableUpdateChecker)) + + cfg.Section("session").Key("PROVIDER").SetValue("file") + + cfg.Section("log").Key("MODE").MustString("console") + cfg.Section("log").Key("LEVEL").SetValue(setting.Log.Level.String()) + cfg.Section("log").Key("ROOT_PATH").SetValue(form.LogRootPath) + + cfg.Section("repository.pull-request").Key("DEFAULT_MERGE_STYLE").SetValue("merge") + + cfg.Section("repository.signing").Key("DEFAULT_TRUST_MODEL").SetValue("committer") + + cfg.Section("security").Key("INSTALL_LOCK").SetValue("true") + + // the internal token could be read from INTERNAL_TOKEN or INTERNAL_TOKEN_URI (the file is guaranteed to be non-empty) + // if there is no InternalToken, generate one and save to security.INTERNAL_TOKEN + if setting.InternalToken == "" { + var internalToken string + if internalToken, err = generate.NewInternalToken(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.internal_token_failed", err), tplInstall, &form) + return + } + cfg.Section("security").Key("INTERNAL_TOKEN").SetValue(internalToken) + } + + // FIXME: at the moment, no matter oauth2 is enabled or not, it must generate a "oauth2 JWT_SECRET" + // see the "loadOAuth2From" in "setting/oauth2.go" + if !cfg.Section("oauth2").HasKey("JWT_SECRET") && !cfg.Section("oauth2").HasKey("JWT_SECRET_URI") { + _, jwtSecretBase64, err := generate.NewJwtSecret() + if err != nil { + ctx.RenderWithErr(ctx.Tr("install.secret_key_failed", err), tplInstall, &form) + return + } + cfg.Section("oauth2").Key("JWT_SECRET").SetValue(jwtSecretBase64) + } + + // if there is already a SECRET_KEY, we should not overwrite it, otherwise the encrypted data will not be able to be decrypted + if setting.SecretKey == "" { + var secretKey string + if secretKey, err = generate.NewSecretKey(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.secret_key_failed", err), tplInstall, &form) + return + } + cfg.Section("security").Key("SECRET_KEY").SetValue(secretKey) + } + + if len(form.PasswordAlgorithm) > 0 { + var algorithm *hash.PasswordHashAlgorithm + setting.PasswordHashAlgo, algorithm = hash.SetDefaultPasswordHashAlgorithm(form.PasswordAlgorithm) + if algorithm == nil { + ctx.RenderWithErr(ctx.Tr("install.invalid_password_algorithm"), tplInstall, &form) + return + } + cfg.Section("security").Key("PASSWORD_HASH_ALGO").SetValue(form.PasswordAlgorithm) + } + + log.Info("Save settings to custom config file %s", setting.CustomConf) + + err = os.MkdirAll(filepath.Dir(setting.CustomConf), os.ModePerm) + if err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + + setting.EnvironmentToConfig(cfg, os.Environ()) + + if err = cfg.SaveTo(setting.CustomConf); err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + + // unset default engine before reload database setting + db.UnsetDefaultEngine() + + // ---- All checks are passed + + // Reload settings (and re-initialize database connection) + setting.InitCfgProvider(setting.CustomConf) + setting.LoadCommonSettings() + setting.MustInstalled() + setting.LoadDBSetting() + if err := common.InitDBEngine(ctx); err != nil { + log.Fatal("ORM engine initialization failed: %v", err) + } + + // Create admin account + if len(form.AdminName) > 0 { + u := &user_model.User{ + Name: form.AdminName, + Email: form.AdminEmail, + Passwd: form.AdminPasswd, + IsAdmin: true, + } + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsRestricted: optional.Some(false), + IsActive: optional.Some(true), + } + + if err = user_model.CreateUser(ctx, u, overwriteDefault); err != nil { + if !user_model.IsErrUserAlreadyExist(err) { + setting.InstallLock = false + ctx.Data["Err_AdminName"] = true + ctx.Data["Err_AdminEmail"] = true + ctx.RenderWithErr(ctx.Tr("install.invalid_admin_setting", err), tplInstall, &form) + return + } + log.Info("Admin account already exist") + u, _ = user_model.GetUserByName(ctx, u.Name) + } + + if err := ctx.SetLTACookie(u); err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + + // Auto-login for admin + if err = ctx.Session.Set("uid", u.ID); err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + + if err = ctx.Session.Release(); err != nil { + ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) + return + } + } + + setting.ClearEnvConfigKeys() + log.Info("First-time run install finished!") + InstallDone(ctx) + + go func() { + // Sleep for a while to make sure the user's browser has loaded the post-install page and its assets (images, css, js) + // What if this duration is not long enough? That's impossible -- if the user can't load the simple page in time, how could they install or use Gitea in the future .... + time.Sleep(3 * time.Second) + + // Now get the http.Server from this request and shut it down + // NB: This is not our hammerable graceful shutdown this is http.Server.Shutdown + srv := ctx.Value(http.ServerContextKey).(*http.Server) + if err := srv.Shutdown(graceful.GetManager().HammerContext()); err != nil { + log.Error("Unable to shutdown the install server! Error: %v", err) + } + + // After the HTTP server for "install" shuts down, the `runWeb()` will continue to run the "normal" server + }() +} + +// InstallDone shows the "post-install" page, makes it easier to develop the page. +// The name is not called as "PostInstall" to avoid misinterpretation as a handler for "POST /install" +func InstallDone(ctx *context.Context) { //nolint + ctx.HTML(http.StatusOK, tplPostInstall) +} diff --git a/routers/install/routes.go b/routers/install/routes.go new file mode 100644 index 0000000..06c9d38 --- /dev/null +++ b/routers/install/routes.go @@ -0,0 +1,44 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package install + +import ( + "fmt" + "html" + "net/http" + + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/services/forms" +) + +// Routes registers the installation routes +func Routes() *web.Route { + base := web.NewRoute() + base.Use(common.ProtocolMiddlewares()...) + base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc()) + + r := web.NewRoute() + r.Use(common.Sessioner(), Contexter()) + r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL + r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall) + r.Get("/post-install", InstallDone) + r.Get("/api/healthz", healthcheck.Check) + r.NotFound(installNotFound) + + base.Mount("", r) + return base +} + +func installNotFound(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Content-Type", "text/html; charset=utf-8") + w.Header().Add("Refresh", fmt.Sprintf("1; url=%s", setting.AppSubURL+"/")) + // do not use 30x status, because the "post-install" page needs to use 404/200 to detect if Gitea has been installed. + // the fetch API could follow 30x requests to the page with 200 status. + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprintf(w, `Not Found. Go to default page.`, html.EscapeString(setting.AppSubURL+"/")) +} diff --git a/routers/install/routes_test.go b/routers/install/routes_test.go new file mode 100644 index 0000000..2aa7f5d --- /dev/null +++ b/routers/install/routes_test.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package install + +import ( + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestRoutes(t *testing.T) { + r := Routes() + assert.NotNil(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + r.ServeHTTP(w, req) + assert.EqualValues(t, 200, w.Code) + assert.Contains(t, w.Body.String(), `class="page-content install"`) + + w = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/no-such", nil) + r.ServeHTTP(w, req) + assert.EqualValues(t, 404, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/assets/img/gitea.svg", nil) + r.ServeHTTP(w, req) + assert.EqualValues(t, 200, w.Code) +} + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/private/actions.go b/routers/private/actions.go new file mode 100644 index 0000000..425c480 --- /dev/null +++ b/routers/private/actions.go @@ -0,0 +1,97 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + gocontext "context" + "errors" + "fmt" + "net/http" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// GenerateActionsRunnerToken generates a new runner token for a given scope +func GenerateActionsRunnerToken(ctx *context.PrivateContext) { + var genRequest private.GenerateTokenRequest + rd := ctx.Req.Body + defer rd.Close() + + if err := json.NewDecoder(rd).Decode(&genRequest); err != nil { + log.Error("JSON Decode failed: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + owner, repo, err := parseScope(ctx, genRequest.Scope) + if err != nil { + log.Error("parseScope failed: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + } + + token, err := actions_model.GetLatestRunnerToken(ctx, owner, repo) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { + token, err = actions_model.NewRunnerToken(ctx, owner, repo) + if err != nil { + errMsg := fmt.Sprintf("error while creating runner token: %v", err) + log.Error("NewRunnerToken failed: %v", errMsg) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: errMsg, + }) + return + } + } else if err != nil { + errMsg := fmt.Sprintf("could not get unactivated runner token: %v", err) + log.Error("GetLatestRunnerToken failed: %v", errMsg) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: errMsg, + }) + return + } + + ctx.PlainText(http.StatusOK, token.Token) +} + +func ParseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) { + return parseScope(ctx, scope) +} + +func parseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) { + ownerID = 0 + repoID = 0 + if scope == "" { + return ownerID, repoID, nil + } + + ownerName, repoName, found := strings.Cut(scope, "/") + + u, err := user_model.GetUserByName(ctx, ownerName) + if err != nil { + return ownerID, repoID, err + } + ownerID = u.ID + + if !found { + return ownerID, repoID, nil + } + + r, err := repo_model.GetRepositoryByName(ctx, u.ID, repoName) + if err != nil { + return ownerID, repoID, err + } + repoID = r.ID + return ownerID, repoID, nil +} diff --git a/routers/private/default_branch.go b/routers/private/default_branch.go new file mode 100644 index 0000000..33890be --- /dev/null +++ b/routers/private/default_branch.go @@ -0,0 +1,40 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "fmt" + "net/http" + + 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/private" + gitea_context "code.gitea.io/gitea/services/context" +) + +// SetDefaultBranch updates the default branch +func SetDefaultBranch(ctx *gitea_context.PrivateContext) { + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + branch := ctx.Params(":branch") + + ctx.Repo.Repository.DefaultBranch = branch + if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil { + if !git.IsErrUnsupportedVersion(err) { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + + if err := repo_model.UpdateDefaultBranch(ctx, ctx.Repo.Repository); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + ctx.PlainText(http.StatusOK, "success") +} diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go new file mode 100644 index 0000000..11d1161 --- /dev/null +++ b/routers/private/hook_post_receive.go @@ -0,0 +1,368 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/pushoptions" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + timeutil "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + gitea_context "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// HookPostReceive updates services and users +func HookPostReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + + // We don't rely on RepoAssignment here because: + // a) we don't need the git repo in this function + // OUT OF DATE: we do need the git repo to sync the branch to the db now. + // b) our update function will likely change the repository in the db so we will need to refresh it + // c) we don't always need the repo + + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + + // defer getting the repository at this point - as we should only retrieve it if we're going to call update + var ( + repo *repo_model.Repository + gitRepo *git.Repository + ) + defer gitRepo.Close() // it's safe to call Close on a nil pointer + + updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs)) + wasEmpty := false + + for i := range opts.OldCommitIDs { + refFullName := opts.RefFullNames[i] + + // Only trigger activity updates for changes to branches or + // tags. Updates to other refs (eg, refs/notes, refs/changes, + // or other less-standard refs spaces are ignored since there + // may be a very large number of them). + if refFullName.IsBranch() || refFullName.IsTag() { + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return + } + wasEmpty = repo.IsEmpty + } + + option := &repo_module.PushUpdateOptions{ + RefFullName: refFullName, + OldCommitID: opts.OldCommitIDs[i], + NewCommitID: opts.NewCommitIDs[i], + PusherID: opts.UserID, + PusherName: opts.UserName, + RepoUserName: ownerName, + RepoName: repoName, + TimeNano: time.Now().UnixNano(), + } + updates = append(updates, option) + if repo.IsEmpty && (refFullName.BranchName() == "master" || refFullName.BranchName() == "main") { + // put the master/main branch first + // FIXME: It doesn't always work, since the master/main branch may not be the first batch of updates. + // If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once. + // See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27 + // If the user executes `git push origin --all` and pushes more than 30 branches, the master/main may not be the default branch. + copy(updates[1:], updates) + updates[0] = option + } + } + } + + if repo != nil && len(updates) > 0 { + branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates)) + for _, update := range updates { + if !update.RefFullName.IsBranch() { + continue + } + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + wasEmpty = repo.IsEmpty + } + + if update.IsDelRef() { + if err := git_model.AddDeletedBranch(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil { + log.Error("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } else { + branchesToSync = append(branchesToSync, update) + } + } + if len(branchesToSync) > 0 { + var err error + gitRepo, err = gitrepo.OpenRepository(ctx, repo) + if err != nil { + log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + + var ( + branchNames = make([]string, 0, len(branchesToSync)) + commitIDs = make([]string, 0, len(branchesToSync)) + ) + for _, update := range branchesToSync { + branchNames = append(branchNames, update.RefFullName.BranchName()) + commitIDs = append(commitIDs, update.NewCommitID) + } + + if err := repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, gitRepo.GetCommit); err != nil { + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to sync branch to DB in repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + + if err := repo_service.PushUpdates(updates); err != nil { + log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates)) + for i, update := range updates { + log.Error("Failed to Update: %s/%s Update: %d/%d: Branch: %s", ownerName, repoName, i, len(updates), update.RefFullName.BranchName()) + } + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + + // handle pull request merging, a pull request action should push at least 1 commit + if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase { + handlePullRequestMerging(ctx, opts, ownerName, repoName, updates) + if ctx.Written() { + return + } + } + + // Handle Push Options + if !opts.GetGitPushOptions().Empty() { + // load the repository + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return + } + wasEmpty = repo.IsEmpty + } + + repo.IsPrivate = opts.GetGitPushOptions().GetBool(pushoptions.RepoPrivate, repo.IsPrivate) + repo.IsTemplate = opts.GetGitPushOptions().GetBool(pushoptions.RepoTemplate, repo.IsTemplate) + if err := repo_model.UpdateRepositoryCols(ctx, repo, "is_private", "is_template"); err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + } + } + + results := make([]private.HookPostReceiveBranchResult, 0, len(opts.OldCommitIDs)) + + // We have to reload the repo in case its state is changed above + repo = nil + var baseRepo *repo_model.Repository + + // Now handle the pull request notification trailers + for i := range opts.OldCommitIDs { + refFullName := opts.RefFullNames[i] + newCommitID := opts.NewCommitIDs[i] + + // post update for agit pull request + // FIXME: use pr.Flow to test whether it's an Agit PR or a GH PR + if git.SupportProcReceive && refFullName.IsPull() { + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + } + + pullIndex, _ := strconv.ParseInt(refFullName.PullName(), 10, 64) + if pullIndex <= 0 { + continue + } + + pr, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, pullIndex) + if err != nil && !issues_model.IsErrPullRequestNotExist(err) { + log.Error("Failed to get PR by index %v Error: %v", pullIndex, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get PR by index %v Error: %v", pullIndex, err), + }) + return + } + if pr == nil { + continue + } + + results = append(results, private.HookPostReceiveBranchResult{ + Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx), + Create: false, + Branch: "", + URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index), + }) + continue + } + + // If we've pushed a branch (and not deleted it) + if !git.IsEmptyCommitID(newCommitID, nil) && refFullName.IsBranch() { + // First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + + baseRepo = repo + + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + log.Error("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err), + RepoWasEmpty: wasEmpty, + }) + return + } + if repo.BaseRepo.AllowsPulls(ctx) { + baseRepo = repo.BaseRepo + } + } + + if !baseRepo.AllowsPulls(ctx) { + // We can stop there's no need to go any further + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ + RepoWasEmpty: wasEmpty, + }) + return + } + } + + branch := refFullName.BranchName() + + // If our branch is the default branch of an unforked repo - there's no PR to create or refer to + if !repo.IsFork && branch == baseRepo.DefaultBranch { + results = append(results, private.HookPostReceiveBranchResult{}) + continue + } + + pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, issues_model.PullRequestFlowGithub) + if err != nil && !issues_model.IsErrPullRequestNotExist(err) { + log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf( + "Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err), + RepoWasEmpty: wasEmpty, + }) + return + } + + if pr == nil { + if repo.IsFork { + branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch) + } + results = append(results, private.HookPostReceiveBranchResult{ + Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx), + Create: true, + Branch: branch, + URL: fmt.Sprintf("%s/compare/%s...%s", baseRepo.HTMLURL(), util.PathEscapeSegments(baseRepo.DefaultBranch), util.PathEscapeSegments(branch)), + }) + } else { + results = append(results, private.HookPostReceiveBranchResult{ + Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx), + Create: false, + Branch: branch, + URL: fmt.Sprintf("%s/pulls/%d", baseRepo.HTMLURL(), pr.Index), + }) + } + } + } + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ + Results: results, + RepoWasEmpty: wasEmpty, + }) +} + +func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) { + return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) { + return user_model.GetUserByID(ctx, id) + }) +} + +// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit +func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) { + if len(updates) == 0 { + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID), + }) + return + } + + pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID) + if err != nil { + log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"}) + return + } + + pusher, err := loadContextCacheUser(ctx, opts.UserID) + if err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"}) + return + } + + pr.MergedCommitID = updates[len(updates)-1].NewCommitID + pr.MergedUnix = timeutil.TimeStampNow() + pr.Merger = pusher + pr.MergerID = pusher.ID + err = db.WithTx(ctx, func(ctx context.Context) error { + // Removing an auto merge pull and ignore if not exist + if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) { + return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err) + } + if _, err := pr.SetMerged(ctx); err != nil { + return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err) + } + return nil + }) + if err != nil { + log.Error("Failed to update PR to merged: %v", err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"}) + } +} diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go new file mode 100644 index 0000000..bfd647e --- /dev/null +++ b/routers/private/hook_post_receive_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/private" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandlePullRequestMerging(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub) + require.NoError(t, err) + require.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr") + require.NoError(t, err) + + autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID}) + + ctx, resp := contexttest.MockPrivateContext(t, "/") + handlePullRequestMerging(ctx, &private.HookOptions{ + PullRequestID: pr.ID, + UserID: 2, + }, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{ + {NewCommitID: "01234567"}, + }) + assert.Empty(t, resp.Body.String()) + pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID) + require.NoError(t, err) + assert.True(t, pr.HasMerged) + assert.EqualValues(t, "01234567", pr.MergedCommitID) + + unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID}) +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go new file mode 100644 index 0000000..4b8439d --- /dev/null +++ b/routers/private/hook_pre_receive.go @@ -0,0 +1,629 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "errors" + "fmt" + "net/http" + "os" + + "code.gitea.io/gitea/models" + asymkey_model "code.gitea.io/gitea/models/asymkey" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + perm_model "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + "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/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + gitea_context "code.gitea.io/gitea/services/context" + pull_service "code.gitea.io/gitea/services/pull" +) + +type preReceiveContext struct { + *gitea_context.PrivateContext + + // loadedPusher indicates that where the following information are loaded + loadedPusher bool + user *user_model.User // it's the org user if a DeployKey is used + userPerm access_model.Permission + deployKeyAccessMode perm_model.AccessMode + + canCreatePullRequest bool + checkedCanCreatePullRequest bool + + canWriteCode bool + checkedCanWriteCode bool + + protectedTags []*git_model.ProtectedTag + gotProtectedTags bool + + env []string + + opts *private.HookOptions + + isOverQuota bool + + branchName string +} + +// CanWriteCode returns true if pusher can write code +func (ctx *preReceiveContext) CanWriteCode() bool { + if !ctx.checkedCanWriteCode { + if !ctx.loadPusherAndPermission() { + return false + } + ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite + ctx.checkedCanWriteCode = true + } + return ctx.canWriteCode +} + +// AssertCanWriteCode returns true if pusher can write code +func (ctx *preReceiveContext) AssertCanWriteCode() bool { + if !ctx.CanWriteCode() { + if ctx.Written() { + return false + } + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "User permission denied for writing.", + }) + return false + } + return true +} + +// CanCreatePullRequest returns true if pusher can create pull requests +func (ctx *preReceiveContext) CanCreatePullRequest() bool { + if !ctx.checkedCanCreatePullRequest { + if !ctx.loadPusherAndPermission() { + return false + } + ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests) + ctx.checkedCanCreatePullRequest = true + } + return ctx.canCreatePullRequest +} + +// AssertCreatePullRequest returns true if can create pull requests +func (ctx *preReceiveContext) AssertCreatePullRequest() bool { + if !ctx.CanCreatePullRequest() { + if ctx.Written() { + return false + } + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "User permission denied for creating pull-request.", + }) + return false + } + return true +} + +var errPermissionDenied = errors.New("permission denied for changing repo settings") + +func (ctx *preReceiveContext) canChangeSettings() error { + if !ctx.loadPusherAndPermission() { + return errPermissionDenied + } + + if !ctx.userPerm.IsOwner() && !ctx.userPerm.IsAdmin() { + return errPermissionDenied + } + + if ctx.Repo.Repository.IsFork { + return errPermissionDenied + } + + return nil +} + +func (ctx *preReceiveContext) validatePushOptions() error { + opts := web.GetForm(ctx).(*private.HookOptions) + + if opts.GetGitPushOptions().ChangeRepoSettings() { + return ctx.canChangeSettings() + } + + return nil +} + +func (ctx *preReceiveContext) assertPushOptions() bool { + if err := ctx.validatePushOptions(); err != nil { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("options validation failed: %v", err), + }) + return false + } + return true +} + +func (ctx *preReceiveContext) checkQuota() error { + if !setting.Quota.Enabled { + ctx.isOverQuota = false + return nil + } + + if !ctx.loadPusherAndPermission() { + ctx.isOverQuota = true + return nil + } + + ok, err := quota_model.EvaluateForUser(ctx, ctx.PrivateContext.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + UserMsg: "Error checking user quota", + }) + return err + } + + ctx.isOverQuota = !ok + return nil +} + +func (ctx *preReceiveContext) quotaExceeded() { + ctx.JSON(http.StatusRequestEntityTooLarge, private.Response{ + UserMsg: "Quota exceeded", + }) +} + +// HookPreReceive checks whether a individual commit is acceptable +func HookPreReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + + ourCtx := &preReceiveContext{ + PrivateContext: ctx, + env: generateGitEnv(opts), // Generate git environment for checking commits + opts: opts, + } + + if !ourCtx.assertPushOptions() { + log.Trace("Git push options validation failed") + return + } + log.Trace("Git push options validation succeeded") + + if err := ourCtx.checkQuota(); err != nil { + return + } + + // Iterate across the provided old commit IDs + for i := range opts.OldCommitIDs { + oldCommitID := opts.OldCommitIDs[i] + newCommitID := opts.NewCommitIDs[i] + refFullName := opts.RefFullNames[i] + + switch { + case refFullName.IsBranch(): + preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName) + case refFullName.IsTag(): + preReceiveTag(ourCtx, oldCommitID, newCommitID, refFullName) + case git.SupportProcReceive && refFullName.IsFor(): + preReceiveFor(ourCtx, oldCommitID, newCommitID, refFullName) + default: + if ourCtx.isOverQuota { + ourCtx.quotaExceeded() + return + } + ourCtx.AssertCanWriteCode() + } + if ctx.Written() { + return + } + } + + ctx.PlainText(http.StatusOK, "ok") +} + +func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { + branchName := refFullName.BranchName() + ctx.branchName = branchName + + if !ctx.AssertCanWriteCode() { + return + } + + repo := ctx.Repo.Repository + gitRepo := ctx.Repo.GitRepo + objectFormat := ctx.Repo.GetObjectFormat() + + if branchName == repo.DefaultBranch && newCommitID == objectFormat.EmptyObjectID().String() { + log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is the default branch and cannot be deleted", branchName), + }) + return + } + + protectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) + if err != nil { + log.Error("Unable to get protected branch: %s in %-v Error: %v", branchName, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + // Allow pushes to non-protected branches + if protectBranch == nil { + // ...unless the user is over quota, and the operation is not a delete + if newCommitID != objectFormat.EmptyObjectID().String() && ctx.isOverQuota { + ctx.quotaExceeded() + } + + return + } + protectBranch.Repo = repo + + // This ref is a protected branch. + // + // First of all we need to enforce absolutely: + // + // 1. Detect and prevent deletion of the branch + if newCommitID == objectFormat.EmptyObjectID().String() { + log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName), + }) + return + } + + // 2. Disallow force pushes to protected branches + if oldCommitID != objectFormat.EmptyObjectID().String() { + output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+newCommitID).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: ctx.env}) + if err != nil { + log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Fail to detect force push: %v", err), + }) + return + } else if len(output) > 0 { + log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from force push", branchName), + }) + return + } + } + + // 3. Enforce require signed commits + if protectBranch.RequireSignedCommits { + err := verifyCommits(oldCommitID, newCommitID, gitRepo, ctx.env) + if err != nil { + if !isErrUnverifiedCommit(err) { + log.Error("Unable to check commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + unverifiedCommit := err.(*errUnverifiedCommit).sha + log.Warn("Forbidden: Branch: %s in %-v is protected from unverified commit %s", branchName, repo, unverifiedCommit) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from unverified commit %s", branchName, unverifiedCommit), + }) + return + } + } + + // Now there are several tests which can be overridden: + // + // 4. Check protected file patterns - this is overridable from the UI + changedProtectedfiles := false + protectedFilePath := "" + + globs := protectBranch.GetProtectedFilePatterns() + if len(globs) > 0 { + _, err := pull_service.CheckFileProtection(gitRepo, oldCommitID, newCommitID, globs, 1, ctx.env) + if err != nil { + if !models.IsErrFilePathProtected(err) { + log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + + changedProtectedfiles = true + protectedFilePath = err.(models.ErrFilePathProtected).Path + } + } + + // 5. Check if the doer is allowed to push + var canPush bool + if ctx.opts.DeployKeyID != 0 { + canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) + } else { + user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) + if err != nil { + log.Error("Unable to GetUserByID for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to GetUserByID for commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user) + } + + // 6. If we're not allowed to push directly + if !canPush { + // Is this is a merge from the UI/API? + if ctx.opts.PullRequestID == 0 { + // 6a. If we're not merging from the UI/API then there are two ways we got here: + // + // We are changing a protected file and we're not allowed to do that + if changedProtectedfiles { + log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), + }) + return + } + + // Allow commits that only touch unprotected files + globs := protectBranch.GetUnprotectedFilePatterns() + if len(globs) > 0 { + unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(gitRepo, oldCommitID, newCommitID, globs, ctx.env) + if err != nil { + log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + if unprotectedFilesOnly { + // Commit only touches unprotected files, this is allowed + return + } + } + + // Or we're simply not able to push to this protected branch + log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), + }) + return + } + // 6b. Merge (from UI or API) + + // Get the PR, user and permissions for the user in the repository + pr, err := issues_model.GetPullRequestByID(ctx, ctx.opts.PullRequestID) + if err != nil { + log.Error("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err), + }) + return + } + + // although we should have called `loadPusherAndPermission` before, here we call it explicitly again because we need to access ctx.user below + if !ctx.loadPusherAndPermission() { + // if error occurs, loadPusherAndPermission had written the error response + return + } + + // Now check if the user is allowed to merge PRs for this repository + // Note: we can use ctx.perm and ctx.user directly as they will have been loaded above + allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user) + if err != nil { + log.Error("Error calculating if allowed to merge: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Error calculating if allowed to merge: %v", err), + }) + return + } + + if !allowedMerge { + log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v and is not allowed to merge pr #%d", ctx.opts.UserID, branchName, repo, pr.Index) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), + }) + return + } + + // If we're an admin for the instance, we can ignore checks + if ctx.user.IsAdmin { + return + } + + // It's not allowed t overwrite protected files. Unless if the user is an + // admin and the protected branch rule doesn't apply to admins. + if changedProtectedfiles && (!ctx.userPerm.IsAdmin() || protectBranch.ApplyToAdmins) { + log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), + }) + return + } + + // Check all status checks and reviews are ok + if pb, err := pull_service.CheckPullBranchProtections(ctx, pr, true); err != nil { + if models.IsErrDisallowedToMerge(err) { + // Allow this if the rule doesn't apply to admins and the user is an admin. + if ctx.userPerm.IsAdmin() && !pb.ApplyToAdmins { + return + } + log.Warn("Forbidden: User %d is not allowed push to protected branch %s in %-v and pr #%d is not ready to be merged: %s", ctx.opts.UserID, branchName, repo, pr.Index, err.Error()) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s and pr #%d is not ready to be merged: %s", branchName, ctx.opts.PullRequestID, err.Error()), + }) + return + } + log.Error("Unable to check if mergeable: protected branch %s in %-v and pr #%d. Error: %v", ctx.opts.UserID, branchName, repo, pr.Index, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get status of pull request %d. Error: %v", ctx.opts.PullRequestID, err), + }) + return + } + } +} + +func preReceiveTag(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { //nolint:unparam + if !ctx.AssertCanWriteCode() { + return + } + + tagName := refFullName.TagName() + + if !ctx.gotProtectedTags { + var err error + ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID) + if err != nil { + log.Error("Unable to get protected tags for %-v Error: %v", ctx.Repo.Repository, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + ctx.gotProtectedTags = true + } + + isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + if !isAllowed { + log.Warn("Forbidden: Tag %s in %-v is protected", tagName, ctx.Repo.Repository) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Tag %s is protected", tagName), + }) + return + } + + // If the user is over quota, and the push isn't a tag deletion, deny it + if ctx.isOverQuota { + objectFormat := ctx.Repo.GetObjectFormat() + if newCommitID != objectFormat.EmptyObjectID().String() { + ctx.quotaExceeded() + return + } + } +} + +func preReceiveFor(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { //nolint:unparam + if !ctx.AssertCreatePullRequest() { + return + } + + if ctx.Repo.Repository.IsEmpty { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Can't create pull request for an empty repository.", + }) + return + } + + if ctx.opts.IsWiki { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Pull requests are not supported on the wiki.", + }) + return + } + + baseBranchName := refFullName.ForBranchName() + + baseBranchExist := false + if ctx.Repo.GitRepo.IsBranchExist(baseBranchName) { + baseBranchExist = true + } + + if !baseBranchExist { + for p, v := range baseBranchName { + if v == '/' && ctx.Repo.GitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 { + baseBranchExist = true + break + } + } + } + + if !baseBranchExist { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Unexpected ref: %s", refFullName), + }) + return + } +} + +func generateGitEnv(opts *private.HookOptions) (env []string) { + env = os.Environ() + if opts.GitAlternativeObjectDirectories != "" { + env = append(env, + private.GitAlternativeObjectDirectories+"="+opts.GitAlternativeObjectDirectories) + } + if opts.GitObjectDirectory != "" { + env = append(env, + private.GitObjectDirectory+"="+opts.GitObjectDirectory) + } + if opts.GitQuarantinePath != "" { + env = append(env, + private.GitQuarantinePath+"="+opts.GitQuarantinePath) + } + return env +} + +// loadPusherAndPermission returns false if an error occurs, and it writes the error response +func (ctx *preReceiveContext) loadPusherAndPermission() bool { + if ctx.loadedPusher { + return true + } + + if ctx.opts.UserID == user_model.ActionsUserID { + ctx.user = user_model.NewActionsUser() + ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm) + if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { + log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), + }) + return false + } + ctx.userPerm.Units = ctx.Repo.Repository.Units + ctx.userPerm.UnitsMode = make(map[unit.Type]perm_model.AccessMode) + for _, u := range ctx.Repo.Repository.Units { + ctx.userPerm.UnitsMode[u.Type] = ctx.userPerm.AccessMode + } + } else { + user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) + if err != nil { + log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), + }) + return false + } + ctx.user = user + userPerm, err := access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, user) + if err != nil { + log.Error("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err), + }) + return false + } + ctx.userPerm = userPerm + } + + if ctx.opts.DeployKeyID != 0 { + deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.opts.DeployKeyID) + if err != nil { + log.Error("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err), + }) + return false + } + ctx.deployKeyAccessMode = deployKey.Mode + } + + ctx.loadedPusher = true + return true +} diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go new file mode 100644 index 0000000..e4aabd8 --- /dev/null +++ b/routers/private/hook_proc_receive.go @@ -0,0 +1,43 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/agit" + gitea_context "code.gitea.io/gitea/services/context" +) + +// HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present +func HookProcReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + if !git.SupportProcReceive { + ctx.Status(http.StatusNotFound) + return + } + + results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts) + if err != nil { + if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + } else { + log.Error(err.Error()) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + } + + return + } + + ctx.JSON(http.StatusOK, private.HookProcReceiveResult{ + Results: results, + }) +} diff --git a/routers/private/hook_verification.go b/routers/private/hook_verification.go new file mode 100644 index 0000000..764c976 --- /dev/null +++ b/routers/private/hook_verification.go @@ -0,0 +1,122 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// This file contains commit verification functions for refs passed across in hooks + +func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + log.Error("Unable to create os.Pipe for %s", repo.Path) + return err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + + var command *git.Command + objectFormat, _ := repo.GetObjectFormat() + if oldCommitID == objectFormat.EmptyObjectID().String() { + // When creating a new branch, the oldCommitID is empty, by using "newCommitID --not --all": + // List commits that are reachable by following the newCommitID, exclude "all" existing heads/tags commits + // So, it only lists the new commits received, doesn't list the commits already present in the receiving repository + command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(newCommitID).AddArguments("--not", "--all") + } else { + command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(oldCommitID + "..." + newCommitID) + } + // This is safe as force pushes are already forbidden + err = command.Run(&git.RunOpts{ + Env: env, + Dir: repo.Path, + Stdout: stdoutWriter, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) + if err != nil { + log.Error("readAndVerifyCommitsFromShaReader failed: %v", err) + cancel() + } + _ = stdoutReader.Close() + return err + }, + }) + if err != nil && !isErrUnverifiedCommit(err) { + log.Error("Unable to check commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) + } + return err +} + +func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error { + scanner := bufio.NewScanner(input) + for scanner.Scan() { + line := scanner.Text() + err := readAndVerifyCommit(line, repo, env) + if err != nil { + return err + } + } + return scanner.Err() +} + +func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + log.Error("Unable to create pipe for %s: %v", repo.Path, err) + return err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + + commitID := git.MustIDFromString(sha) + + return git.NewCommand(repo.Ctx, "cat-file", "commit").AddDynamicArguments(sha). + Run(&git.RunOpts{ + Env: env, + Dir: repo.Path, + Stdout: stdoutWriter, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + commit, err := git.CommitFromReader(repo, commitID, stdoutReader) + if err != nil { + return err + } + verification := asymkey_model.ParseCommitWithSignature(ctx, commit) + if !verification.Verified { + cancel() + return &errUnverifiedCommit{ + commit.ID.String(), + } + } + return nil + }, + }) +} + +type errUnverifiedCommit struct { + sha string +} + +func (e *errUnverifiedCommit) Error() string { + return fmt.Sprintf("Unverified commit: %s", e.sha) +} + +func isErrUnverifiedCommit(err error) bool { + _, ok := err.(*errUnverifiedCommit) + return ok +} diff --git a/routers/private/hook_verification_test.go b/routers/private/hook_verification_test.go new file mode 100644 index 0000000..5f0d1d0 --- /dev/null +++ b/routers/private/hook_verification_test.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + + "github.com/stretchr/testify/require" +) + +var testReposDir = "tests/repos/" + +func TestVerifyCommits(t *testing.T) { + unittest.PrepareTestEnv(t) + + gitRepo, err := git.OpenRepository(context.Background(), testReposDir+"repo1_hook_verification") + defer gitRepo.Close() + require.NoError(t, err) + + objectFormat, err := gitRepo.GetObjectFormat() + require.NoError(t, err) + + testCases := []struct { + base, head string + verified bool + }{ + {"72920278f2f999e3005801e5d5b8ab8139d3641c", "d766f2917716d45be24bfa968b8409544941be32", true}, + {objectFormat.EmptyObjectID().String(), "93eac826f6188f34646cea81bf426aa5ba7d3bfe", true}, // New branch with verified commit + {"9779d17a04f1e2640583d35703c62460b2d86e0a", "72920278f2f999e3005801e5d5b8ab8139d3641c", false}, + {objectFormat.EmptyObjectID().String(), "9ce3f779ae33f31fce17fac3c512047b75d7498b", false}, // New branch with unverified commit + } + + for _, tc := range testCases { + err = verifyCommits(tc.base, tc.head, gitRepo, nil) + if tc.verified { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } +} diff --git a/routers/private/internal.go b/routers/private/internal.go new file mode 100644 index 0000000..ede3101 --- /dev/null +++ b/routers/private/internal.go @@ -0,0 +1,84 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package private contains all internal routes. The package name "internal" isn't usable because Golang reserves it for disabling cross-package usage. +package private + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + + "gitea.com/go-chi/binding" + chi_middleware "github.com/go-chi/chi/v5/middleware" +) + +// CheckInternalToken check internal token is set +func CheckInternalToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + tokens := req.Header.Get("Authorization") + fields := strings.SplitN(tokens, " ", 2) + if setting.InternalToken == "" { + log.Warn(`The INTERNAL_TOKEN setting is missing from the configuration file: %q, internal API can't work.`, setting.CustomConf) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + if len(fields) != 2 || fields[0] != "Bearer" || fields[1] != setting.InternalToken { + log.Debug("Forbidden attempt to access internal url: Authorization header: %s", tokens) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + } else { + next.ServeHTTP(w, req) + } + }) +} + +// bind binding an obj to a handler +func bind[T any](_ T) any { + return func(ctx *context.PrivateContext) { + theObj := new(T) // create a new form obj for every request but not use obj directly + binding.Bind(ctx.Req, theObj) + web.SetForm(ctx, theObj) + } +} + +// Routes registers all internal APIs routes to web application. +// These APIs will be invoked by internal commands for example `gitea serv` and etc. +func Routes() *web.Route { + r := web.NewRoute() + r.Use(context.PrivateContexter()) + r.Use(CheckInternalToken) + // Log the real ip address of the request from SSH is really helpful for diagnosing sometimes. + // Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers. + r.Use(chi_middleware.RealIP) + + r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent) + r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo) + r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog) + r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive) + r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext, bind(private.HookOptions{}), HookPostReceive) + r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext, RepoAssignment, bind(private.HookOptions{}), HookProcReceive) + r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", RepoAssignment, SetDefaultBranch) + r.Get("/serv/none/{keyid}", ServNoCommand) + r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand) + r.Post("/manager/shutdown", Shutdown) + r.Post("/manager/restart", Restart) + r.Post("/manager/reload-templates", ReloadTemplates) + r.Post("/manager/flush-queues", bind(private.FlushOptions{}), FlushQueues) + r.Post("/manager/pause-logging", PauseLogging) + r.Post("/manager/resume-logging", ResumeLogging) + r.Post("/manager/release-and-reopen-logging", ReleaseReopenLogging) + r.Post("/manager/set-log-sql", SetLogSQL) + r.Post("/manager/add-logger", bind(private.LoggerOptions{}), AddLogger) + r.Post("/manager/remove-logger/{logger}/{writer}", RemoveLogger) + r.Get("/manager/processes", Processes) + r.Post("/mail/send", SendEmail) + r.Post("/restore_repo", RestoreRepo) + r.Post("/actions/generate_actions_runner_token", GenerateActionsRunnerToken) + + return r +} diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go new file mode 100644 index 0000000..e8ee8ba --- /dev/null +++ b/routers/private/internal_repo.go @@ -0,0 +1,69 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + "fmt" + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + gitea_context "code.gitea.io/gitea/services/context" +) + +// This file contains common functions relating to setting the Repository for the internal routes + +// RepoAssignment assigns the repository and gitrepository to the private context +func RepoAssignment(ctx *gitea_context.PrivateContext) context.CancelFunc { + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + + repo := loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return nil + } + + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + + ctx.Repo = &gitea_context.Repository{ + Repository: repo, + GitRepo: gitRepo, + } + + // We opened it, we should close it + cancel := func() { + // If it's been set to nil then assume someone else has closed it. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + } + + return cancel +} + +func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository { + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName) + if err != nil { + log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + if repo.OwnerName == "" { + repo.OwnerName = ownerName + } + return repo +} diff --git a/routers/private/key.go b/routers/private/key.go new file mode 100644 index 0000000..5b8f238 --- /dev/null +++ b/routers/private/key.go @@ -0,0 +1,61 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/context" +) + +// UpdatePublicKeyInRepo update public key and deploy key updates +func UpdatePublicKeyInRepo(ctx *context.PrivateContext) { + keyID := ctx.ParamsInt64(":id") + repoID := ctx.ParamsInt64(":repoid") + if err := asymkey_model.UpdatePublicKeyUpdated(ctx, keyID); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + deployKey, err := asymkey_model.GetDeployKeyByRepo(ctx, keyID, repoID) + if err != nil { + if asymkey_model.IsErrDeployKeyNotExist(err) { + ctx.PlainText(http.StatusOK, "success") + return + } + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + deployKey.UpdatedUnix = timeutil.TimeStampNow() + if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + ctx.PlainText(http.StatusOK, "success") +} + +// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part) +// and returns public key found. +func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) { + content := ctx.FormString("content") + + publicKey, err := asymkey_model.SearchPublicKeyByContent(ctx, content) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + ctx.PlainText(http.StatusOK, publicKey.AuthorizedString()) +} diff --git a/routers/private/mail.go b/routers/private/mail.go new file mode 100644 index 0000000..cf3abb3 --- /dev/null +++ b/routers/private/mail.go @@ -0,0 +1,91 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + stdCtx "context" + "fmt" + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" +) + +// SendEmail pushes messages to mail queue +// +// It doesn't wait before each message will be processed +func SendEmail(ctx *context.PrivateContext) { + if setting.MailService == nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: "Mail service is not enabled.", + }) + return + } + + var mail private.Email + rd := ctx.Req.Body + defer rd.Close() + + if err := json.NewDecoder(rd).Decode(&mail); err != nil { + log.Error("JSON Decode failed: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + var emails []string + if len(mail.To) > 0 { + for _, uname := range mail.To { + user, err := user_model.GetUserByName(ctx, uname) + if err != nil { + err := fmt.Sprintf("Failed to get user information: %v", err) + log.Error(err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err, + }) + return + } + + if user != nil && len(user.Email) > 0 { + emails = append(emails, user.Email) + } + } + } else { + err := db.Iterate(ctx, nil, func(ctx stdCtx.Context, user *user_model.User) error { + if len(user.Email) > 0 && user.IsActive { + emails = append(emails, user.Email) + } + return nil + }) + if err != nil { + err := fmt.Sprintf("Failed to find users: %v", err) + log.Error(err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err, + }) + return + } + } + + sendEmail(ctx, mail.Subject, mail.Message, emails) +} + +func sendEmail(ctx *context.PrivateContext, subject, message string, to []string) { + for _, email := range to { + msg := mailer.NewMessage(email, subject, message) + mailer.SendAsync(msg) + } + + wasSent := strconv.Itoa(len(to)) + + ctx.PlainText(http.StatusOK, wasSent) +} diff --git a/routers/private/main_test.go b/routers/private/main_test.go new file mode 100644 index 0000000..a6bec72 --- /dev/null +++ b/routers/private/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/private/manager.go b/routers/private/manager.go new file mode 100644 index 0000000..a6aa03e --- /dev/null +++ b/routers/private/manager.go @@ -0,0 +1,195 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/graceful/releasereopen" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// ReloadTemplates reloads all the templates +func ReloadTemplates(ctx *context.PrivateContext) { + err := templates.ReloadHTMLTemplates() + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + UserMsg: fmt.Sprintf("Template error: %v", err), + }) + return + } + ctx.PlainText(http.StatusOK, "success") +} + +// FlushQueues flushes all the Queues +func FlushQueues(ctx *context.PrivateContext) { + opts := web.GetForm(ctx).(*private.FlushOptions) + if opts.NonBlocking { + // Save the hammer ctx here - as a new one is created each time you call this. + baseCtx := graceful.GetManager().HammerContext() + go func() { + err := queue.GetManager().FlushAll(baseCtx, opts.Timeout) + if err != nil { + log.Error("Flushing request timed-out with error: %v", err) + } + }() + ctx.JSON(http.StatusAccepted, private.Response{ + UserMsg: "Flushing", + }) + return + } + err := queue.GetManager().FlushAll(ctx, opts.Timeout) + if err != nil { + ctx.JSON(http.StatusRequestTimeout, private.Response{ + UserMsg: fmt.Sprintf("%v", err), + }) + } + ctx.PlainText(http.StatusOK, "success") +} + +// PauseLogging pauses logging +func PauseLogging(ctx *context.PrivateContext) { + log.GetManager().PauseAll() + ctx.PlainText(http.StatusOK, "success") +} + +// ResumeLogging resumes logging +func ResumeLogging(ctx *context.PrivateContext) { + log.GetManager().ResumeAll() + ctx.PlainText(http.StatusOK, "success") +} + +// ReleaseReopenLogging releases and reopens logging files +func ReleaseReopenLogging(ctx *context.PrivateContext) { + if err := releasereopen.GetManager().ReleaseReopen(); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Error during release and reopen: %v", err), + }) + return + } + ctx.PlainText(http.StatusOK, "success") +} + +// SetLogSQL re-sets database SQL logging +func SetLogSQL(ctx *context.PrivateContext) { + db.SetLogSQL(ctx, ctx.FormBool("on")) + ctx.PlainText(http.StatusOK, "success") +} + +// RemoveLogger removes a logger +func RemoveLogger(ctx *context.PrivateContext) { + logger := ctx.Params("logger") + writer := ctx.Params("writer") + err := log.GetManager().GetLogger(logger).RemoveWriter(writer) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to remove log writer: %s %s %v", logger, writer, err), + }) + return + } + ctx.PlainText(http.StatusOK, fmt.Sprintf("Removed %s %s", logger, writer)) +} + +// AddLogger adds a logger +func AddLogger(ctx *context.PrivateContext) { + opts := web.GetForm(ctx).(*private.LoggerOptions) + + if len(opts.Logger) == 0 { + opts.Logger = log.DEFAULT + } + + writerMode := log.WriterMode{} + writerType := opts.Mode + + var flags string + var ok bool + if flags, ok = opts.Config["flags"].(string); !ok { + switch opts.Logger { + case "access": + flags = "" + case "router": + flags = "date,time" + default: + flags = "stdflags" + } + } + writerMode.Flags = log.FlagsFromString(flags) + + if writerMode.Colorize, ok = opts.Config["colorize"].(bool); !ok && opts.Mode == "console" { + if _, ok := opts.Config["stderr"]; ok { + writerMode.Colorize = log.CanColorStderr + } else { + writerMode.Colorize = log.CanColorStdout + } + } + + writerMode.Level = setting.Log.Level + if level, ok := opts.Config["level"].(string); ok { + writerMode.Level = log.LevelFromString(level) + } + + writerMode.StacktraceLevel = setting.Log.StacktraceLogLevel + if stacktraceLevel, ok := opts.Config["level"].(string); ok { + writerMode.StacktraceLevel = log.LevelFromString(stacktraceLevel) + } + + writerMode.Prefix, _ = opts.Config["prefix"].(string) + writerMode.Expression, _ = opts.Config["expression"].(string) + + switch writerType { + case "console": + writerOption := log.WriterConsoleOption{} + writerOption.Stderr, _ = opts.Config["stderr"].(bool) + writerMode.WriterOption = writerOption + case "file": + writerOption := log.WriterFileOption{} + fileName, _ := opts.Config["filename"].(string) + writerOption.FileName = setting.LogPrepareFilenameForWriter(fileName, opts.Writer+".log") + writerOption.LogRotate, _ = opts.Config["rotate"].(bool) + maxSizeShift, _ := opts.Config["maxsize"].(int) + if maxSizeShift == 0 { + maxSizeShift = 28 + } + writerOption.MaxSize = 1 << maxSizeShift + writerOption.DailyRotate, _ = opts.Config["daily"].(bool) + writerOption.MaxDays, _ = opts.Config["maxdays"].(int) + if writerOption.MaxDays == 0 { + writerOption.MaxDays = 7 + } + writerOption.Compress, _ = opts.Config["compress"].(bool) + writerOption.CompressionLevel, _ = opts.Config["compressionLevel"].(int) + if writerOption.CompressionLevel == 0 { + writerOption.CompressionLevel = -1 + } + writerMode.WriterOption = writerOption + case "conn": + writerOption := log.WriterConnOption{} + writerOption.ReconnectOnMsg, _ = opts.Config["reconnectOnMsg"].(bool) + writerOption.Reconnect, _ = opts.Config["reconnect"].(bool) + writerOption.Protocol, _ = opts.Config["net"].(string) + writerOption.Addr, _ = opts.Config["address"].(string) + writerMode.WriterOption = writerOption + default: + panic(fmt.Sprintf("invalid log writer mode: %s", writerType)) + } + writer, err := log.NewEventWriter(opts.Writer, writerType, writerMode) + if err != nil { + log.Error("Failed to create new log writer: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to create new log writer: %v", err), + }) + return + } + log.GetManager().GetLogger(opts.Logger).AddWriters(writer) + ctx.PlainText(http.StatusOK, "success") +} diff --git a/routers/private/manager_process.go b/routers/private/manager_process.go new file mode 100644 index 0000000..9a0298a --- /dev/null +++ b/routers/private/manager_process.go @@ -0,0 +1,160 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "bytes" + "fmt" + "io" + "net/http" + "runtime" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + process_module "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/services/context" +) + +// Processes prints out the processes +func Processes(ctx *context.PrivateContext) { + pid := ctx.FormString("cancel-pid") + if pid != "" { + process_module.GetManager().Cancel(process_module.IDType(pid)) + runtime.Gosched() + time.Sleep(100 * time.Millisecond) + } + + flat := ctx.FormBool("flat") + noSystem := ctx.FormBool("no-system") + stacktraces := ctx.FormBool("stacktraces") + json := ctx.FormBool("json") + + var processes []*process_module.Process + goroutineCount := int64(0) + var processCount int + var err error + if stacktraces { + processes, processCount, goroutineCount, err = process_module.GetManager().ProcessStacktraces(flat, noSystem) + if err != nil { + log.Error("Unable to get stacktrace: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get stacktraces: %v", err), + }) + return + } + } else { + processes, processCount = process_module.GetManager().Processes(flat, noSystem) + } + + if json { + ctx.JSON(http.StatusOK, map[string]any{ + "TotalNumberOfGoroutines": goroutineCount, + "TotalNumberOfProcesses": processCount, + "Processes": processes, + }) + return + } + + ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + + if err := writeProcesses(ctx.Resp, processes, processCount, goroutineCount, "", flat); err != nil { + log.Error("Unable to write out process stacktrace: %v", err) + if !ctx.Written() { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get stacktraces: %v", err), + }) + } + return + } +} + +func writeProcesses(out io.Writer, processes []*process_module.Process, processCount int, goroutineCount int64, indent string, flat bool) error { + if goroutineCount > 0 { + if _, err := fmt.Fprintf(out, "%sTotal Number of Goroutines: %d\n", indent, goroutineCount); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, "%sTotal Number of Processes: %d\n", indent, processCount); err != nil { + return err + } + if len(processes) > 0 { + if err := writeProcess(out, processes[0], " ", flat); err != nil { + return err + } + } + if len(processes) > 1 { + for _, process := range processes[1:] { + if _, err := fmt.Fprintf(out, "%s | \n", indent); err != nil { + return err + } + if err := writeProcess(out, process, " ", flat); err != nil { + return err + } + } + } + return nil +} + +func writeProcess(out io.Writer, process *process_module.Process, indent string, flat bool) error { + sb := &bytes.Buffer{} + if flat { + if process.ParentPID != "" { + _, _ = fmt.Fprintf(sb, "%s+ PID: %s\t\tType: %s\n", indent, process.PID, process.Type) + } else { + _, _ = fmt.Fprintf(sb, "%s+ PID: %s:%s\tType: %s\n", indent, process.ParentPID, process.PID, process.Type) + } + } else { + _, _ = fmt.Fprintf(sb, "%s+ PID: %s\tType: %s\n", indent, process.PID, process.Type) + } + indent += "| " + + _, _ = fmt.Fprintf(sb, "%sDescription: %s\n", indent, process.Description) + _, _ = fmt.Fprintf(sb, "%sStart: %s\n", indent, process.Start) + + if len(process.Stacks) > 0 { + _, _ = fmt.Fprintf(sb, "%sGoroutines:\n", indent) + for _, stack := range process.Stacks { + indent := indent + " " + _, _ = fmt.Fprintf(sb, "%s+ Description: %s", indent, stack.Description) + if stack.Count > 1 { + _, _ = fmt.Fprintf(sb, "* %d", stack.Count) + } + _, _ = fmt.Fprintf(sb, "\n") + indent += "| " + if len(stack.Labels) > 0 { + _, _ = fmt.Fprintf(sb, "%sLabels: %q:%q", indent, stack.Labels[0].Name, stack.Labels[0].Value) + + if len(stack.Labels) > 1 { + for _, label := range stack.Labels[1:] { + _, _ = fmt.Fprintf(sb, ", %q:%q", label.Name, label.Value) + } + } + _, _ = fmt.Fprintf(sb, "\n") + } + _, _ = fmt.Fprintf(sb, "%sStack:\n", indent) + indent += " " + for _, entry := range stack.Entry { + _, _ = fmt.Fprintf(sb, "%s+ %s\n", indent, entry.Function) + _, _ = fmt.Fprintf(sb, "%s| %s:%d\n", indent, entry.File, entry.Line) + } + } + } + if _, err := out.Write(sb.Bytes()); err != nil { + return err + } + sb.Reset() + if len(process.Children) > 0 { + if _, err := fmt.Fprintf(out, "%sChildren:\n", indent); err != nil { + return err + } + for _, child := range process.Children { + if err := writeProcess(out, child, indent+" ", flat); err != nil { + return err + } + } + } + return nil +} diff --git a/routers/private/manager_unix.go b/routers/private/manager_unix.go new file mode 100644 index 0000000..0c63ebc --- /dev/null +++ b/routers/private/manager_unix.go @@ -0,0 +1,25 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package private + +import ( + "net/http" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/services/context" +) + +// Restart causes the server to perform a graceful restart +func Restart(ctx *context.PrivateContext) { + graceful.GetManager().DoGracefulRestart() + ctx.PlainText(http.StatusOK, "success") +} + +// Shutdown causes the server to perform a graceful shutdown +func Shutdown(ctx *context.PrivateContext) { + graceful.GetManager().DoGracefulShutdown() + ctx.PlainText(http.StatusOK, "success") +} diff --git a/routers/private/manager_windows.go b/routers/private/manager_windows.go new file mode 100644 index 0000000..f1b9365 --- /dev/null +++ b/routers/private/manager_windows.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package private + +import ( + "net/http" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/services/context" +) + +// Restart is not implemented for Windows based servers as they can't fork +func Restart(ctx *context.PrivateContext) { + ctx.JSON(http.StatusNotImplemented, private.Response{ + UserMsg: "windows servers cannot be gracefully restarted - shutdown and restart manually", + }) +} + +// Shutdown causes the server to perform a graceful shutdown +func Shutdown(ctx *context.PrivateContext) { + graceful.GetManager().DoGracefulShutdown() + ctx.PlainText(http.StatusOK, "success") +} diff --git a/routers/private/restore_repo.go b/routers/private/restore_repo.go new file mode 100644 index 0000000..4e95d30 --- /dev/null +++ b/routers/private/restore_repo.go @@ -0,0 +1,53 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "io" + "net/http" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/private" + myCtx "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/migrations" +) + +// RestoreRepo restore a repository from data +func RestoreRepo(ctx *myCtx.PrivateContext) { + bs, err := io.ReadAll(ctx.Req.Body) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + params := struct { + RepoDir string + OwnerName string + RepoName string + Units []string + Validation bool + }{} + if err = json.Unmarshal(bs, ¶ms); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + if err := migrations.RestoreRepository( + ctx, + params.RepoDir, + params.OwnerName, + params.RepoName, + params.Units, + params.Validation, + ); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + } else { + ctx.PlainText(http.StatusOK, "success") + } +} diff --git a/routers/private/serv.go b/routers/private/serv.go new file mode 100644 index 0000000..ef3920d --- /dev/null +++ b/routers/private/serv.go @@ -0,0 +1,397 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "fmt" + "net/http" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "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/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +// ServNoCommand returns information about the provided keyid +func ServNoCommand(ctx *context.PrivateContext) { + keyID := ctx.ParamsInt64(":keyid") + if keyID <= 0 { + ctx.JSON(http.StatusBadRequest, private.Response{ + UserMsg: fmt.Sprintf("Bad key id: %d", keyID), + }) + } + results := private.KeyAndOwner{} + + key, err := asymkey_model.GetPublicKeyByID(ctx, keyID) + if err != nil { + if asymkey_model.IsErrKeyNotExist(err) { + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("Cannot find key: %d", keyID), + }) + return + } + log.Error("Unable to get public key: %d Error: %v", keyID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + results.Key = key + + if key.Type == asymkey_model.KeyTypeUser || key.Type == asymkey_model.KeyTypePrincipal { + user, err := user_model.GetUserByID(ctx, key.OwnerID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("Cannot find owner with id: %d for key: %d", key.OwnerID, keyID), + }) + return + } + log.Error("Unable to get owner with id: %d for public key: %d Error: %v", key.OwnerID, keyID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + if !user.IsActive || user.ProhibitLogin { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Your account is disabled.", + }) + return + } + results.Owner = user + } + ctx.JSON(http.StatusOK, &results) +} + +// ServCommand returns information about the provided keyid +func ServCommand(ctx *context.PrivateContext) { + keyID := ctx.ParamsInt64(":keyid") + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + mode := perm.AccessMode(ctx.FormInt("mode")) + + // Set the basic parts of the results to return + results := private.ServCommandResults{ + RepoName: repoName, + OwnerName: ownerName, + KeyID: keyID, + } + + // Now because we're not translating things properly let's just default some English strings here + modeString := "read" + if mode > perm.AccessModeRead { + modeString = "write to" + } + + // The default unit we're trying to look at is code + unitType := unit.TypeCode + + // Unless we're a wiki... + if strings.HasSuffix(repoName, ".wiki") { + // in which case we need to look at the wiki + unitType = unit.TypeWiki + // And we'd better munge the reponame and tell downstream we're looking at a wiki + results.IsWiki = true + results.RepoName = repoName[:len(repoName)-5] + } + + owner, err := user_model.GetUserByName(ctx, results.OwnerName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + // User is fetching/cloning a non-existent repository + log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), + }) + return + } + log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err), + }) + return + } + if !owner.IsOrganization() && !owner.IsActive { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Repository cannot be accessed, you could retry it later", + }) + return + } + + // Now get the Repository and set the results section + repoExist := true + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + repoExist = false + for _, verb := range ctx.FormStrings("verb") { + if verb == "git-upload-pack" { + // User is fetching/cloning a non-existent repository + log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), + }) + return + } + } + } else { + log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err), + }) + return + } + } + + if repoExist { + repo.Owner = owner + repo.OwnerName = ownerName + results.RepoID = repo.ID + + if repo.IsBeingCreated() { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: "Repository is being created, you could retry after it finished", + }) + return + } + + if repo.IsBroken() { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: "Repository is in a broken state", + }) + return + } + + // We can shortcut at this point if the repo is a mirror + if mode > perm.AccessModeRead && repo.IsMirror { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Mirror Repository %s/%s is read-only", results.OwnerName, results.RepoName), + }) + return + } + } + + // Get the Public Key represented by the keyID + key, err := asymkey_model.GetPublicKeyByID(ctx, keyID) + if err != nil { + if asymkey_model.IsErrKeyNotExist(err) { + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find key: %d", keyID), + }) + return + } + log.Error("Unable to get public key: %d Error: %v", keyID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get key: %d Error: %v", keyID, err), + }) + return + } + results.KeyName = key.Name + results.KeyID = key.ID + results.UserID = key.OwnerID + + // If repo doesn't exist, deploy key doesn't make sense + if !repoExist && key.Type == asymkey_model.KeyTypeDeploy { + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository %s/%s", results.OwnerName, results.RepoName), + }) + return + } + + // Deploy Keys have ownerID set to 0 therefore we can't use the owner + // So now we need to check if the key is a deploy key + // We'll keep hold of the deploy key here for permissions checking + var deployKey *asymkey_model.DeployKey + var user *user_model.User + if key.Type == asymkey_model.KeyTypeDeploy { + var err error + deployKey, err = asymkey_model.GetDeployKeyByRepo(ctx, key.ID, repo.ID) + if err != nil { + if asymkey_model.IsErrDeployKeyNotExist(err) { + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Public (Deploy) Key: %d:%s is not authorized to %s %s/%s.", key.ID, key.Name, modeString, results.OwnerName, results.RepoName), + }) + return + } + log.Error("Unable to get deploy for public (deploy) key: %d in %-v Error: %v", key.ID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Deploy Key for Public Key: %d:%s in %s/%s.", key.ID, key.Name, results.OwnerName, results.RepoName), + }) + return + } + results.DeployKeyID = deployKey.ID + results.KeyName = deployKey.Name + + // FIXME: Deploy keys aren't really the owner of the repo pushing changes + // however we don't have good way of representing deploy keys in hook.go + // so for now use the owner of the repository + results.UserName = results.OwnerName + results.UserID = repo.OwnerID + if !repo.Owner.KeepEmailPrivate { + results.UserEmail = repo.Owner.Email + } + } else { + // Get the user represented by the Key + var err error + user, err = user_model.GetUserByID(ctx, key.OwnerID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("Public Key: %d:%s owner %d does not exist.", key.ID, key.Name, key.OwnerID), + }) + return + } + log.Error("Unable to get owner: %d for public key: %d:%s Error: %v", key.OwnerID, key.ID, key.Name, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Owner: %d for Deploy Key: %d:%s in %s/%s.", key.OwnerID, key.ID, key.Name, ownerName, repoName), + }) + return + } + + if !user.IsActive || user.ProhibitLogin { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Your account is disabled.", + }) + return + } + + results.UserName = user.Name + if !user.KeepEmailPrivate { + results.UserEmail = user.Email + } + } + + // Don't allow pushing if the repo is archived + if repoExist && mode > perm.AccessModeRead && repo.IsArchived { + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("Repo: %s/%s is archived.", results.OwnerName, results.RepoName), + }) + return + } + + // Permissions checking: + if repoExist && + (mode > perm.AccessModeRead || + repo.IsPrivate || + owner.Visibility.IsPrivate() || + (user != nil && user.IsRestricted) || // user will be nil if the key is a deploykey + setting.Service.RequireSignInView) { + if key.Type == asymkey_model.KeyTypeDeploy { + if deployKey.Mode < mode { + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("Deploy Key: %d:%s is not authorized to %s %s/%s.", key.ID, key.Name, modeString, results.OwnerName, results.RepoName), + }) + return + } + } else { + // Because of the special ref "refs/for" we will need to delay write permission check + if git.SupportProcReceive && unitType == unit.TypeCode { + mode = perm.AccessModeRead + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, user) + if err != nil { + log.Error("Unable to get permissions for %-v with key %d in %-v Error: %v", user, key.ID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get permissions for user %d:%s with key %d in %s/%s Error: %v", user.ID, user.Name, key.ID, results.OwnerName, results.RepoName, err), + }) + return + } + + userMode := perm.UnitAccessMode(unitType) + + if userMode < mode { + log.Warn("Failed authentication attempt for %s with key %s (not authorized to %s %s/%s) from %s", user.Name, key.Name, modeString, ownerName, repoName, ctx.RemoteAddr()) + ctx.JSON(http.StatusUnauthorized, private.Response{ + UserMsg: fmt.Sprintf("User: %d:%s with Key: %d:%s is not authorized to %s %s/%s.", user.ID, user.Name, key.ID, key.Name, modeString, ownerName, repoName), + }) + return + } + } + } + + // We already know we aren't using a deploy key + if !repoExist { + owner, err := user_model.GetUserByName(ctx, ownerName) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get owner: %s %v", results.OwnerName, err), + }) + return + } + + if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Push to create is not enabled for organizations.", + }) + return + } + if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "Push to create is not enabled for users.", + }) + return + } + + repo, err = repo_service.PushCreateRepo(ctx, user, owner, results.RepoName) + if err != nil { + log.Error("pushCreateRepo: %v", err) + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), + }) + return + } + results.RepoID = repo.ID + } + + if results.IsWiki { + // Ensure the wiki is enabled before we allow access to it + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "repository wiki is disabled", + }) + return + } + log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get the wiki unit in %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + + // Finally if we're trying to touch the wiki we should init it + if err = wiki_service.InitWiki(ctx, repo); err != nil { + log.Error("Failed to initialize the wiki in %-v Error: %v", repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to initialize the wiki in %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + log.Debug("Serv Results:\nIsWiki: %t\nDeployKeyID: %d\nKeyID: %d\tKeyName: %s\nUserName: %s\nUserID: %d\nOwnerName: %s\nRepoName: %s\nRepoID: %d", + results.IsWiki, + results.DeployKeyID, + results.KeyID, + results.KeyName, + results.UserName, + results.UserID, + results.OwnerName, + results.RepoName, + results.RepoID) + + ctx.JSON(http.StatusOK, results) + // We will update the keys in a different call. +} diff --git a/routers/private/ssh_log.go b/routers/private/ssh_log.go new file mode 100644 index 0000000..5bec632 --- /dev/null +++ b/routers/private/ssh_log.go @@ -0,0 +1,33 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "net/http" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// SSHLog hook to response ssh log +func SSHLog(ctx *context.PrivateContext) { + if !setting.Log.EnableSSHLog { + ctx.Status(http.StatusOK) + return + } + + opts := web.GetForm(ctx).(*private.SSHLogOption) + + if opts.IsError { + log.Error("ssh: %v", opts.Message) + ctx.Status(http.StatusOK) + return + } + + log.Debug("ssh: %v", opts.Message) + ctx.Status(http.StatusOK) +} diff --git a/routers/private/tests/repos/repo1_hook_verification/HEAD b/routers/private/tests/repos/repo1_hook_verification/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/routers/private/tests/repos/repo1_hook_verification/config b/routers/private/tests/repos/repo1_hook_verification/config new file mode 100644 index 0000000..64280b8 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = true + symlinks = false + ignorecase = true diff --git a/routers/private/tests/repos/repo1_hook_verification/info/refs b/routers/private/tests/repos/repo1_hook_verification/info/refs new file mode 100644 index 0000000..ee593c4 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/info/refs @@ -0,0 +1 @@ +d766f2917716d45be24bfa968b8409544941be32 refs/heads/main diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/HEAD b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD new file mode 100644 index 0000000..5c549b9 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea 1693148474 +0800 push diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main new file mode 100644 index 0000000..5c549b9 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea 1693148474 +0800 push diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c new file mode 100644 index 0000000..d55278f Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a new file mode 100644 index 0000000..d8b6020 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e new file mode 100644 index 0000000..77936d8 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 new file mode 100644 index 0000000..5ec09ac Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 new file mode 100644 index 0000000..355b88e Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 new file mode 100644 index 0000000..ba1f06f Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c new file mode 100644 index 0000000..4705fbf --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c @@ -0,0 +1,2 @@ +x•ŽK +1]çÙ ÒéüAÄS¸ï$Í"32 ooð®ŠWð òÞ{›!žæ`–˜JC%¡.˜ $Ár]sѱe$ïmòâMƒ·)£÷±(O`ªbtlÐE[:;4–àHÐ1_û”rayýáþl“é’÷~“ÊE­L@cå€Xv…Mþã":µMÛƒG«_}À?Ý \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd new file mode 100644 index 0000000..fd3c3a4 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe new file mode 100644 index 0000000..80abd3a Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a new file mode 100644 index 0000000..7f3293a --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a @@ -0,0 +1,2 @@ +x•1 +!ES{ŠéAwGGa 9EúQg W·Èí#¹AªÞû©ÕZ§/£‹€³Œ–p±ì(¤(ó®óBhÈÛ¼&ᙟãÝ:pLY`ûÍãU†ð-µzŸÁ°ô†\µ×ZM:†ü¡¨Êå€óxJ/ûG}:µ3 \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b new file mode 100644 index 0000000..61a9ee8 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 new file mode 100644 index 0000000..fb79dc9 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 new file mode 100644 index 0000000..1801a7f Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c new file mode 100644 index 0000000..a765d66 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa new file mode 100644 index 0000000..c7de09f --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa @@ -0,0 +1,3 @@ +x•ŽA +Â0E]ç³$™L“ ˆx•L2µ]´• +ÞÞê \}ø¼ÿøe[–¹:{êM’°õZ5bŠ8$¡–Äv ž°fÉRÍ37];Ôˆìbt¡Ò úå3‡$‰,tXœ¨G“÷>m ²”ªpýÅý1wÍ—²-7p½£Ä„p¶ÉZs´Ç±®L̾¾´Íã¤åµLæëe@ó \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 new file mode 100644 index 0000000..1544584 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 @@ -0,0 +1,3 @@ +x•’Ë®«FE3æ+zn%44æ!%QxƒÛ€s˜AÓ` 8Øæëã{£Ì2IM¶j•ª´¥Údèûf²Ìý2”‚"‡$§e‰¶ +-(â ­Ä!´ÝJ"åaŲ@•BaîùHo3 ŸVØòå<$/)å$JøJD’B¡•H¤§˜ü{¾ #È RRðûOù«nfšÿF†þOÀ‰ +âq[°2„̇~ŒÍô¬Ô÷zjjðëÒLÛÅÀ·}prm¬Fqhþä `@Ø«¦ªš®ª¥Õ˜fî?3Ç[7Š³…ê¨Ð) ^™þuÿÖ¿,µÆl7©zÝÿr|&«Ou4Ø9Ó:µÎQjôû·êÕ1x±õå6ÍQ‡÷ƒÀ%Áåtû‰sò¸íV‰| ( V¿,aL,ù«G~²Ç¹‹‹r¥ùûî@·`·Àþ$[! XËŠep©Œæ[8 oýä(›« k£Z´Î³yóeйÙÆÄ«Y²¿kÖd€¯6•3¾;3ÜÔ RÔi Þ‹dYÓDk91V]/Cê#º¾&ÿêpo´Fáb¯‹¶}§¹ô¦òuW&]+m xaqdÜIõX¯þ3 Žƒ3¶×ÆK’ÚÓI#Æi_ärgðñÁôôõÄ©7=ú`@[õŠ&AóṲ̂ÞLÖo–‹3~MÆóõü8MGtö²ï>ÄôŒx›¼vQ²(…aÅÄWŸo"¡Ës±r‰z”°eÓÅ­}å†QDñóÖ¨fK)ó˜mÆr>>•ª†¿‚†ÝÌš$ÇF8³x™ Ä×^J k{mczþI*²^ÆMb‡þ m¸6Š”M~h¹pÕÍ {¡¡±0€ö• ]€?nUwgþÉ ‰ÿJ ³Ð±©Þ<ó7Û2 \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 new file mode 100644 index 0000000..b3f925e Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 0000000..7112238 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb new file mode 100644 index 0000000..24580f8 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/info/packs b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs @@ -0,0 +1 @@ + diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c new file mode 100644 index 0000000..d55278f Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a new file mode 100644 index 0000000..d8b6020 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e new file mode 100644 index 0000000..77936d8 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 new file mode 100644 index 0000000..5ec09ac Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd new file mode 100644 index 0000000..fd3c3a4 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe new file mode 100644 index 0000000..80abd3a Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b new file mode 100644 index 0000000..61a9ee8 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 new file mode 100644 index 0000000..fb79dc9 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c new file mode 100644 index 0000000..a765d66 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa new file mode 100644 index 0000000..c7de09f --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa @@ -0,0 +1,3 @@ +x•ŽA +Â0E]ç³$™L“ ˆx•L2µ]´• +ÞÞê \}ø¼ÿøe[–¹:{êM’°õZ5bŠ8$¡–Äv ž°fÉRÍ37];Ôˆìbt¡Ò úå3‡$‰,tXœ¨G“÷>m ²”ªpýÅý1wÍ—²-7p½£Ä„p¶ÉZs´Ç±®L̾¾´Íã¤åµLæëe@ó \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main new file mode 100644 index 0000000..2186e82 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main @@ -0,0 +1 @@ +d766f2917716d45be24bfa968b8409544941be32 diff --git a/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt new file mode 100644 index 0000000..3061c75 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt @@ -0,0 +1,127 @@ +# GPG key for abcde@gitea.com + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBGTrY3UBDAC2HLBqmMplAV15qSnC7g1c4dV406f5EHNhFr95Nup2My6b2eaf +Tlvedv77s8PT/I7F3fy4apOZs5A7w2SsPlLMcQ3ev4uGOsxRtkq5RLy1Yb6SNueX +0Da2UVKR5KTC5Q6BWaqxwS0IjKOLZ/xz0Pbe/ClV3bZSKBEY2omkVo3Z0HZ771vB +2clPRvGJ/IdeKOsZ3ZytSFXfyiJBdARmeSPmydXLil8+Ibq5iLAeow5PK8hK1TCO +nKHzLWNqcNq70tyjoHvcGi70iGjoVEEUgPCLLuU8WmzTJwlvA3BuDzjtaO7TLo/j +dE6iqkHtMSS8x+43sAH6hcFRCWAVh/0Uq7n36uGDfNxGnX3YrmX3LR9x5IsBES1r +GGWbpxio4o5GIf/Xd+JgDd9rzJCqRuZ3/sW/TxK38htWaVNZV0kMkHUCTc1ctzWp +Cm635hbFCHBhPYIp+/z206khkAKDbz/CNuU91Wazsh7KO07wrwDtxfDDbInJ8TfH +E2TGjzjQzgChfmcAEQEAAbQXYWJjZGUgPGFiY2RlQGdpdGVhLmNvbT6JAc4EEwEI +ADgWIQRo/BkcvP70fnQCv16xVDFkJim4JgUCZOtjdQIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgAAKCRCxVDFkJim4Js6+C/9yIjHqcyM88hQAYQUoiPYfgJ0f2NsD +Ai/XypyDaFbRy9Wqm3oKvMr9L9G5xgOXshjRaRWOpODAwLmtVrJfOV5BhxLEcBcO +2hDdM3ycp8Gt7+Fx/o0cUjPiiC18hh3K5LRfeE7oYynSJDgjoDNuzIMuyoWuJPNc ++IcE4roND55qyyyC9ObrTLz1GgGm1bXtkHhZ1NdOfQ4q8M48K39Jn7pmnmSX3R74 +CSU6flh/o9AtzGLjU70JUOLFcWnR5D0iEI8mOsdfEHr+p+CvDVG9l4unPhMunT+Q +OUwV2DEmqo9P+yIert1ucVTDoSf+FrRaKUHg8r1Tt6T4/4GyIeSxG72NImK0h8jz ++bADPZhxuG4UR1Mj8bilqhWgODFPi/5DrDsNMWq1pEvjn6f4pCUx0IDTnPTniOXt +afXtAD4Rz0rwJWYqgeJFHgjXzaxBiOE1bhS26NPEvyAa0T9Tj3E73ICMESAmVad2 +JqO/mVxkLDGWdpXM7qB8bO2YGMOplrTvWaa5AY0EZOtjdQEMAOwevO46JxBo91RC +bT7RQ2uz3ZwRKb+P/jIEFST6x8tkCjon31zh6HicBDPNntqXTzStgoHQb7vGhHPV +4dxAfrOtVyoHwpi1/+x1jjtZoyIzLEz6RNK/Onu2y/tC5JBnSd5QRdHJgzPm20F8 +iNZR37c0Mi24fIH4y01aVLfNeBpRt7lWJ+opo2bM3Rh7jJdMpynKkTcA6o9XP6Ig +W/dzpOayosclpHhWiJwKV4CovIX/bxawk7sz10Nb4QzcxlWexWnJxNRHIcAkZ9KT +XTBpBkBpHCZqsI3+rQoQn5oQAr9JGWJSd4Fmgw7mFjmIF4bjfa2h/BpCoBqE+/25 +chvWfYkQwrCcyUwD1QYPUBwNvLB+PWb9kYEHD3mLgSSR+fjdG9XdMevu4lT91Gqo +/6KJzgzClSs7GoQtb+SZ4deUFw1tlmEQS/BGhbtTb/1566iDidGV5EnSmL/E4/3C +bGQqNog8gremF0G0SlWTjD9RMBY13IgisWCC6R4CdkXIYnCWbwARAQABiQG2BBgB +CAAgFiEEaPwZHLz+9H50Ar9esVQxZCYpuCYFAmTrY3UCGwwACgkQsVQxZCYpuCb1 +AAv/dI5YtGxBXaHAMj+lOLmZi5w4t0M7Zafa8tNnWrBwj4KixiXEt52i5YKxuaVD +3+/cMqidSDp0M5Cxx0wcmnmg+mdFFcowtXIXuk1TGTcHcOCPoXgF6gfoGimNNE1A +w1+EnC4/TbjMCKEM7b2QZ7/CgkBxZJWbScN4Jtawory9LEQqo0/epYJwf+79GHIJ +rpODAPiPJEMKmlej23KyoFuusOi17C0vHCf3GZNj4F2So3LOrcs51qTlOum2MdL5 +oTdqffatzs6p4u5bHBxyRugQlQggTRSK+TXLdxnFXr9ukXjIC2mFir7CCnZHw4e+ +2JwZfaAom0ZX+pLwrReSop4BPPU2YDzt3XCUk0S9kpiOsN7iFWUMCFreIE50DOxt +9406kSGopYKVaifbDl4MdLXM4v+oucLe7/yOViT/dm4FcIytIR+jzC8MaLQTB23e +uzm2wOjI1YOwv7Il6PWZyDdU+tyzXcaJ7wSFBeQFZZtqph2TItCeV04HoaKHHc25 +4akc +=OYIo +-----END PGP PUBLIC KEY BLOCK----- + +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQWGBGTrY3UBDAC2HLBqmMplAV15qSnC7g1c4dV406f5EHNhFr95Nup2My6b2eaf +Tlvedv77s8PT/I7F3fy4apOZs5A7w2SsPlLMcQ3ev4uGOsxRtkq5RLy1Yb6SNueX +0Da2UVKR5KTC5Q6BWaqxwS0IjKOLZ/xz0Pbe/ClV3bZSKBEY2omkVo3Z0HZ771vB +2clPRvGJ/IdeKOsZ3ZytSFXfyiJBdARmeSPmydXLil8+Ibq5iLAeow5PK8hK1TCO +nKHzLWNqcNq70tyjoHvcGi70iGjoVEEUgPCLLuU8WmzTJwlvA3BuDzjtaO7TLo/j +dE6iqkHtMSS8x+43sAH6hcFRCWAVh/0Uq7n36uGDfNxGnX3YrmX3LR9x5IsBES1r +GGWbpxio4o5GIf/Xd+JgDd9rzJCqRuZ3/sW/TxK38htWaVNZV0kMkHUCTc1ctzWp +Cm635hbFCHBhPYIp+/z206khkAKDbz/CNuU91Wazsh7KO07wrwDtxfDDbInJ8TfH +E2TGjzjQzgChfmcAEQEAAf4HAwKN54iG/XBl5/UViAmmiESRj3u+uJC9EztalVbj +156bjamUHBYIoCH4SBB0l0bR/o9ZN3vE4ZvyF3OyJ0AKF9epjWIuz7S+QIm1NLzk +IqwRyfGPsktwtZOF1CsathN4RyJL5/3nB9g4BLYfRARe9lwU0C0HQjBwAVj8m6RN ++wMTHZqW7tUN75npgPRLUI30H3GPVm3yLfS88Ol8nd31r7V0JsXZ2/mM9CWF4sUy +o1DW3P/rBn49s/x2qL/acEL+5PK7suFBP8Pjp5cwGjnSehoWeOclXgstkg3OEryY +2JP74muDVmaEVOAk7wiRjUD7HYuEOm/MbphFyen7QtO8WtN3IRKgNm19v5Skd4AF +NW9ZAdQOk2yHw7zyRk7HOPmEbEstbyE1RYWIfgZGjJlEJ2DI5ABwVJJ3W6DRPiZ3 +owd/JxBUVu/wigIjbg6z6ZQd/bn1XwKyhyTtgyTyILzE1gqtO7xs1XmK3wcww794 +cVLjqSnAdaeXMt4P+sDA17Wqky0f/jQ9kq7/tv7ipq9jvp9RaQ1ccRsz+mGgBVl+ +oLg4klKN47ZQGt0SQpLzHLL8SHzY0dz5US+Z2J+hdZia6jEmfilY9r4WPe7djMYz +Na908DmcbjfAg4XHPqVRXjgraUiT2YTo2LOV2dHn7550hJ/JshpOVqrJUrjhCgDN +usEMK3KXJkFvf6zflMv3t8HMD2SGBfpCJSwDaW+mrmtpR6a5laoZxg/009qZqgpj +FuenLuZmgYrHXozMXllwi6MLvSE/ioXrK4fqvpAwzOk6ArqZdWfxoJDYNQKXVL7z +Arniq9Ctaag8hr5T+JoZ9wNPNVF/LuEwPTWDur4qpU07KqWt9OFKPsEDNzxVZfNM +vtSCYvQ1uUH3CbPLQvPpd5TnyhjwKYtTzyW4OcuZHrWIZp9fZi5QdhWxobqGQiBk ++nRNFe0FPVEN0VcNdYJIDKcDLsOYCkGy08tucZnbKtr8JaK7XBSOo9Frg1i/j4Aa +GnXWlkMTVAkuxLZPATTOgdBoYmHMYKQvw31aFBrf3QU9c3EEg9UPYFMErVIeBHBB +BS+E7QZToHScCG1zezlr4rdqarkz0Yvzc3aduoSAOJHDf/Il+tOkepMne1y5fi72 +5UT1yWGbXXkTCV/pM6s0pLaEvNHmGvPQ6VGbJ//5w+42PFD1d7yEai53OgSZNs7B ++Ie/6Vq5GYzTM0bT3/o7/O1Zi56y791YKaas9wgxOhmMIZ0hsTecQJLJZGotUlOv +V7fZUhPRc4ksUeCyM3G0E89ilFtY6NuPcWQ8yMeS4sRRLmie+iaT+kNvAqL5mXvg +WNLhFIXPC1gpGLB8lpT5YEY647aPjQEig7QXYWJjZGUgPGFiY2RlQGdpdGVhLmNv +bT6JAc4EEwEIADgWIQRo/BkcvP70fnQCv16xVDFkJim4JgUCZOtjdQIbAwULCQgH +AgYVCgkICwIEFgIDAQIeAQIXgAAKCRCxVDFkJim4Js6+C/9yIjHqcyM88hQAYQUo +iPYfgJ0f2NsDAi/XypyDaFbRy9Wqm3oKvMr9L9G5xgOXshjRaRWOpODAwLmtVrJf +OV5BhxLEcBcO2hDdM3ycp8Gt7+Fx/o0cUjPiiC18hh3K5LRfeE7oYynSJDgjoDNu +zIMuyoWuJPNc+IcE4roND55qyyyC9ObrTLz1GgGm1bXtkHhZ1NdOfQ4q8M48K39J +n7pmnmSX3R74CSU6flh/o9AtzGLjU70JUOLFcWnR5D0iEI8mOsdfEHr+p+CvDVG9 +l4unPhMunT+QOUwV2DEmqo9P+yIert1ucVTDoSf+FrRaKUHg8r1Tt6T4/4GyIeSx +G72NImK0h8jz+bADPZhxuG4UR1Mj8bilqhWgODFPi/5DrDsNMWq1pEvjn6f4pCUx +0IDTnPTniOXtafXtAD4Rz0rwJWYqgeJFHgjXzaxBiOE1bhS26NPEvyAa0T9Tj3E7 +3ICMESAmVad2JqO/mVxkLDGWdpXM7qB8bO2YGMOplrTvWaadBYYEZOtjdQEMAOwe +vO46JxBo91RCbT7RQ2uz3ZwRKb+P/jIEFST6x8tkCjon31zh6HicBDPNntqXTzSt +goHQb7vGhHPV4dxAfrOtVyoHwpi1/+x1jjtZoyIzLEz6RNK/Onu2y/tC5JBnSd5Q +RdHJgzPm20F8iNZR37c0Mi24fIH4y01aVLfNeBpRt7lWJ+opo2bM3Rh7jJdMpynK +kTcA6o9XP6IgW/dzpOayosclpHhWiJwKV4CovIX/bxawk7sz10Nb4QzcxlWexWnJ +xNRHIcAkZ9KTXTBpBkBpHCZqsI3+rQoQn5oQAr9JGWJSd4Fmgw7mFjmIF4bjfa2h +/BpCoBqE+/25chvWfYkQwrCcyUwD1QYPUBwNvLB+PWb9kYEHD3mLgSSR+fjdG9Xd +Mevu4lT91Gqo/6KJzgzClSs7GoQtb+SZ4deUFw1tlmEQS/BGhbtTb/1566iDidGV +5EnSmL/E4/3CbGQqNog8gremF0G0SlWTjD9RMBY13IgisWCC6R4CdkXIYnCWbwAR +AQAB/gcDAgtreHsdznsa9bAha2g+J5zygs7rp95KvqRm4SGrgWPnngMewrHXrJAx +REUQFbOYJKvb6+SB47N8BTIh/nEY/B6dpvC36QSHB0XAgkktiOhdS2rTlrq+bKse +rZzoM/jbcxS3/cwi4VWH4lQhz7TLZtQxFZDuwyiik8/m5KscMxQrbYJg++4KpFQQ +En7RRUO0hEaYdnqQ9t3M8SWLwZn2yK3hzBE0gkQ8CJA3Zokv3DO7FSsAX823O25B +X7NgIpmbHCeYK6YV0gjQUKP1o3Sf7DhJzO1iltg0+obNTDl9RoeFgxTVORCdUlGA +kPdgoBbAGtadpZlCMThn7FlIn+ogqwQpAcoSTZjX31SOQBBpgMW9yf3GTNk2Nvrn +08zIA0hnUWFfc4VY6fbjbX5bF0jpoJ3XG6Hwa1VVRwQGFLxFV23TbZ+baLLuxEBx +A86XDC5zWFMwF/7aYL8oeXgoI+499u9G4Gw9G87va7rQXlTQJcHQRqu9YaGcxwOi +UslhNtVWz52iIURappUfFaGBRGUvtx2DOTgn4m099nnPaKDUiLmc4bFIHwzyA7Pl +RdAmLosrxSyIxHdlUOS/KshucXXKGVoYkJqGLXNQCY6x2zbyBPX9/a/0P59UP/WU +qwAHuGbXlToGhSKZzC8KmVs12tyQsAZ/47D+G29kEcRlaey1+N3Uor1jN7D66uyj +M1jYFhBudNIuuTR8sfrYjmbYIj8y0bgvF4RN6sU1padoTETadWNyIcFiRMZQ0oQd +KJBa3CxdqQZ2EU4a5jkA4UTQE13IySh7eNbYP5VwBgr3Z59gcbouKfFxKBhmPHF2 +BAmC0VXI2BgqKNqM6QgVj5UKrp41AX4D+iIhyKa0D3rapuIywXg1AtsrAlrOU/Ig +tQCj/a0NjIVJpLqVKBUdd4Eea69fDCJGIoaDNyp7qwo+nA1O2oDbc32EryJYUkHm +XMoLmx5y+/rxRsRevBv0ojwu3zsx2K93M1wHYd0z+SJsU8QGFinoFgYcmNp/tgMW +WtHBN4AijDuDSZAyG+MrWIj3NS4mbajx+utEIn3DC/ofFPlTmgX3OvpOPG1hnhBH +xSZUME+znOnqJMpUqnna4jbHEPwvRIXUY6InFKgl1Bu4grww/oo3qi7NwWL0Mcdy +qabWhdlEz5N/QBBPWVQllelgI+xTmZoCRUhh1mn+PM900vXXeM/DIALnxEXs9I/m +l4wPdLZlCdaKZS8vv33adyS6i9gWfI3NPWxZ2TyqC7nf5D5OK1zKSu3iWx17nXn2 +ak5hZnaXfzTxuZL3E8KZD/qsDm80c2PXFitogJTih37N6A8UQOJPtWbkfvPiwUvI +gw0oouggn0iJQVNoiQG2BBgBCAAgFiEEaPwZHLz+9H50Ar9esVQxZCYpuCYFAmTr +Y3UCGwwACgkQsVQxZCYpuCb1AAv/dI5YtGxBXaHAMj+lOLmZi5w4t0M7Zafa8tNn +WrBwj4KixiXEt52i5YKxuaVD3+/cMqidSDp0M5Cxx0wcmnmg+mdFFcowtXIXuk1T +GTcHcOCPoXgF6gfoGimNNE1Aw1+EnC4/TbjMCKEM7b2QZ7/CgkBxZJWbScN4Jtaw +ory9LEQqo0/epYJwf+79GHIJrpODAPiPJEMKmlej23KyoFuusOi17C0vHCf3GZNj +4F2So3LOrcs51qTlOum2MdL5oTdqffatzs6p4u5bHBxyRugQlQggTRSK+TXLdxnF +Xr9ukXjIC2mFir7CCnZHw4e+2JwZfaAom0ZX+pLwrReSop4BPPU2YDzt3XCUk0S9 +kpiOsN7iFWUMCFreIE50DOxt9406kSGopYKVaifbDl4MdLXM4v+oucLe7/yOViT/ +dm4FcIytIR+jzC8MaLQTB23euzm2wOjI1YOwv7Il6PWZyDdU+tyzXcaJ7wSFBeQF +ZZtqph2TItCeV04HoaKHHc254akc +=PPG4 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/routers/utils/utils.go b/routers/utils/utils.go new file mode 100644 index 0000000..3035073 --- /dev/null +++ b/routers/utils/utils.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "html" + "strings" +) + +// SanitizeFlashErrorString will sanitize a flash error string +func SanitizeFlashErrorString(x string) string { + return strings.ReplaceAll(html.EscapeString(x), "\n", "
") +} diff --git a/routers/utils/utils_test.go b/routers/utils/utils_test.go new file mode 100644 index 0000000..6e7f3c3 --- /dev/null +++ b/routers/utils/utils_test.go @@ -0,0 +1,40 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" +) + +func TestSanitizeFlashErrorString(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + { + name: "no error", + arg: "", + want: "", + }, + { + name: "normal error", + arg: "can not open file: \"abc.exe\"", + want: "can not open file: "abc.exe"", + }, + { + name: "line break error", + arg: "some error:\n\nawesome!", + want: "some error:

awesome!", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SanitizeFlashErrorString(tt.arg); got != tt.want { + t.Errorf("SanitizeFlashErrorString() = '%v', want '%v'", got, tt.want) + } + }) + } +} diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go new file mode 100644 index 0000000..067203b --- /dev/null +++ b/routers/web/admin/admin.go @@ -0,0 +1,254 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + "reflect" + "runtime" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/updatechecker" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/cron" + "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplDashboard base.TplName = "admin/dashboard" + tplSystemStatus base.TplName = "admin/system_status" + tplSelfCheck base.TplName = "admin/self_check" + tplCron base.TplName = "admin/cron" + tplQueue base.TplName = "admin/queue" + tplStacktrace base.TplName = "admin/stacktrace" + tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.TplName = "admin/stats" +) + +var sysStatus struct { + StartTime string + NumGoroutine int + + // General statistics. + MemAllocated string // bytes allocated and still in use + MemTotal string // bytes allocated (even if freed) + MemSys string // bytes obtained from system (sum of XxxSys below) + Lookups uint64 // number of pointer lookups + MemMallocs uint64 // number of mallocs + MemFrees uint64 // number of frees + + // Main allocation heap statistics. + HeapAlloc string // bytes allocated and still in use + HeapSys string // bytes obtained from system + HeapIdle string // bytes in idle spans + HeapInuse string // bytes in non-idle span + HeapReleased string // bytes released to the OS + HeapObjects uint64 // total number of allocated objects + + // Low-level fixed-size structure allocator statistics. + // Inuse is bytes used now. + // Sys is bytes obtained from system. + StackInuse string // bootstrap stacks + StackSys string + MSpanInuse string // mspan structures + MSpanSys string + MCacheInuse string // mcache structures + MCacheSys string + BuckHashSys string // profiling bucket hash table + GCSys string // GC metadata + OtherSys string // other system allocations + + // Garbage collector statistics. + NextGC string // next run in HeapAlloc time (bytes) + LastGCTime string // last run time + PauseTotalNs string + PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] + NumGC uint32 +} + +func updateSystemStatus() { + sysStatus.StartTime = setting.AppStartTime.Format(time.RFC3339) + + m := new(runtime.MemStats) + runtime.ReadMemStats(m) + sysStatus.NumGoroutine = runtime.NumGoroutine() + + sysStatus.MemAllocated = base.FileSize(int64(m.Alloc)) + sysStatus.MemTotal = base.FileSize(int64(m.TotalAlloc)) + sysStatus.MemSys = base.FileSize(int64(m.Sys)) + sysStatus.Lookups = m.Lookups + sysStatus.MemMallocs = m.Mallocs + sysStatus.MemFrees = m.Frees + + sysStatus.HeapAlloc = base.FileSize(int64(m.HeapAlloc)) + sysStatus.HeapSys = base.FileSize(int64(m.HeapSys)) + sysStatus.HeapIdle = base.FileSize(int64(m.HeapIdle)) + sysStatus.HeapInuse = base.FileSize(int64(m.HeapInuse)) + sysStatus.HeapReleased = base.FileSize(int64(m.HeapReleased)) + sysStatus.HeapObjects = m.HeapObjects + + sysStatus.StackInuse = base.FileSize(int64(m.StackInuse)) + sysStatus.StackSys = base.FileSize(int64(m.StackSys)) + sysStatus.MSpanInuse = base.FileSize(int64(m.MSpanInuse)) + sysStatus.MSpanSys = base.FileSize(int64(m.MSpanSys)) + sysStatus.MCacheInuse = base.FileSize(int64(m.MCacheInuse)) + sysStatus.MCacheSys = base.FileSize(int64(m.MCacheSys)) + sysStatus.BuckHashSys = base.FileSize(int64(m.BuckHashSys)) + sysStatus.GCSys = base.FileSize(int64(m.GCSys)) + sysStatus.OtherSys = base.FileSize(int64(m.OtherSys)) + + sysStatus.NextGC = base.FileSize(int64(m.NextGC)) + sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339) + sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) + sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) + sysStatus.NumGC = m.NumGC +} + +func prepareDeprecatedWarningsAlert(ctx *context.Context) { + if len(setting.DeprecatedWarnings) > 0 { + content := setting.DeprecatedWarnings[0] + if len(setting.DeprecatedWarnings) > 1 { + content += fmt.Sprintf(" (and %d more)", len(setting.DeprecatedWarnings)-1) + } + ctx.Flash.Error(content, true) + } +} + +// Dashboard show admin panel dashboard +func Dashboard(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.dashboard") + ctx.Data["PageIsAdminDashboard"] = true + ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate(ctx) + ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx) + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.Data["SSH"] = setting.SSH + prepareDeprecatedWarningsAlert(ctx) + ctx.HTML(http.StatusOK, tplDashboard) +} + +func SystemStatus(ctx *context.Context) { + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.HTML(http.StatusOK, tplSystemStatus) +} + +// DashboardPost run an admin operation +func DashboardPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminDashboardForm) + ctx.Data["Title"] = ctx.Tr("admin.dashboard") + ctx.Data["PageIsAdminDashboard"] = true + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + + // Run operation. + if form.Op != "" { + switch form.Op { + case "sync_repo_branches": + go func() { + if err := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil { + log.Error("AddAllRepoBranchesToSyncQueue: %v: %v", ctx.Doer.ID, err) + } + }() + ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_branch.started")) + case "sync_repo_tags": + go func() { + if err := release_service.AddAllRepoTagsToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil { + log.Error("AddAllRepoTagsToSyncQueue: %v: %v", ctx.Doer.ID, err) + } + }() + ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_tag.started")) + default: + task := cron.GetTask(form.Op) + if task != nil { + go task.RunWithUser(ctx.Doer, nil) + ctx.Flash.Success(ctx.Tr("admin.dashboard.task.started", ctx.Tr("admin.dashboard."+form.Op))) + } else { + ctx.Flash.Error(ctx.Tr("admin.dashboard.task.unknown", form.Op)) + } + } + } + if form.From == "monitor" { + ctx.Redirect(setting.AppSubURL + "/admin/monitor/cron") + } else { + ctx.Redirect(setting.AppSubURL + "/admin") + } +} + +func SelfCheck(ctx *context.Context) { + ctx.Data["PageIsAdminSelfCheck"] = true + r, err := db.CheckCollationsDefaultEngine() + if err != nil { + ctx.Flash.Error(fmt.Sprintf("CheckCollationsDefaultEngine: %v", err), true) + } + + if r != nil { + ctx.Data["DatabaseType"] = setting.Database.Type + ctx.Data["DatabaseCheckResult"] = r + hasProblem := false + if !r.CollationEquals(r.DatabaseCollation, r.ExpectedCollation) { + ctx.Data["DatabaseCheckCollationMismatch"] = true + hasProblem = true + } + if !r.IsCollationCaseSensitive(r.DatabaseCollation) { + ctx.Data["DatabaseCheckCollationCaseInsensitive"] = true + hasProblem = true + } + ctx.Data["DatabaseCheckInconsistentCollationColumns"] = r.InconsistentCollationColumns + hasProblem = hasProblem || len(r.InconsistentCollationColumns) > 0 + + ctx.Data["DatabaseCheckHasProblems"] = hasProblem + } + + elapsed, err := cache.Test() + if err != nil { + ctx.Data["CacheError"] = err + } else if elapsed > cache.SlowCacheThreshold { + ctx.Data["CacheSlow"] = fmt.Sprint(elapsed) + } + + ctx.HTML(http.StatusOK, tplSelfCheck) +} + +func CronTasks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.cron") + ctx.Data["PageIsAdminMonitorCron"] = true + ctx.Data["Entries"] = cron.ListTasks() + ctx.HTML(http.StatusOK, tplCron) +} + +func MonitorStats(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.stats") + ctx.Data["PageIsAdminMonitorStats"] = true + modelStats := activities_model.GetStatistic(ctx).Counter + stats := map[string]any{} + + // To avoid manually converting the values of the stats struct to an map, + // and to avoid using JSON to do this for us (JSON encoder converts numbers to + // scientific notation). Use reflect to convert the struct to an map. + rv := reflect.ValueOf(modelStats) + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + // Preserve old behavior, do not show arrays that are empty. + if field.Kind() == reflect.Slice && field.Len() == 0 { + continue + } + stats[rv.Type().Field(i).Name] = field.Interface() + } + + ctx.Data["Stats"] = stats + ctx.HTML(http.StatusOK, tplStats) +} diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go new file mode 100644 index 0000000..3518869 --- /dev/null +++ b/routers/web/admin/admin_test.go @@ -0,0 +1,117 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestShadowPassword(t *testing.T) { + kases := []struct { + Provider string + CfgItem string + Result string + }{ + { + Provider: "redis", + CfgItem: "network=tcp,addr=:6379,password=gitea,db=0,pool_size=100,idle_timeout=180", + Result: "network=tcp,addr=:6379,password=******,db=0,pool_size=100,idle_timeout=180", + }, + { + Provider: "mysql", + CfgItem: "root:@tcp(localhost:3306)/gitea?charset=utf8", + Result: "root:******@tcp(localhost:3306)/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "/gitea?charset=utf8", + Result: "/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "user:mypassword@/dbname", + Result: "user:******@/dbname", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest password= dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest password=****** dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "postgres://user:pass@hostname/dbname", + Result: "postgres://user:******@hostname/dbname", + }, + { + Provider: "couchbase", + CfgItem: "http://dev-couchbase.example.com:8091/", + Result: "http://dev-couchbase.example.com:8091/", + }, + { + Provider: "couchbase", + CfgItem: "http://user:the_password@dev-couchbase.example.com:8091/", + Result: "http://user:******@dev-couchbase.example.com:8091/", + }, + } + + for _, k := range kases { + assert.EqualValues(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) + } +} + +func TestMonitorStats(t *testing.T) { + unittest.PrepareTestEnv(t) + + t.Run("Normal", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, false)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, false)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + // Test some of the stats manually. + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.EqualValues(t, stats.Comment, mappedStats["Comment"]) + assert.EqualValues(t, stats.Issue, mappedStats["Issue"]) + assert.EqualValues(t, stats.User, mappedStats["User"]) + assert.EqualValues(t, stats.Milestone, mappedStats["Milestone"]) + + // Ensure that these aren't set. + assert.Empty(t, stats.IssueByLabel) + assert.Empty(t, stats.IssueByRepository) + assert.Nil(t, mappedStats["IssueByLabel"]) + assert.Nil(t, mappedStats["IssueByRepository"]) + }) + + t.Run("IssueByX", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, true)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, true)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.NotEmpty(t, stats.IssueByLabel) + assert.NotEmpty(t, stats.IssueByRepository) + assert.EqualValues(t, stats.IssueByLabel, mappedStats["IssueByLabel"]) + assert.EqualValues(t, stats.IssueByRepository, mappedStats["IssueByRepository"]) + }) +} diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go new file mode 100644 index 0000000..8583398 --- /dev/null +++ b/routers/web/admin/applications.go @@ -0,0 +1,90 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" +) + +var ( + tplSettingsApplications base.TplName = "admin/applications/list" + tplSettingsOauth2ApplicationEdit base.TplName = "admin/applications/oauth2_edit" +) + +func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { + return &user_setting.OAuth2CommonHandlers{ + OwnerID: 0, + BasePathList: fmt.Sprintf("%s/admin/applications", setting.AppSubURL), + BasePathEditPrefix: fmt.Sprintf("%s/admin/applications/oauth2", setting.AppSubURL), + TplAppEdit: tplSettingsOauth2ApplicationEdit, + } +} + +// Applications render org applications page (for org, at the moment, there are only OAuth2 applications) +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + IsGlobal: true, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Applications"] = apps + ctx.Data["BuiltinApplications"] = auth.BuiltinApplications() + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// ApplicationsPost response for adding an oauth2 application +func ApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.AddApp(ctx) +} + +// EditApplication displays the given application +func EditApplication(ctx *context.Context) { + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.EditShow(ctx) +} + +// EditApplicationPost response for editing oauth2 application +func EditApplicationPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.EditSave(ctx) +} + +// ApplicationsRegenerateSecret handles the post request for regenerating the secret +func ApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsAdminApplications"] = true + + oa := newOAuth2CommonHandlers() + oa.RegenerateSecret(ctx) +} + +// DeleteApplication deletes the given oauth2 application +func DeleteApplication(ctx *context.Context) { + oa := newOAuth2CommonHandlers() + oa.DeleteApp(ctx) +} + +// TODO: revokes the grant with the given id diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go new file mode 100644 index 0000000..799b7e8 --- /dev/null +++ b/routers/web/admin/auths.go @@ -0,0 +1,465 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/auth/pam" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/ldap" + "code.gitea.io/gitea/services/auth/source/oauth2" + pam_service "code.gitea.io/gitea/services/auth/source/pam" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/auth/source/sspi" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + + "xorm.io/xorm/convert" +) + +const ( + tplAuths base.TplName = "admin/auth/list" + tplAuthNew base.TplName = "admin/auth/new" + tplAuthEdit base.TplName = "admin/auth/edit" +) + +var ( + separatorAntiPattern = regexp.MustCompile(`[^\w-\.]`) + langCodePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`) +) + +// Authentications show authentication config page +func Authentications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.authentication") + ctx.Data["PageIsAdminAuthentications"] = true + + var err error + ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{}) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + + ctx.HTML(http.StatusOK, tplAuths) +} + +type dropdownItem struct { + Name string + Type any +} + +var ( + authSources = func() []dropdownItem { + items := []dropdownItem{ + {auth.LDAP.String(), auth.LDAP}, + {auth.DLDAP.String(), auth.DLDAP}, + {auth.SMTP.String(), auth.SMTP}, + {auth.OAuth2.String(), auth.OAuth2}, + {auth.SSPI.String(), auth.SSPI}, + } + if pam.Supported { + items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) + } + return items + }() + + securityProtocols = []dropdownItem{ + {ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted}, + {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, + {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, + } +) + +// NewAuthSource render adding a new auth source page +func NewAuthSource(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.auths.new") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["type"] = auth.LDAP.Int() + ctx.Data["CurrentTypeName"] = auth.Names[auth.LDAP] + ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted] + ctx.Data["smtp_auth"] = "PLAIN" + ctx.Data["is_active"] = true + ctx.Data["is_sync_enabled"] = true + ctx.Data["AuthSources"] = authSources + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + ctx.Data["SSPIAutoCreateUsers"] = true + ctx.Data["SSPIAutoActivateUsers"] = true + ctx.Data["SSPIStripDomainNames"] = true + ctx.Data["SSPISeparatorReplacement"] = "_" + ctx.Data["SSPIDefaultLanguage"] = "" + + // only the first as default + ctx.Data["oauth2_provider"] = oauth2providers[0].Name() + + ctx.HTML(http.StatusOK, tplAuthNew) +} + +func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source { + var pageSize uint32 + if form.UsePagedSearch { + pageSize = uint32(form.SearchPageSize) + } + return &ldap.Source{ + Name: form.Name, + Host: form.Host, + Port: form.Port, + SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol), + SkipVerify: form.SkipVerify, + BindDN: form.BindDN, + UserDN: form.UserDN, + BindPassword: form.BindPassword, + UserBase: form.UserBase, + DefaultDomainName: form.DefaultDomainName, + AttributeUsername: form.AttributeUsername, + AttributeName: form.AttributeName, + AttributeSurname: form.AttributeSurname, + AttributeMail: form.AttributeMail, + AttributesInBind: form.AttributesInBind, + AttributeSSHPublicKey: form.AttributeSSHPublicKey, + AttributeAvatar: form.AttributeAvatar, + SearchPageSize: pageSize, + Filter: form.Filter, + GroupsEnabled: form.GroupsEnabled, + GroupDN: form.GroupDN, + GroupFilter: form.GroupFilter, + GroupMemberUID: form.GroupMemberUID, + GroupTeamMap: form.GroupTeamMap, + GroupTeamMapRemoval: form.GroupTeamMapRemoval, + UserUID: form.UserUID, + AdminFilter: form.AdminFilter, + RestrictedFilter: form.RestrictedFilter, + AllowDeactivateAll: form.AllowDeactivateAll, + Enabled: true, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source { + return &smtp.Source{ + Auth: form.SMTPAuth, + Host: form.SMTPHost, + Port: form.SMTPPort, + AllowedDomains: form.AllowedDomains, + ForceSMTPS: form.ForceSMTPS, + SkipVerify: form.SkipVerify, + HeloHostname: form.HeloHostname, + DisableHelo: form.DisableHelo, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { + var customURLMapping *oauth2.CustomURLMapping + if form.Oauth2UseCustomURL { + customURLMapping = &oauth2.CustomURLMapping{ + TokenURL: form.Oauth2TokenURL, + AuthURL: form.Oauth2AuthURL, + ProfileURL: form.Oauth2ProfileURL, + EmailURL: form.Oauth2EmailURL, + Tenant: form.Oauth2Tenant, + } + } else { + customURLMapping = nil + } + var scopes []string + for _, s := range strings.Split(form.Oauth2Scopes, ",") { + s = strings.TrimSpace(s) + if s != "" { + scopes = append(scopes, s) + } + } + + return &oauth2.Source{ + Provider: form.Oauth2Provider, + ClientID: form.Oauth2Key, + ClientSecret: form.Oauth2Secret, + OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL, + CustomURLMapping: customURLMapping, + IconURL: form.Oauth2IconURL, + Scopes: scopes, + RequiredClaimName: form.Oauth2RequiredClaimName, + RequiredClaimValue: form.Oauth2RequiredClaimValue, + SkipLocalTwoFA: form.SkipLocalTwoFA, + GroupClaimName: form.Oauth2GroupClaimName, + RestrictedGroup: form.Oauth2RestrictedGroup, + AdminGroup: form.Oauth2AdminGroup, + GroupTeamMap: form.Oauth2GroupTeamMap, + GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, + } +} + +func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) { + if util.IsEmptyString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.require_error")) + } + if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) { + ctx.Data["Err_SSPISeparatorReplacement"] = true + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.alpha_dash_dot_error")) + } + + if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) { + ctx.Data["Err_SSPIDefaultLanguage"] = true + return nil, errors.New(ctx.Locale.TrString("form.lang_select_error")) + } + + return &sspi.Source{ + AutoCreateUsers: form.SSPIAutoCreateUsers, + AutoActivateUsers: form.SSPIAutoActivateUsers, + StripDomainNames: form.SSPIStripDomainNames, + SeparatorReplacement: form.SSPISeparatorReplacement, + DefaultLanguage: form.SSPIDefaultLanguage, + }, nil +} + +// NewAuthSourcePost response for adding an auth source +func NewAuthSourcePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AuthenticationForm) + ctx.Data["Title"] = ctx.Tr("admin.auths.new") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["CurrentTypeName"] = auth.Type(form.Type).String() + ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)] + ctx.Data["AuthSources"] = authSources + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + ctx.Data["SSPIAutoCreateUsers"] = true + ctx.Data["SSPIAutoActivateUsers"] = true + ctx.Data["SSPIStripDomainNames"] = true + ctx.Data["SSPISeparatorReplacement"] = "_" + ctx.Data["SSPIDefaultLanguage"] = "" + + hasTLS := false + var config convert.Conversion + switch auth.Type(form.Type) { + case auth.LDAP, auth.DLDAP: + config = parseLDAPConfig(form) + hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted + case auth.SMTP: + config = parseSMTPConfig(form) + hasTLS = true + case auth.PAM: + config = &pam_service.Source{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } + case auth.OAuth2: + config = parseOAuth2Config(form) + oauth2Config := config.(*oauth2.Source) + if oauth2Config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + ctx.Data["Err_DiscoveryURL"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form) + return + } + } + case auth.SSPI: + var err error + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthNew, form) + return + } + existing, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{LoginType: auth.SSPI}) + if err != nil || len(existing) > 0 { + ctx.Data["Err_Type"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) + return + } + default: + ctx.Error(http.StatusBadRequest) + return + } + ctx.Data["HasTLS"] = hasTLS + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthNew) + return + } + + if err := auth.CreateSource(ctx, &auth.Source{ + Type: auth.Type(form.Type), + Name: form.Name, + IsActive: form.IsActive, + IsSyncEnabled: form.IsSyncEnabled, + Cfg: config, + }); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form) + } else if oauth2.IsErrOpenIDConnectInitialize(err) { + ctx.Data["Err_DiscoveryURL"] = true + unwrapped := err.(oauth2.ErrOpenIDConnectInitialize).Unwrap() + ctx.RenderWithErr(ctx.Tr("admin.auths.unable_to_initialize_openid", unwrapped), tplAuthNew, form) + } else { + ctx.ServerError("auth.CreateSource", err) + } + return + } + + log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name) + + ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name)) + ctx.Redirect(setting.AppSubURL + "/admin/auths") +} + +// EditAuthSource render editing auth source page +func EditAuthSource(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.auths.edit") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + + if source.IsOAuth2() { + type Named interface { + Name() string + } + + for _, provider := range oauth2providers { + if provider.Name() == source.Cfg.(Named).Name() { + ctx.Data["CurrentOAuth2Provider"] = provider + break + } + } + } + + ctx.HTML(http.StatusOK, tplAuthEdit) +} + +// EditAuthSourcePost response for editing auth source +func EditAuthSourcePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AuthenticationForm) + ctx.Data["Title"] = ctx.Tr("admin.auths.edit") + ctx.Data["PageIsAdminAuthentications"] = true + + ctx.Data["SMTPAuths"] = smtp.Authenticators + oauth2providers := oauth2.GetSupportedOAuth2Providers() + ctx.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthEdit) + return + } + + var config convert.Conversion + switch auth.Type(form.Type) { + case auth.LDAP, auth.DLDAP: + config = parseLDAPConfig(form) + case auth.SMTP: + config = parseSMTPConfig(form) + case auth.PAM: + config = &pam_service.Source{ + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, + } + case auth.OAuth2: + config = parseOAuth2Config(form) + oauth2Config := config.(*oauth2.Source) + if oauth2Config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + ctx.Data["Err_DiscoveryURL"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form) + return + } + } + case auth.SSPI: + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthEdit, form) + return + } + default: + ctx.Error(http.StatusBadRequest) + return + } + + source.Name = form.Name + source.IsActive = form.IsActive + source.IsSyncEnabled = form.IsSyncEnabled + source.Cfg = config + if err := auth.UpdateSource(ctx, source); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form) + } else if oauth2.IsErrOpenIDConnectInitialize(err) { + ctx.Flash.Error(err.Error(), true) + ctx.Data["Err_DiscoveryURL"] = true + ctx.HTML(http.StatusOK, tplAuthEdit) + } else { + ctx.ServerError("UpdateSource", err) + } + return + } + log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.update_success")) + ctx.Redirect(setting.AppSubURL + "/admin/auths/" + strconv.FormatInt(form.ID, 10)) +} + +// DeleteAuthSource response for deleting an auth source +func DeleteAuthSource(ctx *context.Context) { + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return + } + + if err = auth_service.DeleteSource(ctx, source); err != nil { + if auth.IsErrSourceInUse(err) { + ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used")) + } else { + ctx.Flash.Error(fmt.Sprintf("auth_service.DeleteSource: %v", err)) + } + ctx.JSONRedirect(setting.AppSubURL + "/admin/auths/" + url.PathEscape(ctx.Params(":authid"))) + return + } + log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID) + + ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/auths") +} diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go new file mode 100644 index 0000000..06d0ea6 --- /dev/null +++ b/routers/web/admin/config.go @@ -0,0 +1,255 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + + "code.forgejo.org/go-chi/session" +) + +const ( + tplConfig base.TplName = "admin/config" + tplConfigSettings base.TplName = "admin/config_settings" +) + +// SendTestMail send test mail to confirm mail service is OK +func SendTestMail(ctx *context.Context) { + email := ctx.FormString("email") + // Send a test email to the user's email address and redirect back to Config + if err := mailer.SendTestMail(email); err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err)) + } else { + ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email)) + } + + ctx.Redirect(setting.AppSubURL + "/admin/config") +} + +// TestCache test the cache settings +func TestCache(ctx *context.Context) { + elapsed, err := cache.Test() + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.cache_test_failed", err)) + } else { + if elapsed > cache.SlowCacheThreshold { + ctx.Flash.Warning(ctx.Tr("admin.config.cache_test_slow", elapsed)) + } else { + ctx.Flash.Info(ctx.Tr("admin.config.cache_test_succeeded", elapsed)) + } + } + + ctx.Redirect(setting.AppSubURL + "/admin/config") +} + +func shadowPasswordKV(cfgItem, splitter string) string { + fields := strings.Split(cfgItem, splitter) + for i := 0; i < len(fields); i++ { + if strings.HasPrefix(fields[i], "password=") { + fields[i] = "password=******" + break + } + } + return strings.Join(fields, splitter) +} + +func shadowURL(provider, cfgItem string) string { + u, err := url.Parse(cfgItem) + if err != nil { + log.Error("Shadowing Password for %v failed: %v", provider, err) + return cfgItem + } + if u.User != nil { + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.LastIndex(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + } + return cfgItem +} + +func shadowPassword(provider, cfgItem string) string { + switch provider { + case "redis": + return shadowPasswordKV(cfgItem, ",") + case "mysql": + // root:@tcp(localhost:3306)/macaron?charset=utf8 + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.Index(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + return cfgItem + case "postgres": + // user=jiahuachen dbname=macaron port=5432 sslmode=disable + if !strings.HasPrefix(cfgItem, "postgres://") { + return shadowPasswordKV(cfgItem, " ") + } + fallthrough + case "couchbase": + return shadowURL(provider, cfgItem) + // postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full + // Notice: use shadowURL + } + return cfgItem +} + +// Config show admin config page +func Config(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_summary") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSummary"] = true + + ctx.Data["CustomConf"] = setting.CustomConf + ctx.Data["AppUrl"] = setting.AppURL + ctx.Data["AppBuiltWith"] = setting.AppBuiltWith + ctx.Data["Domain"] = setting.Domain + ctx.Data["OfflineMode"] = setting.OfflineMode + ctx.Data["RunUser"] = setting.RunUser + ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) + ctx.Data["GitVersion"] = git.VersionInfo() + + ctx.Data["AppDataPath"] = setting.AppDataPath + ctx.Data["RepoRootPath"] = setting.RepoRootPath + ctx.Data["CustomRootPath"] = setting.CustomPath + ctx.Data["LogRootPath"] = setting.Log.RootPath + ctx.Data["ScriptType"] = setting.ScriptType + ctx.Data["ReverseProxyAuthUser"] = setting.ReverseProxyAuthUser + ctx.Data["ReverseProxyAuthEmail"] = setting.ReverseProxyAuthEmail + + ctx.Data["SSH"] = setting.SSH + ctx.Data["LFS"] = setting.LFS + + ctx.Data["Service"] = setting.Service + ctx.Data["DbCfg"] = setting.Database + ctx.Data["Webhook"] = setting.Webhook + + ctx.Data["MailerEnabled"] = false + if setting.MailService != nil { + ctx.Data["MailerEnabled"] = true + ctx.Data["Mailer"] = setting.MailService + } + + ctx.Data["CacheAdapter"] = setting.CacheService.Adapter + ctx.Data["CacheInterval"] = setting.CacheService.Interval + + ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn) + ctx.Data["CacheItemTTL"] = setting.CacheService.TTL + + sessionCfg := setting.SessionConfig + if sessionCfg.Provider == "VirtualSession" { + var realSession session.Options + if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil { + log.Error("Unable to unmarshall session config for virtual provider config: %s\nError: %v", sessionCfg.ProviderConfig, err) + } + sessionCfg.Provider = realSession.Provider + sessionCfg.ProviderConfig = realSession.ProviderConfig + sessionCfg.CookieName = realSession.CookieName + sessionCfg.CookiePath = realSession.CookiePath + sessionCfg.Gclifetime = realSession.Gclifetime + sessionCfg.Maxlifetime = realSession.Maxlifetime + sessionCfg.Secure = realSession.Secure + sessionCfg.Domain = realSession.Domain + } + sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig) + ctx.Data["SessionConfig"] = sessionCfg + + ctx.Data["Git"] = setting.Git + ctx.Data["AccessLogTemplate"] = setting.Log.AccessLogTemplate + ctx.Data["LogSQL"] = setting.Database.LogSQL + + ctx.Data["Loggers"] = log.GetManager().DumpLoggers() + config.GetDynGetter().InvalidateCache() + prepareDeprecatedWarningsAlert(ctx) + + ctx.HTML(http.StatusOK, tplConfig) +} + +func ConfigSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_settings") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSettings"] = true + ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.HTML(http.StatusOK, tplConfigSettings) +} + +func ChangeConfig(ctx *context.Context) { + key := strings.TrimSpace(ctx.FormString("key")) + value := ctx.FormString("value") + cfg := setting.Config() + + marshalBool := func(v string) (string, error) { //nolint:unparam + if b, _ := strconv.ParseBool(v); b { + return "true", nil + } + return "false", nil + } + marshalOpenWithApps := func(value string) (string, error) { + lines := strings.Split(value, "\n") + var openWithEditorApps setting.OpenWithEditorAppsType + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + displayName, openURL, ok := strings.Cut(line, "=") + displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL) + if !ok || displayName == "" || openURL == "" { + continue + } + openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{ + DisplayName: strings.TrimSpace(displayName), + OpenURL: strings.TrimSpace(openURL), + }) + } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil + } + marshallers := map[string]func(string) (string, error){ + cfg.Picture.DisableGravatar.DynKey(): marshalBool, + cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, + cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, + } + marshaller, hasMarshaller := marshallers[key] + if !hasMarshaller { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + marshaledValue, err := marshaller(value) + if err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + + config.GetDynGetter().InvalidateCache() + ctx.JSONOK() +} diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go new file mode 100644 index 0000000..020554a --- /dev/null +++ b/routers/web/admin/diagnosis.go @@ -0,0 +1,68 @@ +// Copyright 2023 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "archive/zip" + "fmt" + "runtime/pprof" + "time" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/services/context" +) + +func MonitorDiagnosis(ctx *context.Context) { + seconds := ctx.FormInt64("seconds") + if seconds <= 5 { + seconds = 5 + } + if seconds > 300 { + seconds = 300 + } + + httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ + ContentType: "application/zip", + Disposition: "attachment", + Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), + }) + + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + f, err := zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-before.txt", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("goroutine").WriteTo(f, 1) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "cpu-profile.dat", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + + err = pprof.StartCPUProfile(f) + if err == nil { + time.Sleep(time.Duration(seconds) * time.Second) + pprof.StopCPUProfile() + } else { + _, _ = f.Write([]byte(err.Error())) + } + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-after.txt", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("goroutine").WriteTo(f, 1) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "heap.dat", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("heap").WriteTo(f, 0) +} diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go new file mode 100644 index 0000000..f0d8555 --- /dev/null +++ b/routers/web/admin/emails.go @@ -0,0 +1,182 @@ +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "bytes" + "net/http" + "net/url" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/user" +) + +const ( + tplEmails base.TplName = "admin/emails/list" +) + +// Emails show all emails +func Emails(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.emails") + ctx.Data["PageIsAdminEmails"] = true + + opts := &user_model.SearchEmailOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + }, + } + + if opts.Page <= 1 { + opts.Page = 1 + } + + type ActiveEmail struct { + user_model.SearchEmailResult + CanChange bool + } + + var ( + baseEmails []*user_model.SearchEmailResult + emails []ActiveEmail + count int64 + err error + orderBy user_model.SearchEmailOrderBy + ) + + ctx.Data["SortType"] = ctx.FormString("sort") + switch ctx.FormString("sort") { + case "email": + orderBy = user_model.SearchEmailOrderByEmail + case "reverseemail": + orderBy = user_model.SearchEmailOrderByEmailReverse + case "username": + orderBy = user_model.SearchEmailOrderByName + case "reverseusername": + orderBy = user_model.SearchEmailOrderByNameReverse + default: + ctx.Data["SortType"] = "email" + orderBy = user_model.SearchEmailOrderByEmail + } + + opts.Keyword = ctx.FormTrim("q") + opts.SortType = orderBy + if len(ctx.FormString("is_activated")) != 0 { + opts.IsActivated = optional.Some(ctx.FormBool("activated")) + } + if len(ctx.FormString("is_primary")) != 0 { + opts.IsPrimary = optional.Some(ctx.FormBool("primary")) + } + + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + baseEmails, count, err = user_model.SearchEmails(ctx, opts) + if err != nil { + ctx.ServerError("SearchEmails", err) + return + } + emails = make([]ActiveEmail, len(baseEmails)) + for i := range baseEmails { + emails[i].SearchEmailResult = *baseEmails[i] + // Don't let the admin deactivate its own primary email address + // We already know the user is admin + emails[i].CanChange = ctx.Doer.ID != emails[i].UID || !emails[i].IsPrimary + } + } + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Emails"] = emails + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplEmails) +} + +var nullByte = []byte{0x00} + +func isKeywordValid(keyword string) bool { + return !bytes.Contains([]byte(keyword), nullByte) +} + +// ActivateEmail serves a POST request for activating/deactivating a user's email +func ActivateEmail(ctx *context.Context) { + truefalse := map[string]bool{"1": true, "0": false} + + uid := ctx.FormInt64("uid") + email := ctx.FormString("email") + primary, okp := truefalse[ctx.FormString("primary")] + activate, oka := truefalse[ctx.FormString("activate")] + + if uid == 0 || len(email) == 0 || !okp || !oka { + ctx.Error(http.StatusBadRequest) + return + } + + log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate) + + if err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil { + log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err) + if user_model.IsErrEmailAlreadyUsed(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) + } else { + ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + } + } else { + log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate) + ctx.Flash.Info(ctx.Tr("admin.emails.updated")) + } + + redirect, _ := url.Parse(setting.AppSubURL + "/admin/emails") + q := url.Values{} + if val := ctx.FormTrim("q"); len(val) > 0 { + q.Set("q", val) + } + if val := ctx.FormTrim("sort"); len(val) > 0 { + q.Set("sort", val) + } + if val := ctx.FormTrim("is_primary"); len(val) > 0 { + q.Set("is_primary", val) + } + if val := ctx.FormTrim("is_activated"); len(val) > 0 { + q.Set("is_activated", val) + } + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String()) +} + +// DeleteEmail serves a POST request for delete a user's email +func DeleteEmail(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("Uid")) + if err != nil || u == nil { + ctx.ServerError("GetUserByID", err) + return + } + + email, err := user_model.GetEmailAddressByID(ctx, u.ID, ctx.FormInt64("id")) + if err != nil || email == nil { + ctx.ServerError("GetEmailAddressByID", err) + return + } + + if err := user.DeleteEmailAddresses(ctx, u, []string{email.Email}); err != nil { + if user_model.IsErrPrimaryEmailCannotDelete(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.delete_primary_email_error")) + ctx.JSONRedirect("") + return + } + ctx.ServerError("DeleteEmailAddresses", err) + return + } + log.Trace("Email address deleted: %s %s", u.Name, email.Email) + + ctx.Flash.Success(ctx.Tr("admin.emails.deletion_success")) + ctx.JSONRedirect("") +} diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go new file mode 100644 index 0000000..cdca0a5 --- /dev/null +++ b/routers/web/admin/hooks.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + // tplAdminHooks template path to render hook settings + tplAdminHooks base.TplName = "admin/hooks" +) + +// DefaultOrSystemWebhooks renders both admin default and system webhook list pages +func DefaultOrSystemWebhooks(ctx *context.Context) { + var err error + + ctx.Data["Title"] = ctx.Tr("admin.hooks") + ctx.Data["PageIsAdminSystemHooks"] = true + ctx.Data["PageIsAdminDefaultHooks"] = true + + def := make(map[string]any, len(ctx.Data)) + sys := make(map[string]any, len(ctx.Data)) + for k, v := range ctx.Data { + def[k] = v + sys[k] = v + } + + sys["Title"] = ctx.Tr("admin.systemhooks") + sys["Description"] = ctx.Tr("admin.systemhooks.desc", "https://forgejo.org/docs/latest/user/webhooks/") + sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, false) + sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" + sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" + sys["WebhookList"] = webhook_service.List() + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + def["Title"] = ctx.Tr("admin.defaulthooks") + def["Description"] = ctx.Tr("admin.defaulthooks.desc", "https://forgejo.org/docs/latest/user/webhooks/") + def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx) + def["BaseLink"] = setting.AppSubURL + "/admin/hooks" + def["BaseLinkNew"] = setting.AppSubURL + "/admin/default-hooks" + def["WebhookList"] = webhook_service.List() + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + ctx.Data["DefaultWebhooks"] = def + ctx.Data["SystemWebhooks"] = sys + + ctx.HTML(http.StatusOK, tplAdminHooks) +} + +// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook +func DeleteDefaultOrSystemWebhook(ctx *context.Context) { + if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/admin/hooks") +} diff --git a/routers/web/admin/main_test.go b/routers/web/admin/main_test.go new file mode 100644 index 0000000..e1294dd --- /dev/null +++ b/routers/web/admin/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go new file mode 100644 index 0000000..36303cb --- /dev/null +++ b/routers/web/admin/notice.go @@ -0,0 +1,78 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplNotices base.TplName = "admin/notice" +) + +// Notices show notices for admin +func Notices(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.notices") + ctx.Data["PageIsAdminNotices"] = true + + total := system_model.CountNotices(ctx) + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + notices, err := system_model.Notices(ctx, page, setting.UI.Admin.NoticePagingNum) + if err != nil { + ctx.ServerError("Notices", err) + return + } + ctx.Data["Notices"] = notices + + ctx.Data["Total"] = total + + ctx.Data["Page"] = context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + + ctx.HTML(http.StatusOK, tplNotices) +} + +// DeleteNotices delete the specific notices +func DeleteNotices(ctx *context.Context) { + strs := ctx.FormStrings("ids[]") + ids := make([]int64, 0, len(strs)) + for i := range strs { + id, _ := strconv.ParseInt(strs[i], 10, 64) + if id > 0 { + ids = append(ids, id) + } + } + + if err := db.DeleteByIDs[system_model.Notice](ctx, ids...); err != nil { + ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.Flash.Success(ctx.Tr("admin.notices.delete_success")) + ctx.Status(http.StatusOK) + } +} + +// EmptyNotices delete all the notices +func EmptyNotices(ctx *context.Context) { + if err := system_model.DeleteNotices(ctx, 0, 0); err != nil { + ctx.ServerError("DeleteNotices", err) + return + } + + log.Trace("System notices deleted by admin (%s): [start: %d]", ctx.Doer.Name, 0) + ctx.Flash.Success(ctx.Tr("admin.notices.delete_success")) + ctx.Redirect(setting.AppSubURL + "/admin/notices") +} diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go new file mode 100644 index 0000000..cea28f8 --- /dev/null +++ b/routers/web/admin/orgs.go @@ -0,0 +1,39 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgs base.TplName = "admin/org/list" +) + +// Organizations show all the organizations +func Organizations(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.organizations") + ctx.Data["PageIsAdminOrganizations"] = true + + if ctx.FormString("sort") == "" { + ctx.SetFormString("sort", UserSearchDefaultAdminSort) + } + + explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + IncludeReserved: true, // administrator needs to list all accounts include reserved + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.OrgPagingNum, + }, + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + }, tplOrgs) +} diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go new file mode 100644 index 0000000..39f064a --- /dev/null +++ b/routers/web/admin/packages.go @@ -0,0 +1,113 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" +) + +const ( + tplPackagesList base.TplName = "admin/packages/list" +) + +// Packages shows all packages +func Packages(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + sort := ctx.FormTrim("sort") + + pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Type: packages_model.Type(packageType), + Name: packages_model.SearchValue{Value: query}, + Sort: sort, + IsInternal: optional.Some(false), + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + totalBlobSize, err := packages_model.GetTotalBlobSize(ctx) + if err != nil { + ctx.ServerError("GetTotalBlobSize", err) + return + } + + totalUnreferencedBlobSize, err := packages_model.GetTotalUnreferencedBlobSize(ctx) + if err != nil { + ctx.ServerError("CalculateBlobSize", err) + return + } + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsAdminPackages"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages_model.TypeList + ctx.Data["SortType"] = sort + ctx.Data["PackageDescriptors"] = pds + ctx.Data["TotalCount"] = total + ctx.Data["TotalBlobSize"] = totalBlobSize - totalUnreferencedBlobSize + ctx.Data["TotalUnreferencedBlobSize"] = totalUnreferencedBlobSize + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParamString("q", query) + pager.AddParamString("type", packageType) + pager.AddParamString("sort", sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} + +// DeletePackageVersion deletes a package version +func DeletePackageVersion(ctx *context.Context) { + pv, err := packages_model.GetVersionByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + ctx.ServerError("RemovePackageVersion", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type"))) +} + +func CleanupExpiredData(ctx *context.Context) { + if err := packages_cleanup_service.CleanupExpiredData(ctx, time.Duration(0)); err != nil { + ctx.ServerError("CleanupExpiredData", err) + return + } + + ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success")) + ctx.Redirect(setting.AppSubURL + "/admin/packages") +} diff --git a/routers/web/admin/queue.go b/routers/web/admin/queue.go new file mode 100644 index 0000000..246ab37 --- /dev/null +++ b/routers/web/admin/queue.go @@ -0,0 +1,89 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func Queues(ctx *context.Context) { + if !setting.IsProd { + initTestQueueOnce() + } + ctx.Data["Title"] = ctx.Tr("admin.monitor.queues") + ctx.Data["PageIsAdminMonitorQueue"] = true + ctx.Data["Queues"] = queue.GetManager().ManagedQueues() + ctx.HTML(http.StatusOK, tplQueue) +} + +// QueueManage shows details for a specific queue +func QueueManage(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("admin.monitor.queue", mq.GetName()) + ctx.Data["PageIsAdminMonitor"] = true + ctx.Data["Queue"] = mq + ctx.HTML(http.StatusOK, tplQueueManage) +} + +// QueueSet sets the maximum number of workers and other settings for this queue +func QueueSet(ctx *context.Context) { + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + + maxNumberStr := ctx.FormString("max-number") + + var err error + var maxNumber int + if len(maxNumberStr) > 0 { + maxNumber, err = strconv.Atoi(maxNumberStr) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.monitor.queue.settings.maxnumberworkers.error")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) + return + } + if maxNumber < -1 { + maxNumber = -1 + } + } else { + maxNumber = mq.GetWorkerMaxNumber() + } + + mq.SetWorkerMaxNumber(maxNumber) + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.changed")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} + +func QueueRemoveAllItems(ctx *context.Context) { + // Queue in Forgejo doesn't have transaction support + // So in rare cases, the queue could be corrupted/out-of-sync + // Site admin could remove all items from the queue to make it work again + qid := ctx.ParamsInt64("qid") + mq := queue.GetManager().GetManagedQueue(qid) + if mq == nil { + ctx.Status(http.StatusNotFound) + return + } + + if err := mq.RemoveAllItems(ctx); err != nil { + ctx.ServerError("RemoveAllItems", err) + return + } + + ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.remove_all_items_done")) + ctx.Redirect(setting.AppSubURL + "/admin/monitor/queue/" + strconv.FormatInt(qid, 10)) +} diff --git a/routers/web/admin/queue_tester.go b/routers/web/admin/queue_tester.go new file mode 100644 index 0000000..8f713b3 --- /dev/null +++ b/routers/web/admin/queue_tester.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "runtime/pprof" + "sync" + "time" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" +) + +var testQueueOnce sync.Once + +// initTestQueueOnce initializes the test queue for dev mode +// the test queue will also be shown in the queue list +// developers could see the queue length / worker number / items number on the admin page and try to remove the items +func initTestQueueOnce() { + testQueueOnce.Do(func() { + ctx, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "TestQueue", process.SystemProcessType, false) + qs := setting.QueueSettings{ + Name: "test-queue", + Type: "channel", + Length: 20, + BatchLength: 2, + MaxWorkers: 3, + } + testQueue, err := queue.NewWorkerPoolQueueWithContext(ctx, "test-queue", qs, func(t ...int64) (unhandled []int64) { + for range t { + select { + case <-graceful.GetManager().ShutdownContext().Done(): + case <-time.After(5 * time.Second): + } + } + return nil + }, true) + if err != nil { + log.Error("unable to create test queue: %v", err) + return + } + + queue.GetManager().AddManagedQueue(testQueue) + testQueue.SetWorkerMaxNumber(5) + go graceful.GetManager().RunWithCancel(testQueue) + go func() { + pprof.SetGoroutineLabels(ctx) + defer finished() + + cnt := int64(0) + adding := true + for { + select { + case <-ctx.Done(): + case <-time.After(500 * time.Millisecond): + if adding { + if testQueue.GetQueueItemNumber() == qs.Length { + adding = false + } + } else { + if testQueue.GetQueueItemNumber() == 0 { + adding = true + } + } + if adding { + _ = testQueue.Push(cnt) + cnt++ + } + } + } + }() + }) +} diff --git a/routers/web/admin/repos.go b/routers/web/admin/repos.go new file mode 100644 index 0000000..d0339fd --- /dev/null +++ b/routers/web/admin/repos.go @@ -0,0 +1,163 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplRepos base.TplName = "admin/repo/list" + tplUnadoptedRepos base.TplName = "admin/repo/unadopted" +) + +// Repos show all the repositories +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.repositories") + ctx.Data["PageIsAdminRepositories"] = true + + explore.RenderRepoSearch(ctx, &explore.RepoSearchOptions{ + Private: true, + PageSize: setting.UI.Admin.RepoPagingNum, + TplName: tplRepos, + OnlyShowRelevant: false, + }) +} + +// DeleteRepo delete one repository +func DeleteRepo(ctx *context.Context) { + repo, err := repo_model.GetRepositoryByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + if ctx.Repo != nil && ctx.Repo.GitRepo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repo.ID { + ctx.Repo.GitRepo.Close() + } + + if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s", repo.FullName()) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/admin/repos?page=" + url.QueryEscape(ctx.FormString("page")) + "&sort=" + url.QueryEscape(ctx.FormString("sort"))) +} + +// UnadoptedRepos lists the unadopted repositories +func UnadoptedRepos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.repositories") + ctx.Data["PageIsAdminRepositories"] = true + + opts := db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + + ctx.Data["CurrentPage"] = opts.Page + + doSearch := ctx.FormBool("search") + + ctx.Data["search"] = doSearch + q := ctx.FormString("q") + + if !doSearch { + pager := context.NewPagination(0, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplUnadoptedRepos) + return + } + + ctx.Data["Keyword"] = q + repoNames, count, err := repo_service.ListUnadoptedRepositories(ctx, q, &opts) + if err != nil { + ctx.ServerError("ListUnadoptedRepositories", err) + return + } + ctx.Data["Dirs"] = repoNames + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplUnadoptedRepos) +} + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + dir := ctx.FormString("id") + action := ctx.FormString("action") + page := ctx.FormString("page") + q := ctx.FormString("q") + + dirSplit := strings.SplitN(dir, "/", 2) + if len(dirSplit) != 2 { + ctx.Redirect(setting.AppSubURL + "/admin/repos") + return + } + + ctxUser, err := user_model.GetUserByName(ctx, dirSplit[0]) + if err != nil { + if user_model.IsErrUserNotExist(err) { + log.Debug("User does not exist: %s", dirSplit[0]) + ctx.Redirect(setting.AppSubURL + "/admin/repos") + return + } + ctx.ServerError("GetUserByName", err) + return + } + + repoName := dirSplit[1] + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName)) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // Fallthrough to failure mode + } else if action == "adopt" { + if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ + Name: dirSplit[1], + IsPrivate: true, + }); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir)) + } else if action == "delete" { + if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, dirSplit[1]); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir)) + } + ctx.Redirect(setting.AppSubURL + "/admin/repos/unadopted?search=true&q=" + url.QueryEscape(q) + "&page=" + url.QueryEscape(page)) +} diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go new file mode 100644 index 0000000..d73290a --- /dev/null +++ b/routers/web/admin/runners.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/admin/actions/runners") +} diff --git a/routers/web/admin/stacktrace.go b/routers/web/admin/stacktrace.go new file mode 100644 index 0000000..d6def94 --- /dev/null +++ b/routers/web/admin/stacktrace.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "runtime" + + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// Stacktrace show admin monitor goroutines page +func Stacktrace(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor") + ctx.Data["PageIsAdminMonitorStacktrace"] = true + + ctx.Data["GoroutineCount"] = runtime.NumGoroutine() + + show := ctx.FormString("show") + ctx.Data["ShowGoroutineList"] = show + // by default, do not do anything which might cause server errors, to avoid unnecessary 500 pages. + // this page is the entrance of the chance to collect diagnosis report. + if show != "" { + showNoSystem := show == "process" + processStacks, processCount, _, err := process.GetManager().ProcessStacktraces(false, showNoSystem) + if err != nil { + ctx.ServerError("GoroutineStacktrace", err) + return + } + + ctx.Data["ProcessStacks"] = processStacks + ctx.Data["ProcessCount"] = processCount + } + + ctx.HTML(http.StatusOK, tplStacktrace) +} + +// StacktraceCancel cancels a process +func StacktraceCancel(ctx *context.Context) { + pid := ctx.Params("pid") + process.GetManager().Cancel(process.IDType(pid)) + ctx.JSONRedirect(setting.AppSubURL + "/admin/monitor/stacktrace") +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go new file mode 100644 index 0000000..25fef5f --- /dev/null +++ b/routers/web/admin/users.go @@ -0,0 +1,557 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/explore" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplUsers base.TplName = "admin/user/list" + tplUserNew base.TplName = "admin/user/new" + tplUserView base.TplName = "admin/user/view" + tplUserEdit base.TplName = "admin/user/edit" +) + +// UserSearchDefaultAdminSort is the default sort type for admin view +const UserSearchDefaultAdminSort = "alphabetically" + +// Users show all the users +func Users(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users") + ctx.Data["PageIsAdminUsers"] = true + + extraParamStrings := map[string]string{} + statusFilterKeys := []string{"is_active", "is_admin", "is_restricted", "is_2fa_enabled", "is_prohibit_login"} + statusFilterMap := map[string]string{} + for _, filterKey := range statusFilterKeys { + paramKey := "status_filter[" + filterKey + "]" + paramVal := ctx.FormString(paramKey) + statusFilterMap[filterKey] = paramVal + if paramVal != "" { + extraParamStrings[paramKey] = paramVal + } + } + + sortType := ctx.FormString("sort") + if sortType == "" { + sortType = UserSearchDefaultAdminSort + ctx.SetFormString("sort", sortType) + } + ctx.PageData["adminUserListSearchForm"] = map[string]any{ + "StatusFilterMap": statusFilterMap, + "SortType": sortType, + } + + explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + SearchByEmail: true, + IsActive: util.OptionalBoolParse(statusFilterMap["is_active"]), + IsAdmin: util.OptionalBoolParse(statusFilterMap["is_admin"]), + IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]), + IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]), + IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]), + IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones + ExtraParamStrings: extraParamStrings, + }, tplUsers) +} + +// NewUser render adding a new user page +func NewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.new_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + ctx.Data["login_type"] = "0-0" + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + ctx.Data["Sources"] = sources + + ctx.Data["CanSendEmail"] = setting.MailService != nil + ctx.HTML(http.StatusOK, tplUserNew) +} + +// NewUserPost response for adding a new user +func NewUserPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminCreateUserForm) + ctx.Data["Title"] = ctx.Tr("admin.users.new_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) + if err != nil { + ctx.ServerError("auth.Sources", err) + return + } + ctx.Data["Sources"] = sources + + ctx.Data["CanSendEmail"] = setting.MailService != nil + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserNew) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + LoginType: auth.Plain, + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(true), + Visibility: &form.Visibility, + } + + if len(form.LoginType) > 0 { + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + lType, _ := strconv.ParseInt(fields[0], 10, 0) + u.LoginType = auth.Type(lType) + u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64) + u.LoginName = form.LoginName + } + } + if u.LoginType == auth.NoType || u.LoginType == auth.Plain { + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserNew, &form) + return + } + if err := password.IsPwned(ctx, form.Password); err != nil { + ctx.Data["Err_Password"] = true + errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.RenderWithErr(errMsg, tplUserNew, &form) + return + } + u.MustChangePassword = form.MustChangePassword + } + + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserNew, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserNew, &form) + case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tplUserNew, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tplUserNew, &form) + default: + ctx.ServerError("CreateUser", err) + } + return + } + + if !user_model.IsEmailDomainAllowed(u.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", 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.Flash.Success(ctx.Tr("admin.users.new_success", u.Name)) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} + +func prepareUserInfo(ctx *context.Context) *user_model.User { + u, err := user_model.GetUserByID(ctx, ctx.ParamsInt64(":userid")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/admin/users") + } else { + ctx.ServerError("GetUserByID", err) + } + return nil + } + ctx.Data["User"] = u + + if u.LoginSource > 0 { + ctx.Data["LoginSource"], err = auth.GetSourceByID(ctx, u.LoginSource) + if err != nil { + ctx.ServerError("auth.GetSourceByID", err) + return nil + } + } else { + ctx.Data["LoginSource"] = &auth.Source{} + } + + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{}) + if err != nil { + ctx.ServerError("auth.Sources", err) + return nil + } + ctx.Data["Sources"] = sources + + hasTOTP, err := auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.HasTwoFactorByUID", err) + return nil + } + hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.HasWebAuthnRegistrationsByUID", err) + return nil + } + ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn + + return u +} + +func ViewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.details") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: u.ID, + OrderBy: db.SearchOrderByAlphabetically, + Private: true, + Collaborate: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["ReposTotal"] = int(count) + + emails, err := user_model.GetEmailAddresses(ctx, u.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + ctx.Data["Emails"] = emails + ctx.Data["EmailsTotal"] = len(emails) + + orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ + ListOptions: db.ListOptionsAll, + UserID: u.ID, + IncludePrivate: true, + }) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + + ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template + ctx.Data["OrgsTotal"] = len(orgs) + + ctx.HTML(http.StatusOK, tplUserView) +} + +func editUserCommon(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) +} + +// EditUser show editing user page +func EditUser(ctx *context.Context) { + editUserCommon(ctx) + prepareUserInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplUserEdit) +} + +// EditUserPost response for editing user +func EditUserPost(ctx *context.Context) { + editUserCommon(ctx) + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AdminEditUserForm) + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUserEdit) + return + } + + if form.UserName != "" { + if err := user_service.RenameUser(ctx, u, form.UserName); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form) + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserEdit, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", form.UserName), tplUserEdit, &form) + default: + ctx.ServerError("RenameUser", err) + } + return + } + } + + authOpts := &user_service.UpdateAuthOptions{ + Password: optional.FromNonDefault(form.Password), + LoginName: optional.Some(form.LoginName), + } + + // skip self Prohibit Login + if ctx.Doer.ID == u.ID { + authOpts.ProhibitLogin = optional.Some(false) + } else { + authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin) + } + + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + authSource, _ := strconv.ParseInt(fields[1], 10, 64) + + authOpts.LoginSource = optional.Some(authSource) + } + + if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplUserEdit, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + default: + ctx.ServerError("UpdateUser", err) + } + return + } + + if form.Email != "" { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserEdit, &form) + default: + ctx.ServerError("AddOrSetPrimaryEmailAddress", err) + } + return + } + if !user_model.IsEmailDomainAllowed(form.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", form.Email)) + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Pronouns: optional.Some(form.Pronouns), + IsActive: optional.Some(form.Active), + IsAdmin: optional.Some(form.Admin), + AllowGitHook: optional.Some(form.AllowGitHook), + AllowImportLocal: optional.Some(form.AllowImportLocal), + MaxRepoCreation: optional.Some(form.MaxRepoCreation), + AllowCreateOrganization: optional.Some(form.AllowCreateOrganization), + IsRestricted: optional.Some(form.Restricted), + Visibility: optional.Some(form.Visibility), + Language: optional.Some(form.Language), + } + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if models.IsErrDeleteLastAdminUser(err) { + ctx.RenderWithErr(ctx.Tr("auth.last_admin"), tplUserEdit, &form) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) + + if form.Reset2FA { + tf, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } else if tf != nil { + if err := auth.DeleteTwoFactorByID(ctx, tf.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteTwoFactorByID", err) + return + } + } + + wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } + for _, cred := range wn { + if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteCredential", err) + return + } + } + } + + ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) +} + +// DeleteUser response for deleting a user +func DeleteUser(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.ParamsInt64(":userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + // admin should not delete themself + if u.ID == ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("admin.users.cannot_delete_self")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + return + } + + if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserOwnPackages(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + default: + ctx.ServerError("DeleteUser", err) + } + return + } + log.Trace("Account deleted by admin (%s): %s", ctx.Doer.Name, u.Name) + + ctx.Flash.Success(ctx.Tr("admin.users.deletion_success")) + ctx.Redirect(setting.AppSubURL + "/admin/users") +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context) { + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AvatarForm) + if err := user_setting.UpdateAvatarSetting(ctx, form, u); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_user_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + if err := user_service.DeleteAvatar(ctx, u); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(setting.AppSubURL + "/admin/users/" + strconv.FormatInt(u.ID, 10)) +} diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go new file mode 100644 index 0000000..ae3b130 --- /dev/null +++ b/routers/web/admin/users_test.go @@ -0,0 +1,200 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + 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/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewUserPost_MustChangePassword(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: true, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.True(t, u.MustChangePassword) +} + +func TestNewUserPost_MustChangePasswordFalse(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + assert.False(t, u.MustChangePassword) +} + +func TestNewUserPost_InvalidEmail(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io\r\n" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestNewUserPost_VisibilityDefaultPublic(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.Equal(t, setting.Service.DefaultUserVisibilityMode, u.Visibility) +} + +func TestNewUserPost_VisibilityPrivate(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "admin/users/new") + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + IsAdmin: true, + ID: 2, + }) + + ctx.Doer = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + Visibility: api.VisibleTypePrivate, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := user_model.GetUserByName(ctx, username) + + require.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.True(t, u.Visibility.IsPrivate()) +} diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go new file mode 100644 index 0000000..f93177b --- /dev/null +++ b/routers/web/auth/2fa.go @@ -0,0 +1,163 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" +) + +var ( + tplTwofa base.TplName = "user/auth/twofa" + tplTwofaScratch base.TplName = "user/auth/twofa_scratch" +) + +// TwoFactor shows the user a two-factor authentication page. +func TwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + if CheckAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofa) +} + +// TwoFactorPost validates a user's two-factor authentication token. +func TwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := auth.GetTwoFactorByUID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + ok, err := twofa.ValidateTOTP(form.Passcode) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ok && twofa.LastUsedPasscode != form.Passcode { + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := user_model.GetUserByID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + + twofa.LastUsedPasscode = form.Passcode + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{}) +} + +// TwoFactorScratch shows the scratch code form for two-factor authentication. +func TwoFactorScratch(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + if CheckAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofaScratch) +} + +// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token. +func TwoFactorScratchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := auth.GetTwoFactorByUID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + if twofa.VerifyScratchToken(form.Token) { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := user_model.GetUserByID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + if ctx.Written() { + return + } + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{}) +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go new file mode 100644 index 0000000..bb20309 --- /dev/null +++ b/routers/web/auth/auth.go @@ -0,0 +1,881 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + + "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/base" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + notify_service "code.gitea.io/gitea/services/notify" + user_service "code.gitea.io/gitea/services/user" + + "github.com/markbates/goth" +) + +const ( + // tplSignIn template for sign in page + tplSignIn base.TplName = "user/auth/signin" + // tplSignUp template path for sign up page + tplSignUp base.TplName = "user/auth/signup" + // TplActivate template path for activate user + TplActivate base.TplName = "user/auth/activate" +) + +// autoSignIn reads cookie and try to auto-login. +func autoSignIn(ctx *context.Context) (bool, error) { + isSucceed := false + defer func() { + if !isSucceed { + ctx.DeleteSiteCookie(setting.CookieRememberName) + } + }() + + authCookie := ctx.GetSiteCookie(setting.CookieRememberName) + if len(authCookie) == 0 { + return false, nil + } + + lookupKey, validator, found := strings.Cut(authCookie, ":") + if !found { + return false, nil + } + + authToken, err := auth.FindAuthToken(ctx, lookupKey) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return false, nil + } + return false, err + } + + if authToken.IsExpired() { + err = auth.DeleteAuthToken(ctx, authToken) + return false, err + } + + rawValidator, err := hex.DecodeString(validator) + if err != nil { + return false, err + } + + if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 { + return false, nil + } + + u, err := user_model.GetUserByID(ctx, authToken.UID) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + return false, fmt.Errorf("GetUserByID: %w", err) + } + return false, nil + } + + isSucceed = true + + if err := updateSession(ctx, nil, map[string]any{ + // Set session IDs + "uid": u.ID, + }); err != nil { + return false, fmt.Errorf("unable to updateSession: %w", err) + } + + if err := resetLocale(ctx, u); err != nil { + return false, err + } + + ctx.Csrf.DeleteCookie(ctx) + return true, nil +} + +func resetLocale(ctx *context.Context, u *user_model.User) error { + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + return err + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + if ctx.Locale.Language() != u.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + + return nil +} + +func RedirectAfterLogin(ctx *context.Context) { + redirectTo := ctx.FormString("redirect_to") + if redirectTo == "" { + redirectTo = ctx.GetSiteCookie("redirect_to") + } + middleware.DeleteRedirectToCookie(ctx.Resp) + nextRedirectTo := setting.AppSubURL + string(setting.LandingPageURL) + if setting.LandingPageURL == setting.LandingPageLogin { + nextRedirectTo = setting.AppSubURL + "/" // do not cycle-redirect to the login page + } + ctx.RedirectToFirst(redirectTo, nextRedirectTo) +} + +func CheckAutoLogin(ctx *context.Context) bool { + isSucceed, err := autoSignIn(ctx) // try to auto-login + if err != nil { + ctx.ServerError("autoSignIn", err) + return true + } + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + if isSucceed { + RedirectAfterLogin(ctx) + return true + } + + return false +} + +// SignIn render sign in page +func SignIn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + if CheckAutoLogin(ctx) { + return + } + + if ctx.IsSigned { + RedirectAfterLogin(ctx) + return + } + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) + + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { + context.SetCaptchaData(ctx) + } + + ctx.HTML(http.StatusOK, tplSignIn) +} + +// SignInPost response for sign in request +func SignInPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignIn) + return + } + + form := web.GetForm(ctx).(*forms.SignInForm) + + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { + context.SetCaptchaData(ctx) + + context.VerifyCaptcha(ctx, tplSignIn, form) + if ctx.Written() { + return + } + } + + u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password) + if err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if user_model.IsErrEmailAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignIn, &form) + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if user_model.IsErrUserProhibitLogin(err) { + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if user_model.IsErrUserInactive(err) { + if setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, TplActivate) + } else { + log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + // Now handle 2FA: + + // First of all if the source can skip local two fa we're done + if skipper, ok := source.Cfg.(auth_service.LocalTwoFASkipper); ok && skipper.IsSkipLocalTwoFA() { + handleSignIn(ctx, u, form.Remember) + return + } + + // If this user is enrolled in 2FA TOTP, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Check if the user has webauthn registration + hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if !hasTOTPtwofa && !hasWebAuthnTwofa { + // No two factor auth configured we can sign in the user + handleSignIn(ctx, u, form.Remember) + return + } + + updates := map[string]any{ + // User will need to use 2FA TOTP or WebAuthn, save data + "twofaUid": u.ID, + "twofaRemember": form.Remember, + } + if hasTOTPtwofa { + // User will need to use WebAuthn, save data + updates["totpEnrolled"] = u.ID + } + if err := updateSession(ctx, nil, updates); err != nil { + ctx.ServerError("UserSignIn: Unable to update session", err) + return + } + + // If we have WebAuthn redirect there first + if hasWebAuthnTwofa { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + // Fallback to 2FA + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// This handles the final part of the sign-in process of the user. +func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { + redirect := handleSignInFull(ctx, u, remember, true) + if ctx.Written() { + return + } + ctx.Redirect(redirect) +} + +func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { + if remember { + if err := ctx.SetLTACookie(u); err != nil { + ctx.ServerError("GenerateAuthToken", err) + return setting.AppSubURL + "/" + } + } + + if err := updateSession(ctx, []string{ + // Delete the openid, 2fa and linkaccount data + "openid_verified_uri", + "openid_signin_remember", + "openid_determined_email", + "openid_determined_username", + "twofaUid", + "twofaRemember", + "linkAccount", + }, map[string]any{ + "uid": u.ID, + }); err != nil { + ctx.ServerError("RegenerateSession", err) + return setting.AppSubURL + "/" + } + + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language())) + return setting.AppSubURL + "/" + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + if ctx.Locale.Language() != u.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + // Register last login + if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) + return setting.AppSubURL + "/" + } + + redirectTo := ctx.GetSiteCookie("redirect_to") + if redirectTo != "" { + middleware.DeleteRedirectToCookie(ctx.Resp) + } + if obeyRedirect { + return ctx.RedirectToFirst(redirectTo) + } + if !httplib.IsRiskyRedirectURL(redirectTo) { + return redirectTo + } + return setting.AppSubURL + "/" +} + +func getUserName(gothUser *goth.User) (string, error) { + switch setting.OAuth2Client.Username { + case setting.OAuth2UsernameEmail: + return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0]) + case setting.OAuth2UsernameNickname: + return user_model.NormalizeUserName(gothUser.NickName) + default: // OAuth2UsernameUserid + return gothUser.UserID, nil + } +} + +// HandleSignOut resets the session and sets the cookies +func HandleSignOut(ctx *context.Context) { + _ = ctx.Session.Flush() + _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) + ctx.DeleteSiteCookie(setting.CookieRememberName) + ctx.Csrf.DeleteCookie(ctx) + middleware.DeleteRedirectToCookie(ctx.Resp) +} + +// SignOut sign out from login status +func SignOut(ctx *context.Context) { + if ctx.Doer != nil { + eventsource.GetManager().SendMessage(ctx.Doer.ID, &eventsource.Event{ + Name: "logout", + Data: ctx.Session.ID(), + }) + } + HandleSignOut(ctx) + ctx.JSONRedirect(setting.AppSubURL + "/") +} + +// SignUp render the register page +func SignUp(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers + context.SetCaptchaData(ctx) + + ctx.Data["PageIsSignUp"] = true + + // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + ctx.HTML(http.StatusOK, tplSignUp) +} + +// SignUpPost response for sign up information submission +func SignUpPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers + context.SetCaptchaData(ctx) + + ctx.Data["PageIsSignUp"] = true + + // Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true + if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignUp) + return + } + + context.VerifyCaptcha(ctx, tplSignUp, form) + if ctx.Written() { + return + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplSignUp, &form) + return + } + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplSignUp, &form) + return + } + if err := password.IsPwned(ctx, form.Password); err != nil { + errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplSignUp, &form) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + } + + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { + // error already handled + return + } + + ctx.Flash.Success(ctx.Tr("auth.sign_up_successful")) + handleSignIn(ctx, u, false) +} + +// createAndHandleCreatedUser calls createUserInContext and +// then handleUserCreated. +func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { + if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) { + return false + } + return handleUserCreated(ctx, u, gothUser) +} + +// createUserInContext creates a user and handles errors within a given context. +// Optionally a template can be specified. +func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { + if err := user_model.CreateUser(ctx, u, overwrites); err != nil { + if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { + if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { + var user *user_model.User + user = &user_model.User{Name: u.Name} + hasUser, err := user_model.GetUser(ctx, user) + if !hasUser || err != nil { + user = &user_model.User{Email: u.Email} + hasUser, err = user_model.GetUser(ctx, user) + if !hasUser || err != nil { + ctx.ServerError("UserLinkAccount", err) + return false + } + } + + // TODO: probably we should respect 'remember' user's choice... + linkAccount(ctx, user, *gothUser, true) + return false // user is already created here, all redirects are handled + } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { + showLinkingLogin(ctx, *gothUser) + return false // user will be created only after linking login + } + } + + // handle error without template + if len(tpl) == 0 { + ctx.ServerError("CreateUser", err) + return false + } + + // handle error with template + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form) + case user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tpl, form) + default: + ctx.ServerError("CreateUser", err) + } + return false + } + log.Trace("Account created: %s", u.Name) + return true +} + +// handleUserCreated does additional steps after a new user is created. +// It auto-sets admin for the only user, updates the optional external user and +// sends a confirmation email if required. +func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { + // Auto-set admin for the only user. + if user_model.CountUsers(ctx, nil) == 1 { + opts := &user_service.UpdateOptions{ + IsActive: optional.Some(true), + IsAdmin: optional.Some(true), + SetLastLogin: true, + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return false + } + } + + notify_service.NewUserSignUp(ctx, u) + // update external user information + if gothUser != nil { + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { + log.Error("EnsureLinkExternalToUser failed: %v", err) + } + } + + // Send confirmation email + if !u.IsActive && u.ID > 1 { + if setting.Service.RegisterManualConfirm { + ctx.Data["ManualActivationOnly"] = true + ctx.HTML(http.StatusOK, TplActivate) + return false + } + + mailer.SendActivateAccountMail(ctx.Locale, u) + + ctx.Data["IsSendRegisterMail"] = true + ctx.Data["Email"] = u.Email + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + ctx.HTML(http.StatusOK, TplActivate) + + if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + return false + } + + return true +} + +// Activate render activate user page +func Activate(ctx *context.Context) { + code := ctx.FormString("code") + + if len(code) == 0 { + ctx.Data["IsActivatePage"] = true + if ctx.Doer == nil || ctx.Doer.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Resend confirmation email. + if setting.Service.RegisterEmailConfirm { + var cacheKey string + if ctx.Cache.IsExist("MailChangedJustNow_" + ctx.Doer.LowerName) { + cacheKey = "MailChangedLimit_" + if err := ctx.Cache.Delete("MailChangedJustNow_" + ctx.Doer.LowerName); err != nil { + log.Error("Delete cache(MailChangedJustNow) fail: %v", err) + } + } else { + cacheKey = "MailResendLimit_" + } + if ctx.Cache.IsExist(cacheKey + ctx.Doer.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) + + if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + ctx.HTML(http.StatusOK, TplActivate) + return + } + + user := user_model.VerifyUserActiveCode(ctx, code) + // if code is wrong + if user == nil { + ctx.Data["IsCodeInvalid"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + handleAccountActivation(ctx, user) +} + +// ActivatePost handles account activation with password check +func ActivatePost(ctx *context.Context) { + code := ctx.FormString("code") + if len(code) == 0 { + email := ctx.FormString("email") + if len(email) > 0 { + ctx.Data["IsActivatePage"] = true + if ctx.Doer == nil || ctx.Doer.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Change the primary email + if setting.Service.RegisterEmailConfirm { + if ctx.Cache.IsExist("MailChangeLimit_" + ctx.Doer.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + err := user_service.ReplaceInactivePrimaryEmail(ctx, ctx.Doer.Email, &user_model.EmailAddress{ + UID: ctx.Doer.ID, + Email: email, + }) + if err != nil { + ctx.Data["IsActivatePage"] = false + log.Error("Couldn't replace inactive primary email of user %d: %v", ctx.Doer.ID, err) + ctx.RenderWithErr(ctx.Tr("auth.change_unconfirmed_email_error", err), TplActivate, nil) + return + } + if err := ctx.Cache.Put("MailChangeLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailChangeLimit) fail: %v", err) + } + if err := ctx.Cache.Put("MailChangedJustNow_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailChangedJustNow) fail: %v", err) + } + + // Confirmation mail will be re-sent after the redirect to `/user/activate` below. + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + } + + ctx.Redirect(setting.AppSubURL + "/user/activate") + return + } + + user := user_model.VerifyUserActiveCode(ctx, code) + // if code is wrong + if user == nil { + ctx.Data["IsCodeInvalid"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + password := ctx.FormString("password") + if len(password) == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + if !user.ValidatePassword(password) { + ctx.Data["IsPasswordInvalid"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + } + + handleAccountActivation(ctx, user) +} + +func handleAccountActivation(ctx *context.Context, user *user_model.User) { + user.IsActive = true + var err error + if user.Rands, err = user_model.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("UpdateUserCols", err) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + + if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil { + log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err) + ctx.ServerError("ActivateUserEmail", err) + return + } + + log.Trace("User activated: %s", user.Name) + + if err := updateSession(ctx, nil, map[string]any{ + "uid": user.ID, + }); err != nil { + log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) + ctx.ServerError("ActivateUserEmail", err) + return + } + + if err := resetLocale(ctx, user); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("auth.account_activated")) + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") +} + +// ActivateEmail render the activate email page +func ActivateEmail(ctx *context.Context) { + code := ctx.FormString("code") + emailStr := ctx.FormString("email") + + // Verify code. + if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil { + if err := user_model.ActivateEmail(ctx, email); err != nil { + ctx.ServerError("ActivateEmail", err) + return + } + + log.Trace("Email activated: %s", email.Email) + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + + if u, err := user_model.GetUserByID(ctx, email.UID); err != nil { + log.Warn("GetUserByID: %d", email.UID) + } else { + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) + } + } + + // FIXME: e-mail verification does not require the user to be logged in, + // so this could be redirecting to the login page. + // Should users be logged in automatically here? (consider 2FA requirements, etc.) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error { + if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil { + return fmt.Errorf("regenerate session: %w", err) + } + sess := ctx.Session + sessID := sess.ID() + for _, k := range deletes { + if err := sess.Delete(k); err != nil { + return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err) + } + } + for k, v := range updates { + if err := sess.Set(k, v); err != nil { + return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err) + } + } + if err := sess.Release(); err != nil { + return fmt.Errorf("store session[%s]: %w", sessID, err) + } + return nil +} diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go new file mode 100644 index 0000000..c6afbf8 --- /dev/null +++ b/routers/web/auth/auth_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestUserLogin(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/user/login") + SignIn(ctx) + assert.Equal(t, http.StatusOK, resp.Code) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "/", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to=/other") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.Req.AddCookie(&http.Cookie{Name: "redirect_to", Value: "/other-cookie"}) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other-cookie", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to="+url.QueryEscape("https://example.com")) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/", test.RedirectURL(resp)) +} diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go new file mode 100644 index 0000000..9b0141c --- /dev/null +++ b/routers/web/auth/linkaccount.go @@ -0,0 +1,308 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + + "github.com/markbates/goth" +) + +var tplLinkAccount base.TplName = "user/auth/link_account" + +// LinkAccount shows the page where the user can decide to login or create a new account +func LinkAccount(ctx *context.Context) { + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + gu, _ := gothUser.(goth.User) + uname, err := getUserName(&gu) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + email := gu.Email + ctx.Data["user_name"] = uname + ctx.Data["email"] = email + + if len(email) != 0 { + u, err := user_model.GetUserByEmail(ctx, email) + if err != nil && !user_model.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } else if len(uname) != 0 { + u, err := user_model.GetUserByName(ctx, uname) + if err != nil && !user_model.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } + + ctx.HTML(http.StatusOK, tplLinkAccount) +} + +func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl base.TplName, invoker string, err error) { + if errors.Is(err, util.ErrNotExist) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm) + } else if errors.Is(err, util.ErrInvalidArgument) { + ctx.Data["user_exists"] = true + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm) + } else if user_model.IsErrUserProhibitLogin(err) { + ctx.Data["user_exists"] = true + log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if user_model.IsErrUserInactive(err) { + ctx.Data["user_exists"] = true + if setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, TplActivate) + } else { + log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError(invoker, err) + } +} + +// LinkAccountPostSignIn handle the coupling of external account with another account using signIn +func LinkAccountPostSignIn(ctx *context.Context) { + signInForm := web.GetForm(ctx).(*forms.SignInForm) + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeSignIn"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + u, _, err := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password) + if err != nil { + handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err) + return + } + + linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) +} + +func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here + _, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil { + if !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserLinkAccount", err) + return + } + + err = externalaccount.LinkAccountToUser(ctx, u, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + if err := updateSession(ctx, nil, map[string]any{ + // User needs to use 2FA, save data and redirect to 2FA page. + "twofaUid": u.ID, + "twofaRemember": remember, + "linkAccount": true, + }); err != nil { + ctx.ServerError("RegenerateSession", err) + return + } + + // If WebAuthn is enrolled -> Redirect to WebAuthn instead + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// LinkAccountPostRegister handle the creation of a new account for an external account using signUp +func LinkAccountPostRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + // TODO Make insecure passwords optional for local accounts also, + // once email-based Second-Factor Auth is available + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeRegister"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUserInterface := ctx.Session.Get("linkAccountGothUser") + if gothUserInterface == nil { + ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) + return + } + gothUser, ok := gothUserInterface.(goth.User) + if !ok { + ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha { + context.VerifyCaptcha(ctx, tplLinkAccount, form) + if ctx.Written() { + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form) + return + } + + if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword { + // In user_model.User an empty password is classed as not set, so we set form.Password to empty. + // Eventually the database should be changed to indicate "Second Factor"-enabled accounts + // (accounts that do not introduce the security vulnerabilities of a password). + // If a user decides to circumvent second-factor security, and purposefully create a password, + // they can still do so using the "Recover Account" option. + form.Password = "" + } else { + if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form) + return + } + if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form) + return + } + } + + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) + if err != nil { + ctx.ServerError("CreateUser", err) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { + // error already handled + return + } + + source := authSource.Cfg.(*oauth2.Source) + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + + handleSignIn(ctx, u, false) +} diff --git a/routers/web/auth/main_test.go b/routers/web/auth/main_test.go new file mode 100644 index 0000000..b438e5d --- /dev/null +++ b/routers/web/auth/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go new file mode 100644 index 0000000..0626157 --- /dev/null +++ b/routers/web/auth/oauth.go @@ -0,0 +1,1422 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + go_context "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "html" + "html/template" + "io" + "net/http" + "net/url" + "sort" + "strings" + + "code.gitea.io/gitea/models/auth" + org_model "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + source_service "code.gitea.io/gitea/services/auth/source" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + remote_service "code.gitea.io/gitea/services/remote" + user_service "code.gitea.io/gitea/services/user" + + "gitea.com/go-chi/binding" + "github.com/golang-jwt/jwt/v5" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/fitbit" + "github.com/markbates/goth/providers/openidConnect" + "github.com/markbates/goth/providers/zoom" + go_oauth2 "golang.org/x/oauth2" +) + +const ( + tplGrantAccess base.TplName = "user/auth/grant" + tplGrantError base.TplName = "user/auth/grant_error" +) + +// TODO move error and responses to SDK or models + +// AuthorizeErrorCode represents an error code specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeErrorCode string + +const ( + // ErrorCodeInvalidRequest represents the according error in RFC 6749 + ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request" + // ErrorCodeUnauthorizedClient represents the according error in RFC 6749 + ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client" + // ErrorCodeAccessDenied represents the according error in RFC 6749 + ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied" + // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749 + ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type" + // ErrorCodeInvalidScope represents the according error in RFC 6749 + ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope" + // ErrorCodeServerError represents the according error in RFC 6749 + ErrorCodeServerError AuthorizeErrorCode = "server_error" + // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 + ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" +) + +// AuthorizeError represents an error type specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeError struct { + ErrorCode AuthorizeErrorCode `json:"error" form:"error"` + ErrorDescription string + State string +} + +// Error returns the error message +func (err AuthorizeError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// AccessTokenErrorCode represents an error code specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +type AccessTokenErrorCode string + +const ( + // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request" + // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidClient = "invalid_client" + // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidGrant = "invalid_grant" + // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client" + // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type" + // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidScope = "invalid_scope" +) + +// AccessTokenError represents an error response specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +type AccessTokenError struct { + ErrorCode AccessTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// Error returns the error message +func (err AccessTokenError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// errCallback represents a oauth2 callback error +type errCallback struct { + Code string + Description string +} + +func (err errCallback) Error() string { + return err.Description +} + +// TokenType specifies the kind of token +type TokenType string + +const ( + // TokenTypeBearer represents a token type specified in RFC 6749 + TokenTypeBearer TokenType = "bearer" + // TokenTypeMAC represents a token type specified in RFC 6749 + TokenTypeMAC = "mac" +) + +// AccessTokenResponse represents a successful access token response +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2 +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType TokenType `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` +} + +func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { + if setting.OAuth2.InvalidateRefreshTokens { + if err := grant.IncreaseCounter(ctx); err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "cannot increase the grant counter", + } + } + } + // generate access token to access the API + expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) + accessToken := &oauth2.Token{ + GrantID: grant.ID, + Type: oauth2.TypeAccessToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + }, + } + signedAccessToken, err := accessToken.SignToken(serverKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate refresh token to request an access token after it expired later + refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime() + refreshToken := &oauth2.Token{ + GrantID: grant.ID, + Counter: grant.Counter, + Type: oauth2.TypeRefreshToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpirationDate), + }, + } + signedRefreshToken, err := refreshToken.SignToken(serverKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + user, err := user_model.GetUserByID(ctx, grant.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find user", + } + } + log.Error("Error loading user: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + + idToken := &oauth2.OIDCToken{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + Issuer: setting.AppURL, + Audience: []string{app.ClientID}, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + if grant.ScopeContains("profile") { + idToken.Name = user.GetDisplayName() + idToken.PreferredUsername = user.Name + idToken.Profile = user.HTMLURL() + idToken.Picture = user.AvatarLink(ctx) + idToken.Website = user.Website + idToken.Locale = user.Language + idToken.UpdatedAt = user.UpdatedUnix + } + if grant.ScopeContains("email") { + idToken.Email = user.Email + idToken.EmailVerified = user.IsActive + } + if grant.ScopeContains("groups") { + onlyPublicGroups := ifOnlyPublicGroups(grant.Scope) + + groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups) + if err != nil { + log.Error("Error getting groups: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + idToken.Groups = groups + } + + signedIDToken, err = idToken.SignToken(clientKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + } + + return &AccessTokenResponse{ + AccessToken: signedAccessToken, + TokenType: TokenTypeBearer, + ExpiresIn: setting.OAuth2.AccessTokenExpirationTime, + RefreshToken: signedRefreshToken, + IDToken: signedIDToken, + }, nil +} + +type userInfoResponse struct { + Sub string `json:"sub"` + Name string `json:"name"` + Username string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` + Groups []string `json:"groups,omitempty"` +} + +func ifOnlyPublicGroups(scopes string) bool { + scopes = strings.ReplaceAll(scopes, ",", " ") + scopesList := strings.Fields(scopes) + for _, scope := range scopesList { + if scope == "all" || scope == "read:organization" || scope == "read:admin" { + return false + } + } + return true +} + +// InfoOAuth manages request for userinfo endpoint +func InfoOAuth(ctx *context.Context) { + if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() { + ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + response := &userInfoResponse{ + Sub: fmt.Sprint(ctx.Doer.ID), + Name: ctx.Doer.FullName, + Username: ctx.Doer.Name, + Email: ctx.Doer.Email, + Picture: ctx.Doer.AvatarLink(ctx), + } + + var token string + if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { + auths := strings.Fields(auHead) + if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { + token = auths[1] + } + } + + _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token) + onlyPublicGroups := ifOnlyPublicGroups(grantScopes) + + groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) + if err != nil { + ctx.ServerError("Oauth groups for user", err) + return + } + response.Groups = groups + + ctx.JSON(http.StatusOK, response) +} + +// returns a list of "org" and "org:team" strings, +// that the given user is a part of. +func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) { + orgs, err := org_model.GetUserOrgsList(ctx, user) + if err != nil { + return nil, fmt.Errorf("GetUserOrgList: %w", err) + } + + var groups []string + for _, org := range orgs { + if setting.OAuth2.EnableAdditionalGrantScopes { + if onlyPublicGroups { + public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID) + if !public && err == nil { + continue + } + } + } + + groups = append(groups, org.Name) + teams, err := org.LoadTeams(ctx) + if err != nil { + return nil, fmt.Errorf("LoadTeams: %w", err) + } + for _, team := range teams { + if team.IsMember(ctx, user.ID) { + groups = append(groups, org.Name+":"+team.LowerName) + } + } + } + return groups, nil +} + +func parseBasicAuth(ctx *context.Context) (username, password string, err error) { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + return base.BasicAuthDecode(authData) + } + return "", "", errors.New("invalid basic authentication") +} + +// IntrospectOAuth introspects an oauth token +func IntrospectOAuth(ctx *context.Context) { + clientIDValid := false + if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) + if err != nil && !auth.IsErrOauthClientIDInvalid(err) { + // this is likely a database error; log it and respond without details + log.Error("Error retrieving client_id: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret)) + } + if !clientIDValid { + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + var response struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + Username string `json:"username,omitempty"` + jwt.RegisteredClaims + } + + form := web.GetForm(ctx).(*forms.IntrospectTokenForm) + token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey) + if err == nil { + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err == nil && grant != nil { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err == nil && app != nil { + response.Active = true + response.Scope = grant.Scope + response.Issuer = setting.AppURL + response.Audience = []string{app.ClientID} + response.Subject = fmt.Sprint(grant.UserID) + } + if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { + response.Username = user.Name + } + } + } + + ctx.JSON(http.StatusOK, response) +} + +// AuthorizeOAuth manages authorize requests +func AuthorizeOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + if auth.IsErrOauthClientIDInvalid(err) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnauthorizedClient, + ErrorDescription: "Client ID not registered", + State: form.State, + }, "") + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + + var user *user_model.User + if app.UID != 0 { + user, err = user_model.GetUserByID(ctx, app.UID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + } + + if !app.ContainsRedirectURI(form.RedirectURI) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "Unregistered Redirect URI", + State: form.State, + }, "") + return + } + + if form.ResponseType != "code" { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnsupportedResponseType, + ErrorDescription: "Only code response type is supported.", + State: form.State, + }, form.RedirectURI) + return + } + + // pkce support + switch form.CodeChallengeMethod { + case "S256": + case "plain": + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge", + State: form.State, + }, form.RedirectURI) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + case "": + // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message" + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1 + if !app.ConfidentialClient { + // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request"" + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "PKCE is required for public clients", + State: form.State, + }, form.RedirectURI) + return + } + default: + // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"." + // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "unsupported code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + + // Redirect if user already granted access and the application is confidential. + // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2 + if app.ConfidentialClient && grant != nil { + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + ctx.Redirect(redirect.String()) + return + } + + // show authorize page to grant access + ctx.Data["Application"] = app + ctx.Data["RedirectURI"] = form.RedirectURI + ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce + if user != nil { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))) + } else { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))) + } + ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "") + // TODO document SESSION <=> FORM + err = ctx.Session.Set("client_id", app.ClientID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("redirect_uri", form.RedirectURI) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("state", form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.HTML(http.StatusOK, tplGrantAccess) +} + +// GrantApplicationOAuth manages the post request submitted when a user grants access to an application +func GrantApplicationOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.GrantApplicationForm) + if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || + ctx.Session.Get("redirect_uri") != form.RedirectURI { + ctx.Error(http.StatusBadRequest) + return + } + + if !form.Granted { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "the request is denied", + ErrorCode: ErrorCodeAccessDenied, + }, form.RedirectURI) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + if grant == nil { + grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + } else if grant.Scope != form.Scope { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "a grant exists with different scope", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + + var codeChallenge, codeChallengeMethod string + codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) + codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string) + + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities +func OIDCWellKnown(ctx *context.Context) { + ctx.Data["SigningKey"] = oauth2.DefaultSigningKey + ctx.JSONTemplate("user/auth/oidc_wellknown") +} + +// OIDCKeys generates the JSON Web Key Set +func OIDCKeys(ctx *context.Context) { + jwk, err := oauth2.DefaultSigningKey.ToJWK() + if err != nil { + log.Error("Error converting signing key to JWK: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + + jwk["use"] = "sig" + + jwks := map[string][]map[string]string{ + "keys": { + jwk, + }, + } + + ctx.Resp.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(ctx.Resp) + if err := enc.Encode(jwks); err != nil { + log.Error("Failed to encode representation as json. Error: %v", err) + } +} + +// AccessTokenOAuth manages all access token requests by the client +func AccessTokenOAuth(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AccessTokenForm) + // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header + if form.ClientID == "" || form.ClientSecret == "" { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + clientID, clientSecret, err := base.BasicAuthDecode(authData) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + // validate that any fields present in the form match the Basic auth header + if form.ClientID != "" && form.ClientID != clientID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_id in request body inconsistent with Authorization header", + }) + return + } + form.ClientID = clientID + if form.ClientSecret != "" && form.ClientSecret != clientSecret { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_secret in request body inconsistent with Authorization header", + }) + return + } + form.ClientSecret = clientSecret + } + } + + serverKey := oauth2.DefaultSigningKey + clientKey := serverKey + if serverKey.IsSymmetric() { + var err error + clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret)) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "Error creating signing key", + }) + return + } + } + + switch form.GrantType { + case "refresh_token": + handleRefreshToken(ctx, form, serverKey, clientKey) + case "authorization_code": + handleAuthorizationCode(ctx, form, serverKey, clientKey) + default: + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnsupportedGrantType, + ErrorDescription: "Only refresh_token or authorization_code grant type is supported", + }) + } +} + +func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID), + }) + return + } + // "The authorization server MUST ... require client authentication for confidential clients" + // https://datatracker.ietf.org/doc/html/rfc6749#section-6 + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + // "invalid_client ... Client authentication failed" + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: errorDescription, + }) + return + } + + token, err := oauth2.ParseToken(form.RefreshToken, serverKey) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unable to parse refresh token", + }) + return + } + // get grant before increasing counter + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err != nil || grant == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "grant does not exist", + }) + return + } + + // check if token got already used + if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "token was already used", + }) + log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) + return + } + accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + ctx.JSON(http.StatusOK, accessToken) +} + +func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID), + }) + return + } + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: errorDescription, + }) + return + } + if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unexpected redirect URI", + }) + return + } + authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code) + if err != nil || authorizationCode == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if code verifier authorizes the client, PKCE support + if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "failed PKCE code challenge", + }) + return + } + // check if granted for this application + if authorizationCode.Grant.ApplicationID != app.ID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "invalid grant", + }) + return + } + // remove token from database to deny duplicate usage + if err := authorizationCode.Invalidate(ctx); err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot proceed your request", + }) + } + resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) { + ctx.JSON(http.StatusBadRequest, acErr) +} + +func handleServerError(ctx *context.Context, state, redirectURI string) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "A server error occurred", + State: state, + }, redirectURI) +} + +func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) { + if redirectURI == "" { + log.Warn("Authorization failed: %v", authErr.ErrorDescription) + ctx.Data["Error"] = authErr + ctx.HTML(http.StatusBadRequest, tplGrantError) + return + } + redirect, err := url.Parse(redirectURI) + if err != nil { + ctx.ServerError("url.Parse", err) + return + } + q := redirect.Query() + q.Set("error", string(authErr.ErrorCode)) + q.Set("error_description", authErr.ErrorDescription) + q.Set("state", authErr.State) + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// SignInOAuth handles the OAuth2 login buttons +func SignInOAuth(ctx *context.Context) { + provider := ctx.Params(":provider") + + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user + user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err == nil && user != nil { + // we got the user without going through the whole OAuth2 authentication flow again + handleOAuth2SignIn(ctx, authSource, user, gothUser) + return + } + + codeChallenge, err := generateCodeChallenge(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err)) + return + } + + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + if err = oauth2.ResetOAuth2(ctx); err != nil { + ctx.ServerError("SignIn", err) + return + } + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + ctx.ServerError("SignIn", err) + } + return + } + ctx.ServerError("SignIn", err) + } + // redirect is done in oauth2.Auth +} + +// SignInOAuthCallback handles the callback from the given provider +func SignInOAuthCallback(ctx *context.Context) { + provider := ctx.Params(":provider") + + if ctx.Req.FormValue("error") != "" { + var errorKeyValues []string + for k, vv := range ctx.Req.Form { + for _, v := range vv { + errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v))) + } + } + sort.Strings(errorKeyValues) + ctx.Flash.Error(strings.Join(errorKeyValues, "
"), true) + } + + // first look if the provider is still active + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + if authSource == nil { + ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err != nil { + if user_model.IsErrUserProhibitLogin(err) { + uplerr := err.(user_model.ErrUserProhibitLogin) + log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + if callbackErr, ok := err.(errCallback); ok { + log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description) + switch callbackErr.Code { + case "access_denied": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied")) + case "temporarily_unavailable": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable")) + default: + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error")) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + if err, ok := err.(*go_oauth2.RetrieveError); ok { + ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true) + } + ctx.ServerError("UserSignIn", err) + return + } + + if u == nil { + if ctx.Doer != nil { + // attach user to already logged in user + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration { + // create new user with details from oauth2 provider + if gothUser.UserID == "" { + log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) + if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" { + log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") + } + err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) + ctx.ServerError("CreateUser", err) + return + } + var missingFields []string + if gothUser.Email == "" { + missingFields = append(missingFields, "email") + } + if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { + missingFields = append(missingFields, "nickname") + } + if len(missingFields) > 0 { + // we don't have enough information to create an account automatically, + // so we prompt the user for the remaining bits + log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields) + showLinkingLogin(ctx, gothUser) + return + } + uname, err := getUserName(&gothUser) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + u = &user_model.User{ + Name: uname, + FullName: gothUser.Name, + Email: gothUser.Email, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + LoginName: gothUser.UserID, + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), + } + + source := authSource.Cfg.(*oauth2.Source) + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) + u.IsAdmin = isAdmin.ValueOrDefault(false) + u.IsRestricted = isRestricted.ValueOrDefault(false) + + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + // error already handled + return + } + + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser) + return + } + } + + handleOAuth2SignIn(ctx, authSource, u, gothUser) +} + +func claimValueToStringSet(claimValue any) container.Set[string] { + var groups []string + + switch rawGroup := claimValue.(type) { + case []string: + groups = rawGroup + case []any: + for _, group := range rawGroup { + groups = append(groups, fmt.Sprintf("%s", group)) + } + default: + str := fmt.Sprintf("%s", rawGroup) + groups = strings.Split(str, ",") + } + return container.SetOf(groups...) +} + +func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + + groups := getClaimedGroups(source, gothUser) + + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return err + } + } + + return nil +} + +func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { + groupClaims, has := gothUser.RawData[source.GroupClaimName] + if !has { + return nil + } + + return claimValueToStringSet(groupClaims) +} + +func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { + groups := getClaimedGroups(source, gothUser) + + if source.AdminGroup != "" { + isAdmin = optional.Some(groups.Contains(source.AdminGroup)) + } + if source.RestrictedGroup != "" { + isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) + } + + return isAdmin, isRestricted +} + +func showLinkingLogin(ctx *context.Context, gothUser goth.User) { + if err := updateSession(ctx, nil, map[string]any{ + "linkAccountGothUser": gothUser, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + ctx.Redirect(setting.AppSubURL + "/user/link_account") +} + +func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { + if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { + resp, err := http.Get(url) + if err == nil { + defer func() { + _ = resp.Body.Close() + }() + } + // ignore any error + if err == nil && resp.StatusCode == http.StatusOK { + data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) + if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { + _ = user_service.UploadAvatar(ctx, u, data) + } + } + } +} + +func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + + needs2FA := false + if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { + _, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserSignIn", err) + return + } + needs2FA = err == nil + } + + oauth2Source := source.Cfg.(*oauth2.Source) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) + if err != nil { + ctx.ServerError("UnmarshalGroupTeamMapping", err) + return + } + + groups := getClaimedGroups(oauth2Source, &gothUser) + + opts := &user_service.UpdateOptions{} + + // Reactivate user if they are deactivated + if !u.IsActive { + opts.IsActive = optional.Some(true) + } + + // Update GroupClaims + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { + ctx.ServerError("EnsureLinkExternalToUser", err) + return + } + + // If this user is enrolled in 2FA and this source doesn't override it, + // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. + if !needs2FA { + // Register last login + opts.SetLastLogin = true + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + if err := updateSession(ctx, nil, map[string]any{ + "uid": u.ID, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + if err := resetLocale(ctx, u); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") + return + } + + if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + } + + if err := updateSession(ctx, nil, map[string]any{ + // User needs to use 2FA, save data and redirect to 2FA page. + "twofaUid": u.ID, + "twofaRemember": false, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // If WebAuthn is enrolled -> Redirect to WebAuthn instead + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE +func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) { + // the `code_verifier` is only forwarded by specific providers + // https://codeberg.org/forgejo/forgejo/issues/4033 + p, ok := goth.GetProviders()[provider] + if !ok { + return "", nil + } + switch p.(type) { + default: + return "", nil + case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider: + // those providers forward the `code_verifier` + // a code_challenge can be generated + } + + codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness) + if err != nil { + return "", err + } + if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil { + return "", err + } + return encodeCodeChallenge(codeVerifier) +} + +func encodeCodeChallenge(codeVerifier string) (string, error) { + hasher := sha256.New() + _, err := io.WriteString(hasher, codeVerifier) + codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + return codeChallenge, err +} + +// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful +// login the user +func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil { + return nil, goth.User{}, err + } + + u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser) + return u, gothUser, err +} + +func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { + oauth2Source := authSource.Cfg.(*oauth2.Source) + + // Make sure that the response is not an error response. + errorName := request.FormValue("error") + + if len(errorName) > 0 { + errorDescription := request.FormValue("error_description") + + // Delete the goth session + err := gothic.Logout(response, request) + if err != nil { + return goth.User{}, err + } + + return goth.User{}, errCallback{ + Code: errorName, + Description: errorDescription, + } + } + + // Proceed to authenticate through goth. + codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string) + _ = ctx.Session.Delete("CodeVerifier") + gothUser, err := oauth2Source.Callback(request, response, codeVerifier) + if err != nil { + if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") { + log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) + err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) + } + return goth.User{}, err + } + + if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + + if oauth2Source.RequiredClaimValue != "" { + groups := claimValueToStringSet(claimInterface) + + if !groups.Contains(oauth2Source.RequiredClaimValue) { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + } + } + + return gothUser, nil +} + +func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { + user := &user_model.User{ + LoginName: gothUser.UserID, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return nil, err + } + + if hasUser { + return user, nil + } + log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) + + // search in external linked users + externalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: authSource.ID, + } + hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) + if err != nil { + return nil, err + } + if hasUser { + user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) + return user, err + } + + // no user found to login + return nil, nil +} diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go new file mode 100644 index 0000000..5a4a646 --- /dev/null +++ b/routers/web/auth/oauth_test.go @@ -0,0 +1,103 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/auth/source/oauth2" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken { + signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32)) + require.NoError(t, err) + assert.NotNil(t, signingKey) + + response, terr := newAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey) + assert.Nil(t, terr) + assert.NotNil(t, response) + + parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2.OIDCToken{}, func(token *jwt.Token) (any, error) { + assert.NotNil(t, token.Method) + assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg()) + return signingKey.VerifyKey(), nil + }) + require.NoError(t, err) + assert.True(t, parsedToken.Valid) + + oidcToken, ok := parsedToken.Claims.(*oauth2.OIDCToken) + assert.True(t, ok) + assert.NotNil(t, oidcToken) + + return oidcToken +} + +func TestNewAccessTokenResponse_OIDCToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + grants, err := auth.GetOAuth2GrantsByUserID(db.DefaultContext, 3) + require.NoError(t, err) + assert.Len(t, grants, 1) + + // Scopes: openid + oidcToken := createAndParseToken(t, grants[0]) + assert.Empty(t, oidcToken.Name) + assert.Empty(t, oidcToken.PreferredUsername) + assert.Empty(t, oidcToken.Profile) + assert.Empty(t, oidcToken.Picture) + assert.Empty(t, oidcToken.Website) + assert.Empty(t, oidcToken.UpdatedAt) + assert.Empty(t, oidcToken.Email) + assert.False(t, oidcToken.EmailVerified) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + grants, err = auth.GetOAuth2GrantsByUserID(db.DefaultContext, user.ID) + require.NoError(t, err) + assert.Len(t, grants, 1) + + // Scopes: openid profile email + oidcToken = createAndParseToken(t, grants[0]) + assert.Equal(t, user.Name, oidcToken.Name) + assert.Equal(t, user.Name, oidcToken.PreferredUsername) + assert.Equal(t, user.HTMLURL(), oidcToken.Profile) + assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture) + assert.Equal(t, user.Website, oidcToken.Website) + assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt) + assert.Equal(t, user.Email, oidcToken.Email) + assert.Equal(t, user.IsActive, oidcToken.EmailVerified) + + // set DefaultShowFullName to true + oldDefaultShowFullName := setting.UI.DefaultShowFullName + setting.UI.DefaultShowFullName = true + defer func() { + setting.UI.DefaultShowFullName = oldDefaultShowFullName + }() + + // Scopes: openid profile email + oidcToken = createAndParseToken(t, grants[0]) + assert.Equal(t, user.FullName, oidcToken.Name) + assert.Equal(t, user.Name, oidcToken.PreferredUsername) + assert.Equal(t, user.HTMLURL(), oidcToken.Profile) + assert.Equal(t, user.AvatarLink(db.DefaultContext), oidcToken.Picture) + assert.Equal(t, user.Website, oidcToken.Website) + assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt) + assert.Equal(t, user.Email, oidcToken.Email) + assert.Equal(t, user.IsActive, oidcToken.EmailVerified) +} + +func TestEncodeCodeChallenge(t *testing.T) { + // test vector from https://datatracker.ietf.org/doc/html/rfc7636#page-18 + codeChallenge, err := encodeCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + require.NoError(t, err) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", codeChallenge) +} diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go new file mode 100644 index 0000000..83268fa --- /dev/null +++ b/routers/web/auth/openid.go @@ -0,0 +1,391 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "net/http" + "net/url" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSignInOpenID base.TplName = "user/auth/signin_openid" + tplConnectOID base.TplName = "user/auth/signup_openid_connect" + tplSignUpOID base.TplName = "user/auth/signup_openid_register" +) + +// SignInOpenID render sign in page +func SignInOpenID(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + if ctx.FormString("openid.return_to") != "" { + signInOpenIDVerify(ctx) + return + } + + if CheckAutoLogin(ctx) { + return + } + + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + ctx.HTML(http.StatusOK, tplSignInOpenID) +} + +// Check if the given OpenID URI is allowed by blacklist/whitelist +func allowedOpenIDURI(uri string) (err error) { + // In case a Whitelist is present, URI must be in it + // in order to be accepted + if len(setting.Service.OpenIDWhitelist) != 0 { + for _, pat := range setting.Service.OpenIDWhitelist { + if pat.MatchString(uri) { + return nil // pass + } + } + // must match one of this or be refused + return fmt.Errorf("URI not allowed by whitelist") + } + + // A blacklist match expliclty forbids + for _, pat := range setting.Service.OpenIDBlacklist { + if pat.MatchString(uri) { + return fmt.Errorf("URI forbidden by blacklist") + } + } + + return nil +} + +// SignInOpenIDPost response for openid sign in request +func SignInOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SignInOpenIDForm) + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignInOpenID) + return + } + + id, err := openid.Normalize(form.Openid) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return + } + form.Openid = id + + log.Trace("OpenID uri: " + id) + + err = allowedOpenIDURI(id) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return + } + + redirectTo := setting.AppURL + "user/login/openid" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error()) + ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), tplSignInOpenID, &form) + return + } + + // Request optional nickname and email info + // NOTE: change to `openid.sreg.required` to require it + url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1" + url += "&openid.sreg.optional=nickname%2Cemail" + + log.Trace("Form-passed openid-remember: %t", form.Remember) + + if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil { + log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err) + } + + ctx.Redirect(url) +} + +// signInOpenIDVerify handles response from OpenID provider +func signInOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: %s", ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: %s", fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: %s", id) + + /* Now we should seek for the user and log him in, or prompt + * to register if not found */ + + u, err := user_model.GetUserByOpenID(ctx, id) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("User exists, logging in") + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) + return + } + + log.Trace("User with openid: %s does not exist, should connect or register", id) + + parsedURL, err := url.Parse(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + values, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + email := values.Get("openid.sreg.email") + nickname := values.Get("openid.sreg.nickname") + + log.Trace("User has email=%s and nickname=%s", email, nickname) + + if email != "" { + u, err = user_model.GetUserByEmail(ctx, email) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email) + } + } + + if u == nil && nickname != "" { + u, _ = user_model.GetUserByName(ctx, nickname) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname) + } + } + + if u != nil { + nickname = u.LowerName + } + if err := updateSession(ctx, nil, map[string]any{ + "openid_verified_uri": id, + "openid_determined_email": email, + "openid_determined_username": nickname, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration { + ctx.Redirect(setting.AppSubURL + "/user/openid/connect") + } else { + ctx.Redirect(setting.AppSubURL + "/user/openid/register") + } +} + +// ConnectOpenID shows a form to connect an OpenID URI to an existing account +func ConnectOpenID(ctx *context.Context) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + ctx.HTML(http.StatusOK, tplConnectOID) +} + +// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account +func ConnectOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ConnectOpenIDForm) + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + + u, _, err := auth.UserSignIn(ctx, form.UserName, form.Password) + if err != nil { + handleSignInError(ctx, form.UserName, &form, tplConnectOID, "ConnectOpenIDPost", err) + return + } + + // add OpenID for the user + userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} + if err = user_model.AddUserOpenID(ctx, userOID); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) +} + +// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI +func RegisterOpenID(ctx *context.Context) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey + ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL + ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + email, _ := ctx.Session.Get("openid_determined_email").(string) + if email != "" { + ctx.Data["email"] = email + } + ctx.HTML(http.StatusOK, tplSignUpOID) +} + +// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI +func RegisterOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SignUpOpenIDForm) + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + context.SetCaptchaData(ctx) + ctx.Data["OpenID"] = oid + + if setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha { + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + context.VerifyCaptcha(ctx, tplSignUpOID, form) + } + + length := setting.MinPasswordLength + if length < 256 { + length = 256 + } + password, err := util.CryptoRandomString(int64(length)) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignUpOID, form) + return + } + + u := &user_model.User{ + Name: form.UserName, + Email: form.Email, + Passwd: password, + } + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { + // error already handled + return + } + + // add OpenID for the user + userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} + if err = user_model.AddUserOpenID(ctx, userOID); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + + if !handleUserCreated(ctx, u, nil) { + // error already handled + return + } + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) +} diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go new file mode 100644 index 0000000..d25bd68 --- /dev/null +++ b/routers/web/auth/password.go @@ -0,0 +1,317 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +var ( + // tplMustChangePassword template for updating a user's password + tplMustChangePassword base.TplName = "user/auth/change_passwd" + tplForgotPassword base.TplName = "user/auth/forgot_passwd" + tplResetPassword base.TplName = "user/auth/reset_passwd" +) + +// ForgotPasswd render the forget password page +func ForgotPasswd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + log.Warn("no mail service configured") + ctx.Data["IsResetDisable"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.Data["Email"] = ctx.FormString("email") + + ctx.Data["IsResetRequest"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +// ForgotPasswdPost response for forget password request +func ForgotPasswdPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.NotFound("ForgotPasswdPost", nil) + return + } + ctx.Data["IsResetRequest"] = true + + email := ctx.FormString("email") + ctx.Data["Email"] = email + + u, err := user_model.GetUserByEmail(ctx, email) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.ServerError("user.ResetPasswd(check existence)", err) + return + } + + if !u.IsLocal() && !u.IsOAuth2() { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil) + return + } + + if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) { + ctx.Data["ResendLimited"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + mailer.SendResetPasswordMail(u) + + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFactor) { + code := ctx.FormString("code") + + ctx.Data["Title"] = ctx.Tr("auth.reset_password") + ctx.Data["Code"] = code + + if nil != ctx.Doer { + ctx.Data["user_signed_in"] = true + } + + if len(code) == 0 { + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + return nil, nil + } + + // Fail early, don't frustrate the user + u := user_model.VerifyUserActiveCode(ctx, code) + if u == nil { + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + return nil, nil + } + + twofa, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil { + if !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) + return nil, nil + } + } else { + ctx.Data["has_two_factor"] = true + ctx.Data["scratch_code"] = ctx.FormBool("scratch_code") + } + + // Show the user that they are affecting the account that they intended to + ctx.Data["user_email"] = u.Email + + if nil != ctx.Doer && u.ID != ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email), true) + return nil, nil + } + + return u, twofa +} + +// ResetPasswd render the account recovery page +func ResetPasswd(ctx *context.Context) { + ctx.Data["IsResetForm"] = true + + commonResetPassword(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplResetPassword) +} + +// ResetPasswdPost response from account recovery request +func ResetPasswdPost(ctx *context.Context) { + u, twofa := commonResetPassword(ctx) + if ctx.Written() { + return + } + + if u == nil { + // Flash error has been set + ctx.HTML(http.StatusOK, tplResetPassword) + return + } + + // Handle two-factor + regenerateScratchToken := false + if twofa != nil { + if ctx.FormBool("scratch_code") { + if !twofa.VerifyScratchToken(ctx.FormString("token")) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Token"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil) + return + } + regenerateScratchToken = true + } else { + passcode := ctx.FormString("passcode") + ok, err := twofa.ValidateTOTP(passcode) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error()) + return + } + if !ok || twofa.LastUsedPasscode == passcode { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Passcode"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) + return + } + + twofa.LastUsedPasscode = passcode + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) + return + } + } + } + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(ctx.FormString("password")), + MustChangePassword: optional.Some(false), + } + if err := user_service.UpdateAuth(ctx, u, opts); err != nil { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + switch { + case errors.Is(err, password.ErrMinLength): + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) + case errors.Is(err, password.ErrComplexity): + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil) + case errors.Is(err, password.ErrIsPwned): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil) + case password.IsErrIsPwnedRequest(err): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil) + default: + ctx.ServerError("UpdateAuth", err) + } + return + } + + log.Trace("User password reset: %s", u.Name) + ctx.Data["IsResetFailed"] = true + remember := len(ctx.FormString("remember")) != 0 + + if regenerateScratchToken { + // Invalidate the scratch token. + _, err := twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + if ctx.Written() { + return + } + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + handleSignIn(ctx, u, remember) +} + +// MustChangePassword renders the page to change a user's password +func MustChangePassword(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + ctx.Data["MustChangePassword"] = true + ctx.HTML(http.StatusOK, tplMustChangePassword) +} + +// MustChangePasswordPost response for updating a user's password after their +// account was created by an admin +func MustChangePasswordPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MustChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMustChangePassword) + return + } + + // Make sure only requests for users who are eligible to change their password via + // this method passes through + if !ctx.Doer.MustChangePassword { + ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page")) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form) + return + } + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), + } + if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplMustChangePassword, &form) + default: + ctx.ServerError("UpdateAuth", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + + log.Trace("User updated password: %s", ctx.Doer.Name) + + redirectTo := ctx.GetSiteCookie("redirect_to") + if redirectTo != "" { + middleware.DeleteRedirectToCookie(ctx.Resp) + } + ctx.RedirectToFirst(redirectTo) +} diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go new file mode 100644 index 0000000..5c93c14 --- /dev/null +++ b/routers/web/auth/webauthn.go @@ -0,0 +1,177 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + wa "code.gitea.io/gitea/modules/auth/webauthn" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/externalaccount" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +var tplWebAuthn base.TplName = "user/auth/webauthn" + +// WebAuthn shows the WebAuthn login page +func WebAuthn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + if CheckAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Session.Get("twofaUid").(int64)) + if err != nil { + ctx.ServerError("HasTwoFactorByUID", err) + return + } + + ctx.Data["HasTwoFactor"] = hasTwoFactor + + ctx.HTML(http.StatusOK, tplWebAuthn) +} + +// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser +func WebAuthnLoginAssertion(ctx *context.Context) { + // Ensure user is in a WebAuthn session. + idSess, ok := ctx.Session.Get("twofaUid").(int64) + if !ok || idSess == 0 { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + + user, err := user_model.GetUserByID(ctx, idSess) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + exists, err := auth.ExistsWebAuthnCredentialsForUID(ctx, user.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if !exists { + ctx.ServerError("UserSignIn", errors.New("no device registered")) + return + } + + assertion, sessionData, err := wa.WebAuthn.BeginLogin((*wa.User)(user)) + if err != nil { + ctx.ServerError("webauthn.BeginLogin", err) + return + } + + if err := ctx.Session.Set("webauthnAssertion", sessionData); err != nil { + ctx.ServerError("Session.Set", err) + return + } + ctx.JSON(http.StatusOK, assertion) +} + +// WebAuthnLoginAssertionPost validates the signature and logs the user in +func WebAuthnLoginAssertionPost(ctx *context.Context) { + idSess, ok := ctx.Session.Get("twofaUid").(int64) + sessionData, okData := ctx.Session.Get("webauthnAssertion").(*webauthn.SessionData) + if !ok || !okData || sessionData == nil || idSess == 0 { + ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnAssertion") + }() + + // Load the user from the db + user, err := user_model.GetUserByID(ctx, idSess) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + log.Trace("Finishing webauthn authentication with user: %s", user.Name) + + // Now we do the equivalent of webauthn.FinishLogin using a combination of our session data + // (from webauthnAssertion) and verify the provided request.0 + parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + + dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, parsedResponse.RawID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialByCredID", err) + return + } + + // If the credential is legacy, assume the values are correct. The + // specification mandates these flags don't change. + if dbCred.Legacy { + dbCred.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() + dbCred.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() + dbCred.Legacy = false + + if err := dbCred.UpdateFromLegacy(ctx); err != nil { + ctx.ServerError("UpdateFromLegacy", err) + return + } + } + + // Validate the parsed response. + cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + + // Ensure that the credential wasn't cloned by checking if CloneWarning is set. + // (This is set if the sign counter is less than the one we have stored.) + if cred.Authenticator.CloneWarning { + log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr()) + ctx.Status(http.StatusForbidden) + return + } + + dbCred.SignCount = cred.Authenticator.SignCount + if err := dbCred.UpdateSignCount(ctx); err != nil { + ctx.ServerError("UpdateSignCount", err) + return + } + + // Now handle account linking if that's requested + if ctx.Session.Get("linkAccount") != nil { + if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { + ctx.ServerError("LinkAccountFromStore", err) + return + } + } + + remember := ctx.Session.Get("twofaRemember").(bool) + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + _ = ctx.Session.Delete("twofaUid") + + ctx.JSONRedirect(redirect) +} diff --git a/routers/web/base.go b/routers/web/base.go new file mode 100644 index 0000000..78dde57 --- /dev/null +++ b/routers/web/base.go @@ -0,0 +1,98 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "errors" + "fmt" + "net/http" + "os" + "path" + "strings" + + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web/routing" +) + +func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc { + prefix = strings.Trim(prefix, "/") + funcInfo := routing.GetFuncInfo(storageHandler, prefix) + + if storageSetting.MinioConfig.ServeDirect { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + routing.UpdateFuncInfo(req.Context(), funcInfo) + + rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") + rPath = util.PathJoinRelX(rPath) + + u, err := objStore.URL(rPath, path.Base(rPath)) + if err != nil { + if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { + log.Warn("Unable to find %s %s", prefix, rPath) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error("Error whilst getting URL for %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst getting URL for %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + + http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) + }) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + routing.UpdateFuncInfo(req.Context(), funcInfo) + + rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") + rPath = util.PathJoinRelX(rPath) + if rPath == "" || rPath == "." { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + fi, err := objStore.Stat(rPath) + if err != nil { + if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { + log.Warn("Unable to find %s %s", prefix, rPath) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + + fr, err := objStore.Open(rPath) + if err != nil { + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + defer fr.Close() + httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) + }) +} diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go new file mode 100644 index 0000000..dd20663 --- /dev/null +++ b/routers/web/devtest/devtest.go @@ -0,0 +1,66 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package devtest + +import ( + "net/http" + "path" + "strings" + "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +// List all devtest templates, they will be used for e2e tests for the UI components +func List(ctx *context.Context) { + templateNames, err := templates.AssetFS().ListFiles("devtest", true) + if err != nil { + ctx.ServerError("AssetFS().ListFiles", err) + return + } + var subNames []string + for _, tmplName := range templateNames { + subName := strings.TrimSuffix(tmplName, ".tmpl") + if subName != "list" { + subNames = append(subNames, subName) + } + } + ctx.Data["SubNames"] = subNames + ctx.HTML(http.StatusOK, "devtest/list") +} + +func FetchActionTest(ctx *context.Context) { + _ = ctx.Req.ParseForm() + ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "
" + + "Form: " + ctx.Req.Form.Encode() + "
" + + "PostForm: " + ctx.Req.PostForm.Encode(), + ) + time.Sleep(2 * time.Second) + ctx.JSONRedirect("") +} + +func Tmpl(ctx *context.Context) { + now := time.Now() + ctx.Data["TimeNow"] = now + ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) + ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) + ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) + ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) + ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) + ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) + + if ctx.Req.Method == "POST" { + _ = ctx.Req.ParseForm() + ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"
"+ + "Form: "+ctx.Req.Form.Encode()+"
"+ + "PostForm: "+ctx.Req.PostForm.Encode(), + true, + ) + time.Sleep(2 * time.Second) + } + + ctx.HTML(http.StatusOK, base.TplName("devtest"+path.Clean("/"+ctx.Params("sub")))) +} diff --git a/routers/web/events/events.go b/routers/web/events/events.go new file mode 100644 index 0000000..52f20e0 --- /dev/null +++ b/routers/web/events/events.go @@ -0,0 +1,122 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package events + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/services/context" +) + +// Events listens for events +func Events(ctx *context.Context) { + // FIXME: Need to check if resp is actually a http.Flusher! - how though? + + // Set the headers related to event streaming. + ctx.Resp.Header().Set("Content-Type", "text/event-stream") + ctx.Resp.Header().Set("Cache-Control", "no-cache") + ctx.Resp.Header().Set("Connection", "keep-alive") + ctx.Resp.Header().Set("X-Accel-Buffering", "no") + ctx.Resp.WriteHeader(http.StatusOK) + + if !ctx.IsSigned { + // Return unauthorized status event + event := &eventsource.Event{ + Name: "close", + Data: "unauthorized", + } + _, _ = event.WriteTo(ctx) + ctx.Resp.Flush() + return + } + + // Listen to connection close and un-register messageChan + notify := ctx.Done() + ctx.Resp.Flush() + + shutdownCtx := graceful.GetManager().ShutdownContext() + + uid := ctx.Doer.ID + + messageChan := eventsource.GetManager().Register(uid) + + unregister := func() { + eventsource.GetManager().Unregister(uid, messageChan) + // ensure the messageChan is closed + for { + _, ok := <-messageChan + if !ok { + break + } + } + } + + if _, err := ctx.Resp.Write([]byte("\n")); err != nil { + log.Error("Unable to write to EventStream: %v", err) + unregister() + return + } + + timer := time.NewTicker(30 * time.Second) + +loop: + for { + select { + case <-timer.C: + event := &eventsource.Event{ + Name: "ping", + } + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + case <-notify: + go unregister() + break loop + case <-shutdownCtx.Done(): + go unregister() + break loop + case event, ok := <-messageChan: + if !ok { + break loop + } + + // Handle logout + if event.Name == "logout" { + if ctx.Session.ID() == event.Data { + _, _ = (&eventsource.Event{ + Name: "logout", + Data: "here", + }).WriteTo(ctx.Resp) + ctx.Resp.Flush() + go unregister() + auth.HandleSignOut(ctx) + break loop + } + // Replace the event - we don't want to expose the session ID to the user + event = &eventsource.Event{ + Name: "logout", + Data: "elsewhere", + } + } + + _, err := event.WriteTo(ctx.Resp) + if err != nil { + log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) + go unregister() + break loop + } + ctx.Resp.Flush() + } + } + timer.Stop() +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go new file mode 100644 index 0000000..f61b832 --- /dev/null +++ b/routers/web/explore/code.go @@ -0,0 +1,144 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplExploreCode explore code page template + tplExploreCode base.TplName = "explore/code" +) + +// Code render explore code page +func Code(ctx *context.Context) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.Redirect(setting.AppSubURL + "/explore") + return + } + + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreCode"] = true + + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["PageIsViewCode"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplExploreCode) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + var ( + repoIDs []int64 + err error + isAdmin bool + ) + if ctx.Doer != nil { + isAdmin = ctx.Doer.IsAdmin + } + + // guest user or non-admin user + if ctx.Doer == nil || !isAdmin { + repoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("FindUserCodeAccessibleRepoIDs", err) + return + } + } + + var ( + total int + searchResults []*code_indexer.Result + searchResultLanguages []*code_indexer.SearchResultLanguages + ) + + if (len(repoIDs) > 0) || isAdmin { + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + + loadRepoIDs := make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + ctx.Data["RepoMaps"] = repoMaps + + if len(loadRepoIDs) != len(repoMaps) { + // Remove deleted repos from search results + cleanedSearchResults := make([]*code_indexer.Result, 0, len(repoMaps)) + for _, sr := range searchResults { + if _, found := repoMaps[sr.RepoID]; found { + cleanedSearchResults = append(cleanedSearchResults, sr) + } + } + + searchResults = cleanedSearchResults + } + } + + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "l", "Language") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplExploreCode) +} diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go new file mode 100644 index 0000000..f8fd6ec --- /dev/null +++ b/routers/web/explore/org.go @@ -0,0 +1,48 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +// Organizations render explore organizations page +func Organizations(ctx *context.Context) { + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreOrganizations"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + visibleTypes := []structs.VisibleType{structs.VisibleTypePublic} + if ctx.Doer != nil { + visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate) + } + + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) + } + + RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + Visible: visibleTypes, + + SupportedSortOrders: supportedSortOrders, + }, tplExploreUsers) +} diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go new file mode 100644 index 0000000..116b983 --- /dev/null +++ b/routers/web/explore/repo.go @@ -0,0 +1,193 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplExploreRepos explore repositories page template + tplExploreRepos base.TplName = "explore/repos" + relevantReposOnlyParam string = "only_show_relevant" +) + +// RepoSearchOptions when calling search repositories +type RepoSearchOptions struct { + OwnerID int64 + Private bool + Restricted bool + PageSize int + OnlyShowRelevant bool + TplName base.TplName +} + +// RenderRepoSearch render repositories search page +// This function is also used to render the Admin Repository Management page. +func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { + // Sitemap index for sitemap paths + page := int(ctx.ParamsInt64("idx")) + isSitemap := ctx.Params("idx") != "" + if page <= 1 { + page = ctx.FormInt("page") + } + + if page <= 0 { + page = 1 + } + + if isSitemap { + opts.PageSize = setting.UI.SitemapPagingNum + } + + var ( + repos []*repo_model.Repository + count int64 + err error + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + + if order, ok := repo_model.OrderByFlatMap[sortOrder]; ok { + orderBy = order + } else { + sortOrder = "recentupdate" + orderBy = db.SearchOrderByRecentUpdated + } + ctx.Data["SortType"] = sortOrder + + keyword := ctx.FormTrim("q") + + ctx.Data["OnlyShowRelevant"] = opts.OnlyShowRelevant + + topicOnly := ctx.FormBool("topic") + ctx.Data["TopicOnly"] = topicOnly + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: opts.PageSize, + }, + Actor: ctx.Doer, + OrderBy: orderBy, + Private: opts.Private, + Keyword: keyword, + OwnerID: opts.OwnerID, + AllPublic: true, + AllLimited: true, + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + OnlyShowRelevant: opts.OnlyShowRelevant, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range repos { + m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } + return + } + + ctx.Data["Keyword"] = keyword + ctx.Data["Total"] = count + ctx.Data["Repos"] = repos + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "topic", "TopicOnly") + pager.AddParam(ctx, "language", "Language") + pager.AddParamString(relevantReposOnlyParam, fmt.Sprint(opts.OnlyShowRelevant)) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, opts.TplName) +} + +// Repos render explore repositories page +func Repos(ctx *context.Context) { + ctx.Data["UsersIsDisabled"] = setting.Service.Explore.DisableUsersPage + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreRepositories"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + var ownerID int64 + if ctx.Doer != nil && !ctx.Doer.IsAdmin { + ownerID = ctx.Doer.ID + } + + onlyShowRelevant := setting.UI.OnlyShowRelevantRepos + + _ = ctx.Req.ParseForm() // parse the form first, to prepare the ctx.Req.Form field + if len(ctx.Req.Form[relevantReposOnlyParam]) != 0 { + onlyShowRelevant = ctx.FormBool(relevantReposOnlyParam) + } + + RenderRepoSearch(ctx, &RepoSearchOptions{ + PageSize: setting.UI.ExplorePagingNum, + OwnerID: ownerID, + Private: ctx.Doer != nil, + TplName: tplExploreRepos, + OnlyShowRelevant: onlyShowRelevant, + }) +} diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go new file mode 100644 index 0000000..95fecfe --- /dev/null +++ b/routers/web/explore/topic.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// TopicSearch search for creating topic +func TopicSearch(ctx *context.Context) { + opts := &repo_model.FindTopicOptions{ + Keyword: ctx.FormString("q"), + ListOptions: db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + } + + topics, total, err := repo_model.FindTopics(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError) + 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/web/explore/user.go b/routers/web/explore/user.go new file mode 100644 index 0000000..b79a79f --- /dev/null +++ b/routers/web/explore/user.go @@ -0,0 +1,163 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "bytes" + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplExploreUsers explore users page template + tplExploreUsers base.TplName = "explore/users" +) + +var nullByte = []byte{0x00} + +func isKeywordValid(keyword string) bool { + return !bytes.Contains([]byte(keyword), nullByte) +} + +// RenderUserSearch render user search page +func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, tplName base.TplName) { + // Sitemap index for sitemap paths + opts.Page = int(ctx.ParamsInt64("idx")) + isSitemap := ctx.Params("idx") != "" + if opts.Page <= 1 { + opts.Page = ctx.FormInt("page") + } + if opts.Page <= 1 { + opts.Page = 1 + } + + if isSitemap { + opts.PageSize = setting.UI.SitemapPagingNum + } + + var ( + users []*user_model.User + count int64 + err error + orderBy db.SearchOrderBy + ) + + // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { + case "newest": + orderBy = "`user`.id DESC" + case "oldest": + orderBy = "`user`.id ASC" + case "leastupdate": + orderBy = "`user`.updated_unix ASC" + case "reversealphabetically": + orderBy = "`user`.name DESC" + case "lastlogin": + orderBy = "`user`.last_login_unix ASC" + case "reverselastlogin": + orderBy = "`user`.last_login_unix DESC" + case "alphabetically": + orderBy = "`user`.name ASC" + case "recentupdate": + fallthrough + default: + // in case the sortType is not valid, we set it to recentupdate + sortOrder = "recentupdate" + ctx.Data["SortType"] = "recentupdate" + orderBy = "`user`.updated_unix DESC" + } + + if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) { + ctx.NotFound("unsupported sort order", nil) + return + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + users, count, err = user_model.SearchUsers(ctx, opts) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range users { + m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } + return + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Users"] = users + ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx) + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + for paramKey, paramVal := range opts.ExtraParamStrings { + pager.AddParamString(paramKey, paramVal) + } + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} + +// Users render explore users page +func Users(ctx *context.Context) { + if setting.Service.Explore.DisableUsersPage { + ctx.Redirect(setting.AppSubURL + "/explore/repos") + return + } + ctx.Data["Title"] = ctx.Tr("explore") + ctx.Data["PageIsExplore"] = true + ctx.Data["PageIsExploreUsers"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) + } + + RenderUserSearch(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, + IsActive: optional.Some(true), + Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + + SupportedSortOrders: supportedSortOrders, + }, tplExploreUsers) +} diff --git a/routers/web/feed/branch.go b/routers/web/feed/branch.go new file mode 100644 index 0000000..80ce2ad --- /dev/null +++ b/routers/web/feed/branch.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed +func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) { + commits, err := ctx.Repo.Commit.CommitsByRange(0, 10, "") + if err != nil { + ctx.ServerError("ShowBranchFeed", err) + return + } + + title := fmt.Sprintf("Latest commits for branch %s", ctx.Repo.BranchName) + link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.BranchNameSubURL()} + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + for _, commit := range commits { + feed.Items = append(feed.Items, &feeds.Item{ + Id: commit.ID.String(), + Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]), + Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()}, + Author: &feeds.Author{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Description: commit.Message(), + Content: commit.Message(), + }) + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go new file mode 100644 index 0000000..0f43346 --- /dev/null +++ b/routers/web/feed/convert.go @@ -0,0 +1,332 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "html" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" + "github.com/jaytaylor/html2text" +) + +func toBranchLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/branch/" + util.PathEscapeSegments(act.GetBranch()) +} + +func toTagLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/tag/" + util.PathEscapeSegments(act.GetTag()) +} + +func toIssueLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/issues/" + url.PathEscape(act.GetIssueInfos()[0]) +} + +func toPullLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/pulls/" + url.PathEscape(act.GetIssueInfos()[0]) +} + +func toSrcLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/" + util.PathEscapeSegments(act.GetBranch()) +} + +func toReleaseLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/releases/tag/" + util.PathEscapeSegments(act.GetBranch()) +} + +// renderMarkdown creates a minimal markdown render context from an action. +// If rendering fails, the original markdown text is returned +func renderMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML { + markdownCtx := &markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: act.GetRepoLink(ctx), + }, + Type: markdown.MarkupName, + Metas: map[string]string{ + "user": act.GetRepoUserName(ctx), + "repo": act.GetRepoName(ctx), + }, + } + markdown, err := markdown.RenderString(markdownCtx, content) + if err != nil { + return templates.SanitizeHTML(content) // old code did so: use SanitizeHTML to render in tmpl + } + return markdown +} + +// feedActionsToFeedItems convert gitea's Action feed to feeds Item +func feedActionsToFeedItems(ctx *context.Context, actions activities_model.ActionList) (items []*feeds.Item, err error) { + for _, act := range actions { + act.LoadActUser(ctx) + + // TODO: the code seems quite strange (maybe not right) + // sometimes it uses text content but sometimes it uses HTML content + // it should clearly defines which kind of content it should use for the feed items: plan text or rich HTML + var title, desc string + var content template.HTML + + link := &feeds.Link{Href: act.GetCommentHTMLURL(ctx)} + + // title + title = act.ActUser.GetDisplayName() + " " + var titleExtra template.HTML + switch act.OpType { + case activities_model.ActionCreateRepo: + titleExtra = ctx.Locale.Tr("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) + case activities_model.ActionRenameRepo: + titleExtra = ctx.Locale.Tr("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) + case activities_model.ActionCommitRepo: + link.Href = toBranchLink(ctx, act) + if len(act.Content) != 0 { + titleExtra = ctx.Locale.Tr("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) + } else { + titleExtra = ctx.Locale.Tr("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) + } + case activities_model.ActionCreateIssue: + link.Href = toIssueLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCreatePullRequest: + link.Href = toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionTransferRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + case activities_model.ActionPushTag: + link.Href = toTagLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx)) + case activities_model.ActionCommentIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionMergePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionAutoMergePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCloseIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionReopenIssue: + issueLink := toIssueLink(ctx, act) + if link.Href == "#" { + link.Href = issueLink + } + titleExtra = ctx.Locale.Tr("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionClosePullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionReopenPullRequest: + pullLink := toPullLink(ctx, act) + if link.Href == "#" { + link.Href = pullLink + } + titleExtra = ctx.Locale.Tr("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionDeleteTag: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx)) + case activities_model.ActionDeleteBranch: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncPush: + srcLink := toSrcLink(ctx, act) + if link.Href == "#" { + link.Href = srcLink + } + titleExtra = ctx.Locale.Tr("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncCreate: + srcLink := toSrcLink(ctx, act) + if link.Href == "#" { + link.Href = srcLink + } + titleExtra = ctx.Locale.Tr("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionMirrorSyncDelete: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.GetBranch(), act.ShortRepoPath(ctx)) + case activities_model.ActionApprovePullRequest: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionRejectPullRequest: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionCommentPull: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) + case activities_model.ActionPublishRelease: + releaseLink := toReleaseLink(ctx, act) + if link.Href == "#" { + link.Href = releaseLink + } + titleExtra = ctx.Locale.Tr("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content) + case activities_model.ActionPullReviewDismissed: + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1]) + case activities_model.ActionStarRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) + case activities_model.ActionWatchRepo: + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) + default: + return nil, fmt.Errorf("unknown action type: %v", act.OpType) + } + + // description & content + { + switch act.OpType { + case activities_model.ActionCommitRepo, activities_model.ActionMirrorSyncPush: + push := templates.ActionContent2Commits(act) + + for _, commit := range push.Commits { + if len(desc) != 0 { + desc += "\n\n" + } + desc += fmt.Sprintf("%s\n%s", + html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), commit.Sha1)), + commit.Sha1, + templates.RenderCommitMessage(ctx, commit.Message, nil), + ) + } + + if push.Len > 1 { + link = &feeds.Link{Href: fmt.Sprintf("%s/%s", setting.AppSubURL, push.CompareURL)} + } else if push.Len == 1 { + link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), push.Commits[0].Sha1)} + } + + case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: + desc = strings.Join(act.GetIssueInfos(), "#") + content = renderMarkdown(ctx, act, act.GetIssueContent(ctx)) + case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull: + desc = act.GetIssueTitle(ctx) + comment := act.GetIssueInfos()[1] + if len(comment) != 0 { + desc += "\n\n" + string(renderMarkdown(ctx, act, comment)) + } + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + desc = act.GetIssueInfos()[1] + case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest: + desc = act.GetIssueTitle(ctx) + case activities_model.ActionPullReviewDismissed: + desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2] + } + } + if len(content) == 0 { + content = templates.SanitizeHTML(desc) + } + + // It's a common practice for feed generators to use plain text titles. + // See https://codeberg.org/forgejo/forgejo/pulls/1595 + plainTitle, err := html2text.FromString(title+" "+string(titleExtra), html2text.Options{OmitLinks: true}) + if err != nil { + return nil, err + } + + items = append(items, &feeds.Item{ + Title: plainTitle, + Link: link, + Description: desc, + IsPermaLink: "false", + Author: &feeds.Author{ + Name: act.ActUser.GetDisplayName(), + Email: act.ActUser.GetEmail(), + }, + Id: fmt.Sprintf("%v: %v", strconv.FormatInt(act.ID, 10), link.Href), + Created: act.CreatedUnix.AsTime(), + Content: string(content), + }) + } + return items, err +} + +// GetFeedType return if it is a feed request and altered name and feed type. +func GetFeedType(name string, req *http.Request) (bool, string, string) { + if strings.HasSuffix(name, ".rss") || + strings.Contains(req.Header.Get("Accept"), "application/rss+xml") { + return true, strings.TrimSuffix(name, ".rss"), "rss" + } + + if strings.HasSuffix(name, ".atom") || + strings.Contains(req.Header.Get("Accept"), "application/atom+xml") { + return true, strings.TrimSuffix(name, ".atom"), "atom" + } + + return false, name, "" +} + +// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item +func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) { + for _, rel := range releases { + err := rel.LoadAttributes(ctx) + if err != nil { + return nil, err + } + + var title string + var content template.HTML + + if rel.IsTag { + title = rel.TagName + } else { + title = rel.Title + } + + link := &feeds.Link{Href: rel.HTMLURL()} + content, err = markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: rel.Repo.Link(), + }, + Metas: rel.Repo.ComposeMetas(ctx), + }, rel.Note) + if err != nil { + return nil, err + } + + items = append(items, &feeds.Item{ + Title: title, + Link: link, + Created: rel.CreatedUnix.AsTime(), + Author: &feeds.Author{ + Name: rel.Publisher.GetDisplayName(), + Email: rel.Publisher.GetEmail(), + }, + Id: fmt.Sprintf("%v: %v", strconv.FormatInt(rel.ID, 10), link.Href), + Content: string(content), + }) + } + + return items, err +} diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go new file mode 100644 index 0000000..1ab768f --- /dev/null +++ b/routers/web/feed/file.go @@ -0,0 +1,62 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed +func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) { + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + return + } + commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.RefName, + File: fileName, + Page: 1, + }) + if err != nil { + ctx.ServerError("ShowBranchFeed", err) + return + } + + title := fmt.Sprintf("Latest commits for file %s", ctx.Repo.TreePath) + + link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)} + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + for _, commit := range commits { + feed.Items = append(feed.Items, &feeds.Item{ + Id: commit.ID.String(), + Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]), + Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()}, + Author: &feeds.Author{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Description: commit.Message(), + Content: commit.Message(), + }) + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go new file mode 100644 index 0000000..08cbcd9 --- /dev/null +++ b/routers/web/feed/profile.go @@ -0,0 +1,87 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowUserFeedRSS show user activity as RSS feed +func ShowUserFeedRSS(ctx *context.Context) { + showUserFeed(ctx, "rss") +} + +// ShowUserFeedAtom show user activity as Atom feed +func ShowUserFeedAtom(ctx *context.Context) { + showUserFeed(ctx, "atom") +} + +// showUserFeed show user activity as RSS / Atom feed +func showUserFeed(ctx *context.Context, formatType string) { + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + + actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + OnlyPerformedBy: !ctx.ContextUser.IsOrganization(), + IncludeDeleted: false, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + ctxUserDescription, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: ctx.ContextUser.HTMLURL(), + }, + Metas: map[string]string{ + "user": ctx.ContextUser.GetDisplayName(), + }, + }, ctx.ContextUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + feed := &feeds.Feed{ + Title: ctx.Locale.TrString("home.feed_of", ctx.ContextUser.DisplayName()), + Link: &feeds.Link{Href: ctx.ContextUser.HTMLURL()}, + Description: string(ctxUserDescription), + Created: time.Now(), + } + + feed.Items, err = feedActionsToFeedItems(ctx, actions) + if err != nil { + ctx.ServerError("convert feed", err) + return + } + + writeFeed(ctx, feed, formatType) +} + +// writeFeed write a feeds.Feed as atom or rss to ctx.Resp +func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) { + if formatType == "atom" { + ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8") + if err := feed.WriteAtom(ctx.Resp); err != nil { + ctx.ServerError("Render Atom failed", err) + } + } else { + ctx.Resp.Header().Set("Content-Type", "application/rss+xml;charset=utf-8") + if err := feed.WriteRss(ctx.Resp); err != nil { + ctx.ServerError("Render RSS failed", err) + } + } +} diff --git a/routers/web/feed/release.go b/routers/web/feed/release.go new file mode 100644 index 0000000..fb6e3ad --- /dev/null +++ b/routers/web/feed/release.go @@ -0,0 +1,52 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// shows tags and/or releases on the repo as RSS / Atom feed +func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) { + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeTags: !isReleasesOnly, + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + var title string + var link *feeds.Link + + if isReleasesOnly { + title = ctx.Locale.TrString("repo.release.releases_for", repo.FullName()) + link = &feeds.Link{Href: repo.HTMLURL() + "/release"} + } else { + title = ctx.Locale.TrString("repo.release.tags_for", repo.FullName()) + link = &feeds.Link{Href: repo.HTMLURL() + "/tags"} + } + + feed := &feeds.Feed{ + Title: title, + Link: link, + Description: repo.Description, + Created: time.Now(), + } + + feed.Items, err = releasesToFeedItems(ctx, releases) + if err != nil { + ctx.ServerError("releasesToFeedItems", err) + return + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/feed/render.go b/routers/web/feed/render.go new file mode 100644 index 0000000..dc99fb4 --- /dev/null +++ b/routers/web/feed/render.go @@ -0,0 +1,19 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "code.gitea.io/gitea/services/context" +) + +// RenderBranchFeed render format for branch or file +func RenderBranchFeed(feedType string) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.Repo.TreePath == "" { + ShowBranchFeed(ctx, ctx.Repo.Repository, feedType) + } else { + ShowFileFeed(ctx, ctx.Repo.Repository, feedType) + } + } +} diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go new file mode 100644 index 0000000..a0033c7 --- /dev/null +++ b/routers/web/feed/repo.go @@ -0,0 +1,44 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "time" + + activities_model "code.gitea.io/gitea/models/activities" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + + "github.com/gorilla/feeds" +) + +// ShowRepoFeed shows user activity on the repo as RSS / Atom feed +func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) { + actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + OnlyPerformedByActor: true, + RequestedRepo: repo, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + feed := &feeds.Feed{ + Title: ctx.Locale.TrString("home.feed_of", repo.FullName()), + Link: &feeds.Link{Href: repo.HTMLURL()}, + Description: repo.Description, + Created: time.Now(), + } + + feed.Items, err = feedActionsToFeedItems(ctx, actions) + if err != nil { + ctx.ServerError("convert feed", err) + return + } + + writeFeed(ctx, feed, formatType) +} diff --git a/routers/web/githttp.go b/routers/web/githttp.go new file mode 100644 index 0000000..5f1dedc --- /dev/null +++ b/routers/web/githttp.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" +) + +func requireSignIn(ctx *context.Context) { + if !setting.Service.RequireSignInView { + return + } + + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) + ctx.Error(http.StatusUnauthorized) + } +} + +func gitHTTPRouters(m *web.Route) { + m.Group("", func() { + m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack) + m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack) + m.Methods("GET,OPTIONS", "/info/refs", repo.GetInfoRefs) + m.Methods("GET,OPTIONS", "/HEAD", repo.GetTextFile("HEAD")) + m.Methods("GET,OPTIONS", "/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) + m.Methods("GET,OPTIONS", "/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) + m.Methods("GET,OPTIONS", "/objects/info/packs", repo.GetInfoPacks) + m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile("")) + m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile) + }, ignSignInAndCsrf, requireSignIn, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context.UserAssignmentWeb()) +} diff --git a/routers/web/goget.go b/routers/web/goget.go new file mode 100644 index 0000000..8d5612e --- /dev/null +++ b/routers/web/goget.go @@ -0,0 +1,93 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "html" + "net/http" + "net/url" + "path" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +func goGet(ctx *context.Context) { + if ctx.Req.Method != "GET" || len(ctx.Req.URL.RawQuery) < 8 || ctx.FormString("go-get") != "1" { + return + } + + parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 4) + + if len(parts) < 3 { + return + } + + ownerName := parts[1] + repoName := parts[2] + + // Quick responses appropriate go-get meta with status 200 + // regardless of if user have access to the repository, + // or the repository does not exist at all. + // This is particular a workaround for "go get" command which does not respect + // .netrc file. + + trimmedRepoName := strings.TrimSuffix(repoName, ".git") + + if ownerName == "" || trimmedRepoName == "" { + _, _ = ctx.Write([]byte(` + + + invalid import path + + +`)) + ctx.Status(http.StatusBadRequest) + return + } + branchName := setting.Repository.DefaultBranch + + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName) + if err == nil && len(repo.DefaultBranch) > 0 { + branchName = repo.DefaultBranch + } + prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName)) + + appURL, _ := url.Parse(setting.AppURL) + + insecure := "" + if appURL.Scheme == string(setting.HTTP) { + insecure = "--insecure " + } + + goGetImport := context.ComposeGoGetImport(ownerName, trimmedRepoName) + + var cloneURL string + if setting.Repository.GoGetCloneURLProtocol == "ssh" { + cloneURL = repo_model.ComposeSSHCloneURL(ownerName, repoName) + } else { + cloneURL = repo_model.ComposeHTTPSCloneURL(ownerName, repoName) + } + goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/) + goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/) + goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport) + + res := fmt.Sprintf(` + + + + + + + %s + +`, html.EscapeString(goImportContent), html.EscapeString(goSourceContent), html.EscapeString(goGetCli)) + + ctx.RespHeader().Set("Content-Type", "text/html") + _, _ = ctx.Write([]byte(res)) +} diff --git a/routers/web/healthcheck/check.go b/routers/web/healthcheck/check.go new file mode 100644 index 0000000..83dfe62 --- /dev/null +++ b/routers/web/healthcheck/check.go @@ -0,0 +1,140 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package healthcheck + +import ( + "context" + "net/http" + "os" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +type status string + +const ( + // Pass healthy (acceptable aliases: "ok" to support Node's Terminus and "up" for Java's SpringBoot) + // fail unhealthy (acceptable aliases: "error" to support Node's Terminus and "down" for Java's SpringBoot), and + // warn healthy, with some concerns. + // + // ref https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check#section-3.1 + // status: (required) indicates whether the service status is acceptable + // or not. API publishers SHOULD use following values for the field: + // The value of the status field is case-insensitive and is tightly + // related with the HTTP Response code returned by the health endpoint. + // For "pass" status, HTTP Response code in the 2xx-3xx range MUST be + // used. For "fail" status, HTTP Response code in the 4xx-5xx range + // MUST be used. In case of the "warn" status, endpoints MUST return + // HTTP status in the 2xx-3xx range, and additional information SHOULD + // be provided, utilizing optional fields of the Response. + Pass status = "pass" + Fail status = "fail" + warn status = "warn" +) + +func (s status) ToHTTPStatus() int { + if s == Pass || s == warn { + return http.StatusOK + } + return http.StatusFailedDependency +} + +type checks map[string][]componentStatus + +// Response is the data returned by the health endpoint, which will be marshaled to JSON format +type Response struct { + Status status `json:"status"` + Description string `json:"description"` // a human-friendly description of the service + Checks checks `json:"checks,omitempty"` // The Checks Object, should be omitted on installation route +} + +// componentStatus presents one status of a single check object +// an object that provides detailed health statuses of additional downstream systems and endpoints +// which can affect the overall health of the main API. +type componentStatus struct { + Status status `json:"status"` + Time string `json:"time"` // the date-time, in ISO8601 format + Output string `json:"output,omitempty"` // this field SHOULD be omitted for "pass" state. +} + +// Check is the health check API handler +func Check(w http.ResponseWriter, r *http.Request) { + rsp := Response{ + Status: Pass, + Description: setting.AppName, + Checks: make(checks), + } + + statuses := make([]status, 0) + if setting.InstallLock { + statuses = append(statuses, checkDatabase(r.Context(), rsp.Checks)) + statuses = append(statuses, checkCache(rsp.Checks)) + } + for _, s := range statuses { + if s != Pass { + rsp.Status = Fail + break + } + } + + data, _ := json.MarshalIndent(rsp, "", " ") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(rsp.Status.ToHTTPStatus()) + _, _ = w.Write(data) +} + +// database checks gitea database status +func checkDatabase(ctx context.Context, checks checks) status { + st := componentStatus{} + if err := db.GetEngine(ctx).Ping(); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("database ping failed with error: %v", err) + } else { + st.Status = Pass + st.Time = getCheckTime() + } + + if setting.Database.Type.IsSQLite3() && st.Status == Pass { + if !setting.EnableSQLite3 { + st.Status = Fail + st.Time = getCheckTime() + log.Error("SQLite3 health check failed with error: %v", "this Forgejo binary is built without SQLite3 enabled") + } else { + if _, err := os.Stat(setting.Database.Path); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("SQLite3 file exists check failed with error: %v", err) + } + } + } + + checks["database:ping"] = []componentStatus{st} + return st.Status +} + +// cache checks gitea cache status +func checkCache(checks checks) status { + st := componentStatus{} + if err := cache.GetCache().Ping(); err != nil { + st.Status = Fail + st.Time = getCheckTime() + log.Error("cache ping failed with error: %v", err) + } else { + st.Status = Pass + st.Time = getCheckTime() + } + checks["cache:ping"] = []componentStatus{st} + return st.Status +} + +func getCheckTime() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/routers/web/home.go b/routers/web/home.go new file mode 100644 index 0000000..d4be093 --- /dev/null +++ b/routers/web/home.go @@ -0,0 +1,117 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/routers/web/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplHome home page template + tplHome base.TplName = "home" +) + +// Home render home page +func Home(ctx *context.Context) { + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, auth.TplActivate) + } else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if ctx.Doer.MustChangePassword { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + } else { + user.Dashboard(ctx) + } + return + // Check non-logged users landing page. + } else if setting.LandingPageURL != setting.LandingPageHome { + ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL)) + return + } + + // Check auto-login. + if ctx.GetSiteCookie(setting.CookieRememberName) != "" { + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + ctx.Data["PageIsHome"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.HTML(http.StatusOK, tplHome) +} + +// HomeSitemap renders the main sitemap +func HomeSitemap(ctx *context.Context) { + m := sitemap.NewSitemapIndex() + if !setting.Service.Explore.DisableUsersPage { + _, cnt, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{PageSize: 1}, + IsActive: optional.Some(true), + Visible: []structs.VisibleType{structs.VisibleTypePublic}, + }) + if err != nil { + ctx.ServerError("SearchUsers", err) + return + } + count := int(cnt) + idx := 1 + for i := 0; i < count; i += setting.UI.SitemapPagingNum { + m.Add(sitemap.URL{URL: setting.AppURL + "explore/users/sitemap-" + strconv.Itoa(idx) + ".xml"}) + idx++ + } + } + + _, cnt, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: 1, + }, + Actor: ctx.Doer, + AllPublic: true, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + count := int(cnt) + idx := 1 + for i := 0; i < count; i += setting.UI.SitemapPagingNum { + m.Add(sitemap.URL{URL: setting.AppURL + "explore/repos/sitemap-" + strconv.Itoa(idx) + ".xml"}) + idx++ + } + + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", err) + } +} + +// NotFound render 404 page +func NotFound(ctx *context.Context) { + ctx.Data["Title"] = "Page Not Found" + ctx.NotFound("home.NotFound", nil) +} diff --git a/routers/web/metrics.go b/routers/web/metrics.go new file mode 100644 index 0000000..46c13f0 --- /dev/null +++ b/routers/web/metrics.go @@ -0,0 +1,33 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "crypto/subtle" + "net/http" + + "code.gitea.io/gitea/modules/setting" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics validate auth token and render prometheus metrics +func Metrics(resp http.ResponseWriter, req *http.Request) { + if setting.Metrics.Token == "" { + promhttp.Handler().ServeHTTP(resp, req) + return + } + header := req.Header.Get("Authorization") + if header == "" { + http.Error(resp, "", http.StatusUnauthorized) + return + } + got := []byte(header) + want := []byte("Bearer " + setting.Metrics.Token) + if subtle.ConstantTimeCompare(got, want) != 1 { + http.Error(resp, "", http.StatusUnauthorized) + return + } + promhttp.Handler().ServeHTTP(resp, req) +} diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go new file mode 100644 index 0000000..2dbbd6f --- /dev/null +++ b/routers/web/misc/markup.go @@ -0,0 +1,18 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + 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.Context) { + form := web.GetForm(ctx).(*api.MarkupOption) + common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) +} diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go new file mode 100644 index 0000000..54c9376 --- /dev/null +++ b/routers/web/misc/misc.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + "path" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +func SSHInfo(rw http.ResponseWriter, req *http.Request) { + if !git.SupportProcReceive { + rw.WriteHeader(http.StatusNotFound) + return + } + rw.Header().Set("content-type", "text/json;charset=UTF-8") + _, err := rw.Write([]byte(`{"type":"gitea","version":1}`)) + if err != nil { + log.Error("fail to write result: err: %v", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + rw.WriteHeader(http.StatusOK) +} + +func DummyOK(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func RobotsTxt(w http.ResponseWriter, req *http.Request) { + robotsTxt := util.FilePathJoinAbs(setting.CustomPath, "public/robots.txt") + if ok, _ := util.IsExist(robotsTxt); !ok { + robotsTxt = util.FilePathJoinAbs(setting.CustomPath, "robots.txt") // the legacy "robots.txt" + } + httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + http.ServeFile(w, req, robotsTxt) +} + +func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently) + } +} diff --git a/routers/web/misc/swagger-forgejo.go b/routers/web/misc/swagger-forgejo.go new file mode 100644 index 0000000..e3aff02 --- /dev/null +++ b/routers/web/misc/swagger-forgejo.go @@ -0,0 +1,19 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +// tplSwagger swagger page template +const tplForgejoSwagger base.TplName = "swagger/forgejo-ui" + +func SwaggerForgejo(ctx *context.Context) { + ctx.Data["APIVersion"] = "v1" + ctx.HTML(http.StatusOK, tplForgejoSwagger) +} diff --git a/routers/web/misc/swagger.go b/routers/web/misc/swagger.go new file mode 100644 index 0000000..5fddfa8 --- /dev/null +++ b/routers/web/misc/swagger.go @@ -0,0 +1,20 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +// tplSwagger swagger page template +const tplSwagger base.TplName = "swagger/ui" + +// Swagger render swagger-ui page with v1 json +func Swagger(ctx *context.Context) { + ctx.Data["APIJSONVersion"] = "v1" + ctx.HTML(http.StatusOK, tplSwagger) +} diff --git a/routers/web/nodeinfo.go b/routers/web/nodeinfo.go new file mode 100644 index 0000000..f1cc7bf --- /dev/null +++ b/routers/web/nodeinfo.go @@ -0,0 +1,32 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +type nodeInfoLinks struct { + Links []nodeInfoLink `json:"links"` +} + +type nodeInfoLink struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +// NodeInfoLinks returns links to the node info endpoint +func NodeInfoLinks(ctx *context.Context) { + nodeinfolinks := &nodeInfoLinks{ + Links: []nodeInfoLink{{ + fmt.Sprintf("%sapi/v1/nodeinfo", setting.AppURL), + "http://nodeinfo.diaspora.software/ns/schema/2.1", + }}, + } + ctx.JSON(http.StatusOK, nodeinfolinks) +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go new file mode 100644 index 0000000..92793d9 --- /dev/null +++ b/routers/web/org/home.go @@ -0,0 +1,189 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgHome base.TplName = "org/home" +) + +// Home show organization home page +func Home(ctx *context.Context) { + uname := ctx.Params(":username") + + if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") { + ctx.NotFound("", nil) + return + } + + ctx.SetParams(":org", uname) + context.HandleOrgAssignment(ctx) + if ctx.Written() { + return + } + + org := ctx.Org.Organization + + ctx.Data["PageIsUserProfile"] = true + ctx.Data["Title"] = org.DisplayName() + + var orderBy db.SearchOrderBy + sortOrder := ctx.FormString("sort") + if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok { + sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for org home? + } + ctx.Data["SortType"] = sortOrder + orderBy = repo_model.OrderByFlatMap[sortOrder] + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + var ( + repos []*repo_model.Repository + count int64 + err error + ) + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Keyword: keyword, + OwnerID: org.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Actor: ctx.Doer, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + opts := &organization.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: ctx.Org.PublicMemberOnly, + ListOptions: db.ListOptions{Page: 1, PageSize: 25}, + } + members, _, err := organization.FindOrgMembers(ctx, opts) + if err != nil { + ctx.ServerError("FindOrgMembers", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["Total"] = count + ctx.Data["Members"] = members + ctx.Data["Teams"] = ctx.Org.Teams + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["PageIsViewRepositories"] = true + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(count), setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("language", language) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager + + ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 + + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + prepareOrgProfileReadme(ctx, profileGitRepo, profileDbRepo, profileReadmeBlob) + + ctx.HTML(http.StatusOK, tplOrgHome) +} + +func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repository, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { + if profileGitRepo == nil || profileReadme == nil { + return + } + + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) + } else { + if profileContent, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + GitRepo: profileGitRepo, + Links: markup.Links{ + // Pass repo link to markdown render for the full link of media elements. + // The profile of default branch would be shown. + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, + }, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadme"] = profileContent + } + } +} diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go new file mode 100644 index 0000000..92237d6 --- /dev/null +++ b/routers/web/org/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/org/members.go b/routers/web/org/members.go new file mode 100644 index 0000000..9a3d60e --- /dev/null +++ b/routers/web/org/members.go @@ -0,0 +1,144 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // tplMembers template for organization members page + tplMembers base.TplName = "org/member/members" +) + +// Members render organization users page +func Members(ctx *context.Context) { + org := ctx.Org.Organization + ctx.Data["Title"] = org.FullName + ctx.Data["PageIsOrgMembers"] = true + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + opts := &organization.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: true, + } + + if ctx.Doer != nil { + isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember") + return + } + opts.PublicOnly = !isMember && !ctx.Doer.IsAdmin + } + ctx.Data["PublicOnly"] = opts.PublicOnly + + total, err := organization.CountOrgMembers(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountOrgMembers") + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(total), setting.UI.MembersPagingNum, page, 5) + opts.ListOptions.Page = page + opts.ListOptions.PageSize = setting.UI.MembersPagingNum + members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts) + if err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.Data["Page"] = pager + ctx.Data["Members"] = members + ctx.Data["MembersIsPublicMember"] = membersIsPublic + ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) + ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) + + ctx.HTML(http.StatusOK, tplMembers) +} + +// MembersAction response for operation to a member of organization +func MembersAction(ctx *context.Context) { + uid := ctx.FormInt64("uid") + if uid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/members") + return + } + + org := ctx.Org.Organization + var err error + switch ctx.Params(":action") { + case "private": + if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, false) + case "public": + if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, true) + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.RemoveOrgUser(ctx, org.ID, uid) + if organization.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.JSONRedirect(ctx.Org.OrgLink + "/members") + return + } + case "leave": + err = models.RemoveOrgUser(ctx, org.ID, ctx.Doer.ID) + if err == nil { + ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName())) + ctx.JSON(http.StatusOK, map[string]any{ + "redirect": "", // keep the user stay on current page, in case they want to do other operations. + }) + } else if organization.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + ctx.JSONRedirect(ctx.Org.OrgLink + "/members") + } else { + log.Error("RemoveOrgUser(%d,%d): %v", org.ID, ctx.Doer.ID, err) + } + return + } + + if err != nil { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + + redirect := ctx.Org.OrgLink + "/members" + if ctx.Params(":action") == "leave" { + redirect = setting.AppSubURL + "/" + } + + ctx.JSONRedirect(redirect) +} diff --git a/routers/web/org/org.go b/routers/web/org/org.go new file mode 100644 index 0000000..dd3aab4 --- /dev/null +++ b/routers/web/org/org.go @@ -0,0 +1,80 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "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/forms" +) + +const ( + // tplCreateOrg template path for create organization + tplCreateOrg base.TplName = "org/create" +) + +// Create render the page for create organization +func Create(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_org.title") + ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode + if !ctx.Doer.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) + return + } + ctx.HTML(http.StatusOK, tplCreateOrg) +} + +// CreatePost response for create organization +func CreatePost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.CreateOrgForm) + ctx.Data["Title"] = ctx.Tr("new_org.title") + + if !ctx.Doer.CanCreateOrganization() { + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreateOrg) + return + } + + org := &organization.Organization{ + Name: form.OrgName, + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: form.Visibility, + RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, + } + + if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil { + ctx.Data["Err_OrgName"] = true + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.org_name_been_taken"), tplCreateOrg, &form) + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_reserved", err.(db.ErrNameReserved).Name), tplCreateOrg, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("org.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplCreateOrg, &form) + case organization.IsErrUserNotAllowedCreateOrg(err): + ctx.RenderWithErr(ctx.Tr("org.form.create_org_not_allowed"), tplCreateOrg, &form) + default: + ctx.ServerError("CreateOrganization", err) + } + return + } + log.Trace("Organization created: %s", org.Name) + + ctx.Redirect(org.AsUser().DashboardLink()) +} diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go new file mode 100644 index 0000000..02eae80 --- /dev/null +++ b/routers/web/org/org_labels.go @@ -0,0 +1,116 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/label" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// RetrieveLabels find all the labels of an organization +func RetrieveLabels(ctx *context.Context) { + labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + for _, l := range labels { + l.CalOpenIssues() + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.FormString("sort") +} + +// NewLabel create new label for organization +func NewLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsLabels"] = true + ctx.Data["PageIsOrgSettings"] = true + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + + l := &issues_model.Label{ + OrgID: ctx.Org.Organization.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, + } + if err := issues_model.NewLabel(ctx, l); err != nil { + ctx.ServerError("NewLabel", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} + +// UpdateLabel update a label's name and color +func UpdateLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, form.ID) + if err != nil { + switch { + case issues_model.IsErrOrgLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + + l.Name = form.Title + l.Exclusive = form.Exclusive + l.Description = form.Description + l.Color = form.Color + l.SetArchived(form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels") +} + +// InitializeLabels init labels for an organization +func InitializeLabels(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.InitializeLabelsForm) + if ctx.HasError() { + ctx.Redirect(ctx.Org.OrgLink + "/labels") + return + } + + if err := repo_module.InitializeLabels(ctx, ctx.Org.Organization.ID, form.TemplateName, true); err != nil { + if label.IsErrTemplateLoad(err) { + originalErr := err.(label.ErrTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + return + } + ctx.ServerError("InitializeLabels", err) + return + } + ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go new file mode 100644 index 0000000..64d233f --- /dev/null +++ b/routers/web/org/projects.go @@ -0,0 +1,610 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "org/projects/list" + tplProjectsNew base.TplName = "org/projects/new" + tplProjectsView base.TplName = "org/projects/view" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableProjects", nil) + return + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.Data["Title"] = ctx.Tr("repo.projects") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: projectType, + Title: keyword, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(!isShowClosed), + Type: projectType, + }) + if err != nil { + ctx.ServerError("CountProjects", err) + return + } + + if isShowClosed { + ctx.Data["OpenCount"] = opTotal + ctx.Data["ClosedCount"] = total + } else { + ctx.Data["OpenCount"] = total + ctx.Data["ClosedCount"] = opTotal + } + + ctx.Data["Projects"] = projects + shared_user.RenderUserHeader(ctx) + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + for _, project := range projects { + project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + numPages := 0 + if total > 0 { + numPages = (int(total) - 1/setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["PageIsViewProjects"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +func canWriteProjects(ctx *context.Context) bool { + if ctx.ContextUser.IsOrganization() { + return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) + } + return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID +} + +// RenderNewProject render creating a project page +func RenderNewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs() + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["PageIsViewProjects"] = true + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects" + shared_user.RenderUserHeader(ctx) + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + RenderNewProject(ctx) + return + } + + newProject := project_model.Project{ + OwnerID: ctx.ContextUser.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: form.TemplateType, + CardType: form.CardType, + } + + if ctx.ContextUser.IsOrganization() { + newProject.Type = project_model.TypeOrganization + } else { + newProject.Type = project_model.TypeIndividual + } + + if err := project_model.NewProject(ctx, &newProject); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") + return + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSONRedirect(fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), id)) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// RenderEditProject allows a project to be edited +func RenderEditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + + shared_user.RenderUserHeader(ctx) + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["projectID"] = p.ID + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + ctx.Data["redirect"] = ctx.FormString("redirect") + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + ctx.Data["card_type"] = p.CardType + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), p.ID) + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + projectID := ctx.ParamsInt64(":id") + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) + + shared_user.RenderUserHeader(ctx) + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = form.CardType + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + if ctx.FormString("redirect") == "project" { + ctx.Redirect(p.Link(ctx)) + } else { + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") + } +} + +// ViewProject renders the project with board view for a project +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) + ctx.Data["LinkedPRs"] = linkedPrsMap + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Columns"] = columns + shared_user.RenderUserHeader(ctx) + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSONOK() +} + +// AddColumnToProjectPost allows a new column to be added to a project. +func AddColumnToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + if err := project_model.NewColumn(ctx, &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// CheckProjectColumnChangePermissions check permission +func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + _, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.Context) { + project, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSONOK() +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go new file mode 100644 index 0000000..ab419cc --- /dev/null +++ b/routers/web/org/projects_test.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/routers/web/org" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectColumnChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4") + contexttest.LoadUser(t, ctx, 2) + ctx.ContextUser = ctx.Doer // user2 + ctx.SetParams(":id", "4") + ctx.SetParams(":columnID", "4") + + project, column := org.CheckProjectColumnChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, column) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go new file mode 100644 index 0000000..0be734a --- /dev/null +++ b/routers/web/org/setting.go @@ -0,0 +1,258 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 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/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "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" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + // tplSettingsOptions template path for render settings + tplSettingsOptions base.TplName = "org/settings/options" + // tplSettingsDelete template path for render delete repository + tplSettingsDelete base.TplName = "org/settings/delete" + // tplSettingsHooks template path for render hook settings + tplSettingsHooks base.TplName = "org/settings/hooks" + // tplSettingsLabels template path for render labels settings + tplSettingsLabels base.TplName = "org/settings/labels" +) + +// Settings render the main settings page +func Settings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess + ctx.Data["ContextUser"] = ctx.ContextUser + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsOptions) +} + +// SettingsPost response for settings change submitted +func SettingsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateOrgSettingForm) + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + org := ctx.Org.Organization + + if org.Name != form.Name { + if err := user_service.RenameUser(ctx, org.AsUser(), form.Name); err != nil { + if user_model.IsErrUserAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) + } else if db.IsErrNameReserved(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + } else if db.IsErrNamePatternNotAllowed(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + } else { + ctx.ServerError("RenameUser", err) + } + return + } + + ctx.Org.OrgLink = setting.AppSubURL + "/org/" + url.PathEscape(org.Name) + } + + if form.Email != "" { + if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form) + 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.Some(form.Visibility), + RepoAdminChangeTeamAccess: optional.Some(form.RepoAdminChangeTeamAccess), + } + if ctx.Doer.IsAdmin { + opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation) + } + + visibilityChanged := org.Visibility != form.Visibility + + if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + // update forks visibility + if visibilityChanged { + repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: org.AsUser(), Private: true, ListOptions: db.ListOptions{Page: 1, PageSize: org.NumRepos}, + }) + if err != nil { + ctx.ServerError("GetRepositories", err) + return + } + for _, repo := range repos { + repo.OwnerName = org.Name + if err := repo_service.UpdateRepository(ctx, repo, true); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + } + + log.Trace("Organization setting updated: %s", org.Name) + ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsAvatar response for change avatar on settings page +func SettingsAvatar(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + form.Source = forms.AvatarLocal + if err := user_setting.UpdateAvatarSetting(ctx, form, ctx.Org.Organization.AsUser()); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.settings.update_avatar_success")) + } + + ctx.Redirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDeleteAvatar response for delete avatar on settings page +func SettingsDeleteAvatar(ctx *context.Context) { + if err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings") +} + +// SettingsDelete response for deleting an organization +func SettingsDelete(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsDelete"] = true + + if ctx.Req.Method == "POST" { + if ctx.Org.Organization.Name != ctx.FormString("org_name") { + ctx.Data["Err_OrgName"] = true + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_org_name"), tplSettingsDelete, nil) + return + } + + if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { + if models.IsErrUserOwnRepos(err) { + ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") + } else if models.IsErrUserOwnPackages(err) { + ctx.Flash.Error(ctx.Tr("form.org_still_own_packages")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") + } else { + ctx.ServerError("DeleteOrganization", err) + } + } else { + log.Trace("Organization deleted: %s", ctx.Org.Organization.Name) + ctx.Redirect(setting.AppSubURL + "/") + } + return + } + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsDelete) +} + +// Webhooks render webhook list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" + ctx.Data["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID}) + if err != nil { + ctx.ServerError("ListWebhooksByOpts", err) + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/hooks") +} + +// Labels render organization labels page +func Labels(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsLabels"] = true + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsLabels) +} diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go new file mode 100644 index 0000000..0c7f245 --- /dev/null +++ b/routers/web/org/setting/blocked_users.go @@ -0,0 +1,85 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +const tplBlockedUsers = "org/settings/blocked_users" + +// BlockedUsers renders the blocked users page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + + ctx.HTML(http.StatusOK, tplBlockedUsers) +} + +// BlockedUsersBlock blocks a particular user from the organization. +func BlockedUsersBlock(ctx *context.Context) { + uname := strings.ToLower(ctx.FormString("uname")) + u, err := user_model.GetUserByName(ctx, uname) + if err != nil { + ctx.ServerError("GetUserByName", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("BlockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_block_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} + +// BlockedUsersUnblock unblocks a particular user from the organization. +func BlockedUsersUnblock(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("user_id")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go new file mode 100644 index 0000000..fe05709 --- /dev/null +++ b/routers/web/org/setting/runners.go @@ -0,0 +1,12 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners") +} diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go new file mode 100644 index 0000000..7f85579 --- /dev/null +++ b/routers/web/org/setting_oauth2.go @@ -0,0 +1,102 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsApplications base.TplName = "org/settings/applications" + tplSettingsOAuthApplicationEdit base.TplName = "org/settings/applications_oauth2_edit" +) + +func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers { + return &user_setting.OAuth2CommonHandlers{ + OwnerID: org.Organization.ID, + BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Organization.Name), + BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Organization.Name), + TplAppEdit: tplSettingsOAuthApplicationEdit, + } +} + +// Applications render org applications page (for org, at the moment, there are only OAuth2 applications) +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Org.Organization.ID, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Applications"] = apps + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// OAuthApplicationsPost response for adding an oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.AddApp(ctx) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.EditShow(ctx) +} + +// OAuth2ApplicationEdit response for editing oauth2 application +func OAuth2ApplicationEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.EditSave(ctx) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Org) + oa.RegenerateSecret(ctx) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Org) + oa.DeleteApp(ctx) +} + +// TODO: revokes the grant with the given id diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go new file mode 100644 index 0000000..af9836e --- /dev/null +++ b/routers/web/org/setting_packages.go @@ -0,0 +1,131 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/packages" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsPackages base.TplName = "org/settings/packages" + tplSettingsPackagesRuleEdit base.TplName = "org/settings/packages_cleanup_rules_edit" + tplSettingsPackagesRulePreview base.TplName = "org/settings/packages_cleanup_rules_preview" +) + +func Packages(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetPackagesContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackages) +} + +func PackagesRuleAdd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRuleAddContext(ctx) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRuleEditContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleAddPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleAddPost( + ctx, + ctx.ContextUser, + fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name), + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRuleEditPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleEditPost( + ctx, + ctx.ContextUser, + fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name), + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRulePreview(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + shared.SetRulePreviewContext(ctx, ctx.ContextUser) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview) +} + +func InitializeCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.InitializeCargoIndex(ctx, ctx.ContextUser) + + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + +func RebuildCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsPackages"] = true + + shared.RebuildCargoIndex(ctx, ctx.ContextUser) + + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go new file mode 100644 index 0000000..45c3674 --- /dev/null +++ b/routers/web/org/teams.go @@ -0,0 +1,628 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + 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/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + // tplTeams template path for teams list page + tplTeams base.TplName = "org/team/teams" + // tplTeamNew template path for create new team page + tplTeamNew base.TplName = "org/team/new" + // tplTeamMembers template path for showing team members page + tplTeamMembers base.TplName = "org/team/members" + // tplTeamRepositories template path for showing team repositories page + tplTeamRepositories base.TplName = "org/team/repositories" + // tplTeamInvite template path for team invites page + tplTeamInvite base.TplName = "org/team/invite" +) + +// Teams render teams list page +func Teams(ctx *context.Context) { + org := ctx.Org.Organization + ctx.Data["Title"] = org.FullName + ctx.Data["PageIsOrgTeams"] = true + + for _, t := range ctx.Org.Teams { + if err := t.LoadMembers(ctx); err != nil { + ctx.ServerError("GetMembers", err) + return + } + } + ctx.Data["Teams"] = ctx.Org.Teams + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplTeams) +} + +// TeamsAction response for join, leave, remove, add operations to team +func TeamsAction(ctx *context.Context) { + page := ctx.FormString("page") + var err error + switch ctx.Params(":action") { + case "join": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + case "leave": + err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + if err != nil { + if org_model.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/") + return + case "remove": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + uid := ctx.FormInt64("uid") + if uid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/teams") + return + } + + err = models.RemoveTeamMember(ctx, ctx.Org.Team, uid) + if err != nil { + if org_model.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/"+url.PathEscape(ctx.Org.Team.LowerName)) + return + case "add": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + uname := strings.ToLower(ctx.FormString("uname")) + var u *user_model.User + u, err = user_model.GetUserByName(ctx, uname) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if setting.MailService != nil && user_model.ValidateEmail(uname) == nil { + if err := org_service.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname); err != nil { + if org_model.IsErrTeamInviteAlreadyExist(err) { + ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team")) + } else if org_model.IsErrUserEmailAlreadyAdded(err) { + ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) + } else { + ctx.ServerError("CreateTeamInvite", err) + return + } + } + } else { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if u.IsOrganization() { + ctx.Flash.Error(ctx.Tr("form.cannot_add_org_to_team")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + return + } + + if ctx.Org.Team.IsMember(ctx, u.ID) { + ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) + } else { + err = models.AddTeamMember(ctx, ctx.Org.Team, u.ID) + } + + page = "team" + case "remove_invite": + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + iid := ctx.FormInt64("iid") + if iid == 0 { + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + return + } + + if err := org_model.RemoveInviteByID(ctx, iid, ctx.Org.Team.ID); err != nil { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.ServerError("RemoveInviteByID", err) + return + } + + page = "team" + } + + if err != nil { + if org_model.IsErrLastOrgOwner(err) { + ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else { + log.Error("Action(%s): %v", ctx.Params(":action"), err) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": false, + "err": err.Error(), + }) + return + } + } + + switch page { + case "team": + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName)) + case "home": + ctx.Redirect(ctx.Org.Organization.AsUser().HomeLink()) + default: + ctx.Redirect(ctx.Org.OrgLink + "/teams") + } +} + +func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) { + if isOrgMember, err := org_model.IsOrganizationMember(ctx, ctx.Org.Organization.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("IsOrganizationMember", err) + return + } else if !isOrgMember { + if ctx.Org.Organization.Visibility.IsPrivate() { + defaultRedirect = setting.AppSubURL + "/" + } else { + defaultRedirect = ctx.Org.Organization.HomeLink() + } + } + ctx.JSONRedirect(defaultRedirect) +} + +// TeamsRepoAction operate team's repository +func TeamsRepoAction(ctx *context.Context) { + if !ctx.Org.IsOwner { + ctx.Error(http.StatusNotFound) + return + } + + var err error + action := ctx.Params(":action") + switch action { + case "add": + repoName := path.Base(ctx.FormString("repo_name")) + var repo *repo_model.Repository + repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo")) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + err = org_service.TeamAddRepository(ctx, ctx.Org.Team, repo) + case "remove": + err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid")) + case "addall": + err = models.AddAllRepositories(ctx, ctx.Org.Team) + case "removeall": + err = models.RemoveAllRepositories(ctx, ctx.Org.Team) + } + + if err != nil { + log.Error("Action(%s): '%s' %v", ctx.Params(":action"), ctx.Org.Team.Name, err) + ctx.ServerError("TeamsRepoAction", err) + return + } + + if action == "addall" || action == "removeall" { + ctx.JSONRedirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") + return + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories") +} + +// NewTeam render create new team page +func NewTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Team"] = &org_model.Team{} + ctx.Data["Units"] = unit_model.Units + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplTeamNew) +} + +func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode { + unitPerms := make(map[unit_model.Type]perm.AccessMode) + for _, ut := range unit_model.AllRepoUnitTypes { + // Default accessmode is none + unitPerms[ut] = perm.AccessModeNone + + v, ok := forms[fmt.Sprintf("unit_%d", ut)] + if ok { + vv, _ := strconv.Atoi(v[0]) + if teamPermission >= perm.AccessModeAdmin { + unitPerms[ut] = teamPermission + // Don't allow `TypeExternal{Tracker,Wiki}` to influence this as they can only be set to READ perms. + if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki { + unitPerms[ut] = perm.AccessModeRead + } + } else { + unitPerms[ut] = perm.AccessMode(vv) + if unitPerms[ut] >= perm.AccessModeAdmin { + unitPerms[ut] = perm.AccessModeWrite + } + } + } + } + return unitPerms +} + +// NewTeamPost response for create new team +func NewTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + includesAllRepositories := form.RepoAccess == "all" + p := perm.ParseAccessMode(form.Permission) + unitPerms := getUnitPerms(ctx.Req.Form, p) + if p < perm.AccessModeAdmin { + // if p is less than admin accessmode, then it should be general accessmode, + // so we should calculate the minial accessmode from units accessmodes. + p = unit_model.MinUnitAccessMode(unitPerms) + } + + t := &org_model.Team{ + OrgID: ctx.Org.Organization.ID, + Name: form.TeamName, + Description: form.Description, + AccessMode: p, + IncludesAllRepositories: includesAllRepositories, + CanCreateOrgRepo: form.CanCreateOrgRepo, + } + + units := make([]*org_model.TeamUnit, 0, len(unitPerms)) + for tp, perm := range unitPerms { + units = append(units, &org_model.TeamUnit{ + OrgID: ctx.Org.Organization.ID, + Type: tp, + AccessMode: perm, + }) + } + t.Units = units + + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamsNew"] = true + ctx.Data["Units"] = unit_model.Units + ctx.Data["Team"] = t + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.NewTeam(ctx, t); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case org_model.IsErrTeamAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) + default: + ctx.ServerError("NewTeam", err) + } + return + } + log.Trace("Team created: %s/%s", ctx.Org.Organization.Name, t.Name) + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName)) +} + +// TeamMembers render team members page +func TeamMembers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Team.Name + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamMembers"] = true + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if err := ctx.Org.Team.LoadMembers(ctx); err != nil { + ctx.ServerError("GetMembers", err) + return + } + ctx.Data["Units"] = unit_model.Units + + invites, err := org_model.GetInvitesByTeamID(ctx, ctx.Org.Team.ID) + if err != nil { + ctx.ServerError("GetInvitesByTeamID", err) + return + } + ctx.Data["Invites"] = invites + ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil + + ctx.HTML(http.StatusOK, tplTeamMembers) +} + +// TeamRepositories show the repositories of team +func TeamRepositories(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Team.Name + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["PageIsOrgTeamRepos"] = true + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + if err := ctx.Org.Team.LoadRepositories(ctx); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + ctx.Data["Units"] = unit_model.Units + ctx.HTML(http.StatusOK, tplTeamRepositories) +} + +// SearchTeam api for searching teams +func SearchTeam(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + opts := &org_model.SearchTeamOptions{ + // UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in + Keyword: ctx.FormTrim("q"), + OrgID: ctx.Org.Organization.ID, + IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), + ListOptions: listOptions, + } + + teams, maxResults, err := org_model.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 { + log.Error("convert ToTeams failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": "SearchTeam failed to get units", + }) + return + } + + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": apiTeams, + }) +} + +// EditTeam render team edit page +func EditTeam(ctx *context.Context) { + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + if err := ctx.Org.Team.LoadUnits(ctx); err != nil { + ctx.ServerError("LoadUnits", err) + return + } + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.Data["Team"] = ctx.Org.Team + ctx.Data["Units"] = unit_model.Units + ctx.HTML(http.StatusOK, tplTeamNew) +} + +// EditTeamPost response for modify team information +func EditTeamPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateTeamForm) + t := ctx.Org.Team + newAccessMode := perm.ParseAccessMode(form.Permission) + unitPerms := getUnitPerms(ctx.Req.Form, newAccessMode) + if newAccessMode < perm.AccessModeAdmin { + // if newAccessMode is less than admin accessmode, then it should be general accessmode, + // so we should calculate the minial accessmode from units accessmodes. + newAccessMode = unit_model.MinUnitAccessMode(unitPerms) + } + isAuthChanged := false + isIncludeAllChanged := false + includesAllRepositories := form.RepoAccess == "all" + + ctx.Data["Title"] = ctx.Org.Organization.FullName + ctx.Data["PageIsOrgTeams"] = true + ctx.Data["Team"] = t + ctx.Data["Units"] = unit_model.Units + + if !t.IsOwnerTeam() { + t.Name = form.TeamName + if t.AccessMode != newAccessMode { + isAuthChanged = true + t.AccessMode = newAccessMode + } + + if t.IncludesAllRepositories != includesAllRepositories { + isIncludeAllChanged = true + t.IncludesAllRepositories = includesAllRepositories + } + t.CanCreateOrgRepo = form.CanCreateOrgRepo + + units := make([]*org_model.TeamUnit, 0, len(unitPerms)) + for tp, perm := range unitPerms { + units = append(units, &org_model.TeamUnit{ + OrgID: t.OrgID, + TeamID: t.ID, + Type: tp, + AccessMode: perm, + }) + } + t.Units = units + } else { + t.CanCreateOrgRepo = true + } + + t.Description = form.Description + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTeamNew) + return + } + + if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 { + ctx.RenderWithErr(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form) + return + } + + if err := models.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil { + ctx.Data["Err_TeamName"] = true + switch { + case org_model.IsErrTeamAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form) + default: + ctx.ServerError("UpdateTeam", err) + } + return + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName)) +} + +// DeleteTeam response for the delete team request +func DeleteTeam(ctx *context.Context) { + if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { + ctx.Flash.Error("DeleteTeam: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) + } + + ctx.JSONRedirect(ctx.Org.OrgLink + "/teams") +} + +// TeamInvite renders the team invite page +func TeamInvite(ctx *context.Context) { + invite, org, team, inviter, err := getTeamInviteFromContext(ctx) + if err != nil { + if org_model.IsErrTeamInviteNotFound(err) { + ctx.NotFound("ErrTeamInviteNotFound", err) + } else { + ctx.ServerError("getTeamInviteFromContext", err) + } + return + } + + ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name) + ctx.Data["Invite"] = invite + ctx.Data["Organization"] = org + ctx.Data["Team"] = team + ctx.Data["Inviter"] = inviter + + ctx.HTML(http.StatusOK, tplTeamInvite) +} + +// TeamInvitePost handles the team invitation +func TeamInvitePost(ctx *context.Context) { + invite, org, team, _, err := getTeamInviteFromContext(ctx) + if err != nil { + if org_model.IsErrTeamInviteNotFound(err) { + ctx.NotFound("ErrTeamInviteNotFound", err) + } else { + ctx.ServerError("getTeamInviteFromContext", err) + } + return + } + + if err := models.AddTeamMember(ctx, team, ctx.Doer.ID); err != nil { + ctx.ServerError("AddTeamMember", err) + return + } + + if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil { + log.Error("RemoveInviteByID: %v", err) + } + + ctx.Redirect(org.OrganisationLink() + "/teams/" + url.PathEscape(team.LowerName)) +} + +func getTeamInviteFromContext(ctx *context.Context) (*org_model.TeamInvite, *org_model.Organization, *org_model.Team, *user_model.User, error) { + invite, err := org_model.GetInviteByToken(ctx, ctx.Params("token")) + if err != nil { + return nil, nil, nil, nil, err + } + + inviter, err := user_model.GetUserByID(ctx, invite.InviterID) + if err != nil { + return nil, nil, nil, nil, err + } + + team, err := org_model.GetTeamByID(ctx, invite.TeamID) + if err != nil { + return nil, nil, nil, nil, err + } + + org, err := user_model.GetUserByID(ctx, team.OrgID) + if err != nil { + return nil, nil, nil, nil, err + } + + return invite, org_model.OrgFromUser(org), team, inviter, nil +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go new file mode 100644 index 0000000..ff3b161 --- /dev/null +++ b/routers/web/repo/actions/actions.go @@ -0,0 +1,247 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "bytes" + "fmt" + "net/http" + "slices" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + + "github.com/nektos/act/pkg/model" +) + +const ( + tplListActions base.TplName = "repo/actions/list" + tplViewActions base.TplName = "repo/actions/view" +) + +type Workflow struct { + Entry git.TreeEntry + ErrMsg string +} + +// MustEnableActions check if actions are enabled in settings +func MustEnableActions(ctx *context.Context) { + if !setting.Actions.Enabled { + ctx.NotFound("MustEnableActions", nil) + return + } + + if unit.TypeActions.UnitGlobalDisabled() { + ctx.NotFound("MustEnableActions", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(unit.TypeActions) { + ctx.NotFound("MustEnableActions", nil) + return + } + } +} + +func List(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsActions"] = true + + curWorkflow := ctx.FormString("workflow") + ctx.Data["CurWorkflow"] = curWorkflow + + var workflows []Workflow + if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { + ctx.ServerError("IsEmpty", err) + return + } else if !empty { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + entries, err := actions.ListWorkflows(commit) + if err != nil { + ctx.ServerError("ListWorkflows", err) + return + } + + // Get all runner labels + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + ctx.ServerError("FindRunners", err) + return + } + allRunnerLabels := make(container.Set[string]) + for _, r := range runners { + allRunnerLabels.AddMultiple(r.AgentLabels...) + } + + canRun := ctx.Repo.CanWrite(unit.TypeActions) + + workflows = make([]Workflow, 0, len(entries)) + for _, entry := range entries { + workflow := Workflow{Entry: *entry} + content, err := actions.GetContentFromEntry(entry) + if err != nil { + ctx.ServerError("GetContentFromEntry", err) + return + } + wf, err := model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + workflows = append(workflows, workflow) + continue + } + // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. + hasJobWithoutNeeds := false + // Check whether have matching runner and a job without "needs" + emptyJobsNumber := 0 + for _, j := range wf.Jobs { + if j == nil { + emptyJobsNumber++ + continue + } + if !hasJobWithoutNeeds && len(j.Needs()) == 0 { + hasJobWithoutNeeds = true + } + runsOnList := j.RunsOn() + for _, ro := range runsOnList { + if strings.Contains(ro, "${{") { + // Skip if it contains expressions. + // The expressions could be very complex and could not be evaluated here, + // so just skip it, it's OK since it's just a tooltip message. + continue + } + if !allRunnerLabels.Contains(ro) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) + break + } + } + if workflow.ErrMsg != "" { + break + } + } + if !hasJobWithoutNeeds { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") + } + if emptyJobsNumber == len(wf.Jobs) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") + } + workflows = append(workflows, workflow) + + if canRun && workflow.Entry.Name() == curWorkflow { + config := wf.WorkflowDispatchConfig() + if config != nil { + keys := util.KeysOfMap(config.Inputs) + slices.Sort(keys) + if int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs { + keys = keys[:setting.Actions.LimitDispatchInputs] + } + + ctx.Data["CurWorkflowDispatch"] = config + ctx.Data["CurWorkflowDispatchInputKeys"] = keys + ctx.Data["WarnDispatchInputsLimit"] = int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs + ctx.Data["DispatchInputsLimit"] = setting.Actions.LimitDispatchInputs + } + } + } + } + ctx.Data["workflows"] = workflows + ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + actorID := ctx.FormInt64("actor") + status := ctx.FormInt("status") + + actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + ctx.Data["ActionsConfig"] = actionsConfig + + if len(curWorkflow) > 0 && ctx.Repo.IsAdmin() { + ctx.Data["AllowDisableOrEnableWorkflow"] = true + ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflow) + } + + // if status or actor query param is not given to frontend href, (href="//actions") + // they will be 0 by default, which indicates get all status or actors + ctx.Data["CurActor"] = actorID + ctx.Data["CurStatus"] = status + if actorID > 0 || status > int(actions_model.StatusUnknown) { + ctx.Data["IsFiltered"] = true + } + + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: curWorkflow, + TriggerUserID: actorID, + } + + // if status is not StatusUnknown, it means user has selected a status filter + if actions_model.Status(status) != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{actions_model.Status(status)} + } + + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + ctx.ServerError("FindAndCount", err) + return + } + + for _, run := range runs { + run.Repo = ctx.Repo.Repository + } + + if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) + return + } + + ctx.Data["Runs"] = runs + + ctx.Data["Repo"] = ctx.Repo + + actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetActors", err) + return + } + ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors) + + ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx) + + pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("workflow", curWorkflow) + pager.AddParamString("actor", fmt.Sprint(actorID)) + pager.AddParamString("status", fmt.Sprint(status)) + ctx.Data["Page"] = pager + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 + + ctx.HTML(http.StatusOK, tplListActions) +} diff --git a/routers/web/repo/actions/manual.go b/routers/web/repo/actions/manual.go new file mode 100644 index 0000000..86a6014 --- /dev/null +++ b/routers/web/repo/actions/manual.go @@ -0,0 +1,62 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/url" + + actions_service "code.gitea.io/gitea/services/actions" + context_module "code.gitea.io/gitea/services/context" +) + +func ManualRunWorkflow(ctx *context_module.Context) { + workflowID := ctx.FormString("workflow") + if len(workflowID) == 0 { + ctx.ServerError("workflow", nil) + return + } + + ref := ctx.FormString("ref") + if len(ref) == 0 { + ctx.ServerError("ref", nil) + return + } + + if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { + ctx.ServerError("IsEmpty", err) + return + } else if empty { + ctx.NotFound("IsEmpty", nil) + return + } + + workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, ref, workflowID) + if err != nil { + ctx.ServerError("GetWorkflowFromCommit", err) + return + } + + location := ctx.Repo.RepoLink + "/actions?workflow=" + url.QueryEscape(workflowID) + + "&actor=" + url.QueryEscape(ctx.FormString("actor")) + + "&status=" + url.QueryEscape(ctx.FormString("status")) + + formKeyGetter := func(key string) string { + formKey := "inputs[" + key + "]" + return ctx.FormString(formKey) + } + + if err := workflow.Dispatch(ctx, formKeyGetter, ctx.Repo.Repository, ctx.Doer); err != nil { + if actions_service.IsInputRequiredErr(err) { + ctx.Flash.Error(ctx.Locale.Tr("actions.workflow.dispatch.input_required", err.(actions_service.InputRequiredErr).Name)) + ctx.Redirect(location) + return + } + ctx.ServerError("workflow.Dispatch", err) + return + } + + // forward to the page of the run which was just created + ctx.Flash.Info(ctx.Locale.Tr("actions.workflow.dispatch.success")) + ctx.Redirect(location) +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go new file mode 100644 index 0000000..bc1ecbf --- /dev/null +++ b/routers/web/repo/actions/view.go @@ -0,0 +1,781 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "archive/zip" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "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/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + actions_service "code.gitea.io/gitea/services/actions" + context_module "code.gitea.io/gitea/services/context" + + "xorm.io/builder" +) + +func View(ctx *context_module.Context) { + ctx.Data["PageIsActions"] = true + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + job, _ := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + + workflowName := job.Run.WorkflowID + + ctx.Data["RunIndex"] = runIndex + ctx.Data["JobIndex"] = jobIndex + ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" + ctx.Data["WorkflowName"] = workflowName + ctx.Data["WorkflowURL"] = ctx.Repo.RepoLink + "/actions?workflow=" + workflowName + + ctx.HTML(http.StatusOK, tplViewActions) +} + +func ViewLatest(ctx *context_module.Context) { + run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.NotFound("GetLatestRun", err) + return + } + err = run.LoadAttributes(ctx) + if err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) +} + +func ViewLatestWorkflowRun(ctx *context_module.Context) { + branch := ctx.FormString("branch") + if branch == "" { + branch = ctx.Repo.Repository.DefaultBranch + } + branch = fmt.Sprintf("refs/heads/%s", branch) + event := ctx.FormString("event") + + workflowFile := ctx.Params("workflow_name") + run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound("GetLatestRunForBranchAndWorkflow", err) + } else { + ctx.ServerError("GetLatestRunForBranchAndWorkflow", err) + } + return + } + + err = run.LoadAttributes(ctx) + if err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) +} + +type ViewRequest struct { + LogCursors []struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Expanded bool `json:"expanded"` + } `json:"logCursors"` +} + +type ViewResponse struct { + State struct { + Run struct { + Link string `json:"link"` + Title string `json:"title"` + Status string `json:"status"` + CanCancel bool `json:"canCancel"` + CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve + CanRerun bool `json:"canRerun"` + CanDeleteArtifact bool `json:"canDeleteArtifact"` + Done bool `json:"done"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` + } `json:"run"` + CurrentJob struct { + Title string `json:"title"` + Detail string `json:"detail"` + Steps []*ViewJobStep `json:"steps"` + } `json:"currentJob"` + } `json:"state"` + Logs struct { + StepsLog []*ViewStepLog `json:"stepsLog"` + } `json:"logs"` +} + +type ViewJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CanRerun bool `json:"canRerun"` + Duration string `json:"duration"` +} + +type ViewCommit struct { + LocaleCommit string `json:"localeCommit"` + LocalePushedBy string `json:"localePushedBy"` + LocaleWorkflow string `json:"localeWorkflow"` + ShortSha string `json:"shortSHA"` + Link string `json:"link"` + Pusher ViewUser `json:"pusher"` + Branch ViewBranch `json:"branch"` +} + +type ViewUser struct { + DisplayName string `json:"displayName"` + Link string `json:"link"` +} + +type ViewBranch struct { + Name string `json:"name"` + Link string `json:"link"` +} + +type ViewJobStep struct { + Summary string `json:"summary"` + Duration string `json:"duration"` + Status string `json:"status"` +} + +type ViewStepLog struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Lines []*ViewStepLogLine `json:"lines"` + Started int64 `json:"started"` +} + +type ViewStepLogLine struct { + Index int64 `json:"index"` + Message string `json:"message"` + Timestamp float64 `json:"timestamp"` +} + +func ViewPost(ctx *context_module.Context) { + req := web.GetForm(ctx).(*ViewRequest) + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + current, jobs := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + run := current.Run + if err := run.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + resp := &ViewResponse{} + + resp.State.Run.Title = run.Title + resp.State.Run.Link = run.Link() + resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.Done = run.Status.IsDone() + resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json + resp.State.Run.Status = run.Status.String() + for _, v := range jobs { + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ + ID: v.ID, + Name: v.Name, + Status: v.Status.String(), + CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), + Duration: v.Duration().String(), + }) + } + + pusher := ViewUser{ + DisplayName: run.TriggerUser.GetDisplayName(), + Link: run.TriggerUser.HomeLink(), + } + branch := ViewBranch{ + Name: run.PrettyRef(), + Link: run.RefLink(), + } + resp.State.Run.Commit = ViewCommit{ + LocaleCommit: ctx.Locale.TrString("actions.runs.commit"), + LocalePushedBy: ctx.Locale.TrString("actions.runs.pushed_by"), + LocaleWorkflow: ctx.Locale.TrString("actions.runs.workflow"), + ShortSha: base.ShortSha(run.CommitSHA), + Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA), + Pusher: pusher, + Branch: branch, + } + + var task *actions_model.ActionTask + if current.TaskID > 0 { + var err error + task, err = actions_model.GetTaskByID(ctx, current.TaskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + task.Job = current + if err := task.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + resp.State.CurrentJob.Title = current.Name + resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) + if run.NeedApproval { + resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") + } + resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead of 'null' in json + resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead of 'null' in json + if task != nil { + steps := actions.FullSteps(task) + + for _, v := range steps { + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ + Summary: v.Name, + Duration: v.Duration().String(), + Status: v.Status.String(), + }) + } + + for _, cursor := range req.LogCursors { + if !cursor.Expanded { + continue + } + + step := steps[cursor.Step] + + // if task log is expired, return a consistent log line + if task.LogExpired { + if cursor.Cursor == 0 { + resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ + Step: cursor.Step, + Cursor: 1, + Lines: []*ViewStepLogLine{ + { + Index: 1, + Message: ctx.Locale.TrString("actions.runs.expire_log_message"), + // Timestamp doesn't mean anything when the log is expired. + // Set it to the task's updated time since it's probably the time when the log has expired. + Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), + }, + }, + Started: int64(step.Started), + }) + } + continue + } + + logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead of 'null' in json + + index := step.LogIndex + cursor.Cursor + validCursor := cursor.Cursor >= 0 && + // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. + // So return the same cursor and empty lines to let the frontend retry. + cursor.Cursor < step.LogLength && + // !(index < task.LogIndexes[index]) when task data is older than step data. + // It can be fixed by making sure write/read tasks and steps in the same transaction, + // but it's easier to just treat it as fetching the next line before it's ready. + index < int64(len(task.LogIndexes)) + + if validCursor { + length := step.LogLength - cursor.Cursor + offset := task.LogIndexes[index] + var err error + logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + for i, row := range logRows { + logLines = append(logLines, &ViewStepLogLine{ + Index: cursor.Cursor + int64(i) + 1, // start at 1 + Message: row.Content, + Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), + }) + } + } + + resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ + Step: cursor.Step, + Cursor: cursor.Cursor + int64(len(logLines)), + Lines: logLines, + Started: int64(step.Started), + }) + } + } + + ctx.JSON(http.StatusOK, resp) +} + +// Rerun will rerun jobs in the given run +// If jobIndexStr is a blank string, it means rerun all jobs +func Rerun(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + jobIndexStr := ctx.Params("job") + var jobIndex int64 + if jobIndexStr != "" { + jobIndex, _ = strconv.ParseInt(jobIndexStr, 10, 64) + } + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + // can not rerun job when workflow is disabled + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) + return + } + + // reset run's start and stop time when it is done + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + job, jobs := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + + if jobIndexStr == "" { // rerun all jobs + for _, j := range jobs { + // if the job has needs, it should be set to "blocked" status to wait for other jobs + shouldBlock := len(j.Needs) > 0 + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + ctx.JSON(http.StatusOK, struct{}{}) + return + } + + rerunJobs := actions_service.GetAllRerunJobs(job, jobs) + + for _, j := range rerunJobs { + // jobs other than the specified one should be set to "blocked" status + shouldBlock := j.JobID != job.JobID + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + ctx.JSON(http.StatusOK, struct{}{}) +} + +func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { + status := job.Status + if !status.IsDone() { + return nil + } + + job.TaskID = 0 + job.Status = actions_model.StatusWaiting + if shouldBlock { + job.Status = actions_model.StatusBlocked + } + job.Started = 0 + job.Stopped = 0 + + if err := db.WithTx(ctx, func(ctx context.Context) error { + _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped") + return err + }); err != nil { + return err + } + + actions_service.CreateCommitStatus(ctx, job) + return nil +} + +func Logs(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + jobIndex := ctx.ParamsInt64("job") + + job, _ := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { + return + } + if job.TaskID == 0 { + ctx.Error(http.StatusNotFound, "job is not started") + return + } + + err := job.LoadRun(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + task, err := actions_model.GetTaskByID(ctx, job.TaskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if task.LogExpired { + ctx.Error(http.StatusNotFound, "logs have been cleaned up") + return + } + + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + defer reader.Close() + + workflowName := job.Run.WorkflowID + if p := strings.Index(workflowName, "."); p > 0 { + workflowName = workflowName[0:p] + } + ctx.ServeContent(reader, &context_module.ServeHeaderOptions{ + Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID), + ContentLength: &task.LogSize, + ContentType: "text/plain", + ContentTypeCharset: "utf-8", + Disposition: "attachment", + }) +} + +func Cancel(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + + _, jobs := getRunJobs(ctx, runIndex, -1) + if ctx.Written() { + return + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { + for _, job := range jobs { + status := job.Status + if status.IsDone() { + continue + } + if job.TaskID == 0 { + job.Status = actions_model.StatusCancelled + job.Stopped = timeutil.TimeStampNow() + n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("job has changed, try again") + } + continue + } + if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { + return err + } + } + return nil + }); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + actions_service.CreateCommitStatus(ctx, jobs...) + + ctx.JSON(http.StatusOK, struct{}{}) +} + +func Approve(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + + current, jobs := getRunJobs(ctx, runIndex, -1) + if ctx.Written() { + return + } + run := current.Run + doer := ctx.Doer + + if err := db.WithTx(ctx, func(ctx context.Context) error { + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + for _, job := range jobs { + if len(job.Needs) == 0 && job.Status.IsBlocked() { + job.Status = actions_model.StatusWaiting + _, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + } + } + return nil + }); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + actions_service.CreateCommitStatus(ctx, jobs...) + + ctx.JSON(http.StatusOK, struct{}{}) +} + +// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. +// Any error will be written to the ctx. +// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. +func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return nil, nil + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return nil, nil + } + run.Repo = ctx.Repo.Repository + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return nil, nil + } + if len(jobs) == 0 { + ctx.Error(http.StatusNotFound) + return nil, nil + } + + for _, v := range jobs { + v.Run = run + } + + if jobIndex >= 0 && jobIndex < int64(len(jobs)) { + return jobs[jobIndex], jobs + } + return jobs[0], jobs +} + +type ArtifactsViewResponse struct { + Artifacts []*ArtifactsViewItem `json:"artifacts"` +} + +type ArtifactsViewItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` +} + +func ArtifactsView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + artifactsResponse := ArtifactsViewResponse{ + Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), + } + for _, art := range artifacts { + status := "completed" + if art.Status == actions_model.ArtifactStatusExpired { + status = "expired" + } + artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + Status: status, + }) + } + ctx.JSON(http.StatusOK, artifactsResponse) +} + +func ArtifactsDeleteView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + artifactName := ctx.Params("artifact_name") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, struct{}{}) +} + +func ArtifactsDownloadView(ctx *context_module.Context) { + runIndex := ctx.ParamsInt64("run") + artifactName := ctx.Params("artifact_name") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + + // if artifacts status is not uploaded-confirmed, treat it as not found + for _, art := range artifacts { + if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + } + + // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend + // The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend + if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" { + art := artifacts[0] + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath) + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + common.ServeContentByReadSeeker(ctx.Base, artifactName, util.ToPointer(art.UpdatedUnix.AsTime()), f) + return + } + + // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend + // Those need to be zipped for download + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + writer := zip.NewWriter(ctx.Resp) + defer writer.Close() + for _, art := range artifacts { + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + var r io.ReadCloser + if art.ContentEncoding == "gzip" { + r, err = gzip.NewReader(f) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } else { + r = f + } + defer r.Close() + + w, err := writer.Create(art.ArtifactPath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if _, err := io.Copy(w, r); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } +} + +func DisableWorkflowFile(ctx *context_module.Context) { + disableOrEnableWorkflowFile(ctx, false) +} + +func EnableWorkflowFile(ctx *context_module.Context) { + disableOrEnableWorkflowFile(ctx, true) +} + +func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { + workflow := ctx.FormString("workflow") + if len(workflow) == 0 { + ctx.ServerError("workflow", nil) + return + } + + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + if isEnable { + cfg.EnableWorkflow(workflow) + } else { + cfg.DisableWorkflow(workflow) + } + + if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + + if isEnable { + ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow)) + } else { + ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow)) + } + + redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow), + url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go new file mode 100644 index 0000000..ba776c8 --- /dev/null +++ b/routers/web/repo/activity.go @@ -0,0 +1,105 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" +) + +const ( + tplActivity base.TplName = "repo/activity" +) + +// Activity render the page to show repository latest changes +func Activity(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity") + ctx.Data["PageIsActivity"] = true + + ctx.Data["PageIsPulse"] = true + + ctx.Data["Period"] = ctx.Params("period") + + timeUntil := time.Now() + var timeFrom time.Time + + switch ctx.Data["Period"] { + case "daily": + timeFrom = timeUntil.Add(-time.Hour * 24) + case "halfweekly": + timeFrom = timeUntil.Add(-time.Hour * 72) + case "weekly": + timeFrom = timeUntil.Add(-time.Hour * 168) + case "monthly": + timeFrom = timeUntil.AddDate(0, -1, 0) + case "quarterly": + timeFrom = timeUntil.AddDate(0, -3, 0) + case "semiyearly": + timeFrom = timeUntil.AddDate(0, -6, 0) + case "yearly": + timeFrom = timeUntil.AddDate(-1, 0, 0) + default: + ctx.Data["Period"] = "weekly" + timeFrom = timeUntil.Add(-time.Hour * 168) + } + ctx.Data["DateFrom"] = timeFrom.UTC().Format(time.RFC3339) + ctx.Data["DateUntil"] = timeUntil.UTC().Format(time.RFC3339) + ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) + + var err error + if ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, + ctx.Repo.CanRead(unit.TypeReleases), + ctx.Repo.CanRead(unit.TypeIssues), + ctx.Repo.CanRead(unit.TypePullRequests), + ctx.Repo.CanRead(unit.TypeCode) && !ctx.Repo.Repository.IsEmpty); err != nil { + ctx.ServerError("GetActivityStats", err) + return + } + + if ctx.PageData["repoActivityTopAuthors"], err = activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10); err != nil { + ctx.ServerError("GetActivityStatsTopAuthors", err) + return + } + + ctx.HTML(http.StatusOK, tplActivity) +} + +// ActivityAuthors renders JSON with top commit authors for given time period over all branches +func ActivityAuthors(ctx *context.Context) { + timeUntil := time.Now() + var timeFrom time.Time + + switch ctx.Params("period") { + case "daily": + timeFrom = timeUntil.Add(-time.Hour * 24) + case "halfweekly": + timeFrom = timeUntil.Add(-time.Hour * 72) + case "weekly": + timeFrom = timeUntil.Add(-time.Hour * 168) + case "monthly": + timeFrom = timeUntil.AddDate(0, -1, 0) + case "quarterly": + timeFrom = timeUntil.AddDate(0, -3, 0) + case "semiyearly": + timeFrom = timeUntil.AddDate(0, -6, 0) + case "yearly": + timeFrom = timeUntil.AddDate(-1, 0, 0) + default: + timeFrom = timeUntil.Add(-time.Hour * 168) + } + + var err error + authors, err := activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10) + if err != nil { + ctx.ServerError("GetActivityStatsTopAuthors", err) + return + } + + ctx.JSON(http.StatusOK, authors) +} diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go new file mode 100644 index 0000000..b42effd --- /dev/null +++ b/routers/web/repo/attachment.go @@ -0,0 +1,163 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UploadIssueAttachment response for Issue/PR attachments +func UploadIssueAttachment(ctx *context.Context) { + uploadAttachment(ctx, ctx.Repo.Repository.ID, setting.Attachment.AllowedTypes) +} + +// UploadReleaseAttachment response for uploading release attachments +func UploadReleaseAttachment(ctx *context.Context) { + uploadAttachment(ctx, ctx.Repo.Repository.ID, setting.Repository.Release.AllowedTypes) +} + +// UploadAttachment response for uploading attachments +func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { + if !setting.Attachment.Enabled { + ctx.Error(http.StatusNotFound, "attachment is not enabled") + return + } + + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + defer file.Close() + + attach, err := attachment.UploadAttachment(ctx, file, allowedTypes, header.Size, &repo_model.Attachment{ + Name: header.Filename, + UploaderID: ctx.Doer.ID, + RepoID: repoID, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewAttachment: %v", err)) + return + } + + log.Trace("New attachment uploaded: %s", attach.UUID) + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attach.UUID, + }) +} + +// DeleteAttachment response for deleting issue's attachment +func DeleteAttachment(ctx *context.Context) { + file := ctx.FormString("file") + attach, err := repo_model.GetAttachmentByUUID(ctx, file) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != attach.UploaderID) { + ctx.Error(http.StatusForbidden) + return + } + err = repo_model.DeleteAttachment(ctx, attach, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) + return + } + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attach.UUID, + }) +} + +// GetAttachment serve attachments with the given UUID +func ServeAttachment(ctx *context.Context, uuid string) { + attach, err := repo_model.GetAttachmentByUUID(ctx, uuid) + if err != nil { + if repo_model.IsErrAttachmentNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.ServerError("GetAttachmentByUUID", err) + } + return + } + + repository, unitType, err := repo_service.LinkedRepository(ctx, attach) + if err != nil { + ctx.ServerError("LinkedRepository", err) + return + } + + if repository == nil { // If not linked + if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader + ctx.Error(http.StatusNotFound) + return + } + } else { // If we have the repository we check access + perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + return + } + if !perm.CanRead(unitType) { + ctx.Error(http.StatusNotFound) + return + } + } + + if attach.ExternalURL != "" { + ctx.Redirect(attach.ExternalURL) + return + } + + if err := attach.IncreaseDownloadCount(ctx); err != nil { + ctx.ServerError("IncreaseDownloadCount", err) + return + } + + if setting.Attachment.Storage.MinioConfig.ServeDirect { + // If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name) + + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`) { + return + } + + // If we have matched and access to release or issue + fr, err := storage.Attachments.Open(attach.RelativePath()) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + common.ServeContentByReadSeeker(ctx.Base, attach.Name, util.ToPointer(attach.CreatedUnix.AsTime()), fr) +} + +// GetAttachment serve attachments +func GetAttachment(ctx *context.Context) { + ServeAttachment(ctx, ctx.Params(":uuid")) +} diff --git a/routers/web/repo/badges/badges.go b/routers/web/repo/badges/badges.go new file mode 100644 index 0000000..a2306d5 --- /dev/null +++ b/routers/web/repo/badges/badges.go @@ -0,0 +1,164 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package badges + +import ( + "fmt" + "net/url" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/setting" + context_module "code.gitea.io/gitea/services/context" +) + +func getBadgeURL(ctx *context_module.Context, label, text, color string) string { + sb := &strings.Builder{} + _ = setting.Badges.GeneratorURLTemplateTemplate.Execute(sb, map[string]string{ + "label": url.PathEscape(strings.ReplaceAll(label, "-", "--")), + "text": url.PathEscape(strings.ReplaceAll(text, "-", "--")), + "color": url.PathEscape(color), + }) + + badgeURL := sb.String() + q := ctx.Req.URL.Query() + // Remove any `branch` or `event` query parameters. They're used by the + // workflow badge route, and do not need forwarding to the badge generator. + delete(q, "branch") + delete(q, "event") + if len(q) > 0 { + return fmt.Sprintf("%s?%s", badgeURL, q.Encode()) + } + return badgeURL +} + +func redirectToBadge(ctx *context_module.Context, label, text, color string) { + ctx.Redirect(getBadgeURL(ctx, label, text, color)) +} + +func errorBadge(ctx *context_module.Context, label, text string) { //nolint:unparam + ctx.Redirect(getBadgeURL(ctx, label, text, "crimson")) +} + +func GetWorkflowBadge(ctx *context_module.Context) { + branch := ctx.Req.URL.Query().Get("branch") + if branch != "" { + branch = fmt.Sprintf("refs/heads/%s", branch) + } + event := ctx.Req.URL.Query().Get("event") + + workflowFile := ctx.Params("workflow_name") + run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event) + if err != nil { + errorBadge(ctx, workflowFile, "Not found") + return + } + + var color string + switch run.Status { + case actions_model.StatusUnknown: + color = "lightgrey" + case actions_model.StatusWaiting: + color = "lightgrey" + case actions_model.StatusRunning: + color = "gold" + case actions_model.StatusSuccess: + color = "brightgreen" + case actions_model.StatusFailure: + color = "crimson" + case actions_model.StatusCancelled: + color = "orange" + case actions_model.StatusSkipped: + color = "blue" + case actions_model.StatusBlocked: + color = "yellow" + default: + color = "lightgrey" + } + + redirectToBadge(ctx, workflowFile, run.Status.String(), color) +} + +func getIssueOrPullBadge(ctx *context_module.Context, label, variant string, num int) { + var text string + if len(variant) > 0 { + text = fmt.Sprintf("%d %s", num, variant) + } else { + text = fmt.Sprintf("%d", num) + } + redirectToBadge(ctx, label, text, "blue") +} + +func getIssueBadge(ctx *context_module.Context, variant string, num int) { + if !ctx.Repo.CanRead(unit.TypeIssues) && + !ctx.Repo.CanRead(unit.TypeExternalTracker) { + errorBadge(ctx, "issues", "Not found") + return + } + + _, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil { + errorBadge(ctx, "issues", "Not found") + return + } + + getIssueOrPullBadge(ctx, "issues", variant, num) +} + +func getPullBadge(ctx *context_module.Context, variant string, num int) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + errorBadge(ctx, "pulls", "Not found") + return + } + + getIssueOrPullBadge(ctx, "pulls", variant, num) +} + +func GetOpenIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "open", ctx.Repo.Repository.NumOpenIssues) +} + +func GetClosedIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "closed", ctx.Repo.Repository.NumClosedIssues) +} + +func GetTotalIssuesBadge(ctx *context_module.Context) { + getIssueBadge(ctx, "", ctx.Repo.Repository.NumIssues) +} + +func GetOpenPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "open", ctx.Repo.Repository.NumOpenPulls) +} + +func GetClosedPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "closed", ctx.Repo.Repository.NumClosedPulls) +} + +func GetTotalPullsBadge(ctx *context_module.Context) { + getPullBadge(ctx, "", ctx.Repo.Repository.NumPulls) +} + +func GetStarsBadge(ctx *context_module.Context) { + redirectToBadge(ctx, "stars", fmt.Sprintf("%d", ctx.Repo.Repository.NumStars), "blue") +} + +func GetLatestReleaseBadge(ctx *context_module.Context) { + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + errorBadge(ctx, "release", "Not found") + return + } + ctx.ServerError("GetLatestReleaseByRepoID", err) + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + redirectToBadge(ctx, "release", release.TagName, "blue") +} diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go new file mode 100644 index 0000000..eea3d4d --- /dev/null +++ b/routers/web/repo/blame.go @@ -0,0 +1,298 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + gotemplate "html/template" + "net/http" + "net/url" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +type blameRow struct { + RowNumber int + Avatar gotemplate.HTML + RepoLink string + PartSha string + PreviousSha string + PreviousShaURL string + IsFirstCommit bool + CommitURL string + CommitMessage string + CommitSince gotemplate.HTML + Code gotemplate.HTML + EscapeStatus *charset.EscapeStatus +} + +// RefBlame render blame page +func RefBlame(ctx *context.Context) { + if ctx.Repo.TreePath == "" { + ctx.NotFound("No file specified", nil) + return + } + + paths := make([]string, 0, 5) + treeNames := strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + blob := entry.Blob() + + ctx.Data["PageIsViewCode"] = true + ctx.Data["IsBlame"] = true + + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + ctx.Data["Paths"] = paths + ctx.Data["TreeNames"] = treeNames + + ctx.Data["FileSize"] = blob.Size() + ctx.Data["FileName"] = blob.Name() + + // Do not display a blame view if the size of the file is + // larger than what is configured as the maximum. + if blob.Size() >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + ctx.HTML(http.StatusOK, tplRepoHome) + return + } + + ctx.Data["NumLinesSet"] = true + ctx.Data["NumLines"], err = blob.GetBlobLineCount() + if err != nil { + ctx.ServerError("GetBlobLineCount", err) + return + } + + result, err := performBlame(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormBool("bypass-blame-ignore")) + if err != nil { + ctx.ServerError("performBlame", err) + return + } + + ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs + ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile + + commitNames := processBlameParts(ctx, result.Parts) + if ctx.Written() { + return + } + + renderBlame(ctx, result.Parts, commitNames) + + ctx.HTML(http.StatusOK, tplRepoHome) +} + +type blameResult struct { + Parts []*git.BlamePart + UsesIgnoreRevs bool + FaultyIgnoreRevsFile bool +} + +func performBlame(ctx *context.Context, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { + repoPath := ctx.Repo.Repository.RepoPath() + objectFormat := ctx.Repo.GetObjectFormat() + + blameReader, err := git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, bypassBlameIgnore) + if err != nil { + return nil, err + } + + r := &blameResult{} + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + err = blameReader.Close() + if err != nil { + if len(r.Parts) == 0 && r.UsesIgnoreRevs { + // try again without ignored revs + + blameReader, err = git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, true) + if err != nil { + return nil, err + } + + r := &blameResult{ + FaultyIgnoreRevsFile: true, + } + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + return r, blameReader.Close() + } + return nil, err + } + return r, nil +} + +func fillBlameResult(br *git.BlameReader, r *blameResult) error { + r.UsesIgnoreRevs = br.UsesIgnoreRevs() + + previousHelper := make(map[string]*git.BlamePart) + + r.Parts = make([]*git.BlamePart, 0, 5) + for { + blamePart, err := br.NextPart() + if err != nil { + return fmt.Errorf("BlameReader.NextPart failed: %w", err) + } + if blamePart == nil { + break + } + + if prev, ok := previousHelper[blamePart.Sha]; ok { + if blamePart.PreviousSha == "" { + blamePart.PreviousSha = prev.PreviousSha + blamePart.PreviousPath = prev.PreviousPath + } + } else { + previousHelper[blamePart.Sha] = blamePart + } + + r.Parts = append(r.Parts, blamePart) + } + + return nil +} + +func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[string]*user_model.UserCommit { + // store commit data by SHA to look up avatar info etc + commitNames := make(map[string]*user_model.UserCommit) + // and as blameParts can reference the same commits multiple + // times, we cache the lookup work locally + commits := make([]*git.Commit, 0, len(blameParts)) + commitCache := map[string]*git.Commit{} + commitCache[ctx.Repo.Commit.ID.String()] = ctx.Repo.Commit + + for _, part := range blameParts { + sha := part.Sha + if _, ok := commitNames[sha]; ok { + continue + } + + // find the blamePart commit, to look up parent & email address for avatars + commit, ok := commitCache[sha] + var err error + if !ok { + commit, err = ctx.Repo.GitRepo.GetCommit(sha) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return nil + } + commitCache[sha] = commit + } + + commits = append(commits, commit) + } + + // populate commit email addresses to later look up avatars. + for _, c := range user_model.ValidateCommitsWithEmails(ctx, commits) { + commitNames[c.ID.String()] = c + } + + return commitNames +} + +func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { + repoLink := ctx.Repo.RepoLink + + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) + } + + lines := make([]string, 0) + rows := make([]*blameRow, 0) + escapeStatus := &charset.EscapeStatus{} + + var lexerName string + + avatarUtils := templates.NewAvatarUtils(ctx) + i := 0 + commitCnt := 0 + for _, part := range blameParts { + for index, line := range part.Lines { + i++ + lines = append(lines, line) + + br := &blameRow{ + RowNumber: i, + } + + commit := commitNames[part.Sha] + if index == 0 { + // Count commit number + commitCnt++ + + // User avatar image + commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Locale) + + var avatar string + if commit.User != nil { + avatar = string(avatarUtils.Avatar(commit.User, 18)) + } else { + avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "tw-mr-2")) + } + + br.Avatar = gotemplate.HTML(avatar) + br.RepoLink = repoLink + br.PartSha = part.Sha + br.PreviousSha = part.PreviousSha + br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) + br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) + br.CommitMessage = commit.CommitMessage + br.CommitSince = commitSince + } + + if i != len(lines)-1 { + line += "\n" + } + fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) + line, lexerNameForLine := highlight.Code(fileName, language, line) + + // set lexer name to the first detected lexer. this is certainly suboptimal and + // we should instead highlight the whole file at once + if lexerName == "" { + lexerName = lexerNameForLine + } + + br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale, charset.FileviewContext) + rows = append(rows, br) + escapeStatus = escapeStatus.Or(br.EscapeStatus) + } + } + + ctx.Data["EscapeStatus"] = escapeStatus + ctx.Data["BlameRows"] = rows + ctx.Data["CommitCnt"] = commitCnt + ctx.Data["LexerName"] = lexerName +} diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go new file mode 100644 index 0000000..4897a5f --- /dev/null +++ b/routers/web/repo/branch.go @@ -0,0 +1,262 @@ +// Copyright 2014 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" + "net/url" + "strings" + + "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/base" + "code.gitea.io/gitea/modules/git" + "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" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplBranch base.TplName = "repo/branch/list" +) + +// Branches render repository branch page +func Branches(ctx *context.Context) { + ctx.Data["Title"] = "Branches" + ctx.Data["IsRepoToolbarBranches"] = true + ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx) + ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode) + ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror + ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) || + (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) + ctx.Data["PageIsViewCode"] = true + ctx.Data["PageIsBranches"] = true + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + pageSize := setting.Git.BranchesRangeSize + + kw := ctx.FormString("q") + + defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize) + if err != nil { + ctx.ServerError("LoadBranches", err) + return + } + + commitIDs := []string{defaultBranch.DBBranch.CommitID} + for _, branch := range branches { + commitIDs = append(commitIDs, branch.DBBranch.CommitID) + } + + commitStatuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(ctx, ctx.Repo.Repository.ID, commitIDs) + if err != nil { + ctx.ServerError("LoadBranches", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + commitStatus := make(map[string]*git_model.CommitStatus) + for commitID, cs := range commitStatuses { + commitStatus[commitID] = git_model.CalcCommitStatus(cs) + } + + ctx.Data["Keyword"] = kw + ctx.Data["Branches"] = branches + ctx.Data["CommitStatus"] = commitStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["DefaultBranchBranch"] = defaultBranch + pager := context.NewPagination(int(branchesCount), pageSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplBranch) +} + +// DeleteBranchPost responses for delete merged branch +func DeleteBranchPost(ctx *context.Context) { + defer redirect(ctx) + branchName := ctx.FormString("name") + + if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil { + switch { + case git.IsErrBranchNotExist(err): + log.Debug("DeleteBranch: Can't delete non existing branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + case errors.Is(err, repo_service.ErrBranchIsDefault): + log.Debug("DeleteBranch: Can't delete default branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.default_deletion_failed", branchName)) + case errors.Is(err, git_model.ErrBranchIsProtected): + log.Debug("DeleteBranch: Can't delete protected branch '%s'", branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.protected_deletion_failed", branchName)) + default: + log.Error("DeleteBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName)) + } + + return + } + + ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", branchName)) +} + +// RestoreBranchPost responses for delete merged branch +func RestoreBranchPost(ctx *context.Context) { + defer redirect(ctx) + + branchID := ctx.FormInt64("branch_id") + branchName := ctx.FormString("name") + + deletedBranch, err := git_model.GetDeletedBranchByID(ctx, ctx.Repo.Repository.ID, branchID) + if err != nil { + log.Error("GetDeletedBranchByID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName)) + return + } else if deletedBranch == nil { + log.Debug("RestoreBranch: Can't restore branch[%d] '%s', as it does not exist", branchID, branchName) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName)) + return + } + + if err := git.Push(ctx, ctx.Repo.Repository.RepoPath(), git.PushOptions{ + Remote: ctx.Repo.Repository.RepoPath(), + Branch: fmt.Sprintf("%s:%s%s", deletedBranch.CommitID, git.BranchPrefix, deletedBranch.Name), + Env: repo_module.PushingEnvironment(ctx.Doer, ctx.Repo.Repository), + }); err != nil { + if strings.Contains(err.Error(), "already exists") { + log.Debug("RestoreBranch: Can't restore branch '%s', since one with same name already exist", deletedBranch.Name) + ctx.Flash.Error(ctx.Tr("repo.branch.already_exists", deletedBranch.Name)) + return + } + log.Error("RestoreBranch: CreateBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name)) + return + } + + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) + + // Don't return error below this + if err := repo_service.PushUpdate( + &repo_module.PushUpdateOptions{ + RefFullName: git.RefNameFromBranch(deletedBranch.Name), + OldCommitID: objectFormat.EmptyObjectID().String(), + NewCommitID: deletedBranch.CommitID, + PusherID: ctx.Doer.ID, + PusherName: ctx.Doer.Name, + RepoUserName: ctx.Repo.Owner.Name, + RepoName: ctx.Repo.Repository.Name, + }); err != nil { + log.Error("RestoreBranch: Update: %v", err) + } + + ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name)) +} + +func redirect(ctx *context.Context) { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/branches?page=" + url.QueryEscape(ctx.FormString("page"))) +} + +// CreateBranch creates new branch in repository +func CreateBranch(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewBranchForm) + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("CreateBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + var err error + + if form.CreateTag { + target := ctx.Repo.CommitID + if ctx.Repo.IsViewBranch { + target = ctx.Repo.BranchName + } + err = release_service.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, target, form.NewBranchName, "") + } else if ctx.Repo.IsViewBranch { + err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.BranchName, form.NewBranchName) + } else { + err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.CommitID, form.NewBranchName) + } + if err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if models.IsErrTagAlreadyExists(err) { + e := err.(models.ErrTagAlreadyExists) + ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git_model.IsErrBranchNameConflict(err) { + e := err.(git_model.ErrBranchNameConflict) + ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git.IsErrPushRejected(err) { + e := err.(*git.ErrPushRejected) + if len(e.Message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.editor.push_rejected_no_message")) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(e.Message), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + } + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + ctx.ServerError("CreateNewBranch", err) + return + } + + if form.CreateTag { + ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.NewBranchName)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(form.NewBranchName) + "/" + util.PathEscapeSegments(form.CurrentPath)) +} diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go new file mode 100644 index 0000000..90dae70 --- /dev/null +++ b/routers/web/repo/cherry_pick.go @@ -0,0 +1,192 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "errors" + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +var tplCherryPick base.TplName = "repo/editor/cherry_pick" + +// CherryPick handles cherrypick GETs +func CherryPick(ctx *context.Context) { + ctx.Data["SHA"] = ctx.Params(":sha") + cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(ctx.Params(":sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Missing Commit", err) + return + } + ctx.ServerError("GetCommit", err) + return + } + + if ctx.FormString("cherry-pick-type") == "revert" { + ctx.Data["CherryPickType"] = "revert" + ctx.Data["commit_summary"] = "revert " + ctx.Params(":sha") + ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) + ctx.Data["commit_summary"] = splits[0] + ctx.Data["commit_message"] = splits[1] + } + + canCommit := renderCommitRights(ctx) + ctx.Data["TreePath"] = "" + + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplCherryPick) +} + +// CherryPickPost handles cherrypick POSTs +func CherryPickPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CherryPickForm) + + sha := ctx.Params(":sha") + ctx.Data["SHA"] = sha + if form.Revert { + ctx.Data["CherryPickType"] = "revert" + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + } + + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + if ctx.HasError() { + ctx.HTML(200, tplCherryPick) + return + } + + // Cannot commit to a an existing branch if user doesn't have rights + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplCherryPick, &form) + return + } + + message := strings.TrimSpace(form.CommitSummary) + if message == "" { + if form.Revert { + message = ctx.Locale.TrString("repo.commit.revert-header", sha) + } else { + message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha) + } + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplCherryPick, &form) + if ctx.Written() { + return + } + + opts := &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Author: gitIdentity, + Committer: gitIdentity, + } + + // First lets try the simple plain read-tree -m approach + opts.Content = sha + if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, form.Revert, opts); err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + // Drop through to the apply technique + + buf := &bytes.Buffer{} + if form.Revert { + if err := git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } else { + if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, git.RawDiffType("patch"), buf); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } + } + + opts.Content = buf.String() + ctx.Data["FileContent"] = opts.Content + + if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName)) + } +} diff --git a/routers/web/repo/code_frequency.go b/routers/web/repo/code_frequency.go new file mode 100644 index 0000000..c76f492 --- /dev/null +++ b/routers/web/repo/code_frequency.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplCodeFrequency base.TplName = "repo/activity" +) + +// CodeFrequency renders the page to show repository code frequency +func CodeFrequency(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency") + + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsCodeFrequency"] = true + ctx.PageData["repoLink"] = ctx.Repo.RepoLink + + ctx.HTML(http.StatusOK, tplCodeFrequency) +} + +// CodeFrequencyData returns JSON of code frequency data +func CodeFrequencyData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetCodeFrequencyData", err) + } else { + ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) + } +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go new file mode 100644 index 0000000..0e5d1f0 --- /dev/null +++ b/routers/web/repo/commit.go @@ -0,0 +1,468 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "html/template" + "net/http" + "path" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + 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/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitgraph" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/gitdiff" + git_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplCommits base.TplName = "repo/commits" + tplGraph base.TplName = "repo/graph" + tplGraphDiv base.TplName = "repo/graph/div" + tplCommitPage base.TplName = "repo/commit_page" +) + +// RefCommits render commits page +func RefCommits(ctx *context.Context) { + switch { + case len(ctx.Repo.TreePath) == 0: + Commits(ctx) + case ctx.Repo.TreePath == "search": + SearchCommits(ctx) + default: + FileHistory(ctx) + } +} + +// Commits render branch's commits +func Commits(ctx *context.Context) { + ctx.Data["PageIsCommits"] = true + if ctx.Repo.Commit == nil { + ctx.NotFound("Commit not found", nil) + return + } + ctx.Data["PageIsViewCode"] = true + + commitsCount, err := ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + pageSize := ctx.FormInt("limit") + if pageSize <= 0 { + pageSize = setting.Git.CommitsRangeSize + } + + // Both `git log branchName` and `git log commitId` work. + commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize, "") + if err != nil { + ctx.ServerError("CommitsByRange", err) + return + } + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + + pager := context.NewPagination(int(commitsCount), pageSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplCommits) +} + +// Graph render commit graph - show commits from all branches. +func Graph(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.commit_graph") + ctx.Data["PageIsCommits"] = true + ctx.Data["PageIsViewCode"] = true + mode := strings.ToLower(ctx.FormTrim("mode")) + if mode != "monochrome" { + mode = "color" + } + ctx.Data["Mode"] = mode + hidePRRefs := ctx.FormBool("hide-pr-refs") + ctx.Data["HidePRRefs"] = hidePRRefs + branches := ctx.FormStrings("branch") + realBranches := make([]string, len(branches)) + copy(realBranches, branches) + for i, branch := range realBranches { + if strings.HasPrefix(branch, "--") { + realBranches[i] = git.BranchPrefix + branch + } + } + ctx.Data["SelectedBranches"] = realBranches + files := ctx.FormStrings("file") + + commitsCount, err := ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + + graphCommitsCount, err := ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files) + if err != nil { + log.Warn("GetCommitGraphsCount error for generate graph exclude prs: %t branches: %s in %-v, Will Ignore branches and try again. Underlying Error: %v", hidePRRefs, branches, ctx.Repo.Repository, err) + realBranches = []string{} + branches = []string{} + graphCommitsCount, err = ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraphsCount", err) + return + } + } + + page := ctx.FormInt("page") + + graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0, hidePRRefs, realBranches, files) + if err != nil { + ctx.ServerError("GetCommitGraph", err) + return + } + + if err := graph.LoadAndProcessCommits(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo); err != nil { + ctx.ServerError("LoadAndProcessCommits", err) + return + } + + ctx.Data["Graph"] = graph + + gitRefs, err := ctx.Repo.GitRepo.GetRefs() + if err != nil { + ctx.ServerError("GitRepo.GetRefs", err) + return + } + + ctx.Data["AllRefs"] = gitRefs + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + + paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) + paginator.AddParam(ctx, "mode", "Mode") + paginator.AddParam(ctx, "hide-pr-refs", "HidePRRefs") + for _, branch := range branches { + paginator.AddParamString("branch", branch) + } + for _, file := range files { + paginator.AddParamString("file", file) + } + ctx.Data["Page"] = paginator + if ctx.FormBool("div-only") { + ctx.HTML(http.StatusOK, tplGraphDiv) + return + } + + ctx.HTML(http.StatusOK, tplGraph) +} + +// SearchCommits render commits filtered by keyword +func SearchCommits(ctx *context.Context) { + ctx.Data["PageIsCommits"] = true + ctx.Data["PageIsViewCode"] = true + + query := ctx.FormTrim("q") + if len(query) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/commits/" + ctx.Repo.BranchNameSubURL()) + return + } + + all := ctx.FormBool("all") + opts := git.NewSearchCommitsOptions(query, all) + commits, err := ctx.Repo.Commit.SearchCommits(opts) + if err != nil { + ctx.ServerError("SearchCommits", err) + return + } + ctx.Data["CommitCount"] = len(commits) + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Keyword"] = query + if all { + ctx.Data["All"] = true + } + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.HTML(http.StatusOK, tplCommits) +} + +// FileHistory show a file's reversions +func FileHistory(ctx *context.Context) { + fileName := ctx.Repo.TreePath + if len(fileName) == 0 { + Commits(ctx) + return + } + + commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefName, fileName) + if err != nil { + ctx.ServerError("FileCommitsCount", err) + return + } else if commitsCount == 0 { + ctx.NotFound("FileCommitsCount", nil) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.RefName, + File: fileName, + Page: page, + }) + if err != nil { + ctx.ServerError("CommitsByFileAndRange", err) + return + } + + if len(commits) == 0 { + ctx.NotFound("CommitsByFileAndRange", nil) + return + } + + oldestCommit := commits[len(commits)-1] + + renamedFiles, err := git.GetCommitFileRenames(ctx, ctx.Repo.GitRepo.Path, oldestCommit.ID.String()) + if err != nil { + ctx.ServerError("GetCommitFileRenames", err) + return + } + + for _, renames := range renamedFiles { + if renames[1] == fileName { + ctx.Data["OldFilename"] = renames[0] + ctx.Data["OldFilenameHistory"] = fmt.Sprintf("%s/commits/commit/%s/%s", ctx.Repo.RepoLink, oldestCommit.ID.String(), renames[0]) + break + } + } + + ctx.Data["Commits"] = processGitCommits(ctx, commits) + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["FileName"] = fileName + ctx.Data["CommitCount"] = commitsCount + + pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplCommits) +} + +func LoadBranchesAndTags(ctx *context.Context) { + response, err := git_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.Params("sha")) + if err == nil { + ctx.JSON(http.StatusOK, response) + return + } + ctx.NotFoundOrServerError(fmt.Sprintf("could not load branches and tags the commit %s belongs to", ctx.Params("sha")), git.IsErrNotExist, err) +} + +// Diff show different from current commit to previous commit +func Diff(ctx *context.Context) { + ctx.Data["PageIsDiff"] = true + + userName := ctx.Repo.Owner.Name + repoName := ctx.Repo.Repository.Name + commitID := ctx.Params(":sha") + var ( + gitRepo *git.Repository + err error + ) + + if ctx.Data["PageIsWiki"] != nil { + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + return + } + defer gitRepo.Close() + } else { + gitRepo = ctx.Repo.GitRepo + } + + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("Repo.GitRepo.GetCommit", err) + } else { + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } + return + } + if len(commitID) != commit.ID.Type().FullLength() { + commitID = commit.ID.String() + } + + fileOnly := ctx.FormBool("file-only") + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if fileOnly && (len(files) == 2 || len(files) == 1) { + maxLines, maxFiles = -1, -1 + } + + diff, err := gitdiff.GetDiff(ctx, gitRepo, &gitdiff.DiffOptions{ + AfterCommitID: commitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + }, files...) + if err != nil { + ctx.NotFound("GetDiff", err) + return + } + + parents := make([]string, commit.ParentCount()) + for i := 0; i < commit.ParentCount(); i++ { + sha, err := commit.ParentID(i) + if err != nil { + ctx.NotFound("repo.Diff", err) + return + } + parents[i] = sha.String() + } + + ctx.Data["CommitID"] = commitID + ctx.Data["AfterCommitID"] = commitID + ctx.Data["Username"] = userName + ctx.Data["Reponame"] = repoName + + var parentCommit *git.Commit + if commit.ParentCount() > 0 { + parentCommit, err = gitRepo.GetCommit(parents[0]) + if err != nil { + ctx.NotFound("GetParentCommit", err) + return + } + } + setCompareContext(ctx, parentCommit, commit, userName, repoName) + ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID) + ctx.Data["Commit"] = commit + ctx.Data["Diff"] = diff + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + if !ctx.Repo.CanRead(unit_model.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, statuses) + } + + ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses) + ctx.Data["CommitStatuses"] = statuses + + verification := asymkey_model.ParseCommitWithSignature(ctx, commit) + ctx.Data["Verification"] = verification + ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + ctx.Data["Parents"] = parents + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) + }, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return + } + + note := &git.Note{} + err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note) + if err == nil { + ctx.Data["NoteCommit"] = note.Commit + ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) + ctx.Data["NoteRendered"], err = markup.RenderCommitMessage(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: path.Join("commit", util.PathEscapeSegments(commitID)), + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) + if err != nil { + ctx.ServerError("RenderCommitMessage", err) + return + } + } + + ctx.HTML(http.StatusOK, tplCommitPage) +} + +// RawDiff dumps diff results of repository in given commit ID to io.Writer +func RawDiff(ctx *context.Context) { + var gitRepo *git.Repository + if ctx.Data["PageIsWiki"] != nil { + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer wikiRepo.Close() + gitRepo = wikiRepo + } else { + gitRepo = ctx.Repo.GitRepo + if gitRepo == nil { + ctx.ServerError("GitRepo not open", fmt.Errorf("no open git repo for '%s'", ctx.Repo.Repository.FullName())) + return + } + } + if err := git.GetRawDiff( + gitRepo, + ctx.Params(":sha"), + git.RawDiffType(ctx.Params(":ext")), + ctx.Resp, + ); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetRawDiff", + errors.New("commit "+ctx.Params(":sha")+" does not exist.")) + return + } + ctx.ServerError("GetRawDiff", err) + return + } +} + +func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) []*git_model.SignCommitWithStatuses { + commits := git_model.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository) + if !ctx.Repo.CanRead(unit_model.TypeActions) { + for _, commit := range commits { + if commit.Status == nil { + continue + } + commit.Status.HideActionsURL(ctx) + git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) + } + } + return commits +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go new file mode 100644 index 0000000..38d6004 --- /dev/null +++ b/routers/web/repo/compare.go @@ -0,0 +1,972 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bufio" + gocontext "context" + "encoding/csv" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models/db" + 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" + 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/charset" + csv_module "code.gitea.io/gitea/modules/csv" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/gitdiff" +) + +const ( + tplCompare base.TplName = "repo/diff/compare" + tplBlobExcerpt base.TplName = "repo/diff/blob_excerpt" + tplDiffBox base.TplName = "repo/diff/box" +) + +// setCompareContext sets context data. +func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner, headName string) { + ctx.Data["BeforeCommit"] = before + ctx.Data["HeadCommit"] = head + + ctx.Data["GetBlobByPathForCommit"] = func(commit *git.Commit, path string) *git.Blob { + if commit == nil { + return nil + } + + blob, err := commit.GetBlobByPath(path) + if err != nil { + return nil + } + return blob + } + + ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType { + st := typesniffer.SniffedType{} + + if blob == nil { + return st + } + + st, err := blob.GuessContentType() + if err != nil { + log.Error("GuessContentType failed: %v", err) + return st + } + return st + } + + setPathsCompareContext(ctx, before, head, headOwner, headName) + setImageCompareContext(ctx) + setCsvCompareContext(ctx) +} + +// SourceCommitURL creates a relative URL for a commit in the given repository +func SourceCommitURL(owner, name string, commit *git.Commit) string { + return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String()) +} + +// RawCommitURL creates a relative URL for the raw commit in the given repository +func RawCommitURL(owner, name string, commit *git.Commit) string { + return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String()) +} + +// setPathsCompareContext sets context data for source and raw paths +func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string) { + ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, head) + ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, head) + if base != nil { + ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, base) + ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, base) + } +} + +// setImageCompareContext sets context data that is required by image compare template +func setImageCompareContext(ctx *context.Context) { + ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool { + return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) + } +} + +// setCsvCompareContext sets context data that is required by the CSV compare template +func setCsvCompareContext(ctx *context.Context) { + ctx.Data["IsCsvFile"] = func(diffFile *gitdiff.DiffFile) bool { + extension := strings.ToLower(filepath.Ext(diffFile.Name)) + return extension == ".csv" || extension == ".tsv" + } + + type CsvDiffResult struct { + Sections []*gitdiff.TableDiffSection + Error string + } + + ctx.Data["CreateCsvDiff"] = func(diffFile *gitdiff.DiffFile, baseBlob, headBlob *git.Blob) CsvDiffResult { + if diffFile == nil { + return CsvDiffResult{nil, ""} + } + + errTooLarge := errors.New(ctx.Locale.TrString("repo.error.csv.too_large")) + + csvReaderFromCommit := func(ctx *markup.RenderContext, blob *git.Blob) (*csv.Reader, io.Closer, error) { + if blob == nil { + // It's ok for blob to be nil (file added or deleted) + return nil, nil, nil + } + + if setting.UI.CSV.MaxFileSize != 0 && setting.UI.CSV.MaxFileSize < blob.Size() { + return nil, nil, errTooLarge + } + + reader, err := blob.DataAsync() + if err != nil { + return nil, nil, err + } + + csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader, charset.ConvertOpts{})) + return csvReader, reader, err + } + + baseReader, baseBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.OldName}, baseBlob) + if baseBlobCloser != nil { + defer baseBlobCloser.Close() + } + if err != nil { + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + log.Error("error whilst creating csv.Reader from file %s in base commit %s in %s: %v", diffFile.Name, baseBlob.ID.String(), ctx.Repo.Repository.Name, err) + return CsvDiffResult{nil, "unable to load file"} + } + + headReader, headBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.Name}, headBlob) + if headBlobCloser != nil { + defer headBlobCloser.Close() + } + if err != nil { + if err == errTooLarge { + return CsvDiffResult{nil, err.Error()} + } + log.Error("error whilst creating csv.Reader from file %s in head commit %s in %s: %v", diffFile.Name, headBlob.ID.String(), ctx.Repo.Repository.Name, err) + return CsvDiffResult{nil, "unable to load file"} + } + + sections, err := gitdiff.CreateCsvDiff(diffFile, baseReader, headReader) + if err != nil { + errMessage, err := csv_module.FormatError(err, ctx.Locale) + if err != nil { + log.Error("CreateCsvDiff FormatError failed: %v", err) + return CsvDiffResult{nil, "unknown csv diff error"} + } + return CsvDiffResult{nil, errMessage} + } + return CsvDiffResult{sections, ""} + } +} + +// ParseCompareInfo parse compare info between two commit for preparing comparing references +func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { + baseRepo := ctx.Repo.Repository + ci := &common.CompareInfo{} + + fileOnly := ctx.FormBool("file-only") + + // Get compared branches information + // A full compare url is of the form: + // + // 1. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headBranch} + // 2. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}:{:headBranch} + // 3. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}/{:headRepoName}:{:headBranch} + // 4. /{:baseOwner}/{:baseRepoName}/compare/{:headBranch} + // 5. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}:{:headBranch} + // 6. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}/{:headRepoName}:{:headBranch} + // + // Here we obtain the infoPath "{:baseBranch}...[{:headOwner}/{:headRepoName}:]{:headBranch}" as ctx.Params("*") + // with the :baseRepo in ctx.Repo. + // + // Note: Generally :headRepoName is not provided here - we are only passed :headOwner. + // + // How do we determine the :headRepo? + // + // 1. If :headOwner is not set then the :headRepo = :baseRepo + // 2. If :headOwner is set - then look for the fork of :baseRepo owned by :headOwner + // 3. But... :baseRepo could be a fork of :headOwner's repo - so check that + // 4. Now, :baseRepo and :headRepos could be forks of the same repo - so check that + // + // format: ...[:] + // base<-head: master...head:feature + // same repo: master...feature + + var ( + isSameRepo bool + infoPath string + err error + ) + + infoPath = ctx.Params("*") + var infos []string + if infoPath == "" { + infos = []string{baseRepo.DefaultBranch, baseRepo.DefaultBranch} + } else { + infos = strings.SplitN(infoPath, "...", 2) + if len(infos) != 2 { + if infos = strings.SplitN(infoPath, "..", 2); len(infos) == 2 { + ci.DirectComparison = true + ctx.Data["PageIsComparePull"] = false + } else { + infos = []string{baseRepo.DefaultBranch, infoPath} + } + } + } + + ctx.Data["BaseName"] = baseRepo.OwnerName + ci.BaseBranch = infos[0] + ctx.Data["BaseBranch"] = ci.BaseBranch + + // If there is no head repository, it means compare between same repository. + headInfos := strings.Split(infos[1], ":") + if len(headInfos) == 1 { + isSameRepo = true + ci.HeadUser = ctx.Repo.Owner + ci.HeadBranch = headInfos[0] + } else if len(headInfos) == 2 { + headInfosSplit := strings.Split(headInfos[0], "/") + if len(headInfosSplit) == 1 { + ci.HeadUser, err = user_model.GetUserByName(ctx, headInfos[0]) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + ci.HeadBranch = headInfos[1] + isSameRepo = ci.HeadUser.ID == ctx.Repo.Owner.ID + if isSameRepo { + ci.HeadRepo = baseRepo + } + } else { + ci.HeadRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, headInfosSplit[0], headInfosSplit[1]) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByOwnerAndName", nil) + } else { + ctx.ServerError("GetRepositoryByOwnerAndName", err) + } + return nil + } + if err := ci.HeadRepo.LoadOwner(ctx); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + ci.HeadBranch = headInfos[1] + ci.HeadUser = ci.HeadRepo.Owner + isSameRepo = ci.HeadRepo.ID == ctx.Repo.Repository.ID + } + } else { + ctx.NotFound("CompareAndPullRequest", nil) + return nil + } + ctx.Data["HeadUser"] = ci.HeadUser + ctx.Data["HeadBranch"] = ci.HeadBranch + ctx.Repo.PullRequest.SameRepo = isSameRepo + + // Check if base branch is valid. + baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(ci.BaseBranch) + baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(ci.BaseBranch) + baseIsTag := ctx.Repo.GitRepo.IsTagExist(ci.BaseBranch) + + if !baseIsCommit && !baseIsBranch && !baseIsTag { + // Check if baseBranch is short sha commit hash + if baseCommit, _ := ctx.Repo.GitRepo.GetCommit(ci.BaseBranch); baseCommit != nil { + ci.BaseBranch = baseCommit.ID.String() + ctx.Data["BaseBranch"] = ci.BaseBranch + baseIsCommit = true + } else if ci.BaseBranch == ctx.Repo.GetObjectFormat().EmptyObjectID().String() { + if isSameRepo { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ci.HeadBranch)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ci.HeadRepo.FullName()) + ":" + util.PathEscapeSegments(ci.HeadBranch)) + } + return nil + } else { + ctx.NotFound("IsRefExist", nil) + return nil + } + } + ctx.Data["BaseIsCommit"] = baseIsCommit + ctx.Data["BaseIsBranch"] = baseIsBranch + ctx.Data["BaseIsTag"] = baseIsTag + ctx.Data["IsPull"] = true + + // Now we have the repository that represents the base + + // The current base and head repositories and branches may not + // actually be the intended branches that the user wants to + // create a pull-request from - but also determining the head + // repo is difficult. + + // We will want therefore to offer a few repositories to set as + // our base and head + + // 1. First if the baseRepo is a fork get the "RootRepo" it was + // forked from + var rootRepo *repo_model.Repository + if baseRepo.IsFork { + err = baseRepo.GetBaseRepo(ctx) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + ctx.ServerError("Unable to find root repo", err) + return nil + } + } else { + rootRepo = baseRepo.BaseRepo + } + } + + // 2. Now if the current user is not the owner of the baseRepo, + // check if they have a fork of the base repo and offer that as + // "OwnForkRepo" + var ownForkRepo *repo_model.Repository + if ctx.Doer != nil && baseRepo.OwnerID != ctx.Doer.ID { + repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, baseRepo.ID) + if repo != nil { + ownForkRepo = repo + ctx.Data["OwnForkRepo"] = ownForkRepo + } + } + + has := ci.HeadRepo != nil + // 3. If the base is a forked from "RootRepo" and the owner of + // the "RootRepo" is the :headUser - set headRepo to that + if !has && rootRepo != nil && rootRepo.OwnerID == ci.HeadUser.ID { + ci.HeadRepo = rootRepo + has = true + } + + // 4. If the ctx.Doer has their own fork of the baseRepo and the headUser is the ctx.Doer + // set the headRepo to the ownFork + if !has && ownForkRepo != nil && ownForkRepo.OwnerID == ci.HeadUser.ID { + ci.HeadRepo = ownForkRepo + has = true + } + + // 5. If the headOwner has a fork of the baseRepo - use that + if !has { + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ID) + has = ci.HeadRepo != nil + } + + // 6. If the baseRepo is a fork and the headUser has a fork of that use that + if !has && baseRepo.IsFork { + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ForkID) + has = ci.HeadRepo != nil + } + + // 7. Otherwise if we're not the same repo and haven't found a repo give up + if !isSameRepo && !has { + ctx.Data["PageIsComparePull"] = false + } + + // 8. Finally open the git repo + if isSameRepo { + ci.HeadRepo = ctx.Repo.Repository + ci.HeadGitRepo = ctx.Repo.GitRepo + } else if has { + ci.HeadGitRepo, err = gitrepo.OpenRepository(ctx, ci.HeadRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer ci.HeadGitRepo.Close() + } else { + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + ctx.Data["HeadRepo"] = ci.HeadRepo + ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository + + // Now we need to assert that the ctx.Doer has permission to read + // the baseRepo's code and pulls + // (NOT headRepo's) + permBase, err := access_model.GetUserRepoPermission(ctx, baseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + if !permBase.CanRead(unit.TypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in baseRepo has Permissions: %-+v", + ctx.Doer, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + // If we're not merging from the same repo: + if !isSameRepo { + // Assert ctx.Doer has permission to read headRepo's codes + permHead, err := access_model.GetUserRepoPermission(ctx, ci.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return 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, + ci.HeadRepo, + permHead) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode) + } + + // If we have a rootRepo and it's different from: + // 1. the computed base + // 2. the computed head + // then get the branches of it + if rootRepo != nil && + rootRepo.ID != ci.HeadRepo.ID && + rootRepo.ID != baseRepo.ID { + canRead := access_model.CheckRepoUnitUser(ctx, rootRepo, ctx.Doer, unit.TypeCode) + if canRead { + ctx.Data["RootRepo"] = rootRepo + if !fileOnly { + branches, tags, err := getBranchesAndTagsForRepo(ctx, rootRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil + } + + ctx.Data["RootRepoBranches"] = branches + ctx.Data["RootRepoTags"] = tags + } + } + } + + // If we have a ownForkRepo and it's different from: + // 1. The computed base + // 2. The computed head + // 3. The rootRepo (if we have one) + // then get the branches from it. + if ownForkRepo != nil && + ownForkRepo.ID != ci.HeadRepo.ID && + ownForkRepo.ID != baseRepo.ID && + (rootRepo == nil || ownForkRepo.ID != rootRepo.ID) { + canRead := access_model.CheckRepoUnitUser(ctx, ownForkRepo, ctx.Doer, unit.TypeCode) + if canRead { + ctx.Data["OwnForkRepo"] = ownForkRepo + if !fileOnly { + branches, tags, err := getBranchesAndTagsForRepo(ctx, ownForkRepo) + if err != nil { + ctx.ServerError("GetBranchesForRepo", err) + return nil + } + ctx.Data["OwnForkRepoBranches"] = branches + ctx.Data["OwnForkRepoTags"] = tags + } + } + } + + // Check if head branch is valid. + headIsCommit := ci.HeadGitRepo.IsCommitExist(ci.HeadBranch) + headIsBranch := ci.HeadGitRepo.IsBranchExist(ci.HeadBranch) + headIsTag := ci.HeadGitRepo.IsTagExist(ci.HeadBranch) + if !headIsCommit && !headIsBranch && !headIsTag { + // Check if headBranch is short sha commit hash + if headCommit, _ := ci.HeadGitRepo.GetCommit(ci.HeadBranch); headCommit != nil { + ci.HeadBranch = headCommit.ID.String() + ctx.Data["HeadBranch"] = ci.HeadBranch + headIsCommit = true + } else { + ctx.NotFound("IsRefExist", nil) + return nil + } + } + ctx.Data["HeadIsCommit"] = headIsCommit + ctx.Data["HeadIsBranch"] = headIsBranch + ctx.Data["HeadIsTag"] = headIsTag + + // Treat as pull request if both references are branches + if ctx.Data["PageIsComparePull"] == nil { + ctx.Data["PageIsComparePull"] = headIsBranch && baseIsBranch + } + + if ctx.Data["PageIsComparePull"] == true && !permBase.CanReadIssuesOrPulls(true) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot create/read pull requests in Repo: %-v\nUser in baseRepo has Permissions: %-+v", + ctx.Doer, + baseRepo, + permBase) + } + ctx.NotFound("ParseCompareInfo", nil) + return nil + } + + baseBranchRef := ci.BaseBranch + if baseIsBranch { + baseBranchRef = git.BranchPrefix + ci.BaseBranch + } else if baseIsTag { + baseBranchRef = git.TagPrefix + ci.BaseBranch + } + headBranchRef := ci.HeadBranch + if headIsBranch { + headBranchRef = git.BranchPrefix + ci.HeadBranch + } else if headIsTag { + headBranchRef = git.TagPrefix + ci.HeadBranch + } + + ci.CompareInfo, err = ci.HeadGitRepo.GetCompareInfo(baseRepo.RepoPath(), baseBranchRef, headBranchRef, ci.DirectComparison, fileOnly) + if err != nil { + ctx.ServerError("GetCompareInfo", err) + return nil + } + if ci.DirectComparison { + ctx.Data["BeforeCommitID"] = ci.CompareInfo.BaseCommitID + } else { + ctx.Data["BeforeCommitID"] = ci.CompareInfo.MergeBase + } + + return ci +} + +// PrepareCompareDiff renders compare diff page +func PrepareCompareDiff( + ctx *context.Context, + ci *common.CompareInfo, + whitespaceBehavior git.TrustedCmdArgs, +) bool { + var ( + repo = ctx.Repo.Repository + err error + title string + ) + + // Get diff information. + ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link() + + headCommitID := ci.CompareInfo.HeadCommitID + + ctx.Data["AfterCommitID"] = headCommitID + + if (headCommitID == ci.CompareInfo.MergeBase && !ci.DirectComparison) || + headCommitID == ci.CompareInfo.BaseCommitID { + ctx.Data["IsNothingToCompare"] = true + if unit, err := repo.GetUnit(ctx, unit.TypePullRequests); err == nil { + config := unit.PullRequestsConfig() + + if !config.AutodetectManualMerge { + allowEmptyPr := !(ci.BaseBranch == ci.HeadBranch && ctx.Repo.Repository.Name == ci.HeadRepo.Name) + ctx.Data["AllowEmptyPr"] = allowEmptyPr + + return !allowEmptyPr + } + + ctx.Data["AllowEmptyPr"] = false + } + return true + } + + beforeCommitID := ci.CompareInfo.MergeBase + if ci.DirectComparison { + beforeCommitID = ci.CompareInfo.BaseCommitID + } + + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if len(files) == 2 || len(files) == 1 { + maxLines, maxFiles = -1, -1 + } + + diff, err := gitdiff.GetDiff(ctx, ci.HeadGitRepo, + &gitdiff.DiffOptions{ + BeforeCommitID: beforeCommitID, + AfterCommitID: headCommitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: whitespaceBehavior, + DirectComparison: ci.DirectComparison, + }, ctx.FormStrings("files")...) + if err != nil { + ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) + return false + } + ctx.Data["Diff"] = diff + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + baseGitRepo := ctx.Repo.GitRepo + + beforeCommit, err := baseGitRepo.GetCommit(beforeCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return false + } + + commits := processGitCommits(ctx, ci.CompareInfo.Commits) + ctx.Data["Commits"] = commits + ctx.Data["CommitCount"] = len(commits) + + if len(commits) == 1 { + c := commits[0] + title = strings.TrimSpace(c.UserCommit.Summary()) + + body := strings.Split(strings.TrimSpace(c.UserCommit.Message()), "\n") + if len(body) > 1 { + ctx.Data["content"] = strings.Join(body[1:], "\n") + } + } else { + title = ci.HeadBranch + } + if len(title) > 255 { + var trailer string + title, trailer = util.SplitStringAtByteN(title, 255) + if len(trailer) > 0 { + if ctx.Data["content"] != nil { + ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"]) + } else { + ctx.Data["content"] = trailer + "\n" + } + } + } + + ctx.Data["title"] = title + ctx.Data["Username"] = ci.HeadUser.Name + ctx.Data["Reponame"] = ci.HeadRepo.Name + + setCompareContext(ctx, beforeCommit, headCommit, ci.HeadUser.Name, ci.HeadRepo.Name) + + return false +} + +func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) { + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, nil, err + } + defer gitRepo.Close() + + branches, err = git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: repo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), + }) + if err != nil { + return nil, nil, err + } + tags, err = gitRepo.GetTags(0, 0) + if err != nil { + return nil, nil, err + } + return branches, tags, nil +} + +// CompareDiff show different from one commit to another commit +func CompareDiff(ctx *context.Context) { + ci := ParseCompareInfo(ctx) + defer func() { + if ci != nil && ci.HeadGitRepo != nil { + ci.HeadGitRepo.Close() + } + }() + if ctx.Written() { + return + } + + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["DirectComparison"] = ci.DirectComparison + ctx.Data["OtherCompareSeparator"] = ".." + ctx.Data["CompareSeparator"] = "..." + if ci.DirectComparison { + ctx.Data["CompareSeparator"] = ".." + ctx.Data["OtherCompareSeparator"] = "..." + } + + nothingToCompare := PrepareCompareDiff(ctx, ci, + gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + if ctx.Written() { + return + } + + baseTags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = baseTags + + fileOnly := ctx.FormBool("file-only") + if fileOnly { + ctx.HTML(http.StatusOK, tplDiffBox) + return + } + + headBranches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: ci.HeadRepo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetBranches", err) + return + } + ctx.Data["HeadBranches"] = headBranches + + // For compare repo branches + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + headTags, err := repo_model.GetTagNamesByRepoID(ctx, ci.HeadRepo.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["HeadTags"] = headTags + + if ctx.Data["PageIsComparePull"] == true { + pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, ctx.Repo.Repository.ID, ci.HeadBranch, ci.BaseBranch, issues_model.PullRequestFlowGithub) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.ServerError("GetUnmergedPullRequest", err) + return + } + } else { + ctx.Data["HasPullRequest"] = true + if err := pr.LoadIssue(ctx); err != nil { + ctx.ServerError("LoadIssue", err) + return + } + ctx.Data["PullRequest"] = pr + ctx.HTML(http.StatusOK, tplCompareDiff) + return + } + + if !nothingToCompare { + // Setup information for new form. + RetrieveRepoMetas(ctx, ctx.Repo.Repository, true) + if ctx.Written() { + return + } + } + } + beforeCommitID := ctx.Data["BeforeCommitID"].(string) + afterCommitID := ctx.Data["AfterCommitID"].(string) + + separator := "..." + if ci.DirectComparison { + separator = ".." + } + ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID) + + ctx.Data["IsDiffCompare"] = true + _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) + + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } + + if content, ok := ctx.Data["content"].(string); ok && content != "" { + // If a template content is set, prepend the "content". In this case that's only + // applicable if you have one commit to compare and that commit has a message. + // In that case the commit message will be prepend to the template body. + if templateContent, ok := ctx.Data[pullRequestTemplateKey].(string); ok && templateContent != "" { + // Reuse the same key as that's prioritized over the "content" key. + // Add two new lines between the content to ensure there's always at least + // one empty line between them. + ctx.Data[pullRequestTemplateKey] = content + "\n\n" + templateContent + } + + // When using form fields, also add content to field with id "body". + if fields, ok := ctx.Data["Fields"].([]*api.IssueFormField); ok { + for _, field := range fields { + if field.ID == "body" { + if fieldValue, ok := field.Attributes["value"].(string); ok && fieldValue != "" { + field.Attributes["value"] = content + "\n\n" + fieldValue + } else { + field.Attributes["value"] = content + } + } + } + } + } + + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + + if unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests); err == nil { + config := unit.PullRequestsConfig() + ctx.Data["AllowMaintainerEdit"] = config.DefaultAllowMaintainerEdit + } else { + ctx.Data["AllowMaintainerEdit"] = false + } + + ctx.HTML(http.StatusOK, tplCompare) +} + +// ExcerptBlob render blob excerpt contents +func ExcerptBlob(ctx *context.Context) { + commitID := ctx.Params("sha") + lastLeft := ctx.FormInt("last_left") + lastRight := ctx.FormInt("last_right") + idxLeft := ctx.FormInt("left") + idxRight := ctx.FormInt("right") + leftHunkSize := ctx.FormInt("left_hunk_size") + rightHunkSize := ctx.FormInt("right_hunk_size") + anchor := ctx.FormString("anchor") + direction := ctx.FormString("direction") + filePath := ctx.FormString("path") + gitRepo := ctx.Repo.GitRepo + if ctx.FormBool("wiki") { + var err error + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer gitRepo.Close() + } + chunkSize := gitdiff.BlobExcerptChunkSize + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit") + return + } + section := &gitdiff.DiffSection{ + FileName: filePath, + Name: filePath, + } + if direction == "up" && (idxLeft-lastLeft) > chunkSize { + idxLeft -= chunkSize + idxRight -= chunkSize + leftHunkSize += chunkSize + rightHunkSize += chunkSize + section.Lines, err = getExcerptLines(commit, filePath, idxLeft-1, idxRight-1, chunkSize) + } else if direction == "down" && (idxLeft-lastLeft) > chunkSize { + section.Lines, err = getExcerptLines(commit, filePath, lastLeft, lastRight, chunkSize) + lastLeft += chunkSize + lastRight += chunkSize + } else { + offset := -1 + if direction == "down" { + offset = 0 + } + section.Lines, err = getExcerptLines(commit, filePath, lastLeft, lastRight, idxRight-lastRight+offset) + leftHunkSize = 0 + rightHunkSize = 0 + idxLeft = lastLeft + idxRight = lastRight + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "getExcerptLines") + return + } + if idxRight > lastRight { + lineText := " " + if rightHunkSize > 0 || leftHunkSize > 0 { + lineText = fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", idxLeft, leftHunkSize, idxRight, rightHunkSize) + } + lineText = html.EscapeString(lineText) + lineSection := &gitdiff.DiffLine{ + Type: gitdiff.DiffLineSection, + Content: lineText, + SectionInfo: &gitdiff.DiffLineSectionInfo{ + Path: filePath, + LastLeftIdx: lastLeft, + LastRightIdx: lastRight, + LeftIdx: idxLeft, + RightIdx: idxRight, + LeftHunkSize: leftHunkSize, + RightHunkSize: rightHunkSize, + }, + } + if direction == "up" { + section.Lines = append([]*gitdiff.DiffLine{lineSection}, section.Lines...) + } else if direction == "down" { + section.Lines = append(section.Lines, lineSection) + } + } + ctx.Data["section"] = section + ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath) + ctx.Data["AfterCommitID"] = commitID + ctx.Data["Anchor"] = anchor + ctx.HTML(http.StatusOK, tplBlobExcerpt) +} + +func getExcerptLines(commit *git.Commit, filePath string, idxLeft, idxRight, chunkSize int) ([]*gitdiff.DiffLine, error) { + blob, err := commit.Tree.GetBlobByPath(filePath) + if err != nil { + return nil, err + } + reader, err := blob.DataAsync() + if err != nil { + return nil, err + } + defer reader.Close() + scanner := bufio.NewScanner(reader) + var diffLines []*gitdiff.DiffLine + for line := 0; line < idxRight+chunkSize; line++ { + if ok := scanner.Scan(); !ok { + break + } + if line < idxRight { + continue + } + lineText := scanner.Text() + diffLine := &gitdiff.DiffLine{ + LeftIdx: idxLeft + (line - idxRight) + 1, + RightIdx: line + 1, + Type: gitdiff.DiffLinePlain, + Content: " " + lineText, + } + diffLines = append(diffLines, diffLine) + } + if err = scanner.Err(); err != nil { + return nil, fmt.Errorf("getExcerptLines scan: %w", err) + } + return diffLines, nil +} diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go new file mode 100644 index 0000000..762fbf9 --- /dev/null +++ b/routers/web/repo/contributors.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplContributors base.TplName = "repo/activity" +) + +// Contributors render the page to show repository contributors graph +func Contributors(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.contributors") + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsContributors"] = true + ctx.HTML(http.StatusOK, tplContributors) +} + +// ContributorsData renders JSON of contributors along with their weekly commit statistics +func ContributorsData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetContributorStats", err) + } else { + ctx.JSON(http.StatusOK, contributorStats) + } +} diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go new file mode 100644 index 0000000..c4a8bae --- /dev/null +++ b/routers/web/repo/download.go @@ -0,0 +1,170 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "path" + "time" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/git" + "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" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" +) + +// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary +func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error { + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + return nil + } + + dataRc, err := blob.DataAsync() + if err != nil { + return err + } + closed := false + defer func() { + if closed { + return + } + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + }() + + pointer, _ := lfs.ReadPointer(dataRc) + if pointer.IsValid() { + meta, _ := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) + if meta == nil { + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) + } + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { + return nil + } + + 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 nil + } + } + + lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) + if err != nil { + return err + } + defer func() { + if err = lfsDataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + }() + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) + return nil + } + if err = dataRc.Close(); err != nil { + log.Error("ServeBlobOrLFS: Close: %v", err) + } + closed = true + + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) +} + +func getBlobForEntry(ctx *context.Context) (blob *git.Blob, lastModified *time.Time) { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetTreeEntryByPath", err) + } else { + ctx.ServerError("GetTreeEntryByPath", err) + } + return nil, nil + } + + if entry.IsDir() || entry.IsSubModule() { + ctx.NotFound("getBlobForEntry", nil) + return nil, nil + } + + info, _, err := git.Entries([]*git.TreeEntry{entry}).GetCommitsInfo(ctx, ctx.Repo.Commit, path.Dir("/" + ctx.Repo.TreePath)[1:]) + if err != nil { + ctx.ServerError("GetCommitsInfo", err) + return nil, nil + } + + if len(info) == 1 { + // Not Modified + lastModified = &info[0].Commit.Committer.When + } + blob = entry.Blob() + + return blob, lastModified +} + +// SingleDownload download a file by repos path +func SingleDownload(ctx *context.Context) { + blob, lastModified := getBlobForEntry(ctx) + if blob == nil { + return + } + + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary +func SingleDownloadOrLFS(ctx *context.Context) { + blob, lastModified := getBlobForEntry(ctx) + if blob == nil { + return + } + + if err := ServeBlobOrLFS(ctx, blob, lastModified); err != nil { + ctx.ServerError("ServeBlobOrLFS", err) + } +} + +// DownloadByID download a file by sha1 ID +func DownloadByID(ctx *context.Context) { + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.Params("sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlob", nil) + } else { + ctx.ServerError("GetBlob", err) + } + return + } + if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, nil); err != nil { + ctx.ServerError("ServeBlob", err) + } +} + +// DownloadByIDOrLFS download a file by sha1 ID taking account of LFS +func DownloadByIDOrLFS(ctx *context.Context) { + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.Params("sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlob", nil) + } else { + ctx.ServerError("GetBlob", err) + } + return + } + if err = ServeBlobOrLFS(ctx, blob, nil); err != nil { + ctx.ServerError("ServeBlob", err) + } +} diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go new file mode 100644 index 0000000..00c3d88 --- /dev/null +++ b/routers/web/repo/editor.go @@ -0,0 +1,962 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "io" + "net/http" + "path" + "strings" + + "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" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" + files_service "code.gitea.io/gitea/services/repository/files" +) + +const ( + tplEditFile base.TplName = "repo/editor/edit" + tplEditDiffPreview base.TplName = "repo/editor/diff_preview" + tplDeleteFile base.TplName = "repo/editor/delete" + tplUploadFile base.TplName = "repo/editor/upload" + + frmCommitChoiceDirect string = "direct" + frmCommitChoiceNewBranch string = "commit-to-new-branch" +) + +func canCreateBasePullRequest(ctx *context.Context) bool { + baseRepo := ctx.Repo.Repository.BaseRepo + return baseRepo != nil && baseRepo.UnitEnabled(ctx, unit.TypePullRequests) +} + +func renderCommitRights(ctx *context.Context) bool { + canCommitToBranch, err := ctx.Repo.CanCommitToBranch(ctx, ctx.Doer) + if err != nil { + log.Error("CanCommitToBranch: %v", err) + } + ctx.Data["CanCommitToBranch"] = canCommitToBranch + ctx.Data["CanCreatePullRequest"] = ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) || canCreateBasePullRequest(ctx) + + return canCommitToBranch.CanCommitToBranch +} + +// redirectForCommitChoice redirects after committing the edit to a branch +func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, treePath string) { + if commitChoice == frmCommitChoiceNewBranch { + // Redirect to a pull request when possible + redirectToPullRequest := false + repo := ctx.Repo.Repository + baseBranch := ctx.Repo.BranchName + headBranch := newBranchName + if repo.UnitEnabled(ctx, unit.TypePullRequests) { + redirectToPullRequest = true + } else if canCreateBasePullRequest(ctx) { + redirectToPullRequest = true + baseBranch = repo.BaseRepo.DefaultBranch + headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch + repo = repo.BaseRepo + } + + if redirectToPullRequest { + ctx.Redirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) + return + } + } + + // Redirect to viewing file or folder + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(newBranchName) + "/" + util.PathEscapeSegments(treePath)) +} + +// getParentTreeFields returns list of parent tree names and corresponding tree paths +// based on given tree path. +func getParentTreeFields(treePath string) (treeNames, treePaths []string) { + if len(treePath) == 0 { + return treeNames, treePaths + } + + treeNames = strings.Split(treePath, "/") + treePaths = make([]string, len(treeNames)) + for i := range treeNames { + treePaths[i] = strings.Join(treeNames[:i+1], "/") + } + return treeNames, treePaths +} + +// getSelectableEmailAddresses returns which emails can be used by the user as +// email for a Git commiter. +func getSelectableEmailAddresses(ctx *context.Context) ([]*user_model.ActivatedEmailAddress, error) { + // Retrieve emails that the user could use for commiter identity. + commitEmails, err := user_model.GetActivatedEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + return nil, fmt.Errorf("GetActivatedEmailAddresses: %w", err) + } + + // Allow for the placeholder mail to be used. Use -1 as ID to identify + // this entry to be the placerholder mail of the user. + placeholderMail := &user_model.ActivatedEmailAddress{ID: -1, Email: ctx.Doer.GetPlaceholderEmail()} + if ctx.Doer.KeepEmailPrivate { + commitEmails = append([]*user_model.ActivatedEmailAddress{placeholderMail}, commitEmails...) + } else { + commitEmails = append(commitEmails, placeholderMail) + } + + return commitEmails, nil +} + +// CommonEditorData sets common context data that is used by the editor. +func CommonEditorData(ctx *context.Context) { + // Set context for selectable email addresses. + commitEmails, err := getSelectableEmailAddresses(ctx) + if err != nil { + ctx.ServerError("getSelectableEmailAddresses", err) + return + } + ctx.Data["CommitMails"] = commitEmails + ctx.Data["DefaultCommitMail"] = ctx.Doer.GetEmail() +} + +func editFile(ctx *context.Context, isNewFile bool) { + ctx.Data["PageIsEdit"] = true + ctx.Data["IsNewFile"] = isNewFile + canCommit := renderCommitRights(ctx) + + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if treePath != ctx.Repo.TreePath { + if isNewFile { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_new", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + } else { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_edit", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + } + return + } + + // Check if the filename (and additional path) is specified in the querystring + // (filename is a misnomer, but kept for compatibility with GitHub) + filePath, fileName := path.Split(ctx.Req.URL.Query().Get("filename")) + filePath = strings.Trim(filePath, "/") + treeNames, treePaths := getParentTreeFields(path.Join(ctx.Repo.TreePath, filePath)) + + if !isNewFile { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + + // No way to edit a directory online. + if entry.IsDir() { + ctx.NotFound("entry.IsDir", nil) + return + } + + blob := entry.Blob() + if blob.Size() >= setting.UI.MaxDisplayFileSize { + ctx.NotFound("blob.Size", err) + return + } + + dataRc, err := blob.DataAsync() + if err != nil { + ctx.NotFound("blob.Data", err) + return + } + + defer dataRc.Close() + + ctx.Data["FileSize"] = blob.Size() + ctx.Data["FileName"] = blob.Name() + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + // Only some file types are editable online as text. + if !typesniffer.DetectContentType(buf).IsRepresentableAsText() { + ctx.NotFound("typesniffer.IsRepresentableAsText", nil) + return + } + + d, _ := io.ReadAll(dataRc) + + buf = append(buf, d...) + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + log.Error("ToUTF8: %v", err) + ctx.Data["FileContent"] = string(buf) + } else { + ctx.Data["FileContent"] = content + } + } else { + // Append filename from query, or empty string to allow user name the new file. + treeNames = append(treeNames, fileName) + } + + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, treePath) + + ctx.HTML(http.StatusOK, tplEditFile) +} + +// GetEditorConfig returns a editorconfig JSON string for given treePath or "null" +func GetEditorConfig(ctx *context.Context, treePath string) string { + ec, _, err := ctx.Repo.GetEditorconfig() + if err == nil { + def, err := ec.GetDefinitionForFilename(treePath) + if err == nil { + jsonStr, _ := json.Marshal(def) + return string(jsonStr) + } + } + return "null" +} + +// EditFile render edit file page +func EditFile(ctx *context.Context) { + editFile(ctx, false) +} + +// NewFile render create file page +func NewFile(ctx *context.Context) { + editFile(ctx, true) +} + +func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile bool) { + canCommit := renderCommitRights(ctx) + treeNames, treePaths := getParentTreeFields(form.TreePath) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + ctx.Data["PageIsEdit"] = true + ctx.Data["PageHasPosted"] = true + ctx.Data["IsNewFile"] = isNewFile + ctx.Data["TreePath"] = form.TreePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(ctx.Repo.BranchName) + ctx.Data["FileContent"] = form.Content + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, form.TreePath) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplEditFile) + return + } + + // Cannot commit to a an existing branch if user doesn't have rights + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + return + } + + // CommitSummary is optional in the web form, if empty, give it a default message based on add or update + // `message` will be both the summary and message combined + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + if isNewFile { + message = ctx.Locale.TrString("repo.editor.add", form.TreePath) + } else { + message = ctx.Locale.TrString("repo.editor.update", form.TreePath) + } + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + operation := "update" + if isNewFile { + operation = "create" + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplEditFile, form) + if ctx.Written() { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Files: []*files_service.ChangeRepoFile{ + { + Operation: operation, + FromTreePath: ctx.Repo.TreePath, + TreePath: form.TreePath, + ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")), + }, + }, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); err != nil { + // This is where we handle all the errors thrown by files_service.ChangeRepoFiles + if git.IsErrNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) + } else if git_model.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.ErrLFSFileLocked).UserName), tplEditFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplEditFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplEditFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplEditFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplEditFile, &form) + default: + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrRepoFileAlreadyExists(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplEditFile, &form) + } else if git.IsErrBranchNotExist(err) { + // For when a user adds/updates a file to a branch that no longer exists + if branchErr, ok := err.(git.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplEditFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + if branchErr, ok := err.(git_model.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.commit_id_not_matching"), tplEditFile, &form) + } else if git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_out_of_date"), tplEditFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplEditFile, &form) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("editFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplEditFile, &form) + } + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.fail_to_update_file", form.TreePath), + "Summary": ctx.Tr("repo.editor.fail_to_update_file_summary"), + "Details": utils.SanitizeFlashErrorString(err.Error()), + }) + if err != nil { + ctx.ServerError("editFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplEditFile, &form) + } + } + + if ctx.Repo.Repository.IsEmpty { + if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { + _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) +} + +// EditFilePost response for editing file +func EditFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + editFilePost(ctx, *form, false) +} + +// NewFilePost response for creating file +func NewFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + editFilePost(ctx, *form, true) +} + +// DiffPreviewPost render preview diff page +func DiffPreviewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditPreviewDiffForm) + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if len(treePath) == 0 { + ctx.Error(http.StatusInternalServerError, "file name to diff is invalid") + return + } + + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTreeEntryByPath: "+err.Error()) + return + } else if entry.IsDir() { + ctx.Error(http.StatusUnprocessableEntity) + return + } + + diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetDiffPreview: "+err.Error()) + return + } + + if diff.NumFiles == 0 { + ctx.PlainText(http.StatusOK, ctx.Locale.TrString("repo.editor.no_changes_to_show")) + return + } + ctx.Data["File"] = diff.Files[0] + + ctx.HTML(http.StatusOK, tplEditDiffPreview) +} + +// DeleteFile render delete file page +func DeleteFile(ctx *context.Context) { + ctx.Data["PageIsDelete"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treePath := cleanUploadFileName(ctx.Repo.TreePath) + + if treePath != ctx.Repo.TreePath { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_delete", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + return + } + + ctx.Data["TreePath"] = treePath + canCommit := renderCommitRights(ctx) + + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + ctx.Data["last_commit"] = ctx.Repo.CommitID + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + + ctx.HTML(http.StatusOK, tplDeleteFile) +} + +// DeleteFilePost response for deleting file +func DeleteFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.DeleteRepoFileForm) + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + ctx.Data["PageIsDelete"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["TreePath"] = ctx.Repo.TreePath + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplDeleteFile) + return + } + + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplDeleteFile, &form) + return + } + + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + message = ctx.Locale.TrString("repo.editor.delete", ctx.Repo.TreePath) + } + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplDeleteFile, &form) + if ctx.Written() { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: ctx.Repo.TreePath, + }, + }, + Message: message, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); err != nil { + // This is where we handle all the errors thrown by repofiles.DeleteRepoFile + if git.IsErrNotExist(err) || models.IsErrRepoFileDoesNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_deleting_no_longer_exists", ctx.Repo.TreePath), tplDeleteFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", ctx.Repo.TreePath), tplDeleteFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplDeleteFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplDeleteFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplDeleteFile, &form) + default: + ctx.ServerError("DeleteRepoFile", err) + } + } else { + ctx.ServerError("DeleteRepoFile", err) + } + } else if git.IsErrBranchNotExist(err) { + // For when a user deletes a file to a branch that no longer exists + if branchErr, ok := err.(git.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplDeleteFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + if branchErr, ok := err.(git_model.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplDeleteFile, &form) + } else { + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_deleting", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(form.LastCommit)+"..."+util.PathEscapeSegments(ctx.Repo.CommitID)), tplDeleteFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplDeleteFile, &form) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("DeleteFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplDeleteFile, &form) + } + } else { + ctx.ServerError("DeleteRepoFile", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) + treePath := path.Dir(ctx.Repo.TreePath) + if treePath == "." { + treePath = "" // the file deleted was in the root, so we return the user to the root directory + } + if len(treePath) > 0 { + // Need to get the latest commit since it changed + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err == nil && commit != nil { + // We have the comment, now find what directory we can return the user to + // (must have entries) + treePath = GetClosestParentWithFiles(treePath, commit) + } else { + treePath = "" // otherwise return them to the root of the repo + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, treePath) +} + +// UploadFile render upload file page +func UploadFile(ctx *context.Context) { + ctx.Data["PageIsUpload"] = true + upload.AddUploadContext(ctx, "repo") + canCommit := renderCommitRights(ctx) + treePath := cleanUploadFileName(ctx.Repo.TreePath) + if treePath != ctx.Repo.TreePath { + ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_upload", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) + return + } + ctx.Repo.TreePath = treePath + + treeNames, treePaths := getParentTreeFields(ctx.Repo.TreePath) + if len(treeNames) == 0 { + // We must at least have one element for user to input. + treeNames = []string{""} + } + + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + + ctx.HTML(http.StatusOK, tplUploadFile) +} + +// UploadFilePost response for uploading file +func UploadFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UploadRepoFileForm) + ctx.Data["PageIsUpload"] = true + upload.AddUploadContext(ctx, "repo") + canCommit := renderCommitRights(ctx) + + oldBranchName := ctx.Repo.BranchName + branchName := oldBranchName + + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + + form.TreePath = cleanUploadFileName(form.TreePath) + + treeNames, treePaths := getParentTreeFields(form.TreePath) + if len(treeNames) == 0 { + // We must at least have one element for user to input. + treeNames = []string{""} + } + + ctx.Data["TreePath"] = form.TreePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = branchName + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplUploadFile) + return + } + + if oldBranchName != branchName { + if _, err := ctx.Repo.GitRepo.GetBranch(branchName); err == nil { + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplUploadFile, &form) + return + } + } else if !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplUploadFile, &form) + return + } + + if !ctx.Repo.Repository.IsEmpty { + var newTreePath string + for _, part := range treeNames { + newTreePath = path.Join(newTreePath, part) + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(newTreePath) + if err != nil { + if git.IsErrNotExist(err) { + break // Means there is no item with that name, so we're good + } + ctx.ServerError("Repo.Commit.GetTreeEntryByPath", err) + return + } + + // User can only upload files to a directory, the directory name shouldn't be an existing file. + if !entry.IsDir() { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", part), tplUploadFile, &form) + return + } + } + } + + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + dir := form.TreePath + if dir == "" { + dir = "/" + } + message = ctx.Locale.TrString("repo.editor.upload_files_to_dir", dir) + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdentity := getGitIdentity(ctx, form.CommitMailID, tplUploadFile, &form) + if ctx.Written() { + return + } + + if err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ + LastCommitID: ctx.Repo.CommitID, + OldBranch: oldBranchName, + NewBranch: branchName, + TreePath: form.TreePath, + Message: message, + Files: form.Files, + Signoff: form.Signoff, + Author: gitIdentity, + Committer: gitIdentity, + }); err != nil { + if git_model.IsErrLFSFileLocked(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.ErrLFSFileLocked).UserName), tplUploadFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplUploadFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + fileErr := err.(models.ErrFilePathInvalid) + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplUploadFile, &form) + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplUploadFile, &form) + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplUploadFile, &form) + default: + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } else if models.IsErrRepoFileAlreadyExists(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplUploadFile, &form) + } else if git.IsErrBranchNotExist(err) { + branchErr := err.(git.ErrBranchNotExist) + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplUploadFile, &form) + } else if git_model.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplUploadFile, &form) + } else if git.IsErrPushOutOfDate(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(form.NewBranchName)), tplUploadFile, &form) + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplUploadFile, &form) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(errPushRej.Message), + }) + if err != nil { + ctx.ServerError("UploadFilePost.HTMLString", err) + return + } + ctx.RenderWithErr(flashError, tplUploadFile, &form) + } + } else { + // os.ErrNotExist - upload file missing in the intervening time?! + log.Error("Error during upload to repo: %-v to filepath: %s on %s from %s: %v", ctx.Repo.Repository, form.TreePath, oldBranchName, form.NewBranchName, err) + ctx.RenderWithErr(ctx.Tr("repo.editor.unable_to_upload_files", form.TreePath, err), tplUploadFile, &form) + } + return + } + + if ctx.Repo.Repository.IsEmpty { + if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { + _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") + } + } + + redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) +} + +func cleanUploadFileName(name string) string { + // Rebase the filename + name = util.PathJoinRel(name) + // Git disallows any filenames to have a .git directory in them. + for _, part := range strings.Split(name, "/") { + if strings.ToLower(part) == ".git" { + return "" + } + } + return name +} + +// UploadFileToServer upload file to server file dir not git +func UploadFileToServer(ctx *context.Context) { + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + defer file.Close() + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(file, buf) + if n > 0 { + buf = buf[:n] + } + + err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes) + if err != nil { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + + name := cleanUploadFileName(header.Filename) + if len(name) == 0 { + ctx.Error(http.StatusInternalServerError, "Upload file name is invalid") + return + } + + upload, err := repo_model.NewUpload(ctx, name, buf, file) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewUpload: %v", err)) + return + } + + log.Trace("New file uploaded: %s", upload.UUID) + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": upload.UUID, + }) +} + +// RemoveUploadFileFromServer remove file from server file dir +func RemoveUploadFileFromServer(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RemoveUploadFileForm) + if len(form.File) == 0 { + ctx.Status(http.StatusNoContent) + return + } + + if err := repo_model.DeleteUploadByUUID(ctx, form.File); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) + return + } + + log.Trace("Upload file removed: %s", form.File) + ctx.Status(http.StatusNoContent) +} + +// GetUniquePatchBranchName Gets a unique branch name for a new patch branch +// It will be in the form of -patch- where is the first branch of this format +// that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to +// type in the branch name themselves (will be an empty field) +func GetUniquePatchBranchName(ctx *context.Context) string { + prefix := ctx.Doer.LowerName + "-patch-" + for i := 1; i <= 1000; i++ { + branchName := fmt.Sprintf("%s%d", prefix, i) + if _, err := ctx.Repo.GitRepo.GetBranch(branchName); err != nil { + if git.IsErrBranchNotExist(err) { + return branchName + } + log.Error("GetUniquePatchBranchName: %v", err) + return "" + } + } + return "" +} + +// GetClosestParentWithFiles Recursively gets the path of parent in a tree that has files (used when file in a tree is +// deleted). Returns "" for the root if no parents other than the root have files. If the given treePath isn't a +// SubTree or it has no entries, we go up one dir and see if we can return the user to that listing. +func GetClosestParentWithFiles(treePath string, commit *git.Commit) string { + if len(treePath) == 0 || treePath == "." { + return "" + } + // see if the tree has entries + if tree, err := commit.SubTree(treePath); err != nil { + // failed to get tree, going up a dir + return GetClosestParentWithFiles(path.Dir(treePath), commit) + } else if entries, err := tree.ListEntries(); err != nil || len(entries) == 0 { + // no files in this dir, going up a dir + return GetClosestParentWithFiles(path.Dir(treePath), commit) + } + return treePath +} + +// getGitIdentity returns the Git identity that should be used for an Git +// operation, that takes into account an user's specified email. +func getGitIdentity(ctx *context.Context, commitMailID int64, tpl base.TplName, form any) *files_service.IdentityOptions { + gitIdentity := &files_service.IdentityOptions{ + Name: ctx.Doer.Name, + } + + // -1 is defined as placeholder email. + if commitMailID == -1 { + gitIdentity.Email = ctx.Doer.GetPlaceholderEmail() + } else { + // Check if the given email is activated. + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, commitMailID) + if err != nil { + ctx.ServerError("GetEmailAddressByID", err) + return nil + } + + if email == nil || !email.IsActivated { + ctx.Data["Err_CommitMailID"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_mail"), tpl, form) + return nil + } + + gitIdentity.Email = email.Email + } + + return gitIdentity +} diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go new file mode 100644 index 0000000..4d565b5 --- /dev/null +++ b/routers/web/repo/editor_test.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCleanUploadName(t *testing.T) { + unittest.PrepareTestEnv(t) + + kases := map[string]string{ + ".git/refs/master": "", + "/root/abc": "root/abc", + "./../../abc": "abc", + "a/../.git": "", + "a/../../../abc": "abc", + "../../../acd": "acd", + "../../.git/abc": "", + "..\\..\\.git/abc": "..\\..\\.git/abc", + "..\\../.git/abc": "", + "..\\../.git": "", + "abc/../def": "def", + ".drone.yml": ".drone.yml", + ".abc/def/.drone.yml": ".abc/def/.drone.yml", + "..drone.yml.": "..drone.yml.", + "..a.dotty...name...": "..a.dotty...name...", + "..a.dotty../.folder../.name...": "..a.dotty../.folder../.name...", + } + for k, v := range kases { + assert.EqualValues(t, cleanUploadFileName(k), v) + } +} + +func TestGetUniquePatchBranchName(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + expectedBranchName := "user2-patch-1" + branchName := GetUniquePatchBranchName(ctx) + assert.Equal(t, expectedBranchName, branchName) +} + +func TestGetClosestParentWithFiles(t *testing.T) { + unittest.PrepareTestEnv(t) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + branch := repo.DefaultBranch + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + commit, _ := gitRepo.GetBranchCommit(branch) + var expectedTreePath string // Should return the root dir, empty string, since there are no subdirs in this repo + for _, deletedFile := range []string{ + "dir1/dir2/dir3/file.txt", + "file.txt", + } { + treePath := GetClosestParentWithFiles(deletedFile, commit) + assert.Equal(t, expectedTreePath, treePath) + } +} diff --git a/routers/web/repo/find.go b/routers/web/repo/find.go new file mode 100644 index 0000000..9da4237 --- /dev/null +++ b/routers/web/repo/find.go @@ -0,0 +1,24 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplFindFiles base.TplName = "repo/find/files" +) + +// FindFiles render the page to find repository files +func FindFiles(ctx *context.Context) { + path := ctx.Params("*") + ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path) + ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path) + ctx.HTML(http.StatusOK, tplFindFiles) +} diff --git a/routers/web/repo/flags/manage.go b/routers/web/repo/flags/manage.go new file mode 100644 index 0000000..377a5c2 --- /dev/null +++ b/routers/web/repo/flags/manage.go @@ -0,0 +1,49 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplRepoFlags base.TplName = "repo/flags" +) + +func Manage(ctx *context.Context) { + ctx.Data["IsRepoFlagsPage"] = true + ctx.Data["Title"] = ctx.Tr("repo.admin.manage_flags") + + flags := map[string]bool{} + for _, f := range setting.Repository.SettableFlags { + flags[f] = false + } + repoFlags, _ := ctx.Repo.Repository.ListFlags(ctx) + for _, f := range repoFlags { + flags[f.Name] = true + } + + ctx.Data["Flags"] = flags + + ctx.HTML(http.StatusOK, tplRepoFlags) +} + +func ManagePost(ctx *context.Context) { + newFlags := ctx.FormStrings("flags") + + err := ctx.Repo.Repository.ReplaceAllFlags(ctx, newFlags) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.admin.failed_to_replace_flags")) + log.Error("Error replacing repository flags for repo %d: %v", ctx.Repo.Repository.ID, err) + } else { + ctx.Flash.Success(ctx.Tr("repo.admin.flags_replaced")) + } + + ctx.Redirect(ctx.Repo.Repository.HTMLURL() + "/flags") +} diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go new file mode 100644 index 0000000..a082498 --- /dev/null +++ b/routers/web/repo/githttp.go @@ -0,0 +1,599 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "compress/gzip" + gocontext "context" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "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/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/go-chi/cors" +) + +func HTTPGitEnabledHandler(ctx *context.Context) { + if setting.Repository.DisableHTTPGit { + ctx.Resp.WriteHeader(http.StatusForbidden) + _, _ = ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed")) + } +} + +func CorsHandler() func(next http.Handler) http.Handler { + if setting.Repository.AccessControlAllowOrigin != "" { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{setting.Repository.AccessControlAllowOrigin}, + AllowedHeaders: []string{"Content-Type", "Authorization", "User-Agent"}, + }) + } + return func(next http.Handler) http.Handler { + return next + } +} + +// httpBase implementation git smart HTTP protocol +func httpBase(ctx *context.Context) *serviceHandler { + username := ctx.Params(":username") + reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git") + + if ctx.FormString("go-get") == "1" { + context.EarlyResponseForGoGetMeta(ctx) + return nil + } + + var isPull, receivePack bool + service := ctx.FormString("service") + if service == "git-receive-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") { + isPull = false + receivePack = true + } else if service == "git-upload-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") { + isPull = true + } else if service == "git-upload-archive" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { + isPull = true + } else { + isPull = ctx.Req.Method == "GET" + } + + var accessMode perm.AccessMode + if isPull { + accessMode = perm.AccessModeRead + } else { + accessMode = perm.AccessModeWrite + } + + isWiki := false + unitType := unit.TypeCode + + if strings.HasSuffix(reponame, ".wiki") { + isWiki = true + unitType = unit.TypeWiki + reponame = reponame[:len(reponame)-5] + } + + owner := ctx.ContextUser + if !owner.IsOrganization() && !owner.IsActive { + ctx.PlainText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.") + return nil + } + + repoExist := true + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + ctx.ServerError("GetRepositoryByName", err) + return nil + } + + if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { + context.RedirectToRepo(ctx.Base, redirectRepoID) + return nil + } + repoExist = false + } + + // Don't allow pushing if the repo is archived + if repoExist && repo.IsArchived && !isPull { + ctx.PlainText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.") + return nil + } + + // Only public pull don't need auth. + isPublicPull := repoExist && !repo.IsPrivate && isPull + var ( + askAuth = !isPublicPull || setting.Service.RequireSignInView + environ []string + ) + + // don't allow anonymous pulls if organization is not public + if isPublicPull { + if err := repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return nil + } + + askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) + } + + // check access + if askAuth { + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) + ctx.Error(http.StatusUnauthorized) + return nil + } + + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) + if ctx.Written() { + return nil + } + + if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true { + _, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err == nil { + // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented + ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page") + return nil + } else if !auth_model.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("IsErrTwoFactorNotEnrolled", err) + return nil + } + } + + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + ctx.PlainText(http.StatusForbidden, "Your account is disabled.") + return nil + } + + environ = []string{ + repo_module.EnvRepoUsername + "=" + username, + repo_module.EnvRepoName + "=" + reponame, + repo_module.EnvPusherName + "=" + ctx.Doer.Name, + repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), + repo_module.EnvAppURL + "=" + setting.AppURL, + } + + if repoExist { + // Because of special ref "refs/for" .. , need delay write permission check + if git.SupportProcReceive { + accessMode = perm.AccessModeRead + } + + if ctx.Data["IsActionsToken"] == true { + taskID := ctx.Data["ActionsTaskID"].(int64) + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + ctx.ServerError("GetTaskByID", err) + return nil + } + if task.RepoID != repo.ID { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + + if task.IsForkPullRequest { + if accessMode > perm.AccessModeRead { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead)) + } else { + if accessMode > perm.AccessModeWrite { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return nil + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeWrite)) + } + } else { + p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + + if !p.CanAccess(accessMode, unitType) { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + } + + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return nil + } + } + + if !ctx.Doer.KeepEmailPrivate { + environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) + } + + if isWiki { + environ = append(environ, repo_module.EnvRepoIsWiki+"=true") + } else { + environ = append(environ, repo_module.EnvRepoIsWiki+"=false") + } + } + + if !repoExist { + if !receivePack { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + + if isWiki { // you cannot send wiki operation before create the repository + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + + if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for organizations.") + return nil + } + if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for users.") + return nil + } + + // Return dummy payload if GET receive-pack + if ctx.Req.Method == http.MethodGet { + dummyInfoRefs(ctx) + return nil + } + + repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) + if err != nil { + log.Error("pushCreateRepo: %v", err) + ctx.Status(http.StatusNotFound) + return nil + } + } + + if isWiki { + // Ensure the wiki is enabled before we allow access to it + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return nil + } + log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return nil + } + } + + environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + + ctx.Req.URL.Path = strings.ToLower(ctx.Req.URL.Path) // blue: In case some repo name has upper case name + + return &serviceHandler{repo, isWiki, environ} +} + +var ( + infoRefsCache []byte + infoRefsOnce sync.Once +) + +func dummyInfoRefs(ctx *context.Context) { + infoRefsOnce.Do(func() { + tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache") + if err != nil { + log.Error("Failed to create temp dir for git-receive-pack cache: %v", err) + return + } + + defer func() { + if err := util.RemoveAll(tmpDir); err != nil { + log.Error("RemoveAll: %v", err) + } + }() + + if err := git.InitRepository(ctx, tmpDir, true, git.Sha1ObjectFormat.Name()); err != nil { + log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) + return + } + + refs, _, err := git.NewCommand(ctx, "receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Dir: tmpDir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + log.Debug("populating infoRefsCache: \n%s", string(refs)) + infoRefsCache = refs + }) + + ctx.RespHeader().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.RespHeader().Set("Pragma", "no-cache") + ctx.RespHeader().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + ctx.RespHeader().Set("Content-Type", "application/x-git-receive-pack-advertisement") + _, _ = ctx.Write(packetWrite("# service=git-receive-pack\n")) + _, _ = ctx.Write([]byte("0000")) + _, _ = ctx.Write(infoRefsCache) +} + +type serviceHandler struct { + repo *repo_model.Repository + isWiki bool + environ []string +} + +func (h *serviceHandler) getRepoDir() string { + if h.isWiki { + return h.repo.WikiPath() + } + return h.repo.RepoPath() +} + +func setHeaderNoCache(ctx *context.Context) { + ctx.Resp.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.Resp.Header().Set("Pragma", "no-cache") + ctx.Resp.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") +} + +func setHeaderCacheForever(ctx *context.Context) { + now := time.Now().Unix() + expires := now + 31536000 + ctx.Resp.Header().Set("Date", fmt.Sprintf("%d", now)) + ctx.Resp.Header().Set("Expires", fmt.Sprintf("%d", expires)) + ctx.Resp.Header().Set("Cache-Control", "public, max-age=31536000") +} + +func containsParentDirectorySeparator(v string) bool { + if !strings.Contains(v, "..") { + return false + } + for _, ent := range strings.FieldsFunc(v, isSlashRune) { + if ent == ".." { + return true + } + } + return false +} + +func isSlashRune(r rune) bool { return r == '/' || r == '\\' } + +func (h *serviceHandler) sendFile(ctx *context.Context, contentType, file string) { + if containsParentDirectorySeparator(file) { + log.Error("request file path contains invalid path: %v", file) + ctx.Resp.WriteHeader(http.StatusBadRequest) + return + } + reqFile := filepath.Join(h.getRepoDir(), file) + + fi, err := os.Stat(reqFile) + if os.IsNotExist(err) { + ctx.Resp.WriteHeader(http.StatusNotFound) + return + } + + ctx.Resp.Header().Set("Content-Type", contentType) + ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat + ctx.Resp.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) + http.ServeFile(ctx.Resp, ctx.Req, reqFile) +} + +// one or more key=value pairs separated by colons +var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) + +func prepareGitCmdWithAllowedService(ctx *context.Context, service string) (*git.Command, error) { + if service == "receive-pack" { + return git.NewCommand(ctx, "receive-pack"), nil + } + if service == "upload-pack" { + return git.NewCommand(ctx, "upload-pack"), nil + } + + return nil, fmt.Errorf("service %q is not allowed", service) +} + +func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { + defer func() { + if err := ctx.Req.Body.Close(); err != nil { + log.Error("serviceRPC: Close: %v", err) + } + }() + + expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) + if ctx.Req.Header.Get("Content-Type") != expectedContentType { + log.Error("Content-Type (%q) doesn't match expected: %q", ctx.Req.Header.Get("Content-Type"), expectedContentType) + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + + cmd, err := prepareGitCmdWithAllowedService(ctx, service) + if err != nil { + log.Error("Failed to prepareGitCmdWithService: %v", err) + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) + + reqBody := ctx.Req.Body + + // Handle GZIP. + if ctx.Req.Header.Get("Content-Encoding") == "gzip" { + reqBody, err = gzip.NewReader(reqBody) + if err != nil { + log.Error("Fail to create gzip reader: %v", err) + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + } + + // set this for allow pre-receive and post-receive execute + h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service) + + if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + + var stderr bytes.Buffer + cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.getRepoDir()) + cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.getRepoDir())) + if err := cmd.Run(&git.RunOpts{ + Dir: h.getRepoDir(), + Env: append(os.Environ(), h.environ...), + Stdout: ctx.Resp, + Stdin: reqBody, + Stderr: &stderr, + UseContextTimeout: true, + }); err != nil { + if err.Error() != "signal: killed" { + log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.getRepoDir(), err, stderr.String()) + } + return + } +} + +// ServiceUploadPack implements Git Smart HTTP protocol +func ServiceUploadPack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(ctx, h, "upload-pack") + } +} + +// ServiceReceivePack implements Git Smart HTTP protocol +func ServiceReceivePack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(ctx, h, "receive-pack") + } +} + +func getServiceType(ctx *context.Context) string { + serviceType := ctx.Req.FormValue("service") + if !strings.HasPrefix(serviceType, "git-") { + return "" + } + return strings.TrimPrefix(serviceType, "git-") +} + +func updateServerInfo(ctx gocontext.Context, dir string) []byte { + out, _, err := git.NewCommand(ctx, "update-server-info").RunStdBytes(&git.RunOpts{Dir: dir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(out))) + } + return out +} + +func packetWrite(str string) []byte { + s := strconv.FormatInt(int64(len(str)+4), 16) + if len(s)%4 != 0 { + s = strings.Repeat("0", 4-len(s)%4) + s + } + return []byte(s + str) +} + +// GetInfoRefs implements Git dumb HTTP +func GetInfoRefs(ctx *context.Context) { + h := httpBase(ctx) + if h == nil { + return + } + setHeaderNoCache(ctx) + service := getServiceType(ctx) + cmd, err := prepareGitCmdWithAllowedService(ctx, service) + if err == nil { + if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + h.environ = append(os.Environ(), h.environ...) + + refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.getRepoDir()}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(packetWrite("# service=git-" + service + "\n")) + _, _ = ctx.Resp.Write([]byte("0000")) + _, _ = ctx.Resp.Write(refs) + } else { + updateServerInfo(ctx, h.getRepoDir()) + h.sendFile(ctx, "text/plain; charset=utf-8", "info/refs") + } +} + +// GetTextFile implements Git dumb HTTP +func GetTextFile(p string) func(*context.Context) { + return func(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderNoCache(ctx) + file := ctx.Params("file") + if file != "" { + h.sendFile(ctx, "text/plain", "objects/info/"+file) + } else { + h.sendFile(ctx, "text/plain", p) + } + } + } +} + +// GetInfoPacks implements Git dumb HTTP +func GetInfoPacks(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "text/plain; charset=utf-8", "objects/info/packs") + } +} + +// GetLooseObject implements Git dumb HTTP +func GetLooseObject(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", + ctx.Params("head"), ctx.Params("hash"))) + } +} + +// GetPackFile implements Git dumb HTTP +func GetPackFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") + } +} + +// GetIdxFile implements Git dumb HTTP +func GetIdxFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") + } +} diff --git a/routers/web/repo/githttp_test.go b/routers/web/repo/githttp_test.go new file mode 100644 index 0000000..5ba8de3 --- /dev/null +++ b/routers/web/repo/githttp_test.go @@ -0,0 +1,42 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainsParentDirectorySeparator(t *testing.T) { + tests := []struct { + v string + b bool + }{ + { + v: `user2/repo1/info/refs`, + b: false, + }, + { + v: `user2/repo1/HEAD`, + b: false, + }, + { + v: `user2/repo1/some.../strange_file...mp3`, + b: false, + }, + { + v: `user2/repo1/../../custom/conf/app.ini`, + b: true, + }, + { + v: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`, + b: true, + }, + } + + for i := range tests { + assert.EqualValues(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) + } +} diff --git a/routers/web/repo/helper.go b/routers/web/repo/helper.go new file mode 100644 index 0000000..5e1e116 --- /dev/null +++ b/routers/web/repo/helper.go @@ -0,0 +1,44 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/url" + "sort" + + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" +) + +func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { + if doer != nil { + sort.Slice(users, func(i, j int) bool { + if users[i].ID == users[j].ID { + return false + } + return users[i].ID == doer.ID // if users[i] is self, put it before others, so less=true + }) + } + return users +} + +func HandleGitError(ctx *context.Context, msg string, err error) { + if git.IsErrNotExist(err) { + refType := "" + switch { + case ctx.Repo.IsViewBranch: + refType = "branch" + case ctx.Repo.IsViewTag: + refType = "tag" + case ctx.Repo.IsViewCommit: + refType = "commit" + } + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found_"+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName)) + ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + refType + "/" + url.PathEscape(ctx.Repo.RefName) + ctx.NotFound(msg, err) + } else { + ctx.ServerError(msg, err) + } +} diff --git a/routers/web/repo/helper_test.go b/routers/web/repo/helper_test.go new file mode 100644 index 0000000..978758e --- /dev/null +++ b/routers/web/repo/helper_test.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestMakeSelfOnTop(t *testing.T) { + users := MakeSelfOnTop(nil, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 2, users[0].ID) + + users = MakeSelfOnTop(&user.User{ID: 1}, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 1, users[0].ID) + + users = MakeSelfOnTop(&user.User{ID: 2}, []*user.User{{ID: 2}, {ID: 1}}) + assert.Len(t, users, 2) + assert.EqualValues(t, 2, users[0].ID) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go new file mode 100644 index 0000000..5d13ccc --- /dev/null +++ b/routers/web/repo/issue.go @@ -0,0 +1,3822 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + stdCtx "context" + "errors" + "fmt" + "html/template" + "math/big" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + 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/container" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/git" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + issue_template "code.gitea.io/gitea/modules/issue/template" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "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/templates" + "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + + "gitea.com/go-chi/binding" +) + +const ( + tplAttachment base.TplName = "repo/issue/view_content/attachments" + + tplIssues base.TplName = "repo/issue/list" + tplIssueNew base.TplName = "repo/issue/new" + tplIssueChoose base.TplName = "repo/issue/choose" + tplIssueView base.TplName = "repo/issue/view" + + tplReactions base.TplName = "repo/issue/view_content/reactions" + + issueTemplateKey = "IssueTemplate" + issueTemplateTitleKey = "IssueTemplateTitle" +) + +// IssueTemplateCandidates issue templates +var IssueTemplateCandidates = []string{ + "ISSUE_TEMPLATE.md", + "ISSUE_TEMPLATE.yaml", + "ISSUE_TEMPLATE.yml", + "issue_template.md", + "issue_template.yaml", + "issue_template.yml", + ".forgejo/ISSUE_TEMPLATE.md", + ".forgejo/ISSUE_TEMPLATE.yaml", + ".forgejo/ISSUE_TEMPLATE.yml", + ".forgejo/issue_template.md", + ".forgejo/issue_template.yaml", + ".forgejo/issue_template.yml", + ".gitea/ISSUE_TEMPLATE.md", + ".gitea/ISSUE_TEMPLATE.yaml", + ".gitea/ISSUE_TEMPLATE.yml", + ".gitea/issue_template.md", + ".gitea/issue_template.yaml", + ".gitea/issue_template.yml", + ".github/ISSUE_TEMPLATE.md", + ".github/ISSUE_TEMPLATE.yaml", + ".github/ISSUE_TEMPLATE.yml", + ".github/issue_template.md", + ".github/issue_template.yaml", + ".github/issue_template.yml", +} + +// MustAllowUserComment checks to make sure if an issue is locked. +// If locked and user has permissions to write to the repository, +// then the comment is allowed, else it is blocked +func MustAllowUserComment(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) + ctx.Redirect(issue.Link()) + return + } +} + +// MustEnableIssues check if repository enable internal issues +func MustEnableIssues(ctx *context.Context) { + if !ctx.Repo.CanRead(unit.TypeIssues) && + !ctx.Repo.CanRead(unit.TypeExternalTracker) { + ctx.NotFound("MustEnableIssues", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil { + ctx.Redirect(unit.ExternalTrackerConfig().ExternalTrackerURL) + return + } +} + +// MustAllowPulls check if repository enable pull requests and user have right to do that +func MustAllowPulls(ctx *context.Context) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.NotFound("MustAllowPulls", nil) + return + } + + // User can send pull request if owns a forked repository. + if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { + ctx.Repo.PullRequest.Allowed = true + ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Doer.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName) + } +} + +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { + var err error + viewType := ctx.FormString("type") + sortType := ctx.FormString("sort") + types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"} + if !util.SliceContainsString(types, viewType, true) { + viewType = "all" + } + + var ( + assigneeID = ctx.FormInt64("assignee") + posterID = ctx.FormInt64("poster") + mentionedID int64 + reviewRequestedID int64 + reviewedID int64 + ) + + if ctx.IsSigned { + switch viewType { + case "created_by": + posterID = ctx.Doer.ID + case "mentioned": + mentionedID = ctx.Doer.ID + case "assigned": + assigneeID = ctx.Doer.ID + case "review_requested": + reviewRequestedID = ctx.Doer.ID + case "reviewed_by": + reviewedID = ctx.Doer.ID + } + } + + repo := ctx.Repo.Repository + var labelIDs []int64 + // 1,-2 means including label 1 and excluding label 2 + // 0 means issues with no label + // blank means labels will not be filtered for issues + selectLabels := ctx.FormString("labels") + if selectLabels == "" { + ctx.Data["AllLabels"] = true + } else if selectLabels == "0" { + ctx.Data["NoLabel"] = true + } + if len(selectLabels) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) + } + } + + keyword := strings.Trim(ctx.FormString("q"), " ") + if bytes.Contains([]byte(keyword), []byte{0x00}) { + keyword = "" + } + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + var mileIDs []int64 + if milestoneID > 0 || milestoneID == db.NoConditionID { // -1 to get those issues which have no any milestone assigned + mileIDs = []int64{milestoneID} + } + + var issueStats *issues_model.IssueStats + statsOpts := &issues_model.IssuesOptions{ + RepoIDs: []int64{repo.ID}, + LabelIDs: labelIDs, + MilestoneIDs: mileIDs, + ProjectID: projectID, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsPull: isPullOption, + IssueIDs: nil, + } + if keyword != "" { + allIssueIDs, err := issueIDsFromSearch(ctx, keyword, isFuzzy, statsOpts) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + return + } + statsOpts.IssueIDs = allIssueIDs + } + if keyword != "" && len(statsOpts.IssueIDs) == 0 { + // So it did search with the keyword, but no issue found. + // Just set issueStats to empty. + issueStats = &issues_model.IssueStats{} + } else { + // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. + // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. + issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return + } + } + + var isShowClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isShowClosed = optional.Some(true) + case "all": + isShowClosed = optional.None[bool]() + default: + isShowClosed = optional.Some(false) + } + // if there are closed issues and no open issues, default to showing all issues + if len(ctx.FormString("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 { + isShowClosed = optional.None[bool]() + } + + if repo.IsTimetrackerEnabled(ctx) { + totalTrackedTime, err := issues_model.GetIssueTotalTrackedTime(ctx, statsOpts, isShowClosed) + if err != nil { + ctx.ServerError("GetIssueTotalTrackedTime", err) + return + } + ctx.Data["TotalTrackedTime"] = totalTrackedTime + } + + archived := ctx.FormBool("archived") + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var total int + switch { + case isShowClosed.Value(): + total = int(issueStats.ClosedCount) + case !isShowClosed.Has(): + total = int(issueStats.OpenCount + issueStats.ClosedCount) + default: + total = int(issueStats.OpenCount) + } + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + + var issues issues_model.IssueList + { + ids, err := issueIDsFromSearch(ctx, keyword, isFuzzy, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.UI.IssuePagingNum, + }, + RepoIDs: []int64{repo.ID}, + AssigneeID: assigneeID, + PosterID: posterID, + MentionedID: mentionedID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + MilestoneIDs: mileIDs, + ProjectID: projectID, + IsClosed: isShowClosed, + IsPull: isPullOption, + LabelIDs: labelIDs, + SortType: sortType, + }) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) + return + } + ctx.Data["IssueIndexerUnavailable"] = true + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return + } + } + + approvalCounts, err := issues.GetApprovalCounts(ctx) + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + + if ctx.IsSigned { + if err := issues.LoadIsRead(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("LoadIsRead", err) + return + } + } else { + for i := range issues { + issues[i].IsRead = true + } + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesAllCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } + + ctx.Data["Issues"] = issues + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + + // Get assignees. + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + // Get the exclusive scope for every label ID + labelExclusiveScopes := make([]string, 0, len(labelIDs)) + for _, labelID := range labelIDs { + foundExclusiveScope := false + for _, label := range labels { + if label.ID == labelID || label.ID == -labelID { + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) + foundExclusiveScope = true + break + } + } + if !foundExclusiveScope { + labelExclusiveScopes = append(labelExclusiveScopes, "") + } + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + } + ctx.Data["Labels"] = labels + ctx.Data["NumLabels"] = len(labels) + + if ctx.FormInt64("assignee") == 0 { + assigneeID = 0 // Reset ID to prevent unexpected selection of assignee. + } + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink) + + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + + retrieveProjects(ctx, repo) + if ctx.Written() { + return + } + + pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.Value()) + if err != nil { + ctx.ServerError("GetPinnedIssues", err) + return + } + + ctx.Data["PinnedIssues"] = pinned + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["IssueStats"] = issueStats + ctx.Data["OpenCount"] = issueStats.OpenCount + ctx.Data["ClosedCount"] = issueStats.ClosedCount + linkStr := "%s?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%d&archived=%t" + ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["SelLabelIDs"] = labelIDs + ctx.Data["SelectLabels"] = selectLabels + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["MilestoneID"] = milestoneID + ctx.Data["ProjectID"] = projectID + ctx.Data["AssigneeID"] = assigneeID + ctx.Data["PosterID"] = posterID + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["Keyword"] = keyword + ctx.Data["IsShowClosed"] = isShowClosed + switch { + case isShowClosed.Value(): + ctx.Data["State"] = "closed" + case !isShowClosed.Has(): + ctx.Data["State"] = "all" + default: + ctx.Data["State"] = "open" + } + ctx.Data["ShowArchivedLabels"] = archived + + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "type", "ViewType") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "labels", "SelectLabels") + pager.AddParam(ctx, "milestone", "MilestoneID") + pager.AddParam(ctx, "project", "ProjectID") + pager.AddParam(ctx, "assignee", "AssigneeID") + pager.AddParam(ctx, "poster", "PosterID") + pager.AddParam(ctx, "archived", "ShowArchivedLabels") + pager.AddParam(ctx, "fuzzy", "IsFuzzy") + + ctx.Data["Page"] = pager +} + +func issueIDsFromSearch(ctx *context.Context, keyword string, fuzzy bool, opts *issues_model.IssuesOptions) ([]int64, error) { + ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { + o.IsFuzzyKeyword = fuzzy + }, + )) + if err != nil { + return nil, fmt.Errorf("SearchIssues: %w", err) + } + return ids, nil +} + +// Issues render issues page +func Issues(ctx *context.Context) { + isPullList := ctx.Params(":type") == "pulls" + if isPullList { + MustAllowPulls(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.pulls") + ctx.Data["PageIsPullList"] = true + } else { + MustEnableIssues(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.issues") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + } + + issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) + if ctx.Written() { + return + } + + renderMilestones(ctx) + if ctx.Written() { + return + } + + ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) + + ctx.HTML(http.StatusOK, tplIssues) +} + +func renderMilestones(ctx *context.Context) { + // Get milestones + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("GetAllRepoMilestones", err) + return + } + + openMilestones, closedMilestones := issues_model.MilestoneList{}, issues_model.MilestoneList{} + for _, milestone := range milestones { + if milestone.IsClosed { + closedMilestones = append(closedMilestones, milestone) + } else { + openMilestones = append(openMilestones, milestone) + } + } + ctx.Data["OpenMilestones"] = openMilestones + ctx.Data["ClosedMilestones"] = closedMilestones +} + +// RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository +func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.Repository) { + var err error + ctx.Data["OpenMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + ctx.Data["ClosedMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(true), + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) +} + +func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { + // Distinguish whether the owner of the repository + // is an individual or an organization + repoOwnerType := project_model.TypeIndividual + if repo.Owner.IsOrganization() { + repoOwnerType = project_model.TypeOrganization + } + var err error + projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(false), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + projects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(false), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["OpenProjects"] = append(projects, projects2...) + + projects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(true), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + projects2, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(true), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"] = append(projects, projects2...) +} + +// repoReviewerSelection items to bee shown +type repoReviewerSelection struct { + IsTeam bool + Team *organization.Team + User *user_model.User + Review *issues_model.Review + CanChange bool + Checked bool + ItemID int64 +} + +// RetrieveRepoReviewers find all reviewers of a repository +func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) { + ctx.Data["CanChooseReviewer"] = canChooseReviewer + + originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) + if err != nil { + ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) + return + } + ctx.Data["OriginalReviews"] = originalAuthorReviews + + reviews, err := issues_model.GetReviewsByIssueID(ctx, issue.ID) + if err != nil { + ctx.ServerError("GetReviewersByIssueID", err) + return + } + + if len(reviews) == 0 && !canChooseReviewer { + return + } + + var ( + pullReviews []*repoReviewerSelection + reviewersResult []*repoReviewerSelection + teamReviewersResult []*repoReviewerSelection + teamReviewers []*organization.Team + reviewers []*user_model.User + ) + + if canChooseReviewer { + posterID := issue.PosterID + if issue.OriginalAuthorID > 0 { + posterID = 0 + } + + reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID) + if err != nil { + ctx.ServerError("GetReviewers", err) + return + } + + teamReviewers, err = repo_service.GetReviewerTeams(ctx, repo) + if err != nil { + ctx.ServerError("GetReviewerTeams", err) + return + } + + if len(reviewers) > 0 { + reviewersResult = make([]*repoReviewerSelection, 0, len(reviewers)) + } + + if len(teamReviewers) > 0 { + teamReviewersResult = make([]*repoReviewerSelection, 0, len(teamReviewers)) + } + } + + pullReviews = make([]*repoReviewerSelection, 0, len(reviews)) + + for _, review := range reviews { + tmp := &repoReviewerSelection{ + Checked: review.Type == issues_model.ReviewTypeRequest, + Review: review, + ItemID: review.ReviewerID, + } + if review.ReviewerTeamID > 0 { + tmp.IsTeam = true + tmp.ItemID = -review.ReviewerTeamID + } + + if canChooseReviewer { + // Users who can choose reviewers can also remove review requests + tmp.CanChange = true + } else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest { + // A user can refuse review requests + tmp.CanChange = true + } + + pullReviews = append(pullReviews, tmp) + + if canChooseReviewer { + if tmp.IsTeam { + teamReviewersResult = append(teamReviewersResult, tmp) + } else { + reviewersResult = append(reviewersResult, tmp) + } + } + } + + if len(pullReviews) > 0 { + // Drop all non-existing users and teams from the reviews + currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews)) + for _, item := range pullReviews { + if item.Review.ReviewerID > 0 { + if err = item.Review.LoadReviewer(ctx); err != nil { + if user_model.IsErrUserNotExist(err) { + continue + } + ctx.ServerError("LoadReviewer", err) + return + } + item.User = item.Review.Reviewer + } else if item.Review.ReviewerTeamID > 0 { + if err = item.Review.LoadReviewerTeam(ctx); err != nil { + if organization.IsErrTeamNotExist(err) { + continue + } + ctx.ServerError("LoadReviewerTeam", err) + return + } + item.Team = item.Review.ReviewerTeam + } else { + continue + } + + currentPullReviewers = append(currentPullReviewers, item) + } + ctx.Data["PullReviewers"] = currentPullReviewers + } + + if canChooseReviewer && reviewersResult != nil { + preadded := len(reviewersResult) + for _, reviewer := range reviewers { + found := false + reviewAddLoop: + for _, tmp := range reviewersResult[:preadded] { + if tmp.ItemID == reviewer.ID { + tmp.User = reviewer + found = true + break reviewAddLoop + } + } + + if found { + continue + } + + reviewersResult = append(reviewersResult, &repoReviewerSelection{ + IsTeam: false, + CanChange: true, + User: reviewer, + ItemID: reviewer.ID, + }) + } + + ctx.Data["Reviewers"] = reviewersResult + } + + if canChooseReviewer && teamReviewersResult != nil { + preadded := len(teamReviewersResult) + for _, team := range teamReviewers { + found := false + teamReviewAddLoop: + for _, tmp := range teamReviewersResult[:preadded] { + if tmp.ItemID == -team.ID { + tmp.Team = team + found = true + break teamReviewAddLoop + } + } + + if found { + continue + } + + teamReviewersResult = append(teamReviewersResult, &repoReviewerSelection{ + IsTeam: true, + CanChange: true, + Team: team, + ItemID: -team.ID, + }) + } + + ctx.Data["TeamReviewers"] = teamReviewersResult + } +} + +// RetrieveRepoMetas find all the meta information of a repository +func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull bool) []*issues_model.Label { + if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { + return nil + } + + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return nil + } + ctx.Data["Labels"] = labels + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + return nil + } + + ctx.Data["OrgLabels"] = orgLabels + labels = append(labels, orgLabels...) + } + + RetrieveRepoMilestonesAndAssignees(ctx, repo) + if ctx.Written() { + return nil + } + + retrieveProjects(ctx, repo) + if ctx.Written() { + return nil + } + + PrepareBranchList(ctx) + if ctx.Written() { + return nil + } + + // Contains true if the user can create issue dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) + + return labels +} + +// Tries to load and set an issue template. The first return value indicates if a template was loaded. +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + return false, nil + } + + templateCandidates := make([]string, 0, 1+len(possibleFiles)) + if t := ctx.FormString("template"); t != "" { + templateCandidates = append(templateCandidates, t) + } + templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback + + templateErrs := map[string]error{} + for _, filename := range templateCandidates { + if ok, _ := commit.HasFile(filename); !ok { + continue + } + template, err := issue_template.UnmarshalFromCommit(commit, filename) + if err != nil { + templateErrs[filename] = err + continue + } + ctx.Data[issueTemplateTitleKey] = template.Title + ctx.Data[ctxDataKey] = template.Content + + if template.Type() == api.IssueTemplateTypeYaml { + // Replace field default values by values from query + for _, field := range template.Fields { + fieldValue := ctx.FormString("field:" + field.ID) + if fieldValue != "" { + field.Attributes["value"] = fieldValue + } + } + + ctx.Data["Fields"] = template.Fields + ctx.Data["TemplateFile"] = template.FileName + } + labelIDs := make([]string, 0, len(template.Labels)) + if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { + ctx.Data["Labels"] = repoLabels + if ctx.Repo.Owner.IsOrganization() { + if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil { + ctx.Data["OrgLabels"] = orgLabels + repoLabels = append(repoLabels, orgLabels...) + } + } + + for _, metaLabel := range template.Labels { + for _, repoLabel := range repoLabels { + if strings.EqualFold(repoLabel.Name, metaLabel) { + repoLabel.IsChecked = true + labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) + break + } + } + } + } + + if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/ + template.Ref = git.BranchPrefix + template.Ref + } + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 + ctx.Data["label_ids"] = strings.Join(labelIDs, ",") + ctx.Data["Reference"] = template.Ref + ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName() + return true, templateErrs + } + return false, templateErrs +} + +// NewIssue render creating issue page +func NewIssue(ctx *context.Context) { + issueConfig, _ := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + hasTemplates := issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = hasTemplates + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + title := ctx.FormString("title") + ctx.Data["TitleQuery"] = title + body := ctx.FormString("body") + ctx.Data["BodyQuery"] = body + + isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = isProjectsEnabled + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + milestoneID := ctx.FormInt64("milestone") + if milestoneID > 0 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + log.Error("GetMilestoneByID: %d: %v", milestoneID, err) + } else { + ctx.Data["milestone_id"] = milestoneID + ctx.Data["Milestone"] = milestone + } + } + + projectID := ctx.FormInt64("project") + if projectID > 0 && isProjectsEnabled { + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + log.Error("GetProjectByID: %d: %v", projectID, err) + } else if project.RepoID != ctx.Repo.Repository.ID { + log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) + } else { + ctx.Data["project_id"] = projectID + ctx.Data["Project"] = project + } + + if len(ctx.Req.URL.Query().Get("project")) > 0 { + ctx.Data["redirect_after_creation"] = "project" + } + } + + RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + _, templateErrs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) + for k, v := range errs { + templateErrs[k] = v + } + if ctx.Written() { + return + } + + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) + + if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { + // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. + ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) + return + } + + ctx.HTML(http.StatusOK, tplIssueNew) +} + +func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) template.HTML { + var files []string + for k := range errs { + files = append(files, k) + } + sort.Strings(files) // keep the output stable + + var lines []string + for _, file := range files { + lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file])) + } + + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"), + "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)), + "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")), + }) + if err != nil { + log.Debug("render flash error: %v", err) + flashError = ctx.Locale.Tr("repo.issues.choose.ignore_invalid_templates") + } + return flashError +} + +// NewIssueChooseTemplate render creating issue from template page +func NewIssueChooseTemplate(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + + issueTemplates, errs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["IssueTemplates"] = issueTemplates + + if len(errs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true) + } + + if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) { + // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters. + ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) + return + } + + issueConfig, err := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["IssueConfig"] = issueConfig + ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here + + ctx.Data["milestone"] = ctx.FormInt64("milestone") + ctx.Data["project"] = ctx.FormInt64("project") + + ctx.HTML(http.StatusOK, tplIssueChoose) +} + +// DeleteIssue deletes an issue +func DeleteIssue(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { + ctx.ServerError("DeleteIssueByID", err) + return + } + + if issue.IsPull { + ctx.Redirect(fmt.Sprintf("%s/pulls", ctx.Repo.Repository.Link()), http.StatusSeeOther) + return + } + + ctx.Redirect(fmt.Sprintf("%s/issues", ctx.Repo.Repository.Link()), http.StatusSeeOther) +} + +// ValidateRepoMetas check and returns repository's meta information +func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { + var ( + repo = ctx.Repo.Repository + err error + ) + + labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull) + if ctx.Written() { + return nil, nil, 0, 0 + } + + var labelIDs []int64 + hasSelected := false + // Check labels. + if len(form.LabelIDs) > 0 { + labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + if err != nil { + return nil, nil, 0, 0 + } + labelIDMark := make(container.Set[int64]) + labelIDMark.AddMultiple(labelIDs...) + + for i := range labels { + if labelIDMark.Contains(labels[i].ID) { + labels[i].IsChecked = true + hasSelected = true + } + } + } + + ctx.Data["Labels"] = labels + ctx.Data["HasSelectedLabel"] = hasSelected + ctx.Data["label_ids"] = form.LabelIDs + + // Check milestone. + milestoneID := form.MilestoneID + if milestoneID > 0 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByID", err) + return nil, nil, 0, 0 + } + if milestone.RepoID != repo.ID { + ctx.ServerError("GetMilestoneByID", err) + return nil, nil, 0, 0 + } + ctx.Data["Milestone"] = milestone + ctx.Data["milestone_id"] = milestoneID + } + + if form.ProjectID > 0 { + p, err := project_model.GetProjectByID(ctx, form.ProjectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return nil, nil, 0, 0 + } + if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { + ctx.NotFound("", nil) + return nil, nil, 0, 0 + } + + ctx.Data["Project"] = p + ctx.Data["project_id"] = form.ProjectID + } + + // Check assignees + var assigneeIDs []int64 + if len(form.AssigneeIDs) > 0 { + assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) + if err != nil { + return nil, nil, 0, 0 + } + + // Check if the passed assignees actually exists and is assignable + for _, aID := range assigneeIDs { + assignee, err := user_model.GetUserByID(ctx, aID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return nil, nil, 0, 0 + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull) + if err != nil { + ctx.ServerError("CanBeAssigned", err) + return nil, nil, 0, 0 + } + + if !valid { + ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) + return nil, nil, 0, 0 + } + } + } + + // Keep the old assignee id thingy for compatibility reasons + if form.AssigneeID > 0 { + assigneeIDs = append(assigneeIDs, form.AssigneeID) + } + + return labelIDs, assigneeIDs, milestoneID, form.ProjectID +} + +// NewIssuePost response for creating new issue +func NewIssuePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateIssueForm) + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + var ( + repo = ctx.Repo.Repository + attachments []string + ) + + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, false) + if ctx.Written() { + return + } + + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + if util.IsEmptyString(form.Title) { + ctx.JSONError(ctx.Tr("repo.issues.new.title_empty")) + return + } + + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + MilestoneID: milestoneID, + Content: content, + Ref: form.Ref, + } + + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.JSONError(ctx.Tr("repo.issues.blocked_by_user")) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + return + } + ctx.ServerError("NewIssue", err) + return + } + + if projectID > 0 { + if !ctx.Repo.CanRead(unit.TypeProjects) { + // User must also be able to see the project. + ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + return + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) + if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) + } else { + ctx.JSONRedirect(issue.Link()) + } +} + +// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue +func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) { + roleDescriptor := issues_model.RoleDescriptor{} + + if hasOriginalAuthor { + return roleDescriptor, nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + + // If the poster is the actual poster of the issue, enable Poster role. + roleDescriptor.IsPoster = issue.IsPoster(poster.ID) + + // Check if the poster is owner of the repo. + if perm.IsOwner() { + // If the poster isn't an admin, enable the owner role. + if !poster.IsAdmin { + roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner + return roleDescriptor, nil + } + + // Otherwise check if poster is the real repo admin. + ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + if ok { + roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner + return roleDescriptor, nil + } + } + + // If repo is organization, check Member role + if err := repo.LoadOwner(ctx); err != nil { + return roleDescriptor, err + } + if repo.Owner.IsOrganization() { + if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isMember { + roleDescriptor.RoleInRepo = issues_model.RoleRepoMember + return roleDescriptor, nil + } + } + + // If the poster is the collaborator of the repo + if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isCollaborator { + roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator + return roleDescriptor, nil + } + + hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID) + if err != nil { + return roleDescriptor, err + } else if hasMergedPR { + roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor + } else if issue.IsPull { + // only display first time contributor in the first opening pull request + roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor + } + + return roleDescriptor, nil +} + +func getBranchData(ctx *context.Context, issue *issues_model.Issue) { + ctx.Data["BaseBranch"] = nil + ctx.Data["HeadBranch"] = nil + ctx.Data["HeadUserName"] = nil + ctx.Data["BaseName"] = ctx.Repo.Repository.OwnerName + if issue.IsPull { + pull := issue.PullRequest + ctx.Data["BaseBranch"] = pull.BaseBranch + ctx.Data["HeadBranch"] = pull.HeadBranch + ctx.Data["HeadUserName"] = pull.MustHeadUserName(ctx) + } +} + +func prepareHiddenCommentType(ctx *context.Context) { + var hiddenCommentTypes *big.Int + if ctx.IsSigned { + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + } + + ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool { + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 + } +} + +// ViewIssue render issue view page +func ViewIssue(ctx *context.Context) { + if ctx.Params(":type") == "issues" { + // If issue was requested we check if repo has external tracker and redirect + extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil && extIssueUnit != nil { + if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { + metas := ctx.Repo.Repository.ComposeMetas(ctx) + metas["index"] = ctx.Params(":index") + res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) + if err != nil { + log.Error("unable to expand template vars for issue url. issue: %s, err: %v", metas["index"], err) + ctx.ServerError("Expand", err) + return + } + ctx.Redirect(res) + return + } + } else if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + if issue.Repo == nil { + issue.Repo = ctx.Repo.Repository + } + + // Make sure type and URL matches. + if ctx.Params(":type") == "issues" && issue.IsPull { + ctx.Redirect(issue.Link()) + return + } else if ctx.Params(":type") == "pulls" && !issue.IsPull { + ctx.Redirect(issue.Link()) + return + } + + if issue.IsPull { + MustAllowPulls(ctx) + if ctx.Written() { + return + } + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullConversation"] = true + } else { + MustEnableIssues(ctx) + if ctx.Written() { + return + } + ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + } + + if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.Data["IssueType"] = "pulls" + } else if !issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.Data["IssueType"] = "issues" + } else { + ctx.Data["IssueType"] = "all" + } + + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + if err = filterXRefComments(ctx, issue); err != nil { + ctx.ServerError("filterXRefComments", err) + return + } + + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) + + iw := new(issues_model.IssueWatch) + if ctx.Doer != nil { + iw.UserID = ctx.Doer.ID + iw.IssueID = issue.ID + iw.IsWatching, err = issues_model.CheckIssueWatch(ctx, ctx.Doer, issue) + if err != nil { + ctx.ServerError("CheckIssueWatch", err) + return + } + } + ctx.Data["IssueWatch"] = iw + issue.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, issue.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + repo := ctx.Repo.Repository + + // Get more information if it's a pull request. + if issue.IsPull { + if issue.PullRequest.HasMerged { + ctx.Data["DisableStatusChange"] = issue.PullRequest.HasMerged + PrepareMergedViewPullInfo(ctx, issue) + } else { + PrepareViewPullInfo(ctx, issue) + ctx.Data["DisableStatusChange"] = ctx.Data["IsPullRequestBroken"] == true && issue.IsClosed + } + if ctx.Written() { + return + } + } + + // Metas. + // Check labels. + labelIDMark := make(container.Set[int64]) + for _, label := range issue.Labels { + labelIDMark.Add(label.ID) + } + labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + ctx.Data["Labels"] = labels + + if repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + ctx.Data["OrgLabels"] = orgLabels + + labels = append(labels, orgLabels...) + } + + hasSelected := false + for i := range labels { + if labelIDMark.Contains(labels[i].ID) { + labels[i].IsChecked = true + hasSelected = true + } + } + ctx.Data["HasSelectedLabel"] = hasSelected + + // Check milestone and assignee. + if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + RetrieveRepoMilestonesAndAssignees(ctx, repo) + retrieveProjects(ctx, repo) + + if ctx.Written() { + return + } + } + + if issue.IsPull { + canChooseReviewer := false + if ctx.Doer != nil && ctx.IsSigned { + canChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, issue) + } + + RetrieveRepoReviewers(ctx, repo, issue, canChooseReviewer) + if ctx.Written() { + return + } + } + + if ctx.IsSigned { + // Update issue-user. + if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var ( + role issues_model.RoleDescriptor + ok bool + marked = make(map[int64]issues_model.RoleDescriptor) + comment *issues_model.Comment + participants = make([]*user_model.User, 1, 10) + latestCloseCommentID int64 + ) + if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + if ctx.IsSigned { + // Deal with the stopwatch + ctx.Data["IsStopwatchRunning"] = issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) + if !ctx.Data["IsStopwatchRunning"].(bool) { + var exists bool + var swIssue *issues_model.Issue + if exists, _, swIssue, err = issues_model.HasUserStopwatch(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("HasUserStopwatch", err) + return + } + ctx.Data["HasUserStopwatch"] = exists + if exists { + // Add warning if the user has already a stopwatch + // Add link to the issue of the already running stopwatch + ctx.Data["OtherStopwatchURL"] = swIssue.Link() + } + } + ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) + } else { + ctx.Data["CanUseTimetracker"] = false + } + if ctx.Data["WorkingUsers"], err = issues_model.TotalTimesForEachUser(ctx, &issues_model.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { + ctx.ServerError("TotalTimesForEachUser", err) + return + } + } + + // Check if the user can use the dependencies + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) + + // check if dependencies can be created across repositories + ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + + if issue.ShowRole, err = roleDescriptor(ctx, repo, issue.Poster, issue, issue.HasOriginalAuthor()); err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[issue.PosterID] = issue.ShowRole + + // Render comments and fetch participants. + participants[0] = issue.Poster + + if err := issue.Comments.LoadAttachmentsByIssue(ctx); err != nil { + ctx.ServerError("LoadAttachmentsByIssue", err) + return + } + if err := issue.Comments.LoadPosters(ctx); err != nil { + ctx.ServerError("LoadPosters", err) + return + } + + for _, comment = range issue.Comments { + comment.Issue = issue + + if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + // Check tag. + role, ok = marked[comment.PosterID] + if ok { + comment.ShowRole = role + continue + } + + comment.ShowRole, err = roleDescriptor(ctx, repo, comment.Poster, issue, comment.HasOriginalAuthor()) + if err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[comment.PosterID] = comment.ShowRole + participants = addParticipant(comment.Poster, participants) + } else if comment.Type == issues_model.CommentTypeLabel { + if err = comment.LoadLabel(ctx); err != nil { + ctx.ServerError("LoadLabel", err) + return + } + } else if comment.Type == issues_model.CommentTypeMilestone { + if err = comment.LoadMilestone(ctx); err != nil { + ctx.ServerError("LoadMilestone", err) + return + } + ghostMilestone := &issues_model.Milestone{ + ID: -1, + Name: ctx.Locale.TrString("repo.issues.deleted_milestone"), + } + if comment.OldMilestoneID > 0 && comment.OldMilestone == nil { + comment.OldMilestone = ghostMilestone + } + if comment.MilestoneID > 0 && comment.Milestone == nil { + comment.Milestone = ghostMilestone + } + } else if comment.Type == issues_model.CommentTypeProject { + if err = comment.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + ghostProject := &project_model.Project{ + ID: project_model.GhostProjectID, + Title: ctx.Locale.TrString("repo.issues.deleted_project"), + } + + if comment.OldProjectID > 0 && comment.OldProject == nil { + comment.OldProject = ghostProject + } + + if comment.ProjectID > 0 && comment.Project == nil { + comment.Project = ghostProject + } + } else if comment.Type == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest { + if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil { + ctx.ServerError("LoadAssigneeUserAndTeam", err) + return + } + } else if comment.Type == issues_model.CommentTypeRemoveDependency || comment.Type == issues_model.CommentTypeAddDependency { + if err = comment.LoadDepIssueDetails(ctx); err != nil { + if !issues_model.IsErrIssueNotExist(err) { + ctx.ServerError("LoadDepIssueDetails", err) + return + } + } + } else if comment.Type.HasContentSupport() { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + if err = comment.LoadReview(ctx); err != nil && !issues_model.IsErrReviewNotExist(err) { + ctx.ServerError("LoadReview", err) + return + } + participants = addParticipant(comment.Poster, participants) + if comment.Review == nil { + continue + } + if err = comment.Review.LoadAttributes(ctx); err != nil { + if !user_model.IsErrUserNotExist(err) { + ctx.ServerError("Review.LoadAttributes", err) + return + } + comment.Review.Reviewer = user_model.NewGhostUser() + } + if err = comment.Review.LoadCodeComments(ctx); err != nil { + ctx.ServerError("Review.LoadCodeComments", err) + return + } + for _, codeComments := range comment.Review.CodeComments { + for _, lineComments := range codeComments { + for _, c := range lineComments { + // Check tag. + role, ok = marked[c.PosterID] + if ok { + c.ShowRole = role + continue + } + + c.ShowRole, err = roleDescriptor(ctx, repo, c.Poster, issue, c.HasOriginalAuthor()) + if err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[c.PosterID] = c.ShowRole + participants = addParticipant(c.Poster, participants) + } + } + } + if err = comment.LoadResolveDoer(ctx); err != nil { + ctx.ServerError("LoadResolveDoer", err) + return + } + } else if comment.Type == issues_model.CommentTypePullRequestPush { + participants = addParticipant(comment.Poster, participants) + if err = comment.LoadPushCommits(ctx); err != nil { + ctx.ServerError("LoadPushCommits", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for _, commit := range comment.Commits { + if commit.Status == nil { + continue + } + commit.Status.HideActionsURL(ctx) + git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) + } + } + } else if comment.Type == issues_model.CommentTypeAddTimeManual || + comment.Type == issues_model.CommentTypeStopTracking || + comment.Type == issues_model.CommentTypeDeleteTimeManual { + // drop error since times could be pruned from DB.. + _ = comment.LoadTime(ctx) + if comment.Content != "" { + // Content before v1.21 did store the formatted string instead of seconds, + // so "|" is used as delimiter to mark the new format + if comment.Content[0] != '|' { + // handle old time comments that have formatted text stored + comment.RenderedContent = templates.SanitizeHTML(comment.Content) + comment.Content = "" + } else { + // else it's just a duration in seconds to pass on to the frontend + comment.Content = comment.Content[1:] + } + } + } + + if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull { + // record ID of the latest closed/merged comment. + // if PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered. + latestCloseCommentID = comment.ID + } + } + + ctx.Data["LatestCloseCommentID"] = latestCloseCommentID + + // Combine multiple label assignments into a single comment + combineLabelComments(issue) + + getBranchData(ctx, issue) + if issue.IsPull { + pull := issue.PullRequest + pull.Issue = issue + canDelete := false + allowMerge := false + + if ctx.IsSigned { + if err := pull.LoadHeadRepo(ctx); err != nil { + log.Error("LoadHeadRepo: %v", err) + } else if pull.HeadRepo != nil { + perm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if perm.CanWrite(unit.TypeCode) { + // Check if branch is not protected + if pull.HeadBranch != pull.HeadRepo.DefaultBranch { + if protected, err := git_model.IsBranchProtected(ctx, pull.HeadRepo.ID, pull.HeadBranch); err != nil { + log.Error("IsProtectedBranch: %v", err) + } else if !protected { + canDelete = true + ctx.Data["DeleteBranchLink"] = issue.Link() + "/cleanup" + } + } + ctx.Data["CanWriteToHeadRepo"] = true + } + } + + if err := pull.LoadBaseRepo(ctx); err != nil { + log.Error("LoadBaseRepo: %v", err) + } + perm, err := access_model.GetUserRepoPermission(ctx, pull.BaseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + allowMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + ctx.Data["AllowMerge"] = allowMerge + + prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + prConfig := prUnit.PullRequestsConfig() + + ctx.Data["AutodetectManualMerge"] = prConfig.AutodetectManualMerge + + var mergeStyle repo_model.MergeStyle + // Check correct values and select default + if ms, ok := ctx.Data["MergeStyle"].(repo_model.MergeStyle); !ok || + !prConfig.IsMergeStyleAllowed(ms) { + defaultMergeStyle := prConfig.GetDefaultMergeStyle() + if prConfig.IsMergeStyleAllowed(defaultMergeStyle) && !ok { + mergeStyle = defaultMergeStyle + } else if prConfig.AllowMerge { + mergeStyle = repo_model.MergeStyleMerge + } else if prConfig.AllowRebase { + mergeStyle = repo_model.MergeStyleRebase + } else if prConfig.AllowRebaseMerge { + mergeStyle = repo_model.MergeStyleRebaseMerge + } else if prConfig.AllowSquash { + mergeStyle = repo_model.MergeStyleSquash + } else if prConfig.AllowFastForwardOnly { + mergeStyle = repo_model.MergeStyleFastForwardOnly + } else if prConfig.AllowManualMerge { + mergeStyle = repo_model.MergeStyleManuallyMerged + } + } + + ctx.Data["MergeStyle"] = mergeStyle + + defaultMergeMessage, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle) + if err != nil { + ctx.ServerError("GetDefaultMergeMessage", err) + return + } + ctx.Data["DefaultMergeMessage"] = defaultMergeMessage + ctx.Data["DefaultMergeBody"] = defaultMergeBody + + defaultSquashMergeMessage, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash) + if err != nil { + ctx.ServerError("GetDefaultSquashMergeMessage", err) + return + } + ctx.Data["DefaultSquashMergeMessage"] = defaultSquashMergeMessage + ctx.Data["DefaultSquashMergeBody"] = defaultSquashMergeBody + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + ctx.Data["ShowMergeInstructions"] = true + if pb != nil { + pb.Repo = pull.BaseRepo + var showMergeInstructions bool + if ctx.Doer != nil { + showMergeInstructions = pb.CanUserPush(ctx, ctx.Doer) + } + ctx.Data["ProtectedBranch"] = pb + ctx.Data["IsBlockedByApprovals"] = !issues_model.HasEnoughApprovals(ctx, pb, pull) + ctx.Data["IsBlockedByRejection"] = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull) + ctx.Data["IsBlockedByOfficialReviewRequests"] = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull) + ctx.Data["IsBlockedByOutdatedBranch"] = issues_model.MergeBlockedByOutdatedBranch(pb, pull) + ctx.Data["GrantedApprovals"] = issues_model.GetGrantedApprovalsCount(ctx, pb, pull) + ctx.Data["RequireSigned"] = pb.RequireSignedCommits + ctx.Data["ChangedProtectedFiles"] = pull.ChangedProtectedFiles + ctx.Data["IsBlockedByChangedProtectedFiles"] = len(pull.ChangedProtectedFiles) != 0 + ctx.Data["ChangedProtectedFilesNum"] = len(pull.ChangedProtectedFiles) + ctx.Data["ShowMergeInstructions"] = showMergeInstructions + } + ctx.Data["WillSign"] = false + if ctx.Doer != nil { + sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, pull.BaseRepo.RepoPath(), pull.BaseBranch, pull.GetGitRefName()) + ctx.Data["WillSign"] = sign + ctx.Data["SigningKey"] = key + if err != nil { + if asymkey_service.IsErrWontSign(err) { + ctx.Data["WontSignReason"] = err.(*asymkey_service.ErrWontSign).Reason + } else { + ctx.Data["WontSignReason"] = "error" + log.Error("Error whilst checking if could sign pr %d in repo %s. Error: %v", pull.ID, pull.BaseRepo.FullName(), err) + } + } + } else { + ctx.Data["WontSignReason"] = "not_signed_in" + } + + isPullBranchDeletable := canDelete && + pull.HeadRepo != nil && + git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.HeadBranch) && + (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) + + if isPullBranchDeletable && pull.HasMerged { + exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pull.HeadRepoID, pull.HeadBranch) + if err != nil { + ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err) + return + } + + isPullBranchDeletable = !exist + } + ctx.Data["IsPullBranchDeletable"] = isPullBranchDeletable + + stillCanManualMerge := func() bool { + if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { + return false + } + if pull.CanAutoMerge() || pull.IsWorkInProgress(ctx) || pull.IsChecking() { + return false + } + if allowMerge && prConfig.AllowManualMerge { + return true + } + + return false + } + + ctx.Data["StillCanManualMerge"] = stillCanManualMerge() + + // Check if there is a pending pr merge + ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.ServerError("GetScheduledMergeByPullID", err) + return + } + } + + // Get Dependencies + blockedBy, err := issue.BlockedByDependencies(ctx, db.ListOptions{}) + if err != nil { + ctx.ServerError("BlockedByDependencies", err) + return + } + ctx.Data["BlockedByDependencies"], ctx.Data["BlockedByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blockedBy) + if ctx.Written() { + return + } + + blocking, err := issue.BlockingDependencies(ctx) + if err != nil { + ctx.ServerError("BlockingDependencies", err) + return + } + + ctx.Data["BlockingDependencies"], ctx.Data["BlockingDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) + if ctx.Written() { + return + } + + var pinAllowed bool + if !issue.IsPinned() { + pinAllowed, err = issues_model.IsNewPinAllowed(ctx, issue.RepoID, issue.IsPull) + if err != nil { + ctx.ServerError("IsNewPinAllowed", err) + return + } + } else { + pinAllowed = true + } + + ctx.Data["Participants"] = participants + ctx.Data["NumParticipants"] = len(participants) + ctx.Data["Issue"] = issue + ctx.Data["Reference"] = issue.Ref + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string)) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons + ctx.Data["RefEndName"] = git.RefName(issue.Ref).ShortName() + ctx.Data["NewPinAllowed"] = pinAllowed + ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0 + + prepareHiddenCommentType(ctx) + if ctx.Written() { + return + } + + // For sidebar + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + ctx.HTML(http.StatusOK, tplIssueView) +} + +// checkBlockedByIssues return canRead and notPermitted +func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + for _, blocker := range blockers { + // 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 nil, nil + } + repoPerms[blocker.RepoID] = perm + } + if perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { + canRead = append(canRead, blocker) + } else { + notPermitted = append(notPermitted, blocker) + } + } + sortDependencyInfo(canRead) + sortDependencyInfo(notPermitted) + return canRead, notPermitted +} + +func sortDependencyInfo(blockers []*issues_model.DependencyInfo) { + sort.Slice(blockers, func(i, j int) bool { + if blockers[i].RepoID == blockers[j].RepoID { + return blockers[i].Issue.CreatedUnix < blockers[j].Issue.CreatedUnix + } + return blockers[i].RepoID < blockers[j].RepoID + }) +} + +// GetActionIssue will return the issue which is used in the context. +func GetActionIssue(ctx *context.Context) *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 + checkIssueRights(ctx, issue) + if ctx.Written() { + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + return issue +} + +func checkIssueRights(ctx *context.Context, issue *issues_model.Issue) { + if issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) || + !issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + } +} + +func getActionIssues(ctx *context.Context) issues_model.IssueList { + commaSeparatedIssueIDs := ctx.FormString("issue_ids") + if len(commaSeparatedIssueIDs) == 0 { + return nil + } + issueIDs := make([]int64, 0, 10) + for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { + issueID, err := strconv.ParseInt(stringIssueID, 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return nil + } + issueIDs = append(issueIDs, issueID) + } + issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// GetIssueInfo get an issue of a repository +func GetIssueInfo(ctx *context.Context) { + issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + + if issue.IsPull { + // Need to check if Pulls are enabled and we can read Pulls + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.Error(http.StatusNotFound) + return + } + } else { + // Need to check if Issues are enabled and we can read Issues + if !ctx.Repo.CanRead(unit.TypeIssues) { + ctx.Error(http.StatusNotFound) + return + } + } + + ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue)) +} + +// UpdateIssueTitle change issue's title +func UpdateIssueTitle(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + title := ctx.FormTrim("title") + if util.IsEmptyString(title) { + ctx.Error(http.StatusBadRequest, "Title cannot be empty or spaces") + return + } + + // Creating a CreateIssueForm with the title so that we can validate the max title length + i := forms.CreateIssueForm{ + Title: title, + } + + bindingErr := binding.RawValidate(i) + if bindingErr.Has(binding.ERR_MAX_SIZE) { + ctx.Error(http.StatusBadRequest, "Title cannot be longer than 255 characters") + return + } + + if err := issue_service.ChangeTitle(ctx, issue, ctx.Doer, title); err != nil { + ctx.ServerError("ChangeTitle", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "title": issue.Title, + }) +} + +// UpdateIssueRef change issue's ref (branch) +func UpdateIssueRef(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { + ctx.Error(http.StatusForbidden) + return + } + + ref := ctx.FormTrim("ref") + + if err := issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, ref); err != nil { + ctx.ServerError("ChangeRef", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "ref": ref, + }) +} + +// UpdateIssueContent change issue's content +func UpdateIssueContent(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content"), ctx.FormInt("content_version")); err != nil { + if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.pulls.edit.already_changed")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.edit.already_changed")) + } + } else { + ctx.ServerError("ChangeContent", err) + } + return + } + + // when update the request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates + if !ctx.FormBool("ignore_attachments") { + if err := updateAttachments(ctx, issue, ctx.FormStrings("files[]")); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + } + + content, err := markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, issue.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": content, + "contentVersion": issue.ContentVersion, + "attachments": attachmentsHTML(ctx, issue.Attachments, issue.Content), + }) +} + +// UpdateIssueDeadline updates an issue deadline +func UpdateIssueDeadline(ctx *context.Context) { + 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("GetIssueByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + 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.Error()) + return + } + + ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) +} + +// UpdateIssueMilestone change issue's milestone +func UpdateIssueMilestone(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + milestoneID := ctx.FormInt64("id") + for _, issue := range issues { + oldMilestoneID := issue.MilestoneID + if oldMilestoneID == milestoneID { + continue + } + issue.MilestoneID = milestoneID + if err := issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { + ctx.ServerError("ChangeMilestoneAssign", err) + return + } + } + + if ctx.FormBool("htmx") { + renderMilestones(ctx) + if ctx.Written() { + return + } + prepareHiddenCommentType(ctx) + if ctx.Written() { + return + } + + issue := issues[0] + var err error + if issue.MilestoneID > 0 { + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, issue.MilestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByRepoID", err) + return + } + } else { + issue.Milestone = nil + } + + comment := &issues_model.Comment{} + has, err := db.GetEngine(ctx).Where("issue_id = ? AND type = ?", issue.ID, issues_model.CommentTypeMilestone).OrderBy("id DESC").Limit(1).Get(comment) + if !has || err != nil { + ctx.ServerError("GetLatestMilestoneComment", err) + } + if err := comment.LoadMilestone(ctx); err != nil { + ctx.ServerError("LoadMilestone", err) + return + } + if err := comment.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return + } + issue.Comments = issues_model.CommentList{comment} + + ctx.Data["Issue"] = issue + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.HTML(http.StatusOK, "htmx/milestone_sidebar") + } else { + ctx.JSONOK() + } +} + +// UpdateIssueAssignee change issue's or pull's assignee +func UpdateIssueAssignee(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + assigneeID := ctx.FormInt64("id") + action := ctx.FormString("action") + + for _, issue := range issues { + switch action { + case "clear": + if err := issue_service.DeleteNotPassedAssignee(ctx, issue, ctx.Doer, []*user_model.User{}); err != nil { + ctx.ServerError("ClearAssignees", err) + return + } + default: + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, issue.Repo, issue.IsPull) + if err != nil { + ctx.ServerError("canBeAssigned", err) + return + } + if !valid { + ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: issue.Repo.Name}) + return + } + + _, _, err = issue_service.ToggleAssigneeWithNotify(ctx, issue, ctx.Doer, assigneeID) + if err != nil { + ctx.ServerError("ToggleAssignee", err) + return + } + } + } + ctx.JSONOK() +} + +// UpdatePullReviewRequest add or remove review request +func UpdatePullReviewRequest(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + reviewID := ctx.FormInt64("id") + action := ctx.FormString("action") + + // TODO: Not support 'clear' now + if action != "attach" && action != "detach" { + ctx.Status(http.StatusForbidden) + return + } + + for _, issue := range issues { + if err := issue.LoadRepo(ctx); err != nil { + ctx.ServerError("issue.LoadRepo", err) + return + } + + if !issue.IsPull { + log.Warn( + "UpdatePullReviewRequest: refusing to add review request for non-PR issue %-v#%d", + issue.Repo, issue.Index, + ) + ctx.Status(http.StatusForbidden) + return + } + if reviewID < 0 { + // negative reviewIDs represent team requests + if err := issue.Repo.LoadOwner(ctx); err != nil { + ctx.ServerError("issue.Repo.LoadOwner", err) + return + } + + if !issue.Repo.Owner.IsOrganization() { + log.Warn( + "UpdatePullReviewRequest: refusing to add team review request for %s#%d owned by non organization UID[%d]", + issue.Repo.FullName(), issue.Index, issue.Repo.ID, + ) + ctx.Status(http.StatusForbidden) + return + } + + team, err := organization.GetTeamByID(ctx, -reviewID) + if err != nil { + ctx.ServerError("GetTeamByID", err) + return + } + + if team.OrgID != issue.Repo.OwnerID { + log.Warn( + "UpdatePullReviewRequest: refusing to add team review request for UID[%d] team %s to %s#%d owned by UID[%d]", + team.OrgID, team.Name, issue.Repo.FullName(), issue.Index, issue.Repo.ID) + ctx.Status(http.StatusForbidden) + return + } + + err = issue_service.IsValidTeamReviewRequest(ctx, team, ctx.Doer, action == "attach", issue) + if err != nil { + if issues_model.IsErrNotValidReviewRequest(err) { + log.Warn( + "UpdatePullReviewRequest: refusing to add invalid team review request for UID[%d] team %s to %s#%d owned by UID[%d]: Error: %v", + team.OrgID, team.Name, issue.Repo.FullName(), issue.Index, issue.Repo.ID, + err, + ) + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("IsValidTeamReviewRequest", err) + return + } + + _, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach") + if err != nil { + ctx.ServerError("TeamReviewRequest", err) + return + } + continue + } + + reviewer, err := user_model.GetUserByID(ctx, reviewID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + log.Warn( + "UpdatePullReviewRequest: requested reviewer [%d] for %-v to %-v#%d is not exist: Error: %v", + reviewID, issue.Repo, issue.Index, + err, + ) + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("GetUserByID", err) + return + } + + err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, action == "attach", issue, nil) + if err != nil { + if issues_model.IsErrNotValidReviewRequest(err) { + log.Warn( + "UpdatePullReviewRequest: refusing to add invalid review request for %-v to %-v#%d: Error: %v", + reviewer, issue.Repo, issue.Index, + err, + ) + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("isValidReviewRequest", err) + return + } + + _, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach") + if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("ReviewRequest", err) + return + } + } + + ctx.JSONOK() +} + +// SearchIssues searches for issues across the repositories that the user has access to +func SearchIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + log.Error("GetQueryBeforeSince: %v", err) + ctx.Error(http.StatusUnprocessableEntity, "invalid before or since") + 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 = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + log.Error("GetUserByName: %v", err) + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError) + } + 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 { + log.Error("GetTeam: %v", err) + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest) + } else { + ctx.Error(http.StatusInternalServerError) + } + 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 { + log.Error("SearchRepositoryIDs: %v", err) + ctx.Error(http.StatusInternalServerError) + 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 = "" + } + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + isPull = optional.Some(false) + } + + 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 { + log.Error("GetLabelIDsByNames: %v", err) + ctx.Error(http.StatusInternalServerError) + 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 { + log.Error("GetMilestoneIDsByNames: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + } + + projectID := optional.None[int64]() + if v := ctx.FormInt64("project"); v > 0 { + projectID = optional.Some(v) + } + + // 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{ + Page: ctx.FormInt("page"), + PageSize: limit, + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsPull: isPull, + IsClosed: isClosed, + IncludedAnyLabelIDs: includedAnyLabels, + MilestoneIDs: includedMilestones, + ProjectID: projectID, + 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 { + log.Error("SearchIssues: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + log.Error("GetIssuesByIDs: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) +} + +func getUserIDForFilter(ctx *context.Context, 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.Error(http.StatusInternalServerError, err.Error()) + return 0 + } + + return user.ID +} + +// ListIssues list the issues of a repository +func ListIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + 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, err.Error()) + 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, err.Error()) + 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, err.Error()) + } + } + + projectID := optional.None[int64]() + if v := ctx.FormInt64("project"); v > 0 { + projectID = optional.Some(v) + } + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + 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: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + ProjectID: projectID, + 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.Error()) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) +} + +func BatchDeleteIssues(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + for _, issue := range issues { + if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { + ctx.ServerError("DeleteIssue", err) + return + } + } + ctx.JSONOK() +} + +// UpdateIssueStatus change issue's status +func UpdateIssueStatus(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + var isClosed bool + switch action := ctx.FormString("action"); action { + case "open": + isClosed = false + case "close": + isClosed = true + default: + log.Warn("Unrecognized action: %s", action) + } + + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + if err := issues.LoadPullRequests(ctx); err != nil { + ctx.ServerError("LoadPullRequests", err) + return + } + + for _, issue := range issues { + if issue.IsPull && issue.PullRequest.HasMerged { + continue + } + if issue.IsClosed != isClosed { + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.JSON(http.StatusPreconditionFailed, map[string]any{ + "error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index), + }) + return + } + ctx.ServerError("ChangeStatus", err) + return + } + } + } + ctx.JSONOK() +} + +// NewComment create a comment for issue +func NewComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateCommentForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + issue.PosterID, + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { + ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked")) + return + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + var comment *issues_model.Comment + defer func() { + // Check if issue admin/poster changes the status of issue. + if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) && + (form.Status == "reopen" || form.Status == "close") && + !(issue.IsPull && issue.PullRequest.HasMerged) { + // Duplication and conflict check should apply to reopen pull request. + var pr *issues_model.PullRequest + + if form.Status == "reopen" && issue.IsPull { + pull := issue.PullRequest + var err error + pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + return + } + } + + // Regenerate patch and test conflict. + if pr == nil { + issue.PullRequest.HeadCommitID = "" + pull_service.AddToTaskQueue(ctx, issue.PullRequest) + } + + // check whether the ref of PR in base repo is consistent with the head commit of head branch in the head repo + // get head commit of PR + if pull.Flow == issues_model.PullRequestFlowGithub { + if err := pull.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("Unable to load base repo", err) + return + } + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("Unable to load head repo", err) + return + } + + // Check if the base branch of the pull request still exists. + if ok := git.IsBranchExist(ctx, pull.BaseRepo.RepoPath(), pull.BaseBranch); !ok { + ctx.JSONError(ctx.Tr("repo.pulls.reopen_failed.base_branch")) + return + } + + // Check if the head branch of the pull request still exists. + if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.HeadBranch); !ok { + ctx.JSONError(ctx.Tr("repo.pulls.reopen_failed.head_branch")) + return + } + + prHeadRef := pull.GetGitRefName() + prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef) + if err != nil { + ctx.ServerError("Get head commit Id of pr fail", err) + return + } + + headBranchRef := pull.GetGitHeadBranchRefName() + headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef) + if err != nil { + ctx.ServerError("Get head commit Id of head branch fail", err) + return + } + + err = pull.LoadIssue(ctx) + if err != nil { + ctx.ServerError("load the issue of pull request error", err) + return + } + + if prHeadCommitID != headBranchCommitID { + // force push to base repo + err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{ + Remote: pull.BaseRepo.RepoPath(), + Branch: pull.HeadBranch + ":" + prHeadRef, + Force: true, + Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo), + }) + if err != nil { + ctx.ServerError("force push error", err) + return + } + } + } + } + + if pr != nil { + ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) + } else { + isClosed := form.Status == "close" + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + log.Error("ChangeStatus: %v", err) + + if issues_model.IsErrDependenciesLeft(err) { + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + } + return + } + } else { + if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { + ctx.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) + } + } + } + + // Redirect to comment hashtag if there is any actual content. + typeName := "issues" + if issue.IsPull { + typeName = "pulls" + } + if comment != nil { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) + } else { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + } + }() + + // Fix #321: Allow empty comments, as long as we have attachments. + if len(form.Content) == 0 && len(attachments) == 0 { + return + } + + comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) + if err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_by_user")) + } else { + ctx.ServerError("CreateIssueComment", err) + } + return + } + + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) +} + +// UpdateCommentContent change comment of issue's content +func UpdateCommentContent(ctx *context.Context) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + oldContent := comment.Content + newContent := ctx.FormString("content") + contentVersion := ctx.FormInt("content_version") + + comment.Content = newContent + if err = issue_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, issues_model.ErrCommentAlreadyChanged) { + ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) + } else { + ctx.ServerError("UpdateComment", err) + } + return + } + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + // when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates + if !ctx.FormBool("ignore_attachments") { + if err := updateAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + } + + content, err := markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": content, + "contentVersion": comment.ContentVersion, + "attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content), + }) +} + +// DeleteComment delete comment of issue +func DeleteComment(ctx *context.Context) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } else if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + if err = issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.ServerError("DeleteComment", err) + return + } + + ctx.Status(http.StatusOK) +} + +// ChangeIssueReaction create a reaction for issue +func ChangeIssueReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + issue.PosterID, + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if ctx.HasError() { + ctx.ServerError("ChangeIssueReaction", errors.New(ctx.GetErrMsg())) + return + } + + switch ctx.Params(":action") { + case "react": + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateIssueReaction: %s", err) + break + } + + log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) + case "unreact": + if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil { + ctx.ServerError("DeleteIssueReaction", err) + return + } + + log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) + default: + ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) + return + } + + // Reload new reactions + issue.Reactions = nil + if err := issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("ChangeIssueReaction.LoadAttributes", err) + return + } + + if len(issue.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ctxData": ctx.Data, + "ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), + "Reactions": issue.Reactions.GroupByType(), + }) + if err != nil { + ctx.ServerError("ChangeIssueReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} + +// ChangeCommentReaction create a reaction for comment +func ChangeCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if comment.Issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + comment.Issue.PosterID, + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + switch ctx.Params(":action") { + case "react": + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Content) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } + log.Info("CreateCommentReaction: %s", err) + break + } + + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID, reaction.ID) + case "unreact": + if err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID) + default: + ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) + return + } + + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("ChangeCommentReaction.LoadReactions", err) + return + } + + if len(comment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ctxData": ctx.Data, + "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), + "Reactions": comment.Reactions.GroupByType(), + }) + if err != nil { + ctx.ServerError("ChangeCommentReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} + +func addParticipant(poster *user_model.User, participants []*user_model.User) []*user_model.User { + for _, part := range participants { + if poster.ID == part.ID { + return participants + } + } + return append(participants, poster) +} + +func filterXRefComments(ctx *context.Context, issue *issues_model.Issue) error { + // Remove comments that the user has no permissions to see + for i := 0; i < len(issue.Comments); { + c := issue.Comments[i] + if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issue.RepoID && 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 err + } + perm, err := access_model.GetUserRepoPermission(ctx, c.RefRepo, ctx.Doer) + if err != nil { + return err + } + if !perm.CanReadIssuesOrPulls(c.RefIsPull) { + issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) + continue + } + } + i++ + } + return nil +} + +// GetIssueAttachments returns attachments for the issue +func GetIssueAttachments(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + attachments := make([]*api.Attachment, len(issue.Attachments)) + for i := 0; i < len(issue.Attachments); i++ { + attachments[i] = convert.ToAttachment(ctx.Repo.Repository, issue.Attachments[i]) + } + ctx.JSON(http.StatusOK, attachments) +} + +// GetCommentAttachments returns attachments for the comment +func GetCommentAttachments(ctx *context.Context) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound("CanReadIssuesOrPulls", issues_model.ErrCommentNotExist{}) + return + } + + if !comment.Type.HasAttachmentSupport() { + ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type)) + return + } + + attachments := make([]*api.Attachment, 0) + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + for i := 0; i < len(comment.Attachments); i++ { + attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i])) + } + ctx.JSON(http.StatusOK, attachments) +} + +func updateAttachments(ctx *context.Context, item any, files []string) error { + var attachments []*repo_model.Attachment + switch content := item.(type) { + case *issues_model.Issue: + attachments = content.Attachments + case *issues_model.Comment: + attachments = content.Attachments + default: + return fmt.Errorf("unknown Type: %T", content) + } + for i := 0; i < len(attachments); i++ { + if util.SliceContainsString(files, attachments[i].UUID) { + continue + } + if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil { + return err + } + } + var err error + if len(files) > 0 { + switch content := item.(type) { + case *issues_model.Issue: + err = issues_model.UpdateIssueAttachments(ctx, content.ID, files) + case *issues_model.Comment: + err = content.UpdateAttachments(ctx, files) + default: + return fmt.Errorf("unknown Type: %T", content) + } + if err != nil { + return err + } + } + switch content := item.(type) { + case *issues_model.Issue: + content.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, content.ID) + case *issues_model.Comment: + content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID) + default: + return fmt.Errorf("unknown Type: %T", content) + } + return err +} + +func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment, content string) template.HTML { + attachHTML, err := ctx.RenderToHTML(tplAttachment, map[string]any{ + "ctxData": ctx.Data, + "Attachments": attachments, + "Content": content, + }) + if err != nil { + ctx.ServerError("attachmentsHTML.HTMLString", err) + return "" + } + return attachHTML +} + +// combineLabelComments combine the nearby label comments as one. +func combineLabelComments(issue *issues_model.Issue) { + var prev, cur *issues_model.Comment + for i := 0; i < len(issue.Comments); i++ { + cur = issue.Comments[i] + if i > 0 { + prev = issue.Comments[i-1] + } + if i == 0 || cur.Type != issues_model.CommentTypeLabel || + (prev != nil && prev.PosterID != cur.PosterID) || + (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { + if cur.Type == issues_model.CommentTypeLabel && cur.Label != nil { + if cur.Content != "1" { + cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) + } else { + cur.AddedLabels = append(cur.AddedLabels, cur.Label) + } + } + continue + } + + if cur.Label != nil { // now cur MUST be label comment + if prev.Type == issues_model.CommentTypeLabel { // we can combine them only prev is a label comment + if cur.Content != "1" { + // remove labels from the AddedLabels list if the label that was removed is already + // in this list, and if it's not in this list, add the label to RemovedLabels + addedAndRemoved := false + for i, label := range prev.AddedLabels { + if cur.Label.ID == label.ID { + prev.AddedLabels = append(prev.AddedLabels[:i], prev.AddedLabels[i+1:]...) + addedAndRemoved = true + break + } + } + if !addedAndRemoved { + prev.RemovedLabels = append(prev.RemovedLabels, cur.Label) + } + } else { + // remove labels from the RemovedLabels list if the label that was added is already + // in this list, and if it's not in this list, add the label to AddedLabels + removedAndAdded := false + for i, label := range prev.RemovedLabels { + if cur.Label.ID == label.ID { + prev.RemovedLabels = append(prev.RemovedLabels[:i], prev.RemovedLabels[i+1:]...) + removedAndAdded = true + break + } + } + if !removedAndAdded { + prev.AddedLabels = append(prev.AddedLabels, cur.Label) + } + } + prev.CreatedUnix = cur.CreatedUnix + // remove the current comment since it has been combined to prev comment + issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) + i-- + } else { // if prev is not a label comment, start a new group + if cur.Content != "1" { + cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) + } else { + cur.AddedLabels = append(cur.AddedLabels, cur.Label) + } + } + } + } +} + +// get all teams that current user can mention +func handleTeamMentions(ctx *context.Context) { + if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() { + return + } + + var isAdmin bool + var err error + var teams []*organization.Team + org := organization.OrgFromUser(ctx.Repo.Owner) + // Admin has super access. + if ctx.Doer.IsAdmin { + isAdmin = true + } else { + isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("IsOwnedBy", err) + return + } + } + + if isAdmin { + teams, err = org.LoadTeams(ctx) + if err != nil { + ctx.ServerError("LoadTeams", err) + return + } + } else { + teams, err = org.GetUserTeams(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserTeams", err) + return + } + } + + ctx.Data["MentionableTeams"] = teams + ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name + ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx) +} + +type userSearchInfo struct { + UserID int64 `json:"user_id"` + UserName string `json:"username"` + AvatarLink string `json:"avatar_link"` + FullName string `json:"full_name"` +} + +type userSearchResponse struct { + Results []*userSearchInfo `json:"results"` +} + +// IssuePosters get posters for current repo's issues/pull requests +func IssuePosters(ctx *context.Context) { + issuePosters(ctx, false) +} + +func PullPosters(ctx *context.Context) { + issuePosters(ctx, true) +} + +func issuePosters(ctx *context.Context, isPullList bool) { + repo := ctx.Repo.Repository + search := strings.TrimSpace(ctx.FormString("q")) + posters, err := repo_model.GetIssuePostersWithSearch(ctx, repo, isPullList, search, setting.UI.DefaultShowFullName) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + + if search == "" && ctx.Doer != nil { + // the returned posters slice only contains limited number of users, + // to make the current user (doer) can quickly filter their own issues, always add doer to the posters slice + if !slices.ContainsFunc(posters, func(user *user_model.User) bool { return user.ID == ctx.Doer.ID }) { + posters = append(posters, ctx.Doer) + } + } + + posters = MakeSelfOnTop(ctx.Doer, posters) + + resp := &userSearchResponse{} + resp.Results = make([]*userSearchInfo, len(posters)) + for i, user := range posters { + resp.Results[i] = &userSearchInfo{UserID: user.ID, UserName: user.Name, AvatarLink: user.AvatarLink(ctx)} + if setting.UI.DefaultShowFullName { + resp.Results[i].FullName = user.FullName + } + } + ctx.JSON(http.StatusOK, resp) +} diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go new file mode 100644 index 0000000..16b250a --- /dev/null +++ b/routers/web/repo/issue_content_history.go @@ -0,0 +1,237 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "html" + "net/http" + "strings" + + "code.gitea.io/gitea/models/avatars" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/context" + + "github.com/sergi/go-diff/diffmatchpatch" +) + +// GetContentHistoryOverview get overview +func GetContentHistoryOverview(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + editedHistoryCountMap, _ := issues_model.QueryIssueContentHistoryEditedCountMap(ctx, issue.ID) + ctx.JSON(http.StatusOK, map[string]any{ + "i18n": map[string]any{ + "textEdited": ctx.Tr("repo.issues.content_history.edited"), + "textDeleteFromHistory": ctx.Tr("repo.issues.content_history.delete_from_history"), + "textDeleteFromHistoryConfirm": ctx.Tr("repo.issues.content_history.delete_from_history_confirm"), + "textOptions": ctx.Tr("repo.issues.content_history.options"), + }, + "editedHistoryCountMap": editedHistoryCountMap, + }) +} + +// GetContentHistoryList get list +func GetContentHistoryList(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + commentID := ctx.FormInt64("comment_id") + items, _ := issues_model.FetchIssueContentHistoryList(ctx, issue.ID, commentID) + + // render history list to HTML for frontend dropdown items: (name, value) + // name is HTML of "avatar + userName + userAction + timeSince" + // value is historyId + var results []map[string]any + for _, item := range items { + var actionText string + if item.IsDeleted { + actionTextDeleted := ctx.Locale.TrString("repo.issues.content_history.deleted") + actionText = "" + actionTextDeleted + "" + } else if item.IsFirstCreated { + actionText = ctx.Locale.TrString("repo.issues.content_history.created") + } else { + actionText = ctx.Locale.TrString("repo.issues.content_history.edited") + } + + username := item.UserName + if setting.UI.DefaultShowFullName && strings.TrimSpace(item.UserFullName) != "" { + username = strings.TrimSpace(item.UserFullName) + } + + src := html.EscapeString(item.UserAvatarLink) + class := avatars.DefaultAvatarClass + " tw-mr-2" + name := html.EscapeString(username) + avatarHTML := string(templates.AvatarHTML(src, 28, class, username)) + timeSinceText := string(timeutil.TimeSinceUnix(item.EditedUnix, ctx.Locale)) + + results = append(results, map[string]any{ + "name": avatarHTML + "" + name + " " + actionText + " " + timeSinceText, + "value": item.HistoryID, + }) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "results": results, + }) +} + +// canSoftDeleteContentHistory checks whether current user can soft-delete a history revision +// Admins or owners can always delete history revisions. Normal users can only delete own history revisions. +func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue, comment *issues_model.Comment, + history *issues_model.ContentHistory, +) (canSoftDelete bool) { + // CanWrite means the doer can manage the issue/PR list + if ctx.Repo.IsOwner() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + canSoftDelete = true + } else if ctx.Doer == nil { + canSoftDelete = false + } else { + // for read-only users, they could still post issues or comments, + // they should be able to delete the history related to their own issue/comment, a case is: + // 1. the user posts some sensitive data + // 2. then the repo owner edits the post but didn't remove the sensitive data + // 3. the poster wants to delete the edited history revision + if comment == nil { + // the issue poster or the history poster can soft-delete + canSoftDelete = ctx.Doer.ID == issue.PosterID || ctx.Doer.ID == history.PosterID + canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) + } else { + // the comment poster or the history poster can soft-delete + canSoftDelete = ctx.Doer.ID == comment.PosterID || ctx.Doer.ID == history.PosterID + canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) + canSoftDelete = canSoftDelete && (history.CommentID == comment.ID) + } + } + return canSoftDelete +} + +// GetContentHistoryDetail get detail +func GetContentHistoryDetail(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + historyID := ctx.FormInt64("history_id") + history, prevHistory, err := issues_model.GetIssueContentHistoryAndPrev(ctx, issue.ID, historyID) + if err != nil { + ctx.JSON(http.StatusNotFound, map[string]any{ + "message": "Can not find the content history", + }) + return + } + + // get the related comment if this history revision is for a comment, otherwise the history revision is for an issue. + var comment *issues_model.Comment + if history.CommentID != 0 { + var err error + if comment, err = issues_model.GetCommentByID(ctx, history.CommentID); err != nil { + log.Error("can not get comment for issue content history %v. err=%v", historyID, err) + return + } + } + + // get the previous history revision (if exists) + var prevHistoryID int64 + var prevHistoryContentText string + if prevHistory != nil { + prevHistoryID = prevHistory.ID + prevHistoryContentText = prevHistory.ContentText + } + + // compare the current history revision with the previous one + dmp := diffmatchpatch.New() + // `checklines=false` makes better diff result + diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false) + diff = dmp.DiffCleanupSemantic(diff) + diff = dmp.DiffCleanupEfficiency(diff) + + // use chroma to render the diff html + diffHTMLBuf := bytes.Buffer{} + diffHTMLBuf.WriteString("
")
+	for _, it := range diff {
+		if it.Type == diffmatchpatch.DiffInsert {
+			diffHTMLBuf.WriteString("")
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+			diffHTMLBuf.WriteString("")
+		} else if it.Type == diffmatchpatch.DiffDelete {
+			diffHTMLBuf.WriteString("")
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+			diffHTMLBuf.WriteString("")
+		} else {
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+		}
+	}
+	diffHTMLBuf.WriteString("
") + + ctx.JSON(http.StatusOK, map[string]any{ + "canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history), + "historyId": historyID, + "prevHistoryId": prevHistoryID, + "diffHtml": diffHTMLBuf.String(), + }) +} + +// SoftDeleteContentHistory soft delete +func SoftDeleteContentHistory(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + commentID := ctx.FormInt64("comment_id") + historyID := ctx.FormInt64("history_id") + + var comment *issues_model.Comment + var history *issues_model.ContentHistory + var err error + + if history, err = issues_model.GetIssueContentHistoryByID(ctx, historyID); err != nil { + log.Error("can not get issue content history %v. err=%v", historyID, err) + return + } + if history.IssueID != issue.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + if commentID != 0 { + if history.CommentID != commentID { + ctx.NotFound("CompareCommentID", issues_model.ErrCommentNotExist{}) + return + } + + if comment, err = issues_model.GetCommentByID(ctx, commentID); err != nil { + log.Error("can not get comment for issue content history %v. err=%v", historyID, err) + return + } + if comment.IssueID != issue.ID { + ctx.NotFound("CompareIssueID", issues_model.ErrCommentNotExist{}) + return + } + } + + canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history) + if !canSoftDelete { + ctx.JSON(http.StatusForbidden, map[string]any{ + "message": "Can not delete the content history", + }) + return + } + + err = issues_model.SoftDeleteIssueContentHistory(ctx, historyID) + log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": err == nil, + }) +} diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go new file mode 100644 index 0000000..66b3868 --- /dev/null +++ b/routers/web/repo/issue_dependency.go @@ -0,0 +1,144 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// AddDependency adds new dependencies +func AddDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have dependencies + if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.FormInt64("newDependency") + + if err = issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Redirect + defer ctx.Redirect(issue.Link()) + + // Dependency + dep, err := issues_model.GetIssueByID(ctx, depID) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_issue_not_exist")) + return + } + + // Check if both issues are in the same repo if cross repository dependencies is not enabled + if issue.RepoID != dep.RepoID { + if !setting.Service.AllowCrossRepositoryDependencies { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) + return + } + if err := dep.LoadRepo(ctx); err != nil { + ctx.ServerError("loadRepo", err) + return + } + // Can ctx.Doer read issues in the dep repo? + depRepoPerm, err := access_model.GetUserRepoPermission(ctx, dep.Repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) { + // you can't see this dependency + return + } + } + + // Check if issue and dependency is the same + if dep.ID == issue.ID { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_same_issue")) + return + } + + err = issues_model.CreateIssueDependency(ctx, ctx.Doer, issue, dep) + if err != nil { + if issues_model.IsErrDependencyExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_exists")) + return + } else if issues_model.IsErrCircularDependency(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_cannot_create_circular")) + return + } + ctx.ServerError("CreateOrUpdateIssueDependency", err) + return + } +} + +// RemoveDependency removes the dependency +func RemoveDependency(ctx *context.Context) { + issueIndex := ctx.ParamsInt64("index") + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + + // Check if the Repo is allowed to have dependencies + if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { + ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + return + } + + depID := ctx.FormInt64("removeDependencyID") + + if err = issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Dependency Type + depTypeStr := ctx.Req.PostFormValue("dependencyType") + + var depType issues_model.DependencyType + + switch depTypeStr { + case "blockedBy": + depType = issues_model.DependencyTypeBlockedBy + case "blocking": + depType = issues_model.DependencyTypeBlocking + default: + ctx.Error(http.StatusBadRequest, "GetDependecyType") + return + } + + // Dependency + dep, err := issues_model.GetIssueByID(ctx, depID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil { + if issues_model.IsErrDependencyNotExists(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist")) + return + } + ctx.ServerError("RemoveIssueDependency", err) + return + } + + // Redirect + ctx.Redirect(issue.Link()) +} diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go new file mode 100644 index 0000000..81bee4d --- /dev/null +++ b/routers/web/repo/issue_label.go @@ -0,0 +1,229 @@ +// Copyright 2017 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" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/label" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" +) + +const ( + tplLabels base.TplName = "repo/issue/labels" +) + +// Labels render issue's labels page +func Labels(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsLabels"] = true + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + ctx.HTML(http.StatusOK, tplLabels) +} + +// InitializeLabels init labels for a repository +func InitializeLabels(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.InitializeLabelsForm) + if ctx.HasError() { + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + + if err := repo_module.InitializeLabels(ctx, ctx.Repo.Repository.ID, form.TemplateName, false); err != nil { + if label.IsErrTemplateLoad(err) { + originalErr := err.(label.ErrTemplateLoad).OriginalError + ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr)) + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + ctx.ServerError("InitializeLabels", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// RetrieveLabels find all the labels of a repository and organization +func RetrieveLabels(ctx *context.Context) { + labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("RetrieveLabels.GetLabels", err) + return + } + + for _, l := range labels { + l.CalOpenIssues() + } + + ctx.Data["Labels"] = labels + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + for _, l := range orgLabels { + l.CalOpenOrgIssues(ctx, ctx.Repo.Repository.ID, l.ID) + } + ctx.Data["OrgLabels"] = orgLabels + + org, err := organization.GetOrgByName(ctx, ctx.Repo.Owner.LowerName) + if err != nil { + ctx.ServerError("GetOrgByName", err) + return + } + if ctx.Doer != nil { + ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("org.IsOwnedBy", err) + return + } + ctx.Org.OrgLink = org.AsUser().OrganisationLink() + ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner + ctx.Data["OrganizationLink"] = ctx.Org.OrgLink + } + } + ctx.Data["NumLabels"] = len(labels) + ctx.Data["SortType"] = ctx.FormString("sort") +} + +// NewLabel create new label for repository +func NewLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + ctx.Data["Title"] = ctx.Tr("repo.labels") + ctx.Data["PageIsLabels"] = true + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(ctx.Repo.RepoLink + "/labels") + return + } + + l := &issues_model.Label{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, + } + if err := issues_model.NewLabel(ctx, l); err != nil { + ctx.ServerError("NewLabel", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// UpdateLabel update a label's name and color +func UpdateLabel(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, form.ID) + if err != nil { + switch { + case issues_model.IsErrRepoLabelNotExist(err): + ctx.Error(http.StatusNotFound) + default: + ctx.ServerError("UpdateLabel", err) + } + return + } + l.Name = form.Title + l.Exclusive = form.Exclusive + l.Description = form.Description + l.Color = form.Color + + l.SetArchived(form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.ServerError("UpdateLabel", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/labels") +} + +// DeleteLabel delete a label +func DeleteLabel(ctx *context.Context) { + if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteLabel: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/labels") +} + +// UpdateIssueLabel change issue's labels +func UpdateIssueLabel(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + switch action := ctx.FormString("action"); action { + case "clear": + for _, issue := range issues { + if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("ClearLabels", err) + return + } + } + case "attach", "detach", "toggle", "toggle-alt": + label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id")) + if err != nil { + if issues_model.IsErrRepoLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + + if action == "toggle" { + // detach if any issues already have label, otherwise attach + action = "attach" + if label.ExclusiveScope() == "" { + for _, issue := range issues { + if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) { + action = "detach" + break + } + } + } + } else if action == "toggle-alt" { + // always detach with alt key pressed, to be able to remove + // scoped labels + action = "detach" + } + + if action == "attach" { + for _, issue := range issues { + if err = issue_service.AddLabel(ctx, issue, ctx.Doer, label); err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + } else { + for _, issue := range issues { + if err = issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil { + ctx.ServerError("RemoveLabel", err) + return + } + } + } + default: + log.Warn("Unrecognized action: %s", action) + ctx.Error(http.StatusInternalServerError) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go new file mode 100644 index 0000000..2b4915e --- /dev/null +++ b/routers/web/repo/issue_label_test.go @@ -0,0 +1,173 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strconv" + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func int64SliceToCommaSeparated(a []int64) string { + s := "" + for i, n := range a { + if i > 0 { + s += "," + } + s += strconv.Itoa(int(n)) + } + return s +} + +func TestInitializeLabels(t *testing.T) { + unittest.PrepareTestEnv(t) + require.NoError(t, repository.LoadRepoConfig()) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/initialize") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) + web.SetForm(ctx, &forms.InitializeLabelsForm{TemplateName: "Default"}) + InitializeLabels(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + RepoID: 2, + Name: "enhancement", + Color: "#84b6eb", + }) + assert.Equal(t, "/user2/repo2/labels", test.RedirectURL(ctx.Resp)) +} + +func TestRetrieveLabels(t *testing.T) { + unittest.PrepareTestEnv(t) + for _, testCase := range []struct { + RepoID int64 + Sort string + ExpectedLabelIDs []int64 + }{ + {1, "", []int64{1, 2}}, + {1, "leastissues", []int64{2, 1}}, + {2, "", []int64{}}, + } { + ctx, _ := contexttest.MockContext(t, "user/repo/issues") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, testCase.RepoID) + ctx.Req.Form.Set("sort", testCase.Sort) + RetrieveLabels(ctx) + assert.False(t, ctx.Written()) + labels, ok := ctx.Data["Labels"].([]*issues_model.Label) + assert.True(t, ok) + if assert.Len(t, labels, len(testCase.ExpectedLabelIDs)) { + for i, label := range labels { + assert.EqualValues(t, testCase.ExpectedLabelIDs[i], label.ID) + } + } + } +} + +func TestNewLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + Title: "newlabel", + Color: "#abcdef", + }) + NewLabel(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + Name: "newlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestUpdateLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + ID: 2, + Title: "newnameforlabel", + Color: "#abcdef", + IsArchived: true, + }) + UpdateLabel(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + ID: 2, + Name: "newnameforlabel", + Color: "#abcdef", + }) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) +} + +func TestDeleteLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("id", "2") + DeleteLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + unittest.AssertNotExistsBean(t, &issues_model.Label{ID: 2}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{LabelID: 2}) + assert.EqualValues(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) +} + +func TestUpdateIssueLabel_Clear(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("issue_ids", "1,3") + ctx.Req.Form.Set("action", "clear") + UpdateIssueLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 1}) + unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 3}) + unittest.CheckConsistencyFor(t, &issues_model.Label{}) +} + +func TestUpdateIssueLabel_Toggle(t *testing.T) { + for _, testCase := range []struct { + Action string + IssueIDs []int64 + LabelID int64 + ExpectedAdd bool // whether we expect the label to be added to the issues + }{ + {"attach", []int64{1, 3}, 1, true}, + {"detach", []int64{1, 3}, 1, false}, + {"toggle", []int64{1, 3}, 1, false}, + {"toggle", []int64{1, 2}, 2, true}, + } { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.Req.Form.Set("issue_ids", int64SliceToCommaSeparated(testCase.IssueIDs)) + ctx.Req.Form.Set("action", testCase.Action) + ctx.Req.Form.Set("id", strconv.Itoa(int(testCase.LabelID))) + UpdateIssueLabel(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + for _, issueID := range testCase.IssueIDs { + unittest.AssertExistsIf(t, testCase.ExpectedAdd, &issues_model.IssueLabel{ + IssueID: issueID, + LabelID: testCase.LabelID, + }) + } + unittest.CheckConsistencyFor(t, &issues_model.Label{}) + } +} diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go new file mode 100644 index 0000000..1d5fc8a --- /dev/null +++ b/routers/web/repo/issue_lock.go @@ -0,0 +1,65 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// LockIssue locks an issue. This would limit commenting abilities to +// users with write access to the repo. +func LockIssue(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.IssueLockForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if issue.IsLocked { + ctx.JSONError(ctx.Tr("repo.issues.lock_duplicate")) + return + } + + if !form.HasValidReason() { + ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason")) + return + } + + if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ + Doer: ctx.Doer, + Issue: issue, + Reason: form.Reason, + }); err != nil { + ctx.ServerError("LockIssue", err) + return + } + + ctx.JSONRedirect(issue.Link()) +} + +// UnlockIssue unlocks a previously locked issue. +func UnlockIssue(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !issue.IsLocked { + ctx.JSONError(ctx.Tr("repo.issues.unlock_error")) + return + } + + if err := issues_model.UnlockIssue(ctx, &issues_model.IssueLockOptions{ + Doer: ctx.Doer, + Issue: issue, + }); err != nil { + ctx.ServerError("UnlockIssue", err) + return + } + + ctx.JSONRedirect(issue.Link()) +} diff --git a/routers/web/repo/issue_pin.go b/routers/web/repo/issue_pin.go new file mode 100644 index 0000000..365c812 --- /dev/null +++ b/routers/web/repo/issue_pin.go @@ -0,0 +1,107 @@ +// 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" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// IssuePinOrUnpin pin or unpin a Issue +func IssuePinOrUnpin(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + 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.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + err = issue.PinOrUnpin(ctx, ctx.Doer) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.JSONRedirect(issue.Link()) +} + +// IssueUnpin unpins a Issue +func IssueUnpin(ctx *context.Context) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + 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.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + err = issue.Unpin(ctx, ctx.Doer) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.Status(http.StatusNoContent) +} + +// IssuePinMove moves a pinned Issue +func IssuePinMove(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.") + return + } + + type movePinIssueForm struct { + ID int64 `json:"id"` + Position int `json:"position"` + } + + form := &movePinIssueForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + issue, err := issues_model.GetIssueByID(ctx, form.ID) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + log.Error("Issue does not belong to this repository") + return + } + + err = issue.MovePin(ctx, form.Position) + if err != nil { + ctx.Status(http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go new file mode 100644 index 0000000..70d42b2 --- /dev/null +++ b/routers/web/repo/issue_stopwatch.go @@ -0,0 +1,113 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/services/context" +) + +// IssueStopwatch creates or stops a stopwatch for the given issue. +func IssueStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + + var showSuccessMessage bool + + if !issues_model.StopwatchExists(c, c.Doer.ID, issue.ID) { + showSuccessMessage = true + } + + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + if showSuccessMessage { + c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) + } + + url := issue.Link() + c.Redirect(url, http.StatusSeeOther) +} + +// CancelStopwatch cancel the stopwatch +func CancelStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + if err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("CancelStopwatch", err) + return + } + + stopwatches, err := issues_model.GetUserStopwatches(c, c.Doer.ID, db.ListOptions{}) + if err != nil { + c.ServerError("GetUserStopwatches", err) + return + } + if len(stopwatches) == 0 { + eventsource.GetManager().SendMessage(c.Doer.ID, &eventsource.Event{ + Name: "stopwatches", + Data: "{}", + }) + } + + url := issue.Link() + c.Redirect(url, http.StatusSeeOther) +} + +// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context +func GetActiveStopwatch(ctx *context.Context) { + if strings.HasPrefix(ctx.Req.URL.Path, "/api") { + return + } + + if !ctx.IsSigned { + return + } + + _, sw, issue, err := issues_model.HasUserStopwatch(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("HasUserStopwatch", err) + return + } + + if sw == nil || sw.ID == 0 { + return + } + + ctx.Data["ActiveStopwatch"] = StopwatchTmplInfo{ + issue.Link(), + issue.Repo.FullName(), + issue.Index, + sw.Seconds() + 1, // ensure time is never zero in ui + } +} + +// StopwatchTmplInfo is a view on a stopwatch specifically for template rendering +type StopwatchTmplInfo struct { + IssueLink string + RepoSlug string + IssueIndex int64 + Seconds int64 +} diff --git a/routers/web/repo/issue_test.go b/routers/web/repo/issue_test.go new file mode 100644 index 0000000..f1d0aac --- /dev/null +++ b/routers/web/repo/issue_test.go @@ -0,0 +1,375 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + + "github.com/stretchr/testify/assert" +) + +func TestCombineLabelComments(t *testing.T) { + kases := []struct { + name string + beforeCombined []*issues_model.Comment + afterCombined []*issues_model.Comment + }{ + { + name: "kase 1", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{}, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 2", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 70, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + CreatedUnix: 70, + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 3", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 2, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 0, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 2, + Content: "", + CreatedUnix: 0, + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 1, + Content: "test", + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 4", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/backport", + }, + CreatedUnix: 10, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + CreatedUnix: 10, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + { + Name: "kind/backport", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + }, + }, + }, + { + name: "kase 5", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + AddedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeComment, + PosterID: 2, + Content: "testtest", + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + RemovedLabels: []*issues_model.Label{ + { + Name: "kind/bug", + }, + }, + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + }, + }, + { + name: "kase 6", + beforeCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "reviewed/confirmed", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + CreatedUnix: 0, + }, + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/feature", + }, + CreatedUnix: 0, + }, + }, + afterCombined: []*issues_model.Comment{ + { + Type: issues_model.CommentTypeLabel, + PosterID: 1, + Content: "1", + Label: &issues_model.Label{ + Name: "kind/bug", + }, + AddedLabels: []*issues_model.Label{ + { + Name: "reviewed/confirmed", + }, + { + Name: "kind/feature", + }, + }, + CreatedUnix: 0, + }, + }, + }, + } + + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + issue := issues_model.Issue{ + Comments: kase.beforeCombined, + } + combineLabelComments(&issue) + assert.EqualValues(t, kase.afterCombined, issue.Comments) + }) + } +} diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go new file mode 100644 index 0000000..241e434 --- /dev/null +++ b/routers/web/repo/issue_timetrack.go @@ -0,0 +1,87 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// AddTimeManually tracks time manually +func AddTimeManually(c *context.Context) { + form := web.GetForm(c).(*forms.AddTimeManuallyForm) + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + url := issue.Link() + + if c.HasError() { + c.Flash.Error(c.GetErrMsg()) + c.Redirect(url) + return + } + + total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute + + if total <= 0 { + c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) + c.Redirect(url, http.StatusSeeOther) + return + } + + if _, err := issues_model.AddTime(c, c.Doer, issue, int64(total.Seconds()), time.Now()); err != nil { + c.ServerError("AddTime", err) + return + } + + c.Redirect(url, http.StatusSeeOther) +} + +// DeleteTime deletes tracked time +func DeleteTime(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return + } + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound("CanUseTimetracker", nil) + return + } + + t, err := issues_model.GetTrackedTimeByID(c, c.ParamsInt64(":timeid")) + if err != nil { + if db.IsErrNotExist(err) { + c.NotFound("time not found", err) + return + } + c.Error(http.StatusInternalServerError, "GetTrackedTimeByID", err.Error()) + return + } + + // only OP or admin may delete + if !c.IsSigned || (!c.IsUserSiteAdmin() && c.Doer.ID != t.UserID) { + c.Error(http.StatusForbidden, "not allowed") + return + } + + if err = issues_model.DeleteTime(c, t); err != nil { + c.ServerError("DeleteTime", err) + return + } + + c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) + c.Redirect(issue.Link()) +} diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go new file mode 100644 index 0000000..5cff9f4 --- /dev/null +++ b/routers/web/repo/issue_watch.go @@ -0,0 +1,63 @@ +// Copyright 2017 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/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +const ( + tplWatching base.TplName = "repo/issue/view_content/sidebar/watching" +) + +// IssueWatch sets issue watching +func IssueWatch(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if log.IsTrace() { + if ctx.IsSigned { + issueType := "issues" + if issue.IsPull { + issueType = "pulls" + } + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + issue.PosterID, + issueType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + ctx.Error(http.StatusForbidden) + return + } + + watch, err := strconv.ParseBool(ctx.Req.PostFormValue("watch")) + if err != nil { + ctx.ServerError("watch is not bool", err) + return + } + + if err := issues_model.CreateOrUpdateIssueWatch(ctx, ctx.Doer.ID, issue.ID, watch); err != nil { + ctx.ServerError("CreateOrUpdateIssueWatch", err) + return + } + + ctx.Data["Issue"] = issue + ctx.Data["IssueWatch"] = &issues_model.IssueWatch{IsWatching: watch} + ctx.HTML(http.StatusOK, tplWatching) +} diff --git a/routers/web/repo/main_test.go b/routers/web/repo/main_test.go new file mode 100644 index 0000000..6e469cf --- /dev/null +++ b/routers/web/repo/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go new file mode 100644 index 0000000..ddda9f3 --- /dev/null +++ b/routers/web/repo/middlewares.go @@ -0,0 +1,120 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "strconv" + + system_model "code.gitea.io/gitea/models/system" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +// SetEditorconfigIfExists set editor config as render variable +func SetEditorconfigIfExists(ctx *context.Context) { + if ctx.Repo.Repository.IsEmpty { + return + } + + ec, _, err := ctx.Repo.GetEditorconfig() + + if err != nil && !git.IsErrNotExist(err) { + description := fmt.Sprintf("Error while getting .editorconfig file: %v", err) + if err := system_model.CreateRepositoryNotice(description); err != nil { + ctx.ServerError("ErrCreatingReporitoryNotice", err) + } + return + } + + ctx.Data["Editorconfig"] = ec +} + +// SetDiffViewStyle set diff style as render variable +func SetDiffViewStyle(ctx *context.Context) { + queryStyle := ctx.FormString("style") + + if !ctx.IsSigned { + ctx.Data["IsSplitStyle"] = queryStyle == "split" + return + } + + var ( + userStyle = ctx.Doer.DiffViewStyle + style string + ) + + if queryStyle == "unified" || queryStyle == "split" { + style = queryStyle + } else if userStyle == "unified" || userStyle == "split" { + style = userStyle + } else { + style = "unified" + } + + ctx.Data["IsSplitStyle"] = style == "split" + + opts := &user_service.UpdateOptions{ + DiffViewStyle: optional.Some(style), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + } +} + +// SetWhitespaceBehavior set whitespace behavior as render variable +func SetWhitespaceBehavior(ctx *context.Context) { + const defaultWhitespaceBehavior = "show-all" + whitespaceBehavior := ctx.FormString("whitespace") + switch whitespaceBehavior { + case "", "ignore-all", "ignore-eol", "ignore-change": + break + default: + whitespaceBehavior = defaultWhitespaceBehavior + } + if ctx.IsSigned { + userWhitespaceBehavior, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, defaultWhitespaceBehavior) + if err == nil { + if whitespaceBehavior == "" { + whitespaceBehavior = userWhitespaceBehavior + } else if whitespaceBehavior != userWhitespaceBehavior { + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, whitespaceBehavior) + } + } // else: we can ignore the error safely + } + + // these behaviors are for gitdiff.GetWhitespaceFlag + if whitespaceBehavior == "" { + ctx.Data["WhitespaceBehavior"] = defaultWhitespaceBehavior + } else { + ctx.Data["WhitespaceBehavior"] = whitespaceBehavior + } +} + +// SetShowOutdatedComments set the show outdated comments option as context variable +func SetShowOutdatedComments(ctx *context.Context) { + showOutdatedCommentsValue := ctx.FormString("show-outdated") + // var showOutdatedCommentsValue string + + if showOutdatedCommentsValue != "true" && showOutdatedCommentsValue != "false" { + // invalid or no value for this form string -> use default or stored user setting + if ctx.IsSigned { + showOutdatedCommentsValue, _ = user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, "false") + } else { + // not logged in user -> use the default value + showOutdatedCommentsValue = "false" + } + } else { + // valid value -> update user setting if user is logged in + if ctx.IsSigned { + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, showOutdatedCommentsValue) + } + } + + showOutdatedComments, _ := strconv.ParseBool(showOutdatedCommentsValue) + ctx.Data["ShowOutdatedComments"] = showOutdatedComments +} diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go new file mode 100644 index 0000000..0acf966 --- /dev/null +++ b/routers/web/repo/migrate.go @@ -0,0 +1,310 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models" + admin_model "code.gitea.io/gitea/models/admin" + "code.gitea.io/gitea/models/db" + 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/base" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "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/forms" + "code.gitea.io/gitea/services/migrations" + "code.gitea.io/gitea/services/task" +) + +const ( + tplMigrate base.TplName = "repo/migrate/migrate" +) + +// Migrate render migration of repository page +func Migrate(ctx *context.Context) { + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "Migrate: the site administrator has disabled migrations") + return + } + + serviceType := structs.GitServiceType(ctx.FormInt("service_type")) + + setMigrationContextData(ctx, serviceType) + + if serviceType == 0 { + ctx.Data["Org"] = ctx.FormString("org") + ctx.Data["Mirror"] = ctx.FormString("mirror") + + ctx.HTML(http.StatusOK, tplMigrate) + return + } + + ctx.Data["private"] = getRepoPrivate(ctx) + ctx.Data["mirror"] = ctx.FormString("mirror") == "1" + ctx.Data["lfs"] = ctx.FormString("lfs") == "1" + ctx.Data["wiki"] = ctx.FormString("wiki") == "1" + ctx.Data["milestones"] = ctx.FormString("milestones") == "1" + ctx.Data["labels"] = ctx.FormString("labels") == "1" + ctx.Data["issues"] = ctx.FormString("issues") == "1" + ctx.Data["pull_requests"] = ctx.FormString("pull_requests") == "1" + ctx.Data["releases"] = ctx.FormString("releases") == "1" + + ctxUser := checkContextUser(ctx, ctx.FormInt64("org")) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + ctx.HTML(http.StatusOK, base.TplName("repo/migrate/"+serviceType.Name())) +} + +func handleMigrateError(ctx *context.Context, owner *user_model.User, err error, name string, tpl base.TplName, form *forms.MigrateRepoForm) { + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "MigrateError: the site administrator has disabled migrations") + return + } + + switch { + case migrations.IsRateLimitError(err): + ctx.RenderWithErr(ctx.Tr("form.visit_rate_limit"), tpl, form) + case migrations.IsTwoFactorAuthError(err): + ctx.RenderWithErr(ctx.Tr("form.2fa_auth_required"), tpl, form) + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tpl, form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form) + } + case db.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) + 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.Data["Err_Auth"] = true + ctx.RenderWithErr(ctx.Tr("form.auth_failed", err.Error()), tpl, form) + } else if strings.Contains(err.Error(), "fatal:") { + ctx.Data["Err_CloneAddr"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.failed", err.Error()), tpl, form) + } else { + ctx.ServerError(name, err) + } + } +} + +func handleMigrateRemoteAddrError(ctx *context.Context, err error, tpl base.TplName, form *forms.MigrateRepoForm) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsProtocolInvalid: + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_protocol_invalid"), tpl, form) + case addrErr.IsURLError: + ctx.RenderWithErr(ctx.Tr("form.url_error", addrErr.Host), tpl, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tpl, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tpl, form) + } + case addrErr.IsInvalidPath: + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tpl, form) + default: + log.Error("Error whilst updating url: %v", err) + ctx.RenderWithErr(ctx.Tr("form.url_error", "unknown"), tpl, form) + } + } else { + log.Error("Error whilst updating url: %v", err) + ctx.RenderWithErr(ctx.Tr("form.url_error", "unknown"), tpl, form) + } +} + +// MigratePost response for migrating from external git repository +func MigratePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MigrateRepoForm) + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "MigratePost: the site administrator has disabled migrations") + return + } + + if form.Mirror && setting.Mirror.DisableNewPull { + ctx.Error(http.StatusBadRequest, "MigratePost: the site administrator has disabled creation of new mirrors") + return + } + + setMigrationContextData(ctx, form.Service) + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + tpl := base.TplName("repo/migrate/" + form.Service.Name()) + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tpl) + return + } + + remoteAddr, err := forms.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.Doer) + } + if err != nil { + ctx.Data["Err_CloneAddr"] = true + handleMigrateRemoteAddrError(ctx, err, tpl, form) + return + } + + form.LFS = form.LFS && setting.LFS.StartServer + + if form.LFS && len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Data["Err_LFSEndpoint"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tpl, &form) + return + } + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleMigrateRemoteAddrError(ctx, err, tpl, form) + return + } + } + + opts := migrations.MigrateOptions{ + OriginalURL: form.CloneAddr, + GitServiceType: form.Service, + 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, + } + if opts.Mirror { + opts.Issues = false + opts.Milestones = false + opts.Labels = false + opts.Comments = false + opts.PullRequests = false + opts.Releases = false + } + + err = repo_model.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false) + if err != nil { + handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) + return + } + + err = task.MigrateRepository(ctx, ctx.Doer, ctxUser, opts) + if err == nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(opts.RepoName)) + return + } + + handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) +} + +func setMigrationContextData(ctx *context.Context, serviceType structs.GitServiceType) { + ctx.Data["Title"] = ctx.Tr("new_migrate.title") + + ctx.Data["LFSActive"] = setting.LFS.StartServer + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + + // Plain git should be first + ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...) + ctx.Data["service"] = serviceType +} + +func MigrateRetryPost(ctx *context.Context) { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + if err := task.SetMigrateTaskMessage(ctx, ctx.Repo.Repository.ID, ctx.Locale.TrString("repo.settings.pull_mirror_sync_quota_exceeded")); err != nil { + log.Error("SetMigrateTaskMessage failed: %v", err) + ctx.ServerError("task.SetMigrateTaskMessage", err) + return + } + ctx.JSON(http.StatusRequestEntityTooLarge, map[string]any{ + "ok": false, + "error": ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), + }) + return + } + + if err := task.RetryMigrateTask(ctx, ctx.Repo.Repository.ID); err != nil { + log.Error("Retry task failed: %v", err) + ctx.ServerError("task.RetryMigrateTask", err) + return + } + ctx.JSONOK() +} + +func MigrateCancelPost(ctx *context.Context) { + migratingTask, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) + if err != nil { + log.Error("GetMigratingTask: %v", err) + ctx.Redirect(ctx.Repo.Repository.Link()) + return + } + if migratingTask.Status == structs.TaskStatusRunning { + taskUpdate := &admin_model.Task{ID: migratingTask.ID, Status: structs.TaskStatusFailed, Message: "canceled"} + if err = taskUpdate.UpdateCols(ctx, "status", "message"); err != nil { + ctx.ServerError("task.UpdateCols", err) + return + } + } + ctx.Redirect(ctx.Repo.Repository.Link()) +} diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go new file mode 100644 index 0000000..1c53f73 --- /dev/null +++ b/routers/web/repo/milestone.go @@ -0,0 +1,304 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/issue" + + "xorm.io/builder" +) + +const ( + tplMilestone base.TplName = "repo/issue/milestones" + tplMilestoneNew base.TplName = "repo/issue/milestone_new" + tplMilestoneIssues base.TplName = "repo/issue/milestone_issues" +) + +// Milestones render milestones page +func Milestones(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + + isShowClosed := ctx.FormString("state") == "closed" + sortType := ctx.FormString("sort") + keyword := ctx.FormTrim("q") + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + miles, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoID: ctx.Repo.Repository.ID, + IsClosed: optional.Some(isShowClosed), + SortType: sortType, + Name: keyword, + }) + if err != nil { + ctx.ServerError("GetMilestones", err) + return + } + + stats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, builder.And(builder.Eq{"id": ctx.Repo.Repository.ID}), keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + ctx.Data["OpenCount"] = stats.OpenCount + ctx.Data["ClosedCount"] = stats.ClosedCount + linkStr := "%s/milestones?state=%s&q=%s&sort=%s" + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "open", + url.QueryEscape(keyword), url.QueryEscape(sortType)) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "closed", + url.QueryEscape(keyword), url.QueryEscape(sortType)) + + if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + if err := issues_model.MilestoneList(miles).LoadTotalTrackedTimes(ctx); err != nil { + ctx.ServerError("LoadTotalTrackedTimes", err) + return + } + } + for _, m := range miles { + m.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, m.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } + ctx.Data["Milestones"] = miles + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + ctx.Data["SortType"] = sortType + ctx.Data["Keyword"] = keyword + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "q", "Keyword") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplMilestone) +} + +// NewMilestone render creating milestone page +func NewMilestone(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + ctx.HTML(http.StatusOK, tplMilestoneNew) +} + +// NewMilestonePost response for creating milestone +func NewMilestonePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateMilestoneForm) + ctx.Data["Title"] = ctx.Tr("repo.milestones.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["PageIsMilestones"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMilestoneNew) + return + } + + if len(form.Deadline) == 0 { + form.Deadline = "9999-12-31" + } + deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local) + if err != nil { + ctx.Data["Err_Deadline"] = true + ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), tplMilestoneNew, &form) + return + } + + deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) + if err = issues_model.NewMilestone(ctx, &issues_model.Milestone{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Content: form.Content, + DeadlineUnix: timeutil.TimeStamp(deadline.Unix()), + }); err != nil { + ctx.ServerError("NewMilestone", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.milestones.create_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/milestones") +} + +// EditMilestone render edting milestone page +func EditMilestone(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.milestones.edit") + ctx.Data["PageIsMilestones"] = true + ctx.Data["PageIsEditMilestone"] = true + + m, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetMilestoneByRepoID", err) + } + return + } + ctx.Data["title"] = m.Name + ctx.Data["content"] = m.Content + if len(m.DeadlineString) > 0 { + ctx.Data["deadline"] = m.DeadlineString + } + ctx.HTML(http.StatusOK, tplMilestoneNew) +} + +// EditMilestonePost response for edting milestone +func EditMilestonePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateMilestoneForm) + ctx.Data["Title"] = ctx.Tr("repo.milestones.edit") + ctx.Data["PageIsMilestones"] = true + ctx.Data["PageIsEditMilestone"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMilestoneNew) + return + } + + if len(form.Deadline) == 0 { + form.Deadline = "9999-12-31" + } + deadline, err := time.ParseInLocation("2006-01-02", form.Deadline, time.Local) + if err != nil { + ctx.Data["Err_Deadline"] = true + ctx.RenderWithErr(ctx.Tr("repo.milestones.invalid_due_date_format"), tplMilestoneNew, &form) + return + } + + deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) + m, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetMilestoneByRepoID", err) + } + return + } + m.Name = form.Title + m.Content = form.Content + m.DeadlineUnix = timeutil.TimeStamp(deadline.Unix()) + if err = issues_model.UpdateMilestone(ctx, m, m.IsClosed); err != nil { + ctx.ServerError("UpdateMilestone", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name)) + ctx.Redirect(ctx.Repo.RepoLink + "/milestones") +} + +// ChangeMilestoneStatus response for change a milestone's status +func ChangeMilestoneStatus(ctx *context.Context) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones") + return + } + id := ctx.ParamsInt64(":id") + + if err := issues_model.ChangeMilestoneStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) + } + return + } + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.Params(":action"))) +} + +// DeleteMilestone delete a milestone +func DeleteMilestone(ctx *context.Context) { + if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones") +} + +// MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone +func MilestoneIssuesAndPulls(ctx *context.Context) { + milestoneID := ctx.ParamsInt64(":id") + projectID := ctx.FormInt64("project") + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound("GetMilestoneByID", err) + return + } + + ctx.ServerError("GetMilestoneByID", err) + return + } + + milestone.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, milestone.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["Title"] = milestone.Name + ctx.Data["Milestone"] = milestone + + issues(ctx, milestoneID, projectID, optional.None[bool]()) + + ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["NewIssueChooseTemplate"] = len(ret) > 0 + + ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) + ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) + + ctx.HTML(http.StatusOK, tplMilestoneIssues) +} diff --git a/routers/web/repo/packages.go b/routers/web/repo/packages.go new file mode 100644 index 0000000..11874ab --- /dev/null +++ b/routers/web/repo/packages.go @@ -0,0 +1,78 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplPackagesList base.TplName = "repo/packages" +) + +// Packages displays a list of all packages in the repository +func Packages(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + + pvs, total, err := packages.SearchLatestVersions(ctx, &packages.PackageSearchOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + OwnerID: ctx.ContextUser.ID, + RepoID: ctx.Repo.Repository.ID, + Type: packages.Type(packageType), + Name: packages.SearchValue{Value: query}, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + + pds, err := packages.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + hasPackages, err := packages.HasRepositoryPackages(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("HasRepositoryPackages", err) + return + } + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages.TypeList + ctx.Data["HasPackages"] = hasPackages + if ctx.Repo != nil { + ctx.Data["CanWritePackages"] = ctx.IsUserRepoWriter([]unit.Type{unit.TypePackages}) || ctx.IsUserSiteAdmin() + } + ctx.Data["PackageDescriptors"] = pds + ctx.Data["Total"] = total + ctx.Data["RepositoryAccessMap"] = map[int64]bool{ctx.Repo.Repository.ID: true} // There is only the current repository + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParam(ctx, "q", "Query") + pager.AddParam(ctx, "type", "PackageType") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go new file mode 100644 index 0000000..d234f6c --- /dev/null +++ b/routers/web/repo/patch.go @@ -0,0 +1,124 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +const ( + tplPatchFile base.TplName = "repo/editor/patch" +) + +// NewDiffPatch render create patch page +func NewDiffPatch(ctx *context.Context) { + canCommit := renderCommitRights(ctx) + + ctx.Data["PageIsPatch"] = true + + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + if canCommit { + ctx.Data["commit_choice"] = frmCommitChoiceDirect + } else { + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + } + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + + ctx.HTML(200, tplPatchFile) +} + +// NewDiffPatchPost response for sending patch page +func NewDiffPatchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditRepoFileForm) + + canCommit := renderCommitRights(ctx) + branchName := ctx.Repo.BranchName + if form.CommitChoice == frmCommitChoiceNewBranch { + branchName = form.NewBranchName + } + ctx.Data["PageIsPatch"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["FileContent"] = form.Content + ctx.Data["commit_summary"] = form.CommitSummary + ctx.Data["commit_message"] = form.CommitMessage + ctx.Data["commit_choice"] = form.CommitChoice + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") + + if ctx.HasError() { + ctx.HTML(200, tplPatchFile) + return + } + + // Cannot commit to a an existing branch if user doesn't have rights + if branchName == ctx.Repo.BranchName && !canCommit { + ctx.Data["Err_NewBranchName"] = true + ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + return + } + + // CommitSummary is optional in the web form, if empty, give it a default message based on add or update + // `message` will be both the summary and message combined + message := strings.TrimSpace(form.CommitSummary) + if len(message) == 0 { + message = ctx.Locale.TrString("repo.editor.patch") + } + + form.CommitMessage = strings.TrimSpace(form.CommitMessage) + if len(form.CommitMessage) > 0 { + message += "\n\n" + form.CommitMessage + } + + gitIdenitity := getGitIdentity(ctx, form.CommitMailID, tplPatchFile, &form) + if ctx.Written() { + return + } + + fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, + NewBranch: branchName, + Message: message, + Content: strings.ReplaceAll(form.Content, "\r", ""), + Author: gitIdenitity, + Committer: gitIdenitity, + }) + if err != nil { + if git_model.IsErrBranchAlreadyExists(err) { + // User has specified a branch that already exists + branchErr := err.(git_model.ErrBranchAlreadyExists) + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + return + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) + return + } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return + } + + if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + fileResponse.Commit.SHA) + } +} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go new file mode 100644 index 0000000..878b7ee --- /dev/null +++ b/routers/web/repo/projects.go @@ -0,0 +1,670 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/perm" + project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "repo/projects/list" + tplProjectsNew base.TplName = "repo/projects/new" + tplProjectsView base.TplName = "repo/projects/view" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableRepoProjects", nil) + return + } + + if ctx.Repo.Repository != nil { + if !ctx.Repo.CanRead(unit.TypeProjects) { + ctx.NotFound("MustEnableProjects", nil) + return + } + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + keyword := ctx.FormTrim("q") + repo := ctx.Repo.Repository + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + ctx.Data["OpenCount"] = repo.NumOpenProjects + ctx.Data["ClosedCount"] = repo.NumClosedProjects + + var total int + if !isShowClosed { + total = repo.NumOpenProjects + } else { + total = repo.NumClosedProjects + } + + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.IssuePagingNum, + Page: page, + }, + RepoID: repo.ID, + IsClosed: optional.Some(isShowClosed), + OrderBy: project_model.GetSearchOrderByBySortType(sortType), + Type: project_model.TypeRepository, + Title: keyword, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + for i := range projects { + projects[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, projects[i].Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } + + ctx.Data["Projects"] = projects + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + numPages := 0 + if count > 0 { + numPages = (int(count) - 1/setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["IsProjectsPage"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +// RenderNewProject render creating a project page +func RenderNewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs() + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CancelLink"] = ctx.Repo.Repository.Link() + "/projects" + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + + if ctx.HasError() { + RenderNewProject(ctx) + return + } + + if err := project_model.NewProject(ctx, &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + TemplateType: form.TemplateType, + CardType: form.CardType, + Type: project_model.TypeRepository, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + var toClose bool + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") + return + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSONRedirect(fmt.Sprintf("%s/projects/%d", ctx.Repo.RepoLink, id)) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") +} + +// RenderEditProject allows a project to be edited +func RenderEditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["projectID"] = p.ID + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + ctx.Data["card_type"] = p.CardType + ctx.Data["redirect"] = ctx.FormString("redirect") + ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), p.ID) + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + projectID := ctx.ParamsInt64(":id") + + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CardTypes"] = project_model.GetCardConfig() + ctx.Data["CancelLink"] = fmt.Sprintf("%s/projects/%d", ctx.Repo.Repository.Link(), projectID) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = form.CardType + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + if ctx.FormString("redirect") == "project" { + ctx.Redirect(p.Link(ctx)) + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } +} + +// ViewProject renders the project with board view +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + ctx.Data["LinkedPRs"] = linkedPrsMap + + project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, project.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.Data["IsProjectsPage"] = true + ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Columns"] = columns + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + if err := issues.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } + + projectID := ctx.FormInt64("id") + for _, issue := range issues { + if issue.Project != nil && issue.Project.ID == projectID { + continue + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + continue + } + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + + ctx.JSONOK() +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.ParamsInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSONOK() +} + +// AddColumnToProjectPost allows a new column to be added to a project. +func AddColumnToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := project_model.NewColumn(ctx, &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSONOK() +} + +func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, ctx.Repo.Repository.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectColumnForm) + _, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSONOK() +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.Context) { + project, column := checkProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSONOK() +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotExist", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.NotFound("ProjectColumnNotExist", nil) + } else { + ctx.ServerError("GetProjectColumn", err) + } + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IssueNotExisting", nil) + } else { + ctx.ServerError("GetIssueByID", err) + } + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go new file mode 100644 index 0000000..d61230a --- /dev/null +++ b/routers/web/repo/projects_test.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectColumnChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + ctx.SetParams(":id", "1") + ctx.SetParams(":columnID", "2") + + project, column := checkProjectColumnChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, column) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go new file mode 100644 index 0000000..bc85012 --- /dev/null +++ b/routers/web/repo/pull.go @@ -0,0 +1,1838 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "html" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + pull_model "code.gitea.io/gitea/models/pull" + 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/base" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + issue_template "code.gitea.io/gitea/modules/issue/template" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/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/context/upload" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/gitdiff" + notify_service "code.gitea.io/gitea/services/notify" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/gobwas/glob" +) + +const ( + tplFork base.TplName = "repo/pulls/fork" + tplCompareDiff base.TplName = "repo/diff/compare" + tplPullCommits base.TplName = "repo/pulls/commits" + tplPullFiles base.TplName = "repo/pulls/files" + + pullRequestTemplateKey = "PullRequestTemplate" +) + +var pullRequestTemplateCandidates = []string{ + "PULL_REQUEST_TEMPLATE.md", + "PULL_REQUEST_TEMPLATE.yaml", + "PULL_REQUEST_TEMPLATE.yml", + "pull_request_template.md", + "pull_request_template.yaml", + "pull_request_template.yml", + ".forgejo/PULL_REQUEST_TEMPLATE.md", + ".forgejo/PULL_REQUEST_TEMPLATE.yaml", + ".forgejo/PULL_REQUEST_TEMPLATE.yml", + ".forgejo/pull_request_template.md", + ".forgejo/pull_request_template.yaml", + ".forgejo/pull_request_template.yml", + ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/PULL_REQUEST_TEMPLATE.yaml", + ".gitea/PULL_REQUEST_TEMPLATE.yml", + ".gitea/pull_request_template.md", + ".gitea/pull_request_template.yaml", + ".gitea/pull_request_template.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE.yaml", + ".github/PULL_REQUEST_TEMPLATE.yml", + ".github/pull_request_template.md", + ".github/pull_request_template.yaml", + ".github/pull_request_template.yml", +} + +func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", nil) + } else { + ctx.ServerError("GetRepositoryByID", err) + } + return nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil + } + + if !perm.CanRead(unit.TypeCode) { + log.Trace("Permission Denied: User %-v cannot read %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctx.Doer, + unit.TypeCode, + ctx.Repo, + perm) + ctx.NotFound("getRepository", nil) + return nil + } + return repo +} + +func updateForkRepositoryInContext(ctx *context.Context, forkRepo *repo_model.Repository) bool { + if forkRepo == nil { + ctx.NotFound("No repository in context", nil) + return false + } + + if forkRepo.IsEmpty { + log.Trace("Empty repository %-v", forkRepo) + ctx.NotFound("updateForkRepositoryInContext", nil) + return false + } + + if err := forkRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return false + } + + ctx.Data["repo_name"] = forkRepo.Name + ctx.Data["description"] = forkRepo.Description + ctx.Data["IsPrivate"] = forkRepo.IsPrivate || forkRepo.Owner.Visibility == structs.VisibleTypePrivate + canForkToUser := forkRepo.OwnerID != ctx.Doer.ID && !repo_model.HasForkedRepo(ctx, ctx.Doer.ID, forkRepo.ID) + + ctx.Data["ForkRepo"] = forkRepo + + ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return false + } + var orgs []*organization.Organization + for _, org := range ownedOrgs { + if forkRepo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, forkRepo.ID) { + orgs = append(orgs, org) + } + } + + traverseParentRepo := forkRepo + for { + if ctx.Doer.ID == traverseParentRepo.OwnerID { + canForkToUser = false + } else { + for i, org := range orgs { + if org.ID == traverseParentRepo.OwnerID { + orgs = append(orgs[:i], orgs[i+1:]...) + break + } + } + } + + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return false + } + } + + ctx.Data["CanForkToUser"] = canForkToUser + ctx.Data["Orgs"] = orgs + + if canForkToUser { + ctx.Data["ContextUser"] = ctx.Doer + } else if len(orgs) > 0 { + ctx.Data["ContextUser"] = orgs[0] + } else { + ctx.Data["CanForkRepo"] = false + ctx.RenderWithErr(ctx.Tr("repo.fork_no_valid_owners"), tplFork, nil) + return false + } + + branches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: db.ListOptions{ + ListAll: true, + }, + IsDeletedBranch: optional.Some(false), + // Add it as the first option + ExcludeBranchNames: []string{ctx.Repo.Repository.DefaultBranch}, + }) + if err != nil { + ctx.ServerError("FindBranchNames", err) + return false + } + ctx.Data["Branches"] = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + + return true +} + +// ForkByID redirects (with 301 Moved Permanently) to the repository's `/fork` page +func ForkByID(ctx *context.Context) { + ctx.Redirect(ctx.Repo.Repository.Link()+"/fork", http.StatusMovedPermanently) +} + +// Fork renders the repository fork page +func Fork(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_fork") + + if ctx.Doer.CanForkRepo() { + ctx.Data["CanForkRepo"] = true + } else { + maxCreationLimit := ctx.Doer.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg, true) + } + + if !updateForkRepositoryInContext(ctx, ctx.Repo.Repository) { + return + } + + ctx.HTML(http.StatusOK, tplFork) +} + +// ForkPost response for forking a repository +func ForkPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateRepoForm) + ctx.Data["Title"] = ctx.Tr("new_fork") + ctx.Data["CanForkRepo"] = true + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + forkRepo := ctx.Repo.Repository + if !updateForkRepositoryInContext(ctx, forkRepo) { + return + } + + ctx.Data["ContextUser"] = ctxUser + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplFork) + return + } + + var err error + traverseParentRepo := forkRepo + for { + if ctxUser.ID == traverseParentRepo.OwnerID { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + return + } + repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) + if repo != nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + return + } + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + } + + // Check if user is allowed to create repo's on the organization. + if ctxUser.IsOrganization() { + isAllowedToFork, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return + } else if !isAllowedToFork { + ctx.Error(http.StatusForbidden) + return + } + } + + repo, err := repo_service.ForkRepositoryAndUpdates(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ + BaseRepo: forkRepo, + Name: form.RepoName, + Description: form.Description, + SingleBranch: form.ForkSingleBranch, + }) + if err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := ctxUser.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tplFork, &form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) + } + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + default: + ctx.ServerError("ForkPost", err) + } + return + } + + log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) +} + +func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return nil, false + } + if err = issue.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return nil, false + } + if err := issue.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return nil, false + } + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) + ctx.Data["Issue"] = issue + + if !issue.IsPull { + ctx.NotFound("ViewPullCommits", nil) + return nil, false + } + + if err = issue.LoadPullRequest(ctx); err != nil { + ctx.ServerError("LoadPullRequest", err) + return nil, false + } + + if err = issue.PullRequest.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil, false + } + + if ctx.IsSigned { + // Update issue-user. + if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return nil, false + } + } + + return issue, true +} + +func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) { + if ctx.Repo.Owner.Name == pull.MustHeadUserName(ctx) { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + ":" + pull.HeadBranch + } else { + ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch + } + + if pull.Flow == issues_model.PullRequestFlowAGit { + ctx.Data["MadeUsingAGit"] = true + } + + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["HeadBranchLink"] = pull.GetHeadBranchLink(ctx) + ctx.Data["BaseBranchLink"] = pull.GetBaseBranchLink(ctx) +} + +// GetPullDiffStats get Pull Requests diff stats +func GetPullDiffStats(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + mergeBaseCommitID := GetMergedBaseCommitID(ctx, issue) + + if mergeBaseCommitID == "" { + ctx.NotFound("PullFiles", nil) + return + } + + headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + diffOptions := &gitdiff.DiffOptions{ + BeforeCommitID: mergeBaseCommitID, + AfterCommitID: headCommitID, + MaxLines: setting.Git.MaxGitDiffLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: setting.Git.MaxGitDiffFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + } + + diff, err := gitdiff.GetPullDiffStats(ctx.Repo.GitRepo, diffOptions) + if err != nil { + ctx.ServerError("GetPullDiffStats", err) + return + } + + ctx.Data["Diff"] = diff +} + +func GetMergedBaseCommitID(ctx *context.Context, issue *issues_model.Issue) string { + pull := issue.PullRequest + + var baseCommit string + // Some migrated PR won't have any Base SHA and lose history, try to get one + if pull.MergeBase == "" { + var commitSHA, parentCommit string + // If there is a head or a patch file, and it is readable, grab info + commitSHA, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + // Head File does not exist, try the patch + commitSHA, err = ctx.Repo.GitRepo.ReadPatchCommit(pull.Index) + if err == nil { + // Recreate pull head in files for next time + if err := ctx.Repo.GitRepo.SetReference(pull.GetGitRefName(), commitSHA); err != nil { + log.Error("Could not write head file", err) + } + } else { + // There is no history available + log.Trace("No history file available for PR %d", pull.Index) + } + } + if commitSHA != "" { + // Get immediate parent of the first commit in the patch, grab history back + parentCommit, _, err = git.NewCommand(ctx, "rev-list", "-1", "--skip=1").AddDynamicArguments(commitSHA).RunStdString(&git.RunOpts{Dir: ctx.Repo.GitRepo.Path}) + if err == nil { + parentCommit = strings.TrimSpace(parentCommit) + } + // Special case on Git < 2.25 that doesn't fail on immediate empty history + if err != nil || parentCommit == "" { + log.Info("No known parent commit for PR %d, error: %v", pull.Index, err) + // bring at least partial history if it can work + parentCommit = commitSHA + } + } + baseCommit = parentCommit + } else { + // Keep an empty history or original commit + baseCommit = pull.MergeBase + } + + return baseCommit +} + +// PrepareMergedViewPullInfo show meta information for a merged pull request view page +func PrepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.CompareInfo { + pull := issue.PullRequest + + setMergeTarget(ctx, pull) + ctx.Data["HasMerged"] = true + + baseCommit := GetMergedBaseCommitID(ctx, issue) + + compareInfo, err := ctx.Repo.GitRepo.GetCompareInfo(ctx.Repo.Repository.RepoPath(), + baseCommit, pull.GetGitRefName(), false, false) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") || strings.Contains(err.Error(), "unknown revision or path not in the working tree") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + ctx.Data["NumCommits"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + + if len(compareInfo.Commits) != 0 { + sha := compareInfo.Commits[0].ID.String() + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) != 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + } + + return compareInfo +} + +// PrepareViewPullInfo show meta information for a pull request preview page +func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.CompareInfo { + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + + repo := ctx.Repo.Repository + pull := issue.PullRequest + + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return nil + } + + if err := pull.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return nil + } + + setMergeTarget(ctx, pull) + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return nil + } + ctx.Data["EnableStatusCheck"] = pb != nil && pb.EnableStatusCheck + + var baseGitRepo *git.Repository + if pull.BaseRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { + baseGitRepo = ctx.Repo.GitRepo + } else { + baseGitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer baseGitRepo.Close() + } + + if !baseGitRepo.IsBranchExist(pull.BaseBranch) { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["HeadTarget"] = pull.HeadBranch + + sha, err := baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) + return nil + } + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + pull.MergeBase, pull.GetGitRefName(), false, false) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + + ctx.Data["NumCommits"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo + } + + var headBranchExist bool + var headBranchSha string + // HeadRepo may be missing + if pull.HeadRepo != nil { + headGitRepo, err := gitrepo.OpenRepository(ctx, pull.HeadRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil + } + defer headGitRepo.Close() + + if pull.Flow == issues_model.PullRequestFlowGithub { + headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + } else { + headBranchExist = git.IsReferenceExist(ctx, baseGitRepo.Path, pull.GetGitRefName()) + } + + if headBranchExist { + if pull.Flow != issues_model.PullRequestFlowGithub { + headBranchSha, err = baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + } else { + headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + } + if err != nil { + ctx.ServerError("GetBranchCommitID", err) + return nil + } + } + } + + if headBranchExist { + var err error + ctx.Data["UpdateAllowed"], ctx.Data["UpdateByRebaseAllowed"], err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToUpdate", err) + return nil + } + ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(ctx, pull) + } else { + ctx.Data["GetCommitMessages"] = "" + } + + sha, err := baseGitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + if git.IsErrNotExist(err) { + ctx.Data["IsPullRequestBroken"] = true + if pull.IsSameRepo() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) + } else { + ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch + } + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) + return nil + } + + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return nil + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + if len(commitStatuses) > 0 { + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + } + + if pb != nil && pb.EnableStatusCheck { + var missingRequiredChecks []string + for _, requiredContext := range pb.StatusCheckContexts { + contextFound := false + matchesRequiredContext := createRequiredContextMatcher(requiredContext) + for _, presentStatus := range commitStatuses { + if matchesRequiredContext(presentStatus.Context) { + contextFound = true + break + } + } + + if !contextFound { + missingRequiredChecks = append(missingRequiredChecks, requiredContext) + } + } + ctx.Data["MissingRequiredChecks"] = missingRequiredChecks + + ctx.Data["is_context_required"] = func(context string) bool { + for _, c := range pb.StatusCheckContexts { + if c == context { + return true + } + if gp, err := glob.Compile(c); err != nil { + // All newly created status_check_contexts are checked to ensure they are valid glob expressions before being stored in the database. + // But some old status_check_context created before glob was introduced may be invalid glob expressions. + // So log the error here for debugging. + log.Error("compile glob %q: %v", c, err) + } else if gp.Match(context) { + return true + } + } + return false + } + ctx.Data["RequiredStatusCheckState"] = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pb.StatusCheckContexts) + } + + ctx.Data["HeadBranchMovedOn"] = headBranchSha != sha + ctx.Data["HeadBranchCommitID"] = headBranchSha + ctx.Data["PullHeadCommitID"] = sha + + if pull.HeadRepo == nil || !headBranchExist || (!pull.Issue.IsClosed && (headBranchSha != sha)) { + ctx.Data["IsPullRequestBroken"] = true + if pull.IsSameRepo() { + ctx.Data["HeadTarget"] = pull.HeadBranch + } else if pull.HeadRepo == nil { + ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) + } else { + ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch + } + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), + git.BranchPrefix+pull.BaseBranch, pull.GetGitRefName(), false, false) + if err != nil { + if strings.Contains(err.Error(), "fatal: Not a valid object name") { + ctx.Data["IsPullRequestBroken"] = true + ctx.Data["BaseTarget"] = pull.BaseBranch + ctx.Data["NumCommits"] = 0 + ctx.Data["NumFiles"] = 0 + return nil + } + + ctx.ServerError("GetCompareInfo", err) + return nil + } + + if compareInfo.HeadCommitID == compareInfo.MergeBase { + ctx.Data["IsNothingToCompare"] = true + } + + if pull.IsWorkInProgress(ctx) { + ctx.Data["IsPullWorkInProgress"] = true + ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx) + } + + if pull.IsFilesConflicted() { + ctx.Data["IsPullFilesConflicted"] = true + ctx.Data["ConflictedFiles"] = pull.ConflictedFiles + } + + ctx.Data["NumCommits"] = len(compareInfo.Commits) + ctx.Data["NumFiles"] = compareInfo.NumFiles + return compareInfo +} + +func createRequiredContextMatcher(requiredContext string) func(string) bool { + if gp, err := glob.Compile(requiredContext); err == nil { + return func(contextToCheck string) bool { + return gp.Match(contextToCheck) + } + } + + return func(contextToCheck string) bool { + return requiredContext == contextToCheck + } +} + +type pullCommitList struct { + Commits []pull_service.CommitInfo `json:"commits"` + LastReviewCommitSha string `json:"last_review_commit_sha"` + Locale map[string]any `json:"locale"` +} + +// GetPullCommits get all commits for given pull request +func GetPullCommits(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + resp := &pullCommitList{} + + commits, lastReviewCommitSha, err := pull_service.GetPullCommits(ctx, issue) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + + // Get the needed locale + resp.Locale = map[string]any{ + "lang": ctx.Locale.Language(), + "show_all_commits": ctx.Tr("repo.pulls.show_all_commits"), + "stats_num_commits": ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)), + "show_changes_since_your_last_review": ctx.Tr("repo.pulls.show_changes_since_your_last_review"), + "select_commit_hold_shift_for_range": ctx.Tr("repo.pulls.select_commit_hold_shift_for_range"), + } + + resp.Commits = commits + resp.LastReviewCommitSha = lastReviewCommitSha + + ctx.JSON(http.StatusOK, resp) +} + +// ViewPullCommits show commits for a pull request +func ViewPullCommits(ctx *context.Context) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullCommits"] = true + + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + var prInfo *git.CompareInfo + if pull.HasMerged { + prInfo = PrepareMergedViewPullInfo(ctx, issue) + } else { + prInfo = PrepareViewPullInfo(ctx, issue) + } + + if ctx.Written() { + return + } else if prInfo == nil { + ctx.NotFound("ViewPullCommits", nil) + return + } + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + + commits := processGitCommits(ctx, prInfo.Commits) + ctx.Data["Commits"] = commits + ctx.Data["CommitCount"] = len(commits) + + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + + // For PR commits page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + getBranchData(ctx, issue) + ctx.HTML(http.StatusOK, tplPullCommits) +} + +// ViewPullFiles render pull request changed files list page +func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommit string, willShowSpecifiedCommitRange, willShowSpecifiedCommit bool) { + ctx.Data["PageIsPullList"] = true + ctx.Data["PageIsPullFiles"] = true + + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + var ( + startCommitID string + endCommitID string + gitRepo = ctx.Repo.GitRepo + ) + + var prInfo *git.CompareInfo + if pull.HasMerged { + prInfo = PrepareMergedViewPullInfo(ctx, issue) + } else { + prInfo = PrepareViewPullInfo(ctx, issue) + } + + // Validate the given commit sha to show (if any passed) + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + foundStartCommit := len(specifiedStartCommit) == 0 + foundEndCommit := len(specifiedEndCommit) == 0 + + if !(foundStartCommit && foundEndCommit) { + for _, commit := range prInfo.Commits { + if commit.ID.String() == specifiedStartCommit { + foundStartCommit = true + } + if commit.ID.String() == specifiedEndCommit { + foundEndCommit = true + } + + if foundStartCommit && foundEndCommit { + break + } + } + } + + if !(foundStartCommit && foundEndCommit) { + ctx.NotFound("Given SHA1 not found for this PR", nil) + return + } + } + + if ctx.Written() { + return + } else if prInfo == nil { + ctx.NotFound("ViewPullFiles", nil) + return + } + + headCommitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + ctx.Data["IsShowingOnlySingleCommit"] = willShowSpecifiedCommit + + if willShowSpecifiedCommit || willShowSpecifiedCommitRange { + if len(specifiedEndCommit) > 0 { + endCommitID = specifiedEndCommit + } else { + endCommitID = headCommitID + } + if len(specifiedStartCommit) > 0 { + startCommitID = specifiedStartCommit + } else { + startCommitID = prInfo.MergeBase + } + ctx.Data["IsShowingAllCommits"] = false + } else { + endCommitID = headCommitID + startCommitID = prInfo.MergeBase + ctx.Data["IsShowingAllCommits"] = true + } + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["AfterCommitID"] = endCommitID + ctx.Data["BeforeCommitID"] = startCommitID + + fileOnly := ctx.FormBool("file-only") + + maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles + files := ctx.FormStrings("files") + if fileOnly && (len(files) == 2 || len(files) == 1) { + maxLines, maxFiles = -1, -1 + } + + diffOptions := &gitdiff.DiffOptions{ + AfterCommitID: endCommitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: maxFiles, + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), + } + + if !willShowSpecifiedCommit { + diffOptions.BeforeCommitID = startCommitID + } + + var methodWithError string + var diff *gitdiff.Diff + + // if we're not logged in or only a single commit (or commit range) is shown we + // have to load only the diff and not get the viewed information + // as the viewed information is designed to be loaded only on latest PR + // diff and if you're signed in. + if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { + diff, err = gitdiff.GetDiff(ctx, gitRepo, diffOptions, files...) + methodWithError = "GetDiff" + } else { + diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...) + methodWithError = "SyncAndGetUserSpecificDiff" + } + if err != nil { + ctx.ServerError(methodWithError, err) + return + } + + ctx.PageData["prReview"] = map[string]any{ + "numberOfFiles": diff.NumFiles, + "numberOfViewedFiles": diff.NumViewedFiles, + } + + if err = diff.LoadComments(ctx, issue, ctx.Doer, ctx.Data["ShowOutdatedComments"].(bool)); err != nil { + ctx.ServerError("LoadComments", err) + return + } + + for _, file := range diff.Files { + for _, section := range file.Sections { + for _, line := range section.Lines { + for _, comments := range line.Conversations { + for _, comment := range comments { + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + } + } + } + } + } + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) + if err != nil { + ctx.ServerError("LoadProtectedBranch", err) + return + } + + if pb != nil { + glob := pb.GetProtectedFilePatterns() + if len(glob) != 0 { + for _, file := range diff.Files { + file.IsProtected = pb.IsProtectedFile(glob, file.Name) + } + } + } + + ctx.Data["Diff"] = diff + ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + + baseCommit, err := ctx.Repo.GitRepo.GetCommit(startCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return + } + commit, err := gitRepo.GetCommit(endCommitID) + if err != nil { + ctx.ServerError("GetCommit", err) + return + } + + // determine if the user viewing the pull request can edit the head branch + if ctx.Doer != nil && pull.HeadRepo != nil && !pull.HasMerged { + headRepoPerm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + ctx.Data["HeadBranchIsEditable"] = pull.HeadRepo.CanEnableEditor() && issues_model.CanMaintainerWriteToBranch(ctx, headRepoPerm, pull.HeadBranch, ctx.Doer) + ctx.Data["SourceRepoLink"] = pull.HeadRepo.Link() + ctx.Data["HeadBranch"] = pull.HeadBranch + } + + if ctx.IsSigned && ctx.Doer != nil { + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + } + + setCompareContext(ctx, baseCommit, commit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue) + if err != nil && !issues_model.IsErrReviewNotExist(err) { + ctx.ServerError("GetCurrentReview", err) + return + } + numPendingCodeComments := int64(0) + if currentReview != nil { + numPendingCodeComments, err = issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{ + Type: issues_model.CommentTypeCode, + ReviewID: currentReview.ID, + IssueID: issue.ID, + }) + if err != nil { + ctx.ServerError("CountComments", err) + return + } + } + ctx.Data["CurrentReview"] = currentReview + ctx.Data["PendingCodeCommentNumber"] = numPendingCodeComments + + getBranchData(ctx, issue) + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + // For files changed page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + upload.AddUploadContext(ctx, "comment") + + ctx.HTML(http.StatusOK, tplPullFiles) +} + +func ViewPullFilesForSingleCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, true) +} + +func ViewPullFilesForRange(ctx *context.Context) { + viewPullFiles(ctx, ctx.Params("shaFrom"), ctx.Params("shaTo"), true, false) +} + +func ViewPullFilesStartingFromCommit(ctx *context.Context) { + viewPullFiles(ctx, "", ctx.Params("sha"), true, false) +} + +func ViewPullFilesForAllCommitsOfPr(ctx *context.Context) { + viewPullFiles(ctx, "", "", false, false) +} + +// UpdatePullRequest merge PR's baseBranch into headBranch +func UpdatePullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + if issue.IsClosed { + ctx.NotFound("MergePullRequest", nil) + return + } + if issue.PullRequest.HasMerged { + ctx.NotFound("MergePullRequest", nil) + return + } + + rebase := ctx.FormString("style") == "rebase" + + if err := issue.PullRequest.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } + if err := issue.PullRequest.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } + + allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, issue.PullRequest, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToMerge", err) + return + } + + // ToDo: add check if maintainers are allowed to change branch ... (need migration & co) + if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) { + ctx.Flash.Error(ctx.Tr("repo.pulls.update_not_allowed")) + ctx.Redirect(issue.Link()) + return + } + + // default merge commit message + message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch) + + if err = pull_service.Update(ctx, issue.PullRequest, ctx.Doer, message, rebase); err != nil { + if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.merge_conflict"), + "Summary": ctx.Tr("repo.pulls.merge_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(issue.Link()) + return + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), + "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.Redirect(issue.Link()) + return + } + ctx.Flash.Error(err.Error()) + ctx.Redirect(issue.Link()) + return + } + + time.Sleep(1 * time.Second) + + ctx.Flash.Success(ctx.Tr("repo.pulls.update_branch_success")) + ctx.Redirect(issue.Link()) +} + +// MergePullRequest response for merging pull request +func MergePullRequest(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MergePullRequestForm) + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + pr := issue.PullRequest + pr.Issue = issue + pr.Issue.Repo = ctx.Repo.Repository + + 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 { + switch { + case errors.Is(err, pull_service.ErrIsClosed): + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.pulls.is_closed")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.closed_title")) + } + case errors.Is(err, pull_service.ErrUserNotAllowedToMerge): + ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed")) + case errors.Is(err, pull_service.ErrHasMerged): + ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) + case errors.Is(err, pull_service.ErrIsWorkInProgress): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip")) + case errors.Is(err, pull_service.ErrNotMergeableState): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) + case models.IsErrDisallowedToMerge(err): + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) + case asymkey_service.IsErrWontSign(err): + ctx.JSONError(err.Error()) // has no translation ... + case errors.Is(err, pull_service.ErrDependenciesLeft): + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + default: + ctx.ServerError("WebCheck", err) + } + + return + } + + // handle manually-merged mark + if manuallyMerged { + if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + switch { + case models.IsErrInvalidMergeStyle(err): + ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) + case strings.Contains(err.Error(), "Wrong commit ID"): + ctx.JSONError(ctx.Tr("repo.pulls.wrong_commit_id")) + default: + ctx.ServerError("MergedManually", err) + } + + return + } + + ctx.JSONRedirect(issue.Link()) + return + } + + message := strings.TrimSpace(form.MergeTitleField) + if len(message) == 0 { + var err error + message, _, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pr, repo_model.MergeStyle(form.Do)) + if err != nil { + ctx.ServerError("GetDefaultMergeMessage", err) + return + } + } + + form.MergeMessageField = strings.TrimSpace(form.MergeMessageField) + if len(form.MergeMessageField) > 0 { + message += "\n\n" + form.MergeMessageField + } + + if form.MergeWhenChecksSucceed { + // delete all scheduled auto merges + _ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID) + // schedule auto merge + scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message) + if err != nil { + ctx.ServerError("ScheduleAutoMerge", err) + return + } else if scheduled { + // nothing more to do ... + ctx.Flash.Success(ctx.Tr("repo.pulls.auto_merge_newly_scheduled")) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pr.Index)) + 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.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) + } else if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.merge_conflict"), + "Summary": ctx.Tr("repo.editor.merge_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.JSONRedirect(issue.Link()) + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), + "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), + "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + ctx.JSONRedirect(issue.Link()) + } else if models.IsErrMergeUnrelatedHistories(err) { + log.Debug("MergeUnrelatedHistories error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.unrelated_histories")) + ctx.JSONRedirect(issue.Link()) + } else if git.IsErrPushOutOfDate(err) { + log.Debug("MergePushOutOfDate error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.merge_out_of_date")) + ctx.JSONRedirect(issue.Link()) + } else if models.IsErrSHADoesNotMatch(err) { + log.Debug("MergeHeadOutOfDate error: %v", err) + ctx.Flash.Error(ctx.Tr("repo.pulls.head_out_of_date")) + ctx.JSONRedirect(issue.Link()) + } else if git.IsErrPushRejected(err) { + log.Debug("MergePushRejected error: %v", err) + pushrejErr := err.(*git.ErrPushRejected) + message := pushrejErr.Message + if len(message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.pulls.push_rejected_no_message")) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + }) + if err != nil { + ctx.ServerError("MergePullRequest.HTMLString", err) + return + } + ctx.Flash.Error(flashError) + } + ctx.JSONRedirect(issue.Link()) + } else { + ctx.ServerError("Merge", err) + } + return + } + log.Trace("Pull request merged: %d", pr.ID) + + if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { + ctx.ServerError("stopTimerIfAvailable", err) + return + } + + log.Trace("Pull request merged: %d", pr.ID) + + if form.DeleteBranchAfterMerge { + // Don't cleanup when other pr 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.JSONRedirect(issue.Link()) + return + } + + var headRepo *git.Repository + if ctx.Repo != nil && ctx.Repo.Repository != nil && pr.HeadRepoID == ctx.Repo.Repository.ID && 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() + } + deleteBranch(ctx, pr, headRepo) + } + + ctx.JSONRedirect(issue.Link()) +} + +// CancelAutoMergePullRequest cancels a scheduled pr +func CancelAutoMergePullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, issue.PullRequest); err != nil { + if db.IsErrNotExist(err) { + ctx.Flash.Error(ctx.Tr("repo.pulls.auto_merge_not_scheduled")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) + return + } + ctx.ServerError("RemoveScheduledAutoMerge", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.pulls.auto_merge_canceled_schedule")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) +} + +func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error { + if issues_model.StopwatchExists(ctx, user.ID, issue.ID) { + if err := issues_model.CreateOrStopIssueStopwatch(ctx, user, issue); err != nil { + return err + } + } + + return nil +} + +// CompareAndPullRequestPost response for creating pull request +func CompareAndPullRequestPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateIssueForm) + ctx.Data["Title"] = ctx.Tr("repo.pulls.compare_changes") + ctx.Data["PageIsComparePull"] = true + ctx.Data["IsDiffCompare"] = true + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + + var ( + repo = ctx.Repo.Repository + attachments []string + ) + + ci := ParseCompareInfo(ctx) + defer func() { + if ci != nil && ci.HeadGitRepo != nil { + ci.HeadGitRepo.Close() + } + }() + if ctx.Written() { + return + } + + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, true) + if ctx.Written() { + return + } + + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + if util.IsEmptyString(form.Title) { + ctx.JSONError(ctx.Tr("repo.issues.new.title_empty")) + return + } + + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + + pullIssue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + MilestoneID: milestoneID, + IsPull: true, + Content: content, + } + pullRequest := &issues_model.PullRequest{ + HeadRepoID: ci.HeadRepo.ID, + BaseRepoID: repo.ID, + HeadBranch: ci.HeadBranch, + BaseBranch: ci.BaseBranch, + HeadRepo: ci.HeadRepo, + BaseRepo: repo, + MergeBase: ci.CompareInfo.MergeBase, + Type: issues_model.PullRequestGitea, + AllowMaintainerEdit: form.AllowMaintainerEdit, + } + // FIXME: check error in the case two people send pull request at almost same time, give nice error prompt + // instead of 500. + + if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { + switch { + case errors.Is(err, user_model.ErrBlockedByUser): + ctx.JSONError(ctx.Tr("repo.pulls.blocked_by_user")) + case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + case git.IsErrPushRejected(err): + pushrejErr := err.(*git.ErrPushRejected) + message := pushrejErr.Message + if len(message) == 0 { + ctx.JSONError(ctx.Tr("repo.pulls.push_rejected_no_message")) + return + } + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + }) + if err != nil { + ctx.ServerError("CompareAndPullRequest.HTMLString", err) + return + } + ctx.JSONError(flashError) + default: + // It's an unexpected error. + // If it happens, we should add another case to handle it. + log.Error("Unexpected error of NewPullRequest: %T %s", err, err) + ctx.ServerError("CompareAndPullRequest", err) + } + ctx.ServerError("NewPullRequest", err) + return + } + + if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { + if !errors.Is(err, util.ErrPermissionDenied) { + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } + } + } + + log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) + ctx.JSONRedirect(pullIssue.Link()) +} + +// CleanUpPullRequest responses for delete merged branch when PR has been merged +func CleanUpPullRequest(ctx *context.Context) { + issue, ok := getPullInfo(ctx) + if !ok { + return + } + + pr := issue.PullRequest + + // Don't cleanup unmerged and unclosed PRs + if !pr.HasMerged && !issue.IsClosed { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + // 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.NotFound("CleanUpPullRequest", nil) + return + } + + if err := pr.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } else if pr.HeadRepo == nil { + // Forked repository has already been deleted + ctx.NotFound("CleanUpPullRequest", nil) + return + } else if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } else if err = pr.HeadRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("HeadRepo.LoadOwner", err) + return + } + + perm, err := access_model.GetUserRepoPermission(ctx, pr.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.CanWrite(unit.TypeCode) { + ctx.NotFound("CleanUpPullRequest", nil) + return + } + + fullBranchName := pr.HeadRepo.Owner.Name + "/" + pr.HeadBranch + + var gitBaseRepo *git.Repository + + // Assume that the base repo is the current context (almost certainly) + if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.BaseRepoID && ctx.Repo.GitRepo != nil { + gitBaseRepo = ctx.Repo.GitRepo + } else { + // If not just open it + gitBaseRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.FullName()), err) + return + } + defer gitBaseRepo.Close() + } + + // Now assume that the head repo is the same as the base repo (reasonable chance) + gitRepo := gitBaseRepo + // But if not: is it the same as the context? + if pr.BaseRepoID != pr.HeadRepoID && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil { + gitRepo = ctx.Repo.GitRepo + } else if pr.BaseRepoID != pr.HeadRepoID { + // Otherwise just load it up + gitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) + return + } + defer gitRepo.Close() + } + + defer func() { + ctx.JSONRedirect(issue.Link()) + }() + + // Check if branch has no new commits + headCommitID, err := gitBaseRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("GetRefCommitID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + branchCommitID, err := gitRepo.GetBranchCommitID(pr.HeadBranch) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + if headCommitID != branchCommitID { + ctx.Flash.Error(ctx.Tr("repo.branch.delete_branch_has_new_commits", fullBranchName)) + return + } + + deleteBranch(ctx, pr, gitRepo) +} + +func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) { + fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch + + if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + + if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, gitRepo, pr.HeadBranch); err != nil { + switch { + case git.IsErrBranchNotExist(err): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + case errors.Is(err, git_model.ErrBranchIsProtected): + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + default: + log.Error("DeleteBranch: %v", err) + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + } + return + } + + if err := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.IssueID, pr.HeadBranch); err != nil { + // Do not fail here as branch has already been deleted + log.Error("DeleteBranch: %v", err) + } + + ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", fullBranchName)) +} + +// DownloadPullDiff render a pull's raw diff +func DownloadPullDiff(ctx *context.Context) { + DownloadPullDiffOrPatch(ctx, false) +} + +// DownloadPullPatch render a pull's raw patch +func DownloadPullPatch(ctx *context.Context) { + DownloadPullDiffOrPatch(ctx, true) +} + +// DownloadPullDiffOrPatch render a pull's raw diff or patch +func DownloadPullDiffOrPatch(ctx *context.Context, patch 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.ServerError("GetPullRequestByIndex", err) + } + return + } + + binary := ctx.FormBool("binary") + + if err := pull_service.DownloadDiffOrPatch(ctx, pr, ctx, patch, binary); err != nil { + ctx.ServerError("DownloadDiffOrPatch", err) + return + } +} + +// UpdatePullRequestTarget change pull request's target branch +func UpdatePullRequestTarget(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + pr := issue.PullRequest + if !issue.IsPull { + ctx.Error(http.StatusNotFound) + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + ctx.Error(http.StatusForbidden) + return + } + + targetBranch := ctx.FormTrim("target_branch") + if len(targetBranch) == 0 { + ctx.Error(http.StatusNoContent) + return + } + + if err := pull_service.ChangeTargetBranch(ctx, pr, ctx.Doer, targetBranch); err != nil { + if issues_model.IsErrPullRequestAlreadyExists(err) { + err := err.(issues_model.ErrPullRequestAlreadyExists) + + RepoRelPath := ctx.Repo.Owner.Name + "/" + ctx.Repo.Repository.Name + errorMessage := ctx.Tr("repo.pulls.has_pull_request", html.EscapeString(ctx.Repo.RepoLink+"/pulls/"+strconv.FormatInt(err.IssueID, 10)), html.EscapeString(RepoRelPath), err.IssueID) // FIXME: Creates url inside locale string + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if issues_model.IsErrIssueIsClosed(err) { + errorMessage := ctx.Tr("repo.pulls.is_closed") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if models.IsErrPullRequestHasMerged(err) { + errorMessage := ctx.Tr("repo.pulls.has_merged") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusConflict, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else if git_model.IsErrBranchesEqual(err) { + errorMessage := ctx.Tr("repo.pulls.nothing_to_compare") + + ctx.Flash.Error(errorMessage) + ctx.JSON(http.StatusBadRequest, map[string]any{ + "error": err.Error(), + "user_error": errorMessage, + }) + } else { + ctx.ServerError("UpdatePullRequestTarget", err) + } + return + } + notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, targetBranch) + + ctx.JSON(http.StatusOK, map[string]any{ + "base_branch": pr.BaseBranch, + }) +} + +// SetAllowEdits allow edits from maintainers to PRs +func SetAllowEdits(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateAllowEditsForm) + + 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.ServerError("GetPullRequestByIndex", err) + } + return + } + + if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, form.AllowMaintainerEdit); err != nil { + if errors.Is(err, pull_service.ErrUserHasNoPermissionForAction) { + ctx.Error(http.StatusForbidden) + return + } + ctx.ServerError("SetAllowEdits", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "allow_maintainer_edit": pr.AllowMaintainerEdit, + }) +} diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go new file mode 100644 index 0000000..e8a3c48 --- /dev/null +++ b/routers/web/repo/pull_review.go @@ -0,0 +1,316 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/json" + "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/context/upload" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" +) + +const ( + tplDiffConversation base.TplName = "repo/diff/conversation" + tplTimelineConversation base.TplName = "repo/issue/view_content/conversation" + tplNewComment base.TplName = "repo/diff/new_comment" +) + +// RenderNewCodeCommentForm will render the form for creating a new review comment +func RenderNewCodeCommentForm(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + if !issue.IsPull { + return + } + currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue) + if err != nil && !issues_model.IsErrReviewNotExist(err) { + ctx.ServerError("GetCurrentReview", err) + return + } + ctx.Data["PageIsPullFiles"] = true + ctx.Data["Issue"] = issue + ctx.Data["CurrentReview"] = currentReview + pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(issue.PullRequest.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + ctx.Data["AfterCommitID"] = pullHeadCommitID + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.HTML(http.StatusOK, tplNewComment) +} + +// CreateCodeComment will create a code comment including an pending review if required +func CreateCodeComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CodeCommentForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + if !issue.IsPull { + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + signedLine := form.Line + if form.Side == "previous" { + signedLine *= -1 + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + comment, err := pull_service.CreateCodeComment(ctx, + ctx.Doer, + ctx.Repo.GitRepo, + issue, + signedLine, + form.Content, + form.TreePath, + !form.SingleReview, + form.Reply, + form.LatestCommitID, + attachments, + ) + if err != nil { + ctx.ServerError("CreateCodeComment", err) + return + } + + if comment == nil { + log.Trace("Comment not created: %-v #%d[%d]", ctx.Repo.Repository, issue.Index, issue.ID) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + log.Trace("Comment created: %-v #%d[%d] Comment[%d]", ctx.Repo.Repository, issue.Index, issue.ID, comment.ID) + + renderConversation(ctx, comment, form.Origin) +} + +// UpdateResolveConversation add or remove an Conversation resolved mark +func UpdateResolveConversation(ctx *context.Context) { + origin := ctx.FormString("origin") + action := ctx.FormString("action") + commentID := ctx.FormInt64("comment_id") + + comment, err := issues_model.GetCommentByID(ctx, commentID) + if err != nil { + ctx.ServerError("GetIssueByID", err) + return + } + + if err = comment.LoadIssue(ctx); err != nil { + ctx.ServerError("comment.LoadIssue", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("comment's repoID is incorrect", errors.New("comment's repoID is incorrect")) + return + } + + var permResult bool + if permResult, err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + if !permResult { + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Issue.IsPull { + ctx.Error(http.StatusBadRequest) + return + } + + if action == "Resolve" || action == "UnResolve" { + err = issues_model.MarkConversation(ctx, comment, ctx.Doer, action == "Resolve") + if err != nil { + ctx.ServerError("MarkConversation", err) + return + } + } else { + ctx.Error(http.StatusBadRequest) + return + } + + renderConversation(ctx, comment, origin) +} + +func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) { + comments, err := issues_model.FetchCodeConversation(ctx, comment, ctx.Doer) + if err != nil { + ctx.ServerError("FetchCodeCommentsByLine", err) + return + } + ctx.Data["PageIsPullFiles"] = (origin == "diff") + + if err := comments.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + ctx.Data["comments"] = comments + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } + ctx.Data["Issue"] = comment.Issue + if err = comment.Issue.LoadPullRequest(ctx); err != nil { + ctx.ServerError("comment.Issue.LoadPullRequest", err) + return + } + pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(comment.Issue.PullRequest.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + ctx.Data["AfterCommitID"] = pullHeadCommitID + if origin == "diff" { + ctx.HTML(http.StatusOK, tplDiffConversation) + } else if origin == "timeline" { + ctx.HTML(http.StatusOK, tplTimelineConversation) + } +} + +// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist +func SubmitReview(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SubmitReviewForm) + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + if !issue.IsPull { + return + } + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + + reviewType := form.ReviewType() + switch reviewType { + case issues_model.ReviewTypeUnknown: + ctx.ServerError("ReviewType", fmt.Errorf("unknown ReviewType: %s", form.Type)) + return + + // can not approve/reject your own PR + case issues_model.ReviewTypeApprove, issues_model.ReviewTypeReject: + if issue.IsPoster(ctx.Doer.ID) { + var translated string + if reviewType == issues_model.ReviewTypeApprove { + translated = ctx.Locale.TrString("repo.issues.review.self.approval") + } else { + translated = ctx.Locale.TrString("repo.issues.review.self.rejection") + } + + ctx.Flash.Error(translated) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + return + } + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + _, comm, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID, attachments) + if err != nil { + if issues_model.IsContentEmptyErr(err) { + ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty")) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) + } else { + ctx.ServerError("SubmitReview", err) + } + return + } + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag())) +} + +// DismissReview dismissing stale review by repo admin +func DismissReview(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.DismissReviewForm) + comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true) + if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } + ctx.ServerError("pull_service.DismissReview", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag())) +} + +// viewedFilesUpdate Struct to parse the body of a request to update the reviewed files of a PR +// If you want to implement an API to update the review, simply move this struct into modules. +type viewedFilesUpdate struct { + Files map[string]bool `json:"files"` + HeadCommitSHA string `json:"headCommitSHA"` +} + +func UpdateViewedFiles(ctx *context.Context) { + // Find corresponding PR + issue, ok := getPullInfo(ctx) + if !ok { + return + } + pull := issue.PullRequest + + var data *viewedFilesUpdate + err := json.NewDecoder(ctx.Req.Body).Decode(&data) + if err != nil { + log.Warn("Attempted to update a review but could not parse request body: %v", err) + ctx.Resp.WriteHeader(http.StatusBadRequest) + return + } + + // Expect the review to have been now if no head commit was supplied + if data.HeadCommitSHA == "" { + data.HeadCommitSHA = pull.HeadCommitID + } + + updatedFiles := make(map[string]pull_model.ViewedState, len(data.Files)) + for file, viewed := range data.Files { + // Only unviewed and viewed are possible, has-changed can not be set from the outside + state := pull_model.Unviewed + if viewed { + state = pull_model.Viewed + } + updatedFiles[file] = state + } + + if err := pull_model.UpdateReviewState(ctx, ctx.Doer.ID, pull.ID, data.HeadCommitSHA, updatedFiles); err != nil { + ctx.ServerError("UpdateReview", err) + } +} diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go new file mode 100644 index 0000000..329e83f --- /dev/null +++ b/routers/web/repo/pull_review_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderConversation(t *testing.T) { + unittest.PrepareTestEnv(t) + + pr, _ := issues_model.GetPullRequestByID(db.DefaultContext, 2) + _ = pr.LoadIssue(db.DefaultContext) + _ = pr.Issue.LoadPoster(db.DefaultContext) + _ = pr.Issue.LoadRepo(db.DefaultContext) + + run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) { + t.Run(name, func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/") + ctx.Render = templates.HTMLRenderer() + contexttest.LoadUser(t, ctx, pr.Issue.PosterID) + contexttest.LoadRepo(t, ctx, pr.BaseRepoID) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + cb(t, ctx, resp) + }) + } + + var preparedComment *issues_model.Comment + run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID, nil) + require.NoError(t, err) + + comment.Invalidated = true + err = issues_model.UpdateCommentInvalidate(ctx, comment) + require.NoError(t, err) + + preparedComment = comment + }) + if !assert.NotNil(t, preparedComment) { + return + } + run("diff with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "diff") + assert.Contains(t, resp.Body.String(), `
setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("getReleaseInfos", err) + return + } + for _, rel := range releases { + if rel.Release.IsTag && rel.Release.Title == "" { + rel.Release.Title = rel.Release.TagName + } + } + + ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) + + numReleases := ctx.Data["NumReleases"].(int64) + pager := context.NewPagination(int(numReleases), listOptions.PageSize, listOptions.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplReleasesList) +} + +func verifyTagSignature(ctx *context.Context, r *repo_model.Release) (*asymkey.ObjectVerification, error) { + if err := r.LoadAttributes(ctx); err != nil { + return nil, err + } + gitRepo, err := gitrepo.OpenRepository(ctx, r.Repo) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + tag, err := gitRepo.GetTag(r.TagName) + if err != nil { + return nil, err + } + if tag.Signature == nil { + return nil, nil + } + + verification := asymkey.ParseTagWithSignature(ctx, gitRepo, tag) + return verification, nil +} + +func addVerifyTagToContext(ctx *context.Context) { + ctx.Data["VerifyTag"] = func(r *repo_model.Release) *asymkey.ObjectVerification { + v, err := verifyTagSignature(ctx, r) + if err != nil { + return nil + } + return v + } + ctx.Data["HasSignature"] = func(verification *asymkey.ObjectVerification) bool { + if verification == nil { + return false + } + return verification.Reason != "gpg.error.not_signed_commit" + } +} + +// TagsList render tags list page +func TagsList(ctx *context.Context) { + ctx.Data["PageIsTagList"] = true + ctx.Data["Title"] = ctx.Tr("repo.release.tags") + ctx.Data["IsViewBranch"] = false + ctx.Data["IsViewTag"] = true + // Disable the showCreateNewBranch form in the dropdown on this page. + ctx.Data["CanCreateBranch"] = false + ctx.Data["HideBranchesInDropdown"] = true + ctx.Data["CanCreateRelease"] = ctx.Repo.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: ctx.FormInt("limit"), + } + if listOptions.PageSize == 0 { + listOptions.PageSize = setting.Repository.Release.DefaultPagingNum + } + if listOptions.PageSize > setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + opts := repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // for the tags list page, show all releases with real tags (having real commit-id), + // the drafts should also be included because a real tag might be used as a draft. + IncludeDrafts: true, + IncludeTags: true, + HasSha1: optional.Some(true), + RepoID: ctx.Repo.Repository.ID, + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) + + numTags := ctx.Data["NumTags"].(int64) + pager := context.NewPagination(int(numTags), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["PageIsViewCode"] = !ctx.Repo.Repository.UnitEnabled(ctx, unit.TypeReleases) + ctx.HTML(http.StatusOK, tplTagsList) +} + +// ReleasesFeedRSS get feeds for releases in RSS format +func ReleasesFeedRSS(ctx *context.Context) { + releasesOrTagsFeed(ctx, true, "rss") +} + +// TagsListFeedRSS get feeds for tags in RSS format +func TagsListFeedRSS(ctx *context.Context) { + releasesOrTagsFeed(ctx, false, "rss") +} + +// ReleasesFeedAtom get feeds for releases in Atom format +func ReleasesFeedAtom(ctx *context.Context) { + releasesOrTagsFeed(ctx, true, "atom") +} + +// TagsListFeedAtom get feeds for tags in RSS format +func TagsListFeedAtom(ctx *context.Context) { + releasesOrTagsFeed(ctx, false, "atom") +} + +func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType string) { + feed.ShowReleaseFeed(ctx, ctx.Repo.Repository, isReleasesOnly, formatType) +} + +// SingleRelease renders a single release's page +func SingleRelease(ctx *context.Context) { + ctx.Data["PageIsReleaseList"] = true + ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch + + writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{Page: 1, PageSize: 1}, + RepoID: ctx.Repo.Repository.ID, + // Include tags in the search too. + IncludeTags: true, + TagNames: []string{ctx.Params("*")}, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + }) + if err != nil { + ctx.ServerError("getReleaseInfos", err) + return + } + if len(releases) != 1 { + ctx.NotFound("SingleRelease", err) + return + } + + release := releases[0].Release + if release.IsTag && release.Title == "" { + release.Title = release.TagName + } + addVerifyTagToContext(ctx) + + ctx.Data["PageIsSingleTag"] = release.IsTag + if release.IsTag { + ctx.Data["Title"] = release.TagName + } else { + ctx.Data["Title"] = release.Title + } + + err = release.LoadArchiveDownloadCount(ctx) + if err != nil { + ctx.ServerError("LoadArchiveDownloadCount", err) + return + } + + ctx.Data["Releases"] = releases + ctx.HTML(http.StatusOK, tplReleasesList) +} + +// LatestRelease redirects to the latest release +func LatestRelease(ctx *context.Context) { + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("LatestRelease", err) + return + } + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Redirect(release.Link()) +} + +// NewRelease render creating or edit release page +func NewRelease(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.release.new_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch + if tagName := ctx.FormString("tag"); len(tagName) > 0 { + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.ServerError("GetRelease", err) + return + } + + if rel != nil { + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + ctx.Data["tag_name"] = rel.TagName + if rel.Target != "" { + ctx.Data["tag_target"] = rel.Target + } + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["attachments"] = rel.Attachments + } + } + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + upload.AddUploadContext(ctx, "release") + + // For New Release page + PrepareBranchList(ctx) + if ctx.Written() { + return + } + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + // We set the value of the hide_archive_link textbox depending on the latest release + latestRelease, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.Data["hide_archive_links"] = false + } else { + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + } + if latestRelease != nil { + ctx.Data["hide_archive_links"] = latestRelease.HideArchiveLinks + } + + ctx.HTML(http.StatusOK, tplReleaseNew) +} + +// NewReleasePost response for creating a release +func NewReleasePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewReleaseForm) + ctx.Data["Title"] = ctx.Tr("repo.release.new_release") + ctx.Data["PageIsReleaseList"] = true + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + objectFormat, _ := ctx.Repo.GitRepo.GetObjectFormat() + + // form.Target can be a branch name or a full commitID. + if !ctx.Repo.GitRepo.IsBranchExist(form.Target) && + len(form.Target) == objectFormat.FullLength() && !ctx.Repo.GitRepo.IsCommitExist(form.Target) { + ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form) + return + } + + // Title of release cannot be empty + if len(form.TagOnly) == 0 && len(form.Title) == 0 { + ctx.RenderWithErr(ctx.Tr("repo.release.title_empty"), tplReleaseNew, &form) + return + } + + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) + + if setting.Attachment.Enabled { + for _, uuid := range form.Files { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "add", + Type: "attachment", + UUID: uuid, + }) + } + + const namePrefix = "attachment-new-name-" + const exturlPrefix = "attachment-new-exturl-" + for k, v := range ctx.Req.Form { + isNewName := strings.HasPrefix(k, namePrefix) + isNewExturl := strings.HasPrefix(k, exturlPrefix) + if isNewName || isNewExturl { + var id string + if isNewName { + id = k[len(namePrefix):] + } else if isNewExturl { + id = k[len(exturlPrefix):] + } + if _, ok := attachmentChangesByID[id]; !ok { + attachmentChangesByID[id] = &releaseservice.AttachmentChange{ + Action: "add", + Type: "external", + } + attachmentChanges.Add(attachmentChangesByID[id]) + } + if isNewName { + attachmentChangesByID[id].Name = v[0] + } else if isNewExturl { + attachmentChangesByID[id].ExternalURL = v[0] + } + } + } + } + + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) + if err != nil { + if !repo_model.IsErrReleaseNotExist(err) { + ctx.ServerError("GetRelease", err) + return + } + + msg := "" + if len(form.Title) > 0 && form.AddTagMsg { + msg = form.Title + "\n\n" + form.Content + } + + if len(form.TagOnly) > 0 { + if err = releaseservice.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, form.Target, form.TagName, msg); err != nil { + if models.IsErrTagAlreadyExists(err) { + e := err.(models.ErrTagAlreadyExists) + ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if models.IsErrInvalidTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_invalid")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + ctx.ServerError("releaseservice.CreateNewTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName)) + return + } + + rel = &repo_model.Release{ + RepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, + PublisherID: ctx.Doer.ID, + Publisher: ctx.Doer, + Title: form.Title, + TagName: form.TagName, + Target: form.Target, + Note: form.Content, + IsDraft: len(form.Draft) > 0, + IsPrerelease: form.Prerelease, + HideArchiveLinks: form.HideArchiveLinks, + IsTag: false, + } + + if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, msg, attachmentChanges.Values()); err != nil { + ctx.Data["Err_TagName"] = true + switch { + case repo_model.IsErrReleaseAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form) + case models.IsErrInvalidTagName(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form) + case models.IsErrProtectedTagName(err): + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) + default: + ctx.ServerError("CreateRelease", err) + } + return + } + } else { + if !rel.IsTag { + ctx.Data["Err_TagName"] = true + ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form) + return + } + + rel.Title = form.Title + rel.Note = form.Content + rel.Target = form.Target + rel.IsDraft = len(form.Draft) > 0 + rel.IsPrerelease = form.Prerelease + rel.PublisherID = ctx.Doer.ID + rel.HideArchiveLinks = form.HideArchiveLinks + rel.IsTag = false + + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, attachmentChanges.Values()); err != nil { + ctx.Data["Err_TagName"] = true + switch { + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) + default: + ctx.ServerError("UpdateRelease", err) + } + return + } + } + log.Trace("Release created: %s/%s:%s", ctx.Doer.LowerName, ctx.Repo.Repository.Name, form.TagName) + + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// EditRelease render release edit page +func EditRelease(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["PageIsEditRelease"] = true + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") + + tagName := ctx.Params("*") + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("GetRelease", err) + } else { + ctx.ServerError("GetRelease", err) + } + return + } + ctx.Data["ID"] = rel.ID + ctx.Data["tag_name"] = rel.TagName + ctx.Data["tag_target"] = rel.Target + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["prerelease"] = rel.IsPrerelease + ctx.Data["hide_archive_links"] = rel.HideArchiveLinks + ctx.Data["IsDraft"] = rel.IsDraft + + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Data["attachments"] = rel.Attachments + + // Get assignees. + assigneeUsers, err := repo_model.GetRepoAssignees(ctx, rel.Repo) + if err != nil { + ctx.ServerError("GetRepoAssignees", err) + return + } + ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers) + + ctx.HTML(http.StatusOK, tplReleaseNew) +} + +// EditReleasePost response for edit release +func EditReleasePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditReleaseForm) + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") + ctx.Data["PageIsReleaseList"] = true + ctx.Data["PageIsEditRelease"] = true + + tagName := ctx.Params("*") + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("GetRelease", err) + } else { + ctx.ServerError("GetRelease", err) + } + return + } + if rel.IsTag { + ctx.NotFound("GetRelease", err) + return + } + ctx.Data["tag_name"] = rel.TagName + ctx.Data["tag_target"] = rel.Target + ctx.Data["title"] = rel.Title + ctx.Data["content"] = rel.Note + ctx.Data["prerelease"] = rel.IsPrerelease + ctx.Data["hide_archive_links"] = rel.HideArchiveLinks + + rel.Repo = ctx.Repo.Repository + if err := rel.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + // TODO: If an error occurs, do not forget the attachment edits the user made + // when displaying the error message. + ctx.Data["attachments"] = rel.Attachments + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplReleaseNew) + return + } + + const delPrefix = "attachment-del-" + const editPrefix = "attachment-edit-" + const newPrefix = "attachment-new-" + const namePrefix = "name-" + const exturlPrefix = "exturl-" + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) + + if setting.Attachment.Enabled { + for _, uuid := range form.Files { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "add", + Type: "attachment", + UUID: uuid, + }) + } + + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, delPrefix) && v[0] == "true" { + attachmentChanges.Add(&releaseservice.AttachmentChange{ + Action: "delete", + UUID: k[len(delPrefix):], + }) + } else { + isUpdatedName := strings.HasPrefix(k, editPrefix+namePrefix) + isUpdatedExturl := strings.HasPrefix(k, editPrefix+exturlPrefix) + isNewName := strings.HasPrefix(k, newPrefix+namePrefix) + isNewExturl := strings.HasPrefix(k, newPrefix+exturlPrefix) + + if isUpdatedName || isUpdatedExturl || isNewName || isNewExturl { + var uuid string + + if isUpdatedName { + uuid = k[len(editPrefix+namePrefix):] + } else if isUpdatedExturl { + uuid = k[len(editPrefix+exturlPrefix):] + } else if isNewName { + uuid = k[len(newPrefix+namePrefix):] + } else if isNewExturl { + uuid = k[len(newPrefix+exturlPrefix):] + } + + if _, ok := attachmentChangesByID[uuid]; !ok { + attachmentChangesByID[uuid] = &releaseservice.AttachmentChange{ + Type: "attachment", + UUID: uuid, + } + attachmentChanges.Add(attachmentChangesByID[uuid]) + } + + if isUpdatedName || isUpdatedExturl { + attachmentChangesByID[uuid].Action = "update" + } else if isNewName || isNewExturl { + attachmentChangesByID[uuid].Action = "add" + } + + if isUpdatedName || isNewName { + attachmentChangesByID[uuid].Name = v[0] + } else if isUpdatedExturl || isNewExturl { + attachmentChangesByID[uuid].ExternalURL = v[0] + attachmentChangesByID[uuid].Type = "external" + } + } + } + } + } + + rel.Title = form.Title + rel.Note = form.Content + rel.IsDraft = len(form.Draft) > 0 + rel.IsPrerelease = form.Prerelease + rel.HideArchiveLinks = form.HideArchiveLinks + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil { + switch { + case repo_model.IsErrInvalidExternalURL(err): + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) + default: + ctx.ServerError("UpdateRelease", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// DeleteRelease deletes a release +func DeleteRelease(ctx *context.Context) { + deleteReleaseOrTag(ctx, false) +} + +// DeleteTag deletes a tag +func DeleteTag(ctx *context.Context) { + deleteReleaseOrTag(ctx, true) +} + +func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { + redirect := func() { + if isDelTag { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/tags") + return + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases") + } + + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("GetReleaseForRepoByID", err) + } else { + ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) + redirect() + } + return + } + + if err := releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + } else { + ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) + } + } else { + if isDelTag { + ctx.Flash.Success(ctx.Tr("repo.release.deletion_tag_success")) + } else { + ctx.Flash.Success(ctx.Tr("repo.release.deletion_success")) + } + } + + redirect() +} diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go new file mode 100644 index 0000000..5c7b6e2 --- /dev/null +++ b/routers/web/repo/release_test.go @@ -0,0 +1,124 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewReleasePost(t *testing.T) { + for _, testCase := range []struct { + RepoID int64 + UserID int64 + TagName string + Form forms.NewReleaseForm + }{ + { + RepoID: 1, + UserID: 2, + TagName: "v1.1", // pre-existing tag + Form: forms.NewReleaseForm{ + TagName: "newtag", + Target: "master", + Title: "title", + Content: "content", + }, + }, + { + RepoID: 1, + UserID: 2, + TagName: "newtag", + Form: forms.NewReleaseForm{ + TagName: "newtag", + Target: "master", + Title: "title", + Content: "content", + }, + }, + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadGitRepo(t, ctx) + web.SetForm(ctx, &testCase.Form) + NewReleasePost(ctx) + unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ + RepoID: 1, + PublisherID: 2, + TagName: testCase.Form.TagName, + Target: testCase.Form.Target, + Title: testCase.Form.Title, + Note: testCase.Form.Content, + }, unittest.Cond("is_draft=?", len(testCase.Form.Draft) > 0)) + ctx.Repo.GitRepo.Close() + } +} + +func TestCalReleaseNumCommitsBehind(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo-release/releases") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 57) + contexttest.LoadGitRepo(t, ctx) + t.Cleanup(func() { ctx.Repo.GitRepo.Close() }) + + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: ctx.Repo.Repository.ID, + }) + require.NoError(t, err) + + countCache := make(map[string]int64) + for _, release := range releases { + err := calReleaseNumCommitsBehind(ctx.Repo, release, countCache) + require.NoError(t, err) + } + + type computedFields struct { + NumCommitsBehind int64 + TargetBehind string + } + expectedComputation := map[string]computedFields{ + "v1.0": { + NumCommitsBehind: 3, + TargetBehind: "main", + }, + "v1.1": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + "v2.0": { + NumCommitsBehind: 0, + TargetBehind: "main", + }, + "non-existing-target-branch": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + "empty-target-branch": { + NumCommitsBehind: 1, + TargetBehind: "main", + }, + } + for _, r := range releases { + actual := computedFields{ + NumCommitsBehind: r.NumCommitsBehind, + TargetBehind: r.TargetBehind, + } + assert.Equal(t, expectedComputation[r.TagName], actual, "wrong computed fields for %s: %#v", r.TagName, r) + } +} diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go new file mode 100644 index 0000000..e64db03 --- /dev/null +++ b/routers/web/repo/render.go @@ -0,0 +1,76 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "io" + "net/http" + "path" + + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// RenderFile renders a file by repos path +func RenderFile(ctx *context.Context) { + blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetBlobByPath", err) + } else { + ctx.ServerError("GetBlobByPath", err) + } + return + } + + dataRc, err := blob.DataAsync() + if err != nil { + ctx.ServerError("DataAsync", err) + return + } + defer dataRc.Close() + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts") + + if markupType := markup.Type(blob.Name()); markupType == "" { + if isTextFile { + _, _ = io.Copy(ctx.Resp, rd) + } else { + http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError) + } + return + } + + err = markup.Render(&markup.RenderContext{ + Ctx: ctx, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + InStandalonePage: true, + }, rd, ctx.Resp) + if err != nil { + log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err) + http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError) + return + } +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go new file mode 100644 index 0000000..9562491 --- /dev/null +++ b/routers/web/repo/repo.go @@ -0,0 +1,774 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strings" + + "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" + 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/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "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" + "code.gitea.io/gitea/modules/storage" + 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" + repo_service "code.gitea.io/gitea/services/repository" + archiver_service "code.gitea.io/gitea/services/repository/archiver" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" +) + +const ( + tplCreate base.TplName = "repo/create" + tplAlertDetails base.TplName = "base/alert_details" +) + +// MustBeNotEmpty render when a repo is a empty git dir +func MustBeNotEmpty(ctx *context.Context) { + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound("MustBeNotEmpty", nil) + } +} + +// MustBeEditable check that repo can be edited +func MustBeEditable(ctx *context.Context) { + if !ctx.Repo.Repository.CanEnableEditor() || ctx.Repo.IsViewCommit { + ctx.NotFound("", nil) + return + } +} + +// MustBeAbleToUpload check that repo can be uploaded to +func MustBeAbleToUpload(ctx *context.Context) { + if !setting.Repository.Upload.Enabled { + ctx.NotFound("", nil) + } +} + +func CommitInfoCache(ctx *context.Context) { + var err error + ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount() + if err != nil { + ctx.ServerError("GetCommitsCount", err) + return + } + ctx.Data["CommitsCount"] = ctx.Repo.CommitsCount + ctx.Repo.GitRepo.LastCommitCache = git.NewLastCommitCache(ctx.Repo.CommitsCount, ctx.Repo.Repository.FullName(), ctx.Repo.GitRepo, cache.GetCache()) +} + +func checkContextUser(ctx *context.Context, uid int64) *user_model.User { + orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return nil + } + + if !ctx.Doer.IsAdmin { + orgsAvailable := []*organization.Organization{} + for i := 0; i < len(orgs); i++ { + if orgs[i].CanCreateRepo() { + orgsAvailable = append(orgsAvailable, orgs[i]) + } + } + ctx.Data["Orgs"] = orgsAvailable + } else { + ctx.Data["Orgs"] = orgs + } + + // Not equal means current user is an organization. + if uid == ctx.Doer.ID || uid == 0 { + return ctx.Doer + } + + org, err := user_model.GetUserByID(ctx, uid) + if user_model.IsErrUserNotExist(err) { + return ctx.Doer + } + + if err != nil { + ctx.ServerError("GetUserByID", fmt.Errorf("[%d]: %w", uid, err)) + return nil + } + + // Check ownership of organization. + if !org.IsOrganization() { + ctx.Error(http.StatusForbidden) + return nil + } + if !ctx.Doer.IsAdmin { + canCreate, err := organization.OrgFromUser(org).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return nil + } else if !canCreate { + ctx.Error(http.StatusForbidden) + return nil + } + } else { + ctx.Data["Orgs"] = orgs + } + return org +} + +func getRepoPrivate(ctx *context.Context) bool { + switch strings.ToLower(setting.Repository.DefaultPrivate) { + case setting.RepoCreatingLastUserVisibility: + return ctx.Doer.LastRepoVisibility + case setting.RepoCreatingPrivate: + return true + case setting.RepoCreatingPublic: + return false + default: + return ctx.Doer.LastRepoVisibility + } +} + +// Create render creating repository page +func Create(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_repo.title") + + // Give default value for template to render. + ctx.Data["Gitignores"] = repo_module.Gitignores + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + ctx.Data["Licenses"] = repo_module.Licenses + ctx.Data["Readmes"] = repo_module.Readmes + ctx.Data["readme"] = "Default" + ctx.Data["private"] = getRepoPrivate(ctx) + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["default_branch"] = setting.Repository.DefaultBranch + + ctxUser := checkContextUser(ctx, ctx.FormInt64("org")) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + ctx.Data["repo_template_name"] = ctx.Tr("repo.template_select") + templateID := ctx.FormInt64("template_id") + if templateID > 0 { + templateRepo, err := repo_model.GetRepositoryByID(ctx, templateID) + if err == nil && access_model.CheckRepoUnitUser(ctx, templateRepo, ctxUser, unit.TypeCode) { + ctx.Data["repo_template"] = templateID + ctx.Data["repo_template_name"] = templateRepo.Name + } + } + + ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat + + ctx.HTML(http.StatusOK, tplCreate) +} + +func handleCreateError(ctx *context.Context, owner *user_model.User, err error, name string, tpl base.TplName, form any) { + switch { + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tpl, form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tpl, form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form) + } + case db.IsErrNameReserved(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_RepoName"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) + default: + ctx.ServerError(name, err) + } +} + +// CreatePost response for creating repository +func CreatePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateRepoForm) + ctx.Data["Title"] = ctx.Tr("new_repo.title") + + ctx.Data["Gitignores"] = repo_module.Gitignores + ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + ctx.Data["Licenses"] = repo_module.Licenses + ctx.Data["Readmes"] = repo_module.Readmes + + ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() + ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + ctx.Data["ContextUser"] = ctxUser + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplCreate) + return + } + + var repo *repo_model.Repository + var err error + if form.RepoTemplate > 0 { + opts := repo_service.GenerateRepoOptions{ + Name: form.RepoName, + 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.RenderWithErr(ctx.Tr("repo.template.one_item"), tplCreate, form) + return + } + + templateRepo := getRepository(ctx, form.RepoTemplate) + if ctx.Written() { + return + } + + if !templateRepo.IsTemplate { + ctx.RenderWithErr(ctx.Tr("repo.template.invalid"), tplCreate, form) + return + } + + repo, err = repo_service.GenerateRepository(ctx, ctx.Doer, ctxUser, templateRepo, opts) + if err == nil { + log.Trace("Repository generated [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(repo.Link()) + return + } + } else { + repo, err = repo_service.CreateRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ + Name: form.RepoName, + Description: form.Description, + Gitignores: form.Gitignores, + IssueLabels: form.IssueLabels, + License: form.License, + Readme: form.Readme, + IsPrivate: form.Private || setting.Repository.ForcePrivate, + DefaultBranch: form.DefaultBranch, + AutoInit: form.AutoInit, + IsTemplate: form.Template, + TrustModel: repo_model.DefaultTrustModel, + ObjectFormatName: form.ObjectFormatName, + }) + if err == nil { + log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(repo.Link()) + return + } + } + + handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form) +} + +const ( + tplWatchUnwatch base.TplName = "repo/watch_unwatch" + tplStarUnstar base.TplName = "repo/star_unstar" +) + +func ActionWatch(watch bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, watch) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (watch, %t)", watch), err) + return + } + + ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + + // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (watch, %t)", watch), err) + return + } + + ctx.HTML(http.StatusOK, tplWatchUnwatch) + } +} + +func ActionStar(star bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + err := repo_service.StarRepoAndSendLikeActivities(ctx, *ctx.Doer, ctx.Repo.Repository.ID, star) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (star, %t)", star), err) + return + } + + ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + + // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (star, %t)", star), err) + return + } + + ctx.HTML(http.StatusOK, tplStarUnstar) + } +} + +func ActionTransfer(accept bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + var action string + if accept { + action = "accept_transfer" + } else { + action = "reject_transfer" + } + + ok, err := acceptOrRejectRepoTransfer(ctx, accept) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", action), err) + return + } + if !ok { + return + } + + ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) + } +} + +func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) (bool, error) { + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + return false, err + } + + if err := repoTransfer.LoadAttributes(ctx); err != nil { + return false, err + } + + if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { + return false, errors.New("user does not have enough permissions") + } + + if accept { + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctx.Doer.ID, ctx.Doer.Name) { + return false, nil + } + + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + if err := repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { + return false, err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) + } else { + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { + return false, err + } + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) + } + + ctx.Redirect(ctx.Repo.Repository.Link()) + return true, nil +} + +// RedirectDownload return a file based on the following infos: +func RedirectDownload(ctx *context.Context) { + var ( + vTag = ctx.Params("vTag") + fileName = ctx.Params("fileName") + ) + tagNames := []string{vTag} + curRepo := ctx.Repo.Repository + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: curRepo.ID, + TagNames: tagNames, + }) + if err != nil { + ctx.ServerError("RedirectDownload", err) + return + } + if len(releases) == 1 { + release := releases[0] + att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ServeAttachment(ctx, att.UUID) + return + } + } else if len(releases) == 0 && vTag == "latest" { + // GitHub supports the alias "latest" for the latest release + // We only fetch the latest release if the tag is "latest" and no release with the tag "latest" exists + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ServeAttachment(ctx, att.UUID) + return + } + } + ctx.Error(http.StatusNotFound) +} + +// Download an archive of a repository +func Download(ctx *context.Context) { + 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, err.Error()) + } else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { + ctx.Error(http.StatusNotFound, err.Error()) + } 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.Context, 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 { + if archiver.ReleaseID != 0 { + err = repo_model.CountArchiveDownload(ctx, ctx.Repo.Repository.ID, archiver.ReleaseID, archiver.Type) + if err != nil { + ctx.ServerError("CountArchiveDownload", err) + return + } + } + + 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() + + if archiver.ReleaseID != 0 { + err = repo_model.CountArchiveDownload(ctx, ctx.Repo.Repository.ID, archiver.ReleaseID, archiver.Type) + if err != nil { + ctx.ServerError("CountArchiveDownload", err) + return + } + } + + ctx.ServeContent(fr, &context.ServeHeaderOptions{ + Filename: downloadName, + LastModified: archiver.CreatedUnix.AsLocalTime(), + }) +} + +// InitiateDownload will enqueue an archival request, as needed. It may submit +// a request that's already in-progress, but the archiver service will just +// kind of drop it on the floor if this is the case. +func InitiateDownload(ctx *context.Context) { + uri := ctx.Params("*") + aReq, err := archiver_service.NewRequest(ctx, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + if err != nil { + ctx.ServerError("archiver_service.NewRequest", err) + return + } + if aReq == nil { + ctx.Error(http.StatusNotFound) + return + } + + archiver, err := repo_model.GetRepoArchiver(ctx, aReq.RepoID, aReq.Type, aReq.CommitID) + if err != nil { + ctx.ServerError("archiver_service.StartArchive", err) + return + } + if archiver == nil || archiver.Status != repo_model.ArchiverReady { + if err := archiver_service.StartArchive(aReq); err != nil { + ctx.ServerError("archiver_service.StartArchive", err) + return + } + } + + var completed bool + if archiver != nil && archiver.Status == repo_model.ArchiverReady { + completed = true + } + + ctx.JSON(http.StatusOK, map[string]any{ + "complete": completed, + }) +} + +// SearchRepo repositories via options +func SearchRepo(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + opts := &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + 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: ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("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.Sprintf("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.Sprintf("Invalid sort mode: \"%s\"", sortMode)) + return + } + } else { + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort order: \"%s\"", sortOrder)) + return + } + } + + // To improve performance when only the count is requested + if ctx.FormBool("count_only") { + if count, err := repo_model.CountRepository(ctx, opts); err != nil { + log.Error("CountRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) // frontend JS doesn't handle error response (same as below) + } else { + ctx.SetTotalCountHeader(count) + ctx.JSONOK() + } + return + } + + repos, count, err := repo_model.SearchRepository(ctx, opts) + if err != nil { + log.Error("SearchRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) + return + } + + ctx.SetTotalCountHeader(count) + + latestCommitStatuses, err := commitstatus_service.FindReposLastestCommitStatuses(ctx, repos) + if err != nil { + log.Error("FindReposLastestCommitStatuses: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, latestCommitStatuses) + } + + results := make([]*repo_service.WebSearchRepository, len(repos)) + for i, repo := range repos { + results[i] = &repo_service.WebSearchRepository{ + Repository: &api.Repository{ + ID: repo.ID, + FullName: repo.FullName(), + Fork: repo.IsFork, + Private: repo.IsPrivate, + Template: repo.IsTemplate, + Mirror: repo.IsMirror, + Stars: repo.NumStars, + HTMLURL: repo.HTMLURL(), + Link: repo.Link(), + Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, + }, + } + + if latestCommitStatuses[i] != nil { + results[i].LatestCommitStatus = latestCommitStatuses[i] + results[i].LocaleLatestCommitStatus = latestCommitStatuses[i].LocaleString(ctx.Locale) + } + } + + ctx.JSON(http.StatusOK, repo_service.WebSearchResults{ + OK: true, + Data: results, + }) +} + +type branchTagSearchResponse struct { + Results []string `json:"results"` +} + +// GetBranchesList get branches for current repo' +func GetBranchesList(ctx *context.Context) { + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, + } + branches, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + resp := &branchTagSearchResponse{} + // always put default branch on the top if it exists + if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { + branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) + branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + } + resp.Results = branches + ctx.JSON(http.StatusOK, resp) +} + +// GetTagList get tag list for current repo +func GetTagList(ctx *context.Context) { + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, err) + return + } + resp := &branchTagSearchResponse{} + resp.Results = tags + ctx.JSON(http.StatusOK, resp) +} + +func PrepareBranchList(ctx *context.Context) { + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, + } + brs, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.ServerError("GetBranches", err) + return + } + // always put default branch on the top if it exists + if slices.Contains(brs, ctx.Repo.Repository.DefaultBranch) { + brs = util.SliceRemoveAll(brs, ctx.Repo.Repository.DefaultBranch) + brs = append([]string{ctx.Repo.Repository.DefaultBranch}, brs...) + } + ctx.Data["Branches"] = brs +} diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go new file mode 100644 index 0000000..c4f9f9a --- /dev/null +++ b/routers/web/repo/search.go @@ -0,0 +1,105 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const tplSearch base.TplName = "repo/search" + +// Search render repository search page +func Search(ctx *context.Context) { + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["PageIsViewCode"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplSearch) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + var total int + var searchResults []*code_indexer.Result + var searchResultLanguages []*code_indexer.SearchResultLanguages + if setting.Indexer.RepoIndexerEnabled { + var err error + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: []int64{ctx.Repo.Repository.ID}, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + } else { + res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ + ContextLineNumber: 1, + IsFuzzy: isFuzzy, + RefName: ctx.Repo.RefName, + }) + if err != nil { + ctx.ServerError("GrepSearch", err) + return + } + total = len(res) + pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res)) + pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res)) + res = res[pageStart:pageEnd] + for _, r := range res { + searchResults = append(searchResults, &code_indexer.Result{ + RepoID: ctx.Repo.Repository.ID, + Filename: r.Filename, + CommitID: ctx.Repo.CommitID, + // UpdatedUnix: not supported yet + // Language: not supported yet + // Color: not supported yet + Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, r.HighlightedRanges, strings.Join(r.LineCodes, "\n")), + }) + } + } + + ctx.Data["CodeIndexerDisabled"] = !setting.Indexer.RepoIndexerEnabled + ctx.Data["Repo"] = ctx.Repo.Repository + ctx.Data["SourcePath"] = ctx.Repo.Repository.Link() + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "l", "Language") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplSearch) +} diff --git a/routers/web/repo/setting/avatar.go b/routers/web/repo/setting/avatar.go new file mode 100644 index 0000000..504f57c --- /dev/null +++ b/routers/web/repo/setting/avatar.go @@ -0,0 +1,76 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "io" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UpdateAvatarSetting update repo's avatar +func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { + ctxRepo := ctx.Repo.Repository + + if form.Avatar == nil { + // No avatar is uploaded and we not removing it here. + // No random avatar generated here. + // Just exit, no action. + if ctxRepo.CustomAvatarRelativePath() == "" { + log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID) + } + return nil + } + + r, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %w", err) + } + defer r.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + } + + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("io.ReadAll: %w", err) + } + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) + } + if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil { + return fmt.Errorf("UploadAvatar: %w", err) + } + return nil +} + +// SettingsAvatar save new POSTed repository avatar +func SettingsAvatar(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + form.Source = forms.AvatarLocal + if err := UpdateAvatarSetting(ctx, *form); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.update_avatar_success")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} + +// SettingsDeleteAvatar delete repository avatar +func SettingsDeleteAvatar(ctx *context.Context) { + if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil { + ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err)) + } + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings") +} diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go new file mode 100644 index 0000000..75b5515 --- /dev/null +++ b/routers/web/repo/setting/collaboration.go @@ -0,0 +1,217 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + 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/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +// Collaboration render a repository's collaboration page +func Collaboration(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.collaboration") + ctx.Data["PageIsSettingsCollaboration"] = true + + users, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("GetCollaborators", err) + return + } + ctx.Data["Collaborators"] = users + + teams, err := organization.GetRepoTeams(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoTeams", err) + return + } + ctx.Data["Teams"] = teams + ctx.Data["Repo"] = ctx.Repo.Repository + ctx.Data["OrgID"] = ctx.Repo.Repository.OwnerID + ctx.Data["OrgName"] = ctx.Repo.Repository.OwnerName + ctx.Data["Org"] = ctx.Repo.Repository.Owner + ctx.Data["Units"] = unit_model.Units + + ctx.HTML(http.StatusOK, tplCollaboration) +} + +// CollaborationPost response for actions for a collaboration of a repository +func CollaborationPost(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("collaborator")) + if len(name) == 0 || ctx.Repo.Owner.LowerName == name { + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + + u, err := user_model.GetUserByName(ctx, name) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if !u.IsActive { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_inactive_user")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + + // Organization is not allowed to be added as a collaborator. + if u.IsOrganization() { + ctx.Flash.Error(ctx.Tr("repo.settings.org_not_allowed_to_be_collaborator")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + + if got, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, u.ID); err == nil && got { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_duplicate")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + // find the owner team of the organization the repo belongs too and + // check if the user we're trying to add is an owner. + if ctx.Repo.Repository.Owner.IsOrganization() { + if isOwner, err := organization.IsOrganizationOwner(ctx, ctx.Repo.Repository.Owner.ID, u.ID); err != nil { + ctx.ServerError("IsOrganizationOwner", err) + return + } else if isOwner { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_owner")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + return + } + } + + if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil { + if !errors.Is(err, user_model.ErrBlockedByUser) { + ctx.ServerError("AddCollaborator", err) + return + } + + // To give an good error message, be precise on who has blocked who. + if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our")) + } else { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if setting.Service.EnableNotifyMail { + mailer.SendCollaboratorMail(u, ctx.Doer, ctx.Repo.Repository) + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_collaborator_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// ChangeCollaborationAccessMode response for changing access of a collaboration +func ChangeCollaborationAccessMode(ctx *context.Context) { + if err := repo_model.ChangeCollaborationAccessMode( + ctx, + ctx.Repo.Repository, + ctx.FormInt64("uid"), + perm.AccessMode(ctx.FormInt("mode"))); err != nil { + log.Error("ChangeCollaborationAccessMode: %v", err) + } +} + +// DeleteCollaboration delete a collaboration for a repository +func DeleteCollaboration(ctx *context.Context) { + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") +} + +// AddTeamPost response for adding a team to a repository +func AddTeamPost(ctx *context.Context) { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + name := strings.ToLower(ctx.FormString("team")) + if len(name) == 0 { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + team, err := organization.OrgFromUser(ctx.Repo.Owner).GetTeam(ctx, name) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.team_not_exist")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + } else { + ctx.ServerError("GetTeam", err) + } + return + } + + if team.OrgID != ctx.Repo.Repository.OwnerID { + ctx.Flash.Error(ctx.Tr("repo.settings.team_not_in_organization")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if organization.HasTeamRepo(ctx, ctx.Repo.Repository.OwnerID, team.ID, ctx.Repo.Repository.ID) { + ctx.Flash.Error(ctx.Tr("repo.settings.add_team_duplicate")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + if err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { + ctx.ServerError("TeamAddRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_team_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") +} + +// DeleteTeam response for deleting a team from a repository +func DeleteTeam(ctx *context.Context) { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + return + } + + team, err := organization.GetTeamByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetTeamByID", err) + return + } + + if err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("team.RemoveRepositorys", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.remove_team_success")) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") +} diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go new file mode 100644 index 0000000..881d148 --- /dev/null +++ b/routers/web/repo/setting/default_branch.go @@ -0,0 +1,54 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// SetDefaultBranchPost set default branch +func SetDefaultBranchPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.branches.update_default_branch") + ctx.Data["PageIsSettingsBranches"] = true + + repo.PrepareBranchList(ctx) + if ctx.Written() { + return + } + + repo := ctx.Repo.Repository + + switch ctx.FormString("action") { + case "default_branch": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBranches) + return + } + + branch := ctx.FormString("branch") + if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { + switch { + case git_model.IsErrBranchNotExist(err): + ctx.Status(http.StatusNotFound) + default: + ctx.ServerError("SetDefaultBranch", err) + } + return + } + + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + default: + ctx.NotFound("", nil) + } +} diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go new file mode 100644 index 0000000..abc3eb4 --- /dev/null +++ b/routers/web/repo/setting/deploy_key.go @@ -0,0 +1,109 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// DeployKeys render the deploy keys list of a repository page +func DeployKeys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListDeployKeys", err) + return + } + ctx.Data["Deploykeys"] = keys + + ctx.HTML(http.StatusOK, tplDeployKeys) +} + +// DeployKeysPost response for adding a deploy key of a repository +func DeployKeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListDeployKeys", err) + return + } + ctx.Data["Deploykeys"] = keys + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplDeployKeys) + return + } + + content, err := asymkey_model.CheckPublicKeyString(form.Content) + if err != nil { + if db.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if asymkey_model.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else if err == asymkey_model.ErrKeyIsPrivate { + ctx.Data["HasError"] = true + ctx.Data["Err_Content"] = true + ctx.Flash.Error(ctx.Tr("form.must_use_public_key")) + } else { + ctx.Data["HasError"] = true + ctx.Data["Err_Content"] = true + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + return + } + + key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable) + if err != nil { + ctx.Data["HasError"] = true + switch { + case asymkey_model.IsErrDeployKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), tplDeployKeys, &form) + case asymkey_model.IsErrKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplDeployKeys, &form) + case asymkey_model.IsErrKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form) + case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form) + default: + ctx.ServerError("AddDeployKey", err) + } + return + } + + log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + +// DeleteDeployKey response for deleting a deploy key +func DeleteDeployKey(ctx *context.Context) { + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteDeployKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} diff --git a/routers/web/repo/setting/git_hooks.go b/routers/web/repo/setting/git_hooks.go new file mode 100644 index 0000000..217a01c --- /dev/null +++ b/routers/web/repo/setting/git_hooks.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" +) + +// GitHooks hooks of a repository +func GitHooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.githooks") + ctx.Data["PageIsSettingsGitHooks"] = true + + hooks, err := ctx.Repo.GitRepo.Hooks() + if err != nil { + ctx.ServerError("Hooks", err) + return + } + ctx.Data["Hooks"] = hooks + + ctx.HTML(http.StatusOK, tplGithooks) +} + +// GitHooksEdit render for editing a hook of repository page +func GitHooksEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.githooks") + ctx.Data["PageIsSettingsGitHooks"] = true + + name := ctx.Params(":name") + hook, err := ctx.Repo.GitRepo.GetHook(name) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound("GetHook", err) + } else { + ctx.ServerError("GetHook", err) + } + return + } + ctx.Data["Hook"] = hook + ctx.HTML(http.StatusOK, tplGithookEdit) +} + +// GitHooksEditPost response for editing a git hook of a repository +func GitHooksEditPost(ctx *context.Context) { + name := ctx.Params(":name") + hook, err := ctx.Repo.GitRepo.GetHook(name) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound("GetHook", err) + } else { + ctx.ServerError("GetHook", err) + } + return + } + hook.Content = ctx.FormString("content") + if err = hook.Update(); err != nil { + ctx.ServerError("hook.Update", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks/git") +} diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go new file mode 100644 index 0000000..7e36343 --- /dev/null +++ b/routers/web/repo/setting/lfs.go @@ -0,0 +1,562 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "bytes" + "fmt" + gotemplate "html/template" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsLFS base.TplName = "repo/settings/lfs" + tplSettingsLFSLocks base.TplName = "repo/settings/lfs_locks" + tplSettingsLFSFile base.TplName = "repo/settings/lfs_file" + tplSettingsLFSFileFind base.TplName = "repo/settings/lfs_file_find" + tplSettingsLFSPointers base.TplName = "repo/settings/lfs_pointers" +) + +// LFSFiles shows a repository's LFS files +func LFSFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFiles", nil) + return + } + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + total, err := git_model.CountLFSMetaObjects(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + ctx.Data["Total"] = total + + pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5) + ctx.Data["Title"] = ctx.Tr("repo.settings.lfs") + ctx.Data["PageIsSettingsLFS"] = true + lfsMetaObjects, err := git_model.GetLFSMetaObjects(ctx, ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSFiles", err) + return + } + ctx.Data["LFSFiles"] = lfsMetaObjects + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFS) +} + +// LFSLocks shows a repository's LFS locks +func LFSLocks(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSLocks", nil) + return + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + total, err := git_model.CountLFSLockByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("LFSLocks", err) + return + } + ctx.Data["Total"] = total + + pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5) + ctx.Data["Title"] = ctx.Tr("repo.settings.lfs_locks") + ctx.Data["PageIsSettingsLFS"] = true + lfsLocks, err := git_model.GetLFSLockByRepoID(ctx, ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum) + if err != nil { + ctx.ServerError("LFSLocks", err) + return + } + if err := lfsLocks.LoadAttributes(ctx); err != nil { + ctx.ServerError("LFSLocks", err) + return + } + + ctx.Data["LFSLocks"] = lfsLocks + + if len(lfsLocks) == 0 { + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFSLocks) + return + } + + // Clone base repo. + tmpBasePath, err := repo_module.CreateTemporaryPath("locks") + if err != nil { + log.Error("Failed to create temporary path: %v", err) + ctx.ServerError("LFSLocks", err) + return + } + defer func() { + if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil { + log.Error("LFSLocks: RemoveTemporaryPath: %v", err) + } + }() + + if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{ + Bare: true, + Shared: true, + }); err != nil { + log.Error("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err) + ctx.ServerError("LFSLocks", fmt.Errorf("failed to clone repository: %s (%w)", ctx.Repo.Repository.FullName(), err)) + return + } + + gitRepo, err := git.OpenRepository(ctx, tmpBasePath) + if err != nil { + log.Error("Unable to open temporary repository: %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", fmt.Errorf("failed to open new temporary repository in: %s %w", tmpBasePath, err)) + return + } + defer gitRepo.Close() + + filenames := make([]string, len(lfsLocks)) + + for i, lock := range lfsLocks { + filenames[i] = lock.Path + } + + if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil { + log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err) + ctx.ServerError("LFSLocks", fmt.Errorf("unable to read the default branch to the index: %s (%w)", ctx.Repo.Repository.DefaultBranch, err)) + return + } + + ctx.Data["Lockables"], err = lockablesGitAttributes(gitRepo, lfsLocks) + if err != nil { + log.Error("Unable to get lockablesGitAttributes in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + filelist, err := gitRepo.LsFiles(filenames...) + if err != nil { + log.Error("Unable to lsfiles in %s (%v)", tmpBasePath, err) + ctx.ServerError("LFSLocks", err) + return + } + + fileset := make(container.Set[string], len(filelist)) + fileset.AddMultiple(filelist...) + + linkable := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + linkable[i] = fileset.Contains(lock.Path) + } + ctx.Data["Linkable"] = linkable + + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsLFSLocks) +} + +func lockablesGitAttributes(gitRepo *git.Repository, lfsLocks []*git_model.LFSLock) ([]bool, error) { + checker, err := gitRepo.GitAttributeChecker("", "lockable") + if err != nil { + return nil, fmt.Errorf("could not GitAttributeChecker: %w", err) + } + defer checker.Close() + + lockables := make([]bool, len(lfsLocks)) + for i, lock := range lfsLocks { + attrs, err := checker.CheckPath(lock.Path) + if err != nil { + return nil, fmt.Errorf("could not CheckPath(%s): %w", lock.Path, err) + } + lockables[i] = attrs["lockable"].Bool().Value() + } + return lockables, nil +} + +// LFSLockFile locks a file +func LFSLockFile(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSLocks", nil) + return + } + originalPath := ctx.FormString("path") + lockPath := originalPath + if len(lockPath) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + if lockPath[len(lockPath)-1] == '/' { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_lock_directory", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + lockPath = util.PathJoinRel(lockPath) + if len(lockPath) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + + _, err := git_model.CreateLFSLock(ctx, ctx.Repo.Repository, &git_model.LFSLock{ + Path: lockPath, + OwnerID: ctx.Doer.ID, + }) + if err != nil { + if git_model.IsErrLFSLockAlreadyExist(err) { + ctx.Flash.Error(ctx.Tr("repo.settings.lfs_lock_already_exists", originalPath)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") + return + } + ctx.ServerError("LFSLockFile", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") +} + +// LFSUnlock forcibly unlocks an LFS lock +func LFSUnlock(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSUnlock", nil) + return + } + _, err := git_model.DeleteLFSLockByID(ctx, ctx.ParamsInt64("lid"), ctx.Repo.Repository, ctx.Doer, true) + if err != nil { + ctx.ServerError("LFSUnlock", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks") +} + +// LFSFileGet serves a single LFS file +func LFSFileGet(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + oid := ctx.Params("oid") + + p := lfs.Pointer{Oid: oid} + if !p.IsValid() { + ctx.NotFound("LFSFileGet", nil) + return + } + + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid) + if err != nil { + if err == git_model.ErrLFSObjectNotExist { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.ServerError("LFSFileGet", err) + return + } + ctx.Data["LFSFile"] = meta + dataRc, err := lfs.ReadMetaObject(meta.Pointer) + if err != nil { + ctx.ServerError("LFSFileGet", err) + return + } + defer dataRc.Close() + buf := make([]byte, 1024) + n, err := util.ReadAtMost(dataRc, buf) + if err != nil { + ctx.ServerError("Data", err) + return + } + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + ctx.Data["IsTextFile"] = st.IsText() + isRepresentableAsText := st.IsRepresentableAsText() + + fileSize := meta.Size + ctx.Data["FileSize"] = meta.Size + ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") + switch { + case isRepresentableAsText: + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + } + + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + // Building code view blocks with line number on server side. + escapedContent := &bytes.Buffer{} + ctx.Data["EscapeStatus"], _ = charset.EscapeControlReader(rd, escapedContent, ctx.Locale, charset.FileviewContext) + + var output bytes.Buffer + lines := strings.Split(escapedContent.String(), "\n") + // Remove blank line at the end of file + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + for index, line := range lines { + line = gotemplate.HTMLEscapeString(line) + if index != len(lines)-1 { + line += "\n" + } + output.WriteString(fmt.Sprintf(`
  • %s
  • `, index+1, index+1, line)) + } + ctx.Data["FileContent"] = gotemplate.HTML(output.String()) + + output.Reset() + for i := 0; i < len(lines); i++ { + output.WriteString(fmt.Sprintf(`%d`, i+1, i+1)) + } + ctx.Data["LineNums"] = gotemplate.HTML(output.String()) + + case st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + } + ctx.HTML(http.StatusOK, tplSettingsLFSFile) +} + +// LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it +func LFSDelete(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSDelete", nil) + return + } + oid := ctx.Params("oid") + p := lfs.Pointer{Oid: oid} + if !p.IsValid() { + ctx.NotFound("LFSDelete", nil) + return + } + + count, err := git_model.RemoveLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + // FIXME: Warning: the LFS store is not locked - and can't be locked - there could be a race condition here + // Please note a similar condition happens in models/repo.go DeleteRepository + if count == 0 { + oidPath := path.Join(oid[0:2], oid[2:4], oid[4:]) + err = storage.LFS.Delete(oidPath) + if err != nil { + ctx.ServerError("LFSDelete", err) + return + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} + +// LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha +func LFSFileFind(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFind", nil) + return + } + oid := ctx.FormString("oid") + size := ctx.FormInt64("size") + if len(oid) == 0 || size == 0 { + ctx.NotFound("LFSFind", nil) + return + } + sha := ctx.FormString("sha") + ctx.Data["Title"] = oid + ctx.Data["PageIsSettingsLFS"] = true + objectFormat := ctx.Repo.GetObjectFormat() + var objectID git.ObjectID + if len(sha) == 0 { + pointer := lfs.Pointer{Oid: oid, Size: size} + objectID = git.ComputeBlobHash(objectFormat, []byte(pointer.StringContent())) + sha = objectID.String() + } else { + objectID = git.MustIDFromString(sha) + } + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + ctx.Data["Oid"] = oid + ctx.Data["Size"] = size + ctx.Data["SHA"] = sha + + results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, objectID) + if err != nil && err != io.EOF { + log.Error("Failure in FindLFSFile: %v", err) + ctx.ServerError("LFSFind: FindLFSFile.", err) + return + } + + ctx.Data["Results"] = results + ctx.HTML(http.StatusOK, tplSettingsLFSFileFind) +} + +// LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store +func LFSPointerFiles(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSFileGet", nil) + return + } + ctx.Data["PageIsSettingsLFS"] = true + ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" + + var err error + err = func() error { + pointerChan := make(chan lfs.PointerBlob) + errChan := make(chan error, 1) + go lfs.SearchPointerBlobs(ctx, ctx.Repo.GitRepo, pointerChan, errChan) + + numPointers := 0 + var numAssociated, numNoExist, numAssociatable int + + type pointerResult struct { + SHA string + Oid string + Size int64 + InRepo bool + Exists bool + Accessible bool + Associatable bool + } + + results := []pointerResult{} + + contentStore := lfs.NewContentStore() + repo := ctx.Repo.Repository + + for pointerBlob := range pointerChan { + numPointers++ + + result := pointerResult{ + SHA: pointerBlob.Hash, + Oid: pointerBlob.Oid, + Size: pointerBlob.Size, + } + + if _, err := git_model.GetLFSMetaObjectByOid(ctx, repo.ID, pointerBlob.Oid); err != nil { + if err != git_model.ErrLFSObjectNotExist { + return err + } + } else { + result.InRepo = true + } + + result.Exists, err = contentStore.Exists(pointerBlob.Pointer) + if err != nil { + return err + } + + if result.Exists { + if !result.InRepo { + // Can we fix? + // OK well that's "simple" + // - we need to check whether current user has access to a repo that has access to the file + result.Associatable, err = git_model.LFSObjectAccessible(ctx, ctx.Doer, pointerBlob.Oid) + if err != nil { + return err + } + if !result.Associatable { + associated, err := git_model.ExistsLFSObject(ctx, pointerBlob.Oid) + if err != nil { + return err + } + result.Associatable = !associated + } + } + } + + result.Accessible = result.InRepo || result.Associatable + + if result.InRepo { + numAssociated++ + } + if !result.Exists { + numNoExist++ + } + if result.Associatable { + numAssociatable++ + } + + results = append(results, result) + } + + err, has := <-errChan + if has { + return err + } + + ctx.Data["Pointers"] = results + ctx.Data["NumPointers"] = numPointers + ctx.Data["NumAssociated"] = numAssociated + ctx.Data["NumAssociatable"] = numAssociatable + ctx.Data["NumNoExist"] = numNoExist + ctx.Data["NumNotAssociated"] = numPointers - numAssociated + + return nil + }() + if err != nil { + ctx.ServerError("LFSPointerFiles", err) + return + } + + ctx.HTML(http.StatusOK, tplSettingsLFSPointers) +} + +// LFSAutoAssociate auto associates accessible lfs files +func LFSAutoAssociate(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.NotFound("LFSAutoAssociate", nil) + return + } + oids := ctx.FormStrings("oid") + metas := make([]*git_model.LFSMetaObject, len(oids)) + for i, oid := range oids { + idx := strings.IndexRune(oid, ' ') + if idx < 0 || idx+1 > len(oid) { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("illegal oid input: %s", oid)) + return + } + var err error + metas[i] = &git_model.LFSMetaObject{} + metas[i].Size, err = strconv.ParseInt(oid[idx+1:], 10, 64) + if err != nil { + ctx.ServerError("LFSAutoAssociate", fmt.Errorf("illegal oid input: %s %w", oid, err)) + return + } + metas[i].Oid = oid[:idx] + // metas[i].RepositoryID = ctx.Repo.Repository.ID + } + if err := git_model.LFSAutoAssociate(ctx, metas, ctx.Doer, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("LFSAutoAssociate", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs") +} diff --git a/routers/web/repo/setting/main_test.go b/routers/web/repo/setting/main_test.go new file mode 100644 index 0000000..c414b85 --- /dev/null +++ b/routers/web/repo/setting/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go new file mode 100644 index 0000000..b2f5798 --- /dev/null +++ b/routers/web/repo/setting/protected_branch.go @@ -0,0 +1,347 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + pull_service "code.gitea.io/gitea/services/pull" + "code.gitea.io/gitea/services/repository" + + "github.com/gobwas/glob" +) + +const ( + tplProtectedBranch base.TplName = "repo/settings/protected_branch" +) + +// ProtectedBranchRules render the page to protect the repository +func ProtectedBranchRules(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.branches") + ctx.Data["PageIsSettingsBranches"] = true + + rules, err := git_model.FindRepoProtectedBranchRules(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetProtectedBranches", err) + return + } + ctx.Data["ProtectedBranches"] = rules + + repo.PrepareBranchList(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBranches) +} + +// SettingsProtectedBranch renders the protected branch setting page +func SettingsProtectedBranch(c *context.Context) { + ruleName := c.FormString("rule_name") + var rule *git_model.ProtectedBranch + if ruleName != "" { + var err error + rule, err = git_model.GetProtectedBranchRuleByName(c, c.Repo.Repository.ID, ruleName) + if err != nil { + c.ServerError("GetProtectBranchOfRepoByName", err) + return + } + } + + if rule == nil { + // No options found, create defaults. + rule = &git_model.ProtectedBranch{} + } + + c.Data["PageIsSettingsBranches"] = true + c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName + + users, err := access_model.GetRepoReaders(c, c.Repo.Repository) + if err != nil { + c.ServerError("Repo.Repository.GetReaders", err) + return + } + c.Data["Users"] = users + c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") + c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",") + c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") + c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") + contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts + c.Data["recent_status_checks"] = contexts + + if c.Repo.Owner.IsOrganization() { + teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead) + if err != nil { + c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) + return + } + c.Data["Teams"] = teams + c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") + c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",") + c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") + } + + c.Data["Rule"] = rule + c.HTML(http.StatusOK, tplProtectedBranch) +} + +// SettingsProtectedBranchPost updates the protected branch settings +func SettingsProtectedBranchPost(ctx *context.Context) { + f := web.GetForm(ctx).(*forms.ProtectBranchForm) + var protectBranch *git_model.ProtectedBranch + if f.RuleName == "" { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit", ctx.Repo.RepoLink)) + return + } + + var err error + if f.RuleID > 0 { + // If the RuleID isn't 0, it must be an edit operation. So we get rule by id. + protectBranch, err = git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, f.RuleID) + if err != nil { + ctx.ServerError("GetProtectBranchOfRepoByID", err) + return + } + if protectBranch != nil && protectBranch.RuleName != f.RuleName { + // RuleName changed. We need to check if there is a rule with the same name. + // If a rule with the same name exists, an error should be returned. + sameNameProtectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName) + if err != nil { + ctx.ServerError("GetProtectBranchOfRepoByName", err) + return + } + if sameNameProtectBranch != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) + return + } + } + } else { + // Check if a rule already exists with this rulename, if so redirect to it. + protectBranch, err = git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName) + if err != nil { + ctx.ServerError("GetProtectedBranchRuleByName", err) + return + } + if protectBranch != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) + return + } + } + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &git_model.ProtectedBranch{ + RepoID: ctx.Repo.Repository.ID, + RuleName: f.RuleName, + } + } + + var whitelistUsers, whitelistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 + protectBranch.RuleName = f.RuleName + if f.RequiredApprovals < 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, f.RuleName)) + return + } + + switch f.EnablePush { + case "all": + protectBranch.CanPush = true + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + case "whitelist": + protectBranch.CanPush = true + protectBranch.EnableWhitelist = true + protectBranch.WhitelistDeployKeys = f.WhitelistDeployKeys + if strings.TrimSpace(f.WhitelistUsers) != "" { + whitelistUsers, _ = base.StringsToInt64s(strings.Split(f.WhitelistUsers, ",")) + } + if strings.TrimSpace(f.WhitelistTeams) != "" { + whitelistTeams, _ = base.StringsToInt64s(strings.Split(f.WhitelistTeams, ",")) + } + default: + protectBranch.CanPush = false + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + } + + protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist + if f.EnableMergeWhitelist { + if strings.TrimSpace(f.MergeWhitelistUsers) != "" { + mergeWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistUsers, ",")) + } + if strings.TrimSpace(f.MergeWhitelistTeams) != "" { + mergeWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistTeams, ",")) + } + } + + protectBranch.EnableStatusCheck = f.EnableStatusCheck + if f.EnableStatusCheck { + patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n") + validPatterns := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + continue + } + if _, err := glob.Compile(trimmed); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.protect_invalid_status_check_pattern", pattern)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName))) + return + } + validPatterns = append(validPatterns, trimmed) + } + if len(validPatterns) == 0 { + // if status check is enabled, patterns slice is not allowed to be empty + ctx.Flash.Error(ctx.Tr("repo.settings.protect_no_valid_status_check_patterns")) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName))) + return + } + protectBranch.StatusCheckContexts = validPatterns + } else { + protectBranch.StatusCheckContexts = nil + } + + protectBranch.RequiredApprovals = f.RequiredApprovals + protectBranch.EnableApprovalsWhitelist = f.EnableApprovalsWhitelist + if f.EnableApprovalsWhitelist { + if strings.TrimSpace(f.ApprovalsWhitelistUsers) != "" { + approvalsWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistUsers, ",")) + } + if strings.TrimSpace(f.ApprovalsWhitelistTeams) != "" { + approvalsWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistTeams, ",")) + } + } + protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews + protectBranch.BlockOnOfficialReviewRequests = f.BlockOnOfficialReviewRequests + protectBranch.DismissStaleApprovals = f.DismissStaleApprovals + protectBranch.IgnoreStaleApprovals = f.IgnoreStaleApprovals + protectBranch.RequireSignedCommits = f.RequireSignedCommits + protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns + protectBranch.UnprotectedFilePatterns = f.UnprotectedFilePatterns + protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch + protectBranch.ApplyToAdmins = f.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.ServerError("UpdateProtectBranch", err) + return + } + + // 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.ServerError("FindAllMatchedBranches", err) + return + } + for _, branchName := range matchedBranches { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil { + ctx.ServerError("CheckPRsForBaseBranch", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) +} + +// DeleteProtectedBranchRulePost delete protected branch rule by id +func DeleteProtectedBranchRulePost(ctx *context.Context) { + ruleID := ctx.ParamsInt64("id") + if ruleID <= 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + rule, err := git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, ruleID) + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + if rule == nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, ruleID); err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", rule.RuleName)) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName)) + ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) +} + +// RenameBranchPost responses for rename a branch +func RenameBranchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RenameBranchForm) + + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("RenameBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + msg, err := repository.RenameBranch(ctx, ctx.Repo.Repository, ctx.Doer, ctx.Repo.GitRepo, form.From, form.To) + if err != nil { + if errors.Is(err, git_model.ErrBranchIsProtected) { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_protected", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + } else if git_model.IsErrBranchAlreadyExists(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + } else { + ctx.ServerError("RenameBranch", err) + } + return + } + + if msg == "target_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_exist", form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + if msg == "from_not_exist" { + ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_not_exist", form.From)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.rename_branch_success", form.From, form.To)) + ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) +} diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go new file mode 100644 index 0000000..2c25b65 --- /dev/null +++ b/routers/web/repo/setting/protected_tag.go @@ -0,0 +1,188 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + "strings" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplTags base.TplName = "repo/settings/tags" +) + +// Tags render the page to protect tags +func ProtectedTags(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.HTML(http.StatusOK, tplTags) +} + +// NewProtectedTagPost handles creation of a protect tag +func NewProtectedTagPost(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTags) + return + } + + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*forms.ProtectTagForm) + + pt := &git_model.ProtectedTag{ + RepoID: repo.ID, + NamePattern: strings.TrimSpace(form.NamePattern), + } + + if strings.TrimSpace(form.AllowlistUsers) != "" { + pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) + } + if strings.TrimSpace(form.AllowlistTeams) != "" { + pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) + } + + if err := git_model.InsertProtectedTag(ctx, pt); err != nil { + ctx.ServerError("InsertProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// EditProtectedTag render the page to edit a protect tag +func EditProtectedTag(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.Data["PageIsEditProtectedTag"] = true + + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + ctx.Data["name_pattern"] = pt.NamePattern + ctx.Data["allowlist_users"] = strings.Join(base.Int64sToStrings(pt.AllowlistUserIDs), ",") + ctx.Data["allowlist_teams"] = strings.Join(base.Int64sToStrings(pt.AllowlistTeamIDs), ",") + + ctx.HTML(http.StatusOK, tplTags) +} + +// EditProtectedTagPost handles creation of a protect tag +func EditProtectedTagPost(ctx *context.Context) { + if setTagsContext(ctx) != nil { + return + } + + ctx.Data["PageIsEditProtectedTag"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplTags) + return + } + + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + form := web.GetForm(ctx).(*forms.ProtectTagForm) + + pt.NamePattern = strings.TrimSpace(form.NamePattern) + pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) + pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) + + if err := git_model.UpdateProtectedTag(ctx, pt); err != nil { + ctx.ServerError("UpdateProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") +} + +// DeleteProtectedTagPost handles deletion of a protected tag +func DeleteProtectedTagPost(ctx *context.Context) { + pt := selectProtectedTagByContext(ctx) + if pt == nil { + return + } + + if err := git_model.DeleteProtectedTag(ctx, pt); err != nil { + ctx.ServerError("DeleteProtectedTag", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") +} + +func setTagsContext(ctx *context.Context) error { + ctx.Data["Title"] = ctx.Tr("repo.settings.tags") + ctx.Data["PageIsSettingsTags"] = true + + protectedTags, err := git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetProtectedTags", err) + return err + } + ctx.Data["ProtectedTags"] = protectedTags + + users, err := access_model.GetRepoReaders(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("Repo.Repository.GetReaders", err) + return err + } + ctx.Data["Users"] = users + + if ctx.Repo.Owner.IsOrganization() { + teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead) + if err != nil { + ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) + return err + } + ctx.Data["Teams"] = teams + } + + return nil +} + +func selectProtectedTagByContext(ctx *context.Context) *git_model.ProtectedTag { + id := ctx.FormInt64("id") + if id == 0 { + id = ctx.ParamsInt64(":id") + } + + tag, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.ServerError("GetProtectedTagByID", err) + return nil + } + + if tag != nil && tag.RepoID == ctx.Repo.Repository.ID { + return tag + } + + ctx.NotFound("", fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository)) + + return nil +} diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go new file mode 100644 index 0000000..a47d3b4 --- /dev/null +++ b/routers/web/repo/setting/runners.go @@ -0,0 +1,187 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "net/url" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + actions_shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // TODO: Separate secrets from runners when layout is ready + tplRepoRunners base.TplName = "repo/settings/actions" + tplOrgRunners base.TplName = "org/settings/actions" + tplAdminRunners base.TplName = "admin/actions" + tplUserRunners base.TplName = "user/settings/actions" + tplRepoRunnerEdit base.TplName = "repo/settings/runner_edit" + tplOrgRunnerEdit base.TplName = "org/settings/runners_edit" + tplAdminRunnerEdit base.TplName = "admin/runners/edit" + tplUserRunnerEdit base.TplName = "user/settings/runner_edit" +) + +type runnersCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsAdmin bool + IsUser bool + RunnersTemplate base.TplName + RunnerEditTemplate base.TplName + RedirectLink string +} + +func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &runnersCtx{ + RepoID: ctx.Repo.Repository.ID, + OwnerID: 0, + IsRepo: true, + RunnersTemplate: tplRepoRunners, + RunnerEditTemplate: tplRepoRunnerEdit, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &runnersCtx{ + RepoID: 0, + OwnerID: ctx.Org.Organization.ID, + IsOrg: true, + RunnersTemplate: tplOrgRunners, + RunnerEditTemplate: tplOrgRunnerEdit, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &runnersCtx{ + RepoID: 0, + OwnerID: 0, + IsAdmin: true, + RunnersTemplate: tplAdminRunners, + RunnerEditTemplate: tplAdminRunnerEdit, + RedirectLink: setting.AppSubURL + "/admin/actions/runners/", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &runnersCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + RunnersTemplate: tplUserRunners, + RunnerEditTemplate: tplUserRunnerEdit, + RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/", + }, nil + } + + return nil, errors.New("unable to set Runners context") +} + +// Runners render settings/actions/runners page for repo level +func Runners(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "runners" + + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + opts := actions_model.FindRunnerOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: 100, + }, + Sort: ctx.Req.URL.Query().Get("sort"), + Filter: ctx.Req.URL.Query().Get("q"), + } + if rCtx.IsRepo { + opts.RepoID = rCtx.RepoID + opts.WithAvailable = true + } else if rCtx.IsOrg || rCtx.IsUser { + opts.OwnerID = rCtx.OwnerID + opts.WithAvailable = true + } + actions_shared.RunnersList(ctx, opts) + + ctx.HTML(http.StatusOK, rCtx.RunnersTemplate) +} + +// RunnersEdit renders runner edit page for repository level +func RunnersEdit(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner") + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + actions_shared.RunnerDetails(ctx, page, + ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID, + ) + ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate) +} + +func RunnersEditPost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"), + rCtx.OwnerID, rCtx.RepoID, + rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid"))) +} + +func ResetRunnerRegistrationToken(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink) +} + +// RunnerDeletePost response for deleting runner +func RunnerDeletePost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid"))) +} + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") +} diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go new file mode 100644 index 0000000..d4d56bf --- /dev/null +++ b/routers/web/repo/setting/secrets.go @@ -0,0 +1,127 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/secrets" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + // TODO: Separate secrets from runners when layout is ready + tplRepoSecrets base.TplName = "repo/settings/actions" + tplOrgSecrets base.TplName = "org/settings/actions" + tplUserSecrets base.TplName = "user/settings/actions" +) + +type secretsCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + SecretsTemplate base.TplName + RedirectLink string +} + +func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &secretsCtx{ + OwnerID: 0, + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + SecretsTemplate: tplRepoSecrets, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/secrets", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &secretsCtx{ + OwnerID: ctx.ContextUser.ID, + RepoID: 0, + IsOrg: true, + SecretsTemplate: tplOrgSecrets, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &secretsCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + SecretsTemplate: tplUserSecrets, + RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets", + }, nil + } + + return nil, errors.New("unable to set Secrets context") +} + +func Secrets(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "secrets" + ctx.Data["PageIsSharedSettingsSecrets"] = true + + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + + if sCtx.IsRepo { + ctx.Data["DisableSSH"] = setting.SSH.Disabled + } + + shared.SetSecretsContext(ctx, sCtx.OwnerID, sCtx.RepoID) + if ctx.Written() { + return + } + ctx.HTML(http.StatusOK, sCtx.SecretsTemplate) +} + +func SecretsPost(ctx *context.Context) { + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.PerformSecretsPost( + ctx, + sCtx.OwnerID, + sCtx.RepoID, + sCtx.RedirectLink, + ) +} + +func SecretsDelete(ctx *context.Context) { + sCtx, err := getSecretsCtx(ctx) + if err != nil { + ctx.ServerError("getSecretsCtx", err) + return + } + shared.PerformSecretsDelete( + ctx, + sCtx.OwnerID, + sCtx.RepoID, + sCtx.RedirectLink, + ) +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go new file mode 100644 index 0000000..aee2e2f --- /dev/null +++ b/routers/web/repo/setting/setting.go @@ -0,0 +1,1115 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + 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/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/indexer/stats" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" + repo_service "code.gitea.io/gitea/services/repository" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +const ( + tplSettingsOptions base.TplName = "repo/settings/options" + tplSettingsUnits base.TplName = "repo/settings/units" + tplCollaboration base.TplName = "repo/settings/collaboration" + tplBranches base.TplName = "repo/settings/branches" + tplGithooks base.TplName = "repo/settings/githooks" + tplGithookEdit base.TplName = "repo/settings/githook_edit" + tplDeployKeys base.TplName = "repo/settings/deploy_keys" +) + +// SettingsCtxData is a middleware that sets all the general context data for the +// settings template. +func SettingsCtxData(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.options") + ctx.Data["PageIsSettingsOptions"] = true + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush + ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval + ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval + + signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningSettings"] = setting.Repository.Signing + ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + if ctx.Doer.IsAdmin { + if setting.Indexer.RepoIndexerEnabled { + status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeCode) + if err != nil { + ctx.ServerError("repo.indexer_status", err) + return + } + ctx.Data["CodeIndexerStatus"] = status + } + status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeStats) + if err != nil { + ctx.ServerError("repo.indexer_status", err) + return + } + ctx.Data["StatsIndexerStatus"] = status + } + pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("GetPushMirrorsByRepoID", err) + return + } + ctx.Data["PushMirrors"] = pushMirrors + ctx.Data["CanUseSSHMirroring"] = git.HasSSHExecutable +} + +// Units show a repositorys unit settings page +func Units(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.units.units") + ctx.Data["PageIsRepoSettingsUnits"] = true + + ctx.HTML(http.StatusOK, tplSettingsUnits) +} + +func UnitsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoUnitSettingForm) + + repo := ctx.Repo.Repository + + var repoChanged bool + var units []repo_model.RepoUnit + var deleteUnitTypes []unit_model.Type + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { + repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch + repoChanged = true + } + + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeCode, + }) + } else if !unit_model.TypeCode.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode) + } + + if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalWikiURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) + ctx.Redirect(repo.Link() + "/settings/units") + return + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalWiki, + Config: &repo_model.ExternalWikiConfig{ + ExternalWikiURL: form.ExternalWikiURL, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() { + var wikiPermissions repo_model.UnitAccessMode + if form.GloballyWriteableWiki { + wikiPermissions = repo_model.UnitAccessModeWrite + } else { + wikiPermissions = repo_model.UnitAccessModeRead + } + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeWiki, + Config: new(repo_model.UnitConfig), + DefaultPermissions: wikiPermissions, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } else { + if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } + if !unit_model.TypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } + } + + if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalTrackerURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) + ctx.Redirect(repo.Link() + "/settings/units") + return + } + if len(form.TrackerURLFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(form.TrackerURLFormat) { + ctx.Flash.Error(ctx.Tr("repo.settings.tracker_url_format_error")) + ctx.Redirect(repo.Link() + "/settings/units") + return + } + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalTracker, + Config: &repo_model.ExternalTrackerConfig{ + ExternalTrackerURL: form.ExternalTrackerURL, + ExternalTrackerFormat: form.TrackerURLFormat, + ExternalTrackerStyle: form.TrackerIssueStyle, + ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeIssues, + Config: &repo_model.IssuesConfig{ + EnableTimetracker: form.EnableTimetracker, + AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, + EnableDependencies: form.EnableIssueDependencies, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } else { + if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } + if !unit_model.TypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } + } + + if form.EnableProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeProjects, + }) + } else if !unit_model.TypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) + } + + if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeReleases, + }) + } else if !unit_model.TypeReleases.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) + } + + if form.EnablePackages && !unit_model.TypePackages.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePackages, + }) + } else if !unit_model.TypePackages.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) + } + + if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeActions, + }) + } else if !unit_model.TypeActions.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) + } + + if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePullRequests, + Config: &repo_model.PullRequestsConfig{ + IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, + AllowMerge: form.PullsAllowMerge, + AllowRebase: form.PullsAllowRebase, + AllowRebaseMerge: form.PullsAllowRebaseMerge, + AllowSquash: form.PullsAllowSquash, + AllowFastForwardOnly: form.PullsAllowFastForwardOnly, + AllowManualMerge: form.PullsAllowManualMerge, + AutodetectManualMerge: form.EnableAutodetectManualMerge, + AllowRebaseUpdate: form.PullsAllowRebaseUpdate, + DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge, + DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle), + DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit, + }, + }) + } else if !unit_model.TypePullRequests.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) + } + + if len(units) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/units") + return + } + + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + if repoChanged { + if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/units") +} + +// Settings show a repository's settings page +func Settings(ctx *context.Context) { + ctx.HTML(http.StatusOK, tplSettingsOptions) +} + +// SettingsPost response for changes of a repository +func SettingsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush + ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval + ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval + + signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningSettings"] = setting.Repository.Signing + ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + repo := ctx.Repo.Repository + + switch ctx.FormString("action") { + case "update": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + newRepoName := form.RepoName + // Check if repository name has been changed. + if repo.LowerName != strings.ToLower(newRepoName) { + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + ctx.Data["Err_RepoName"] = true + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form) + } + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + default: + ctx.ServerError("ChangeRepositoryName", err) + } + return + } + + log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName) + } + // In case it's just a case change. + repo.Name = newRepoName + repo.LowerName = strings.ToLower(newRepoName) + repo.Description = form.Description + repo.Website = form.Website + repo.IsTemplate = form.Template + + // Visibility of forked repository is forced sync with base repository. + if repo.IsFork { + form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate + } + + visibilityChanged := repo.IsPrivate != form.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 && !form.Private && !ctx.Doer.IsAdmin { + ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form) + return + } + + repo.IsPrivate = form.Private + if err := repo_service.UpdateRepository(ctx, repo, visibilityChanged); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "federation": + if !setting.Federation.Enabled { + ctx.NotFound("", nil) + ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled")) + return + } + followingRepos := strings.TrimSpace(form.FollowingRepos) + followingRepos = strings.TrimSuffix(followingRepos, ";") + + maxFollowingRepoStrLength := 2048 + errs := validation.ValidateMaxLen(followingRepos, maxFollowingRepoStrLength, "federationRepos") + if len(errs) > 0 { + ctx.Data["ERR_FollowingRepos"] = true + ctx.Flash.Error(ctx.Tr("repo.form.string_too_long", maxFollowingRepoStrLength)) + ctx.Redirect(repo.Link() + "/settings") + return + } + + federationRepoSplit := []string{} + if followingRepos != "" { + federationRepoSplit = strings.Split(followingRepos, ";") + } + for idx, repo := range federationRepoSplit { + federationRepoSplit[idx] = strings.TrimSpace(repo) + } + + if _, _, err := federation.StoreFollowingRepoList(ctx, ctx.Repo.Repository.ID, federationRepoSplit); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "mirror": + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + pullMirror, err := repo_model.GetMirrorByRepoID(ctx, ctx.Repo.Repository.ID) + if err == repo_model.ErrMirrorNotExist { + ctx.NotFound("", nil) + return + } + if err != nil { + ctx.ServerError("GetMirrorByRepoID", err) + return + } + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + interval, err := time.ParseDuration(form.Interval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_Interval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } + + pullMirror.EnablePrune = form.EnablePrune + pullMirror.Interval = interval + pullMirror.ScheduleNextUpdate() + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } + + u, err := git.GetRemoteURL(ctx, ctx.Repo.Repository.RepoPath(), pullMirror.GetRemoteName()) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { + form.MirrorPassword, _ = u.User.Password() + } + + address, err := forms.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + if err := mirror_service.UpdateAddress(ctx, pullMirror, address); err != nil { + ctx.ServerError("UpdateAddress", err) + return + } + remoteAddress, err := util.SanitizeURL(address) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + pullMirror.RemoteAddress = remoteAddress + + form.LFS = form.LFS && setting.LFS.StartServer + + if len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Data["Err_LFSEndpoint"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tplSettingsOptions, &form) + return + } + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + } + + pullMirror.LFS = form.LFS + pullMirror.LFSEndpoint = form.LFSEndpoint + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "mirror-sync": + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + ok, err := quota_model.EvaluateForUser(ctx, repo.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + ctx.RenderWithErr(ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), tplSettingsOptions, &form) + return + } + + mirror_service.AddPullMirrorToQueue(repo.ID) + + ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-sync": + if !setting.Mirror.Enabled { + ctx.NotFound("", nil) + return + } + + m, err := selectPushMirrorByForm(ctx, form, repo) + if err != nil { + ctx.NotFound("", nil) + return + } + + mirror_service.AddPushMirrorToQueue(m.ID) + + ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-update": + if !setting.Mirror.Enabled || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + interval, err := time.ParseDuration(form.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{}) + return + } + + id, err := strconv.ParseInt(form.PushMirrorID, 10, 64) + if err != nil { + ctx.ServerError("UpdatePushMirrorIntervalPushMirrorID", err) + return + } + m := &repo_model.PushMirror{ + ID: id, + Interval: interval, + } + if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { + ctx.ServerError("UpdatePushMirrorInterval", err) + return + } + // Background why we are adding it to Queue + // If we observed its implementation in the context of `push-mirror-sync` where it + // is evident that pushing to the queue is necessary for updates. + // So, there are updates within the given interval, it is necessary to update the queue accordingly. + mirror_service.AddPushMirrorToQueue(m.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-remove": + if !setting.Mirror.Enabled || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + m, err := selectPushMirrorByForm(ctx, form, repo) + if err != nil { + ctx.NotFound("", nil) + return + } + + if err = mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { + ctx.ServerError("RemovePushMirrorRemote", err) + return + } + + if err = repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + ctx.ServerError("DeletePushMirrorByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "push-mirror-add": + if setting.Mirror.DisableNewPush || repo.IsArchived { + ctx.NotFound("", nil) + return + } + + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + interval, err := time.ParseDuration(form.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_PushMirrorInterval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } + + if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") { + ctx.Data["Err_PushMirrorUseSSH"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form) + return + } + + if form.PushMirrorUseSSH && !git.HasSSHExecutable { + ctx.RenderWithErr(ctx.Tr("repo.mirror_use_ssh.not_available"), tplSettingsOptions, &form) + return + } + + address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + remoteSuffix, err := util.CryptoRandomString(10) + if err != nil { + ctx.ServerError("RandomString", err) + return + } + + remoteAddress, err := util.SanitizeURL(address) + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + + m := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + SyncOnCommit: form.PushMirrorSyncOnCommit, + Interval: interval, + RemoteAddress: remoteAddress, + } + + var plainPrivateKey []byte + if form.PushMirrorUseSSH { + publicKey, privateKey, err := util.GenerateSSHKeypair() + if err != nil { + ctx.ServerError("GenerateSSHKeypair", err) + return + } + plainPrivateKey = privateKey + m.PublicKey = string(publicKey) + } + + if err := db.Insert(ctx, m); err != nil { + ctx.ServerError("InsertPushMirror", err) + return + } + + if form.PushMirrorUseSSH { + if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil { + ctx.ServerError("SetPrivatekey", err) + return + } + } + + if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil { + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + log.Error("DeletePushMirrors %v", err) + } + ctx.ServerError("AddPushMirrorRemote", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + + case "signing": + changed := false + trustModel := repo_model.ToTrustModel(form.TrustModel) + if trustModel != repo.TrustModel { + repo.TrustModel = trustModel + changed = true + } + + if changed { + if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + } + log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "admin": + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + + if repo.IsFsckEnabled != form.EnableHealthCheck { + repo.IsFsckEnabled = form.EnableHealthCheck + } + + if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + log.Trace("Repository admin settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "admin_index": + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + + switch form.RequestReindexType { + case "stats": + if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateStatsRepondexer", err) + return + } + case "code": + if !setting.Indexer.RepoIndexerEnabled { + ctx.Error(http.StatusForbidden) + return + } + code.UpdateRepoIndexer(ctx.Repo.Repository) + default: + ctx.NotFound("", nil) + return + } + + log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "convert": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if !repo.IsMirror { + ctx.Error(http.StatusNotFound) + return + } + repo.IsMirror = false + + if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { + ctx.ServerError("CleanUpMigrateInfo", err) + return + } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("DeleteMirrorByRepoID", err) + return + } + log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) + ctx.Redirect(repo.Link()) + + case "convert_fork": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if err := repo.LoadOwner(ctx); err != nil { + ctx.ServerError("Convert Fork", err) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if !repo.IsFork { + ctx.Error(http.StatusNotFound) + return + } + + if !ctx.Repo.Owner.CanCreateRepo() { + maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg) + ctx.Redirect(repo.Link() + "/settings") + return + } + + if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil { + log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err) + ctx.ServerError("Convert Fork", err) + return + } + + log.Trace("Repository converted from fork to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed")) + ctx.Redirect(repo.Link()) + + case "transfer": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + ctx.ServerError("IsUserExist", err) + return + } + + if newOwner.Type == user_model.UserTypeOrganization { + if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { + // The user shouldn't know about this organization + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + return + } + } + + // Check the quota of the new owner + ok, err := quota_model.EvaluateForUser(ctx, newOwner.ID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_quota_exceeded", newOwner.Name), tplSettingsOptions, &form) + return + } + + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + oldFullname := repo.FullName() + if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil) + } else if repo_model.IsErrRepoAlreadyExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) + } else if models.IsErrRepoTransferInProgress(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + } else { + ctx.ServerError("TransferOwnership", err) + } + + return + } + + if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { + log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) + } else { + log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) + } + ctx.Redirect(repo.Link() + "/settings") + + case "cancel_transfer": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.Flash.Error("repo.settings.transfer_abort_invalid") + ctx.Redirect(repo.Link() + "/settings") + } else { + ctx.ServerError("GetPendingRepositoryTransfer", err) + } + + return + } + + if err := repoTransfer.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadRecipient", err) + return + } + + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("CancelRepositoryTransfer", err) + return + } + + log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) + ctx.Redirect(repo.Link() + "/settings") + + case "delete": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + // Close the gitrepository before doing this. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + + if err := repo_service.DeleteRepository(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.Redirect(ctx.Repo.Owner.DashboardLink()) + + case "delete-wiki": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + err := wiki_service.DeleteWiki(ctx, repo) + if err != nil { + log.Error("Delete Wiki: %v", err.Error()) + } + log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "rename-wiki-branch": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusNotFound) + return + } + if repo.FullName() != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if err := wiki_service.NormalizeWikiBranch(ctx, repo, setting.Repository.DefaultBranch); err != nil { + log.Error("Normalize Wiki branch: %v", err.Error()) + ctx.Flash.Error(ctx.Tr("repo.settings.wiki_branch_rename_failure")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + log.Trace("Repository wiki normalized: %s#%s", repo.FullName(), setting.Repository.DefaultBranch) + + ctx.Flash.Success(ctx.Tr("repo.settings.wiki_branch_rename_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "archive": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden) + return + } + + if repo.IsMirror { + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil { + log.Error("Tried to archive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + 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) + } + + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) + + log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + case "unarchive": + if !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden) + return + } + + if err := repo_model.SetArchiveRepoState(ctx, repo, false); err != nil { + log.Error("Tried to unarchive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + + 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) + } + } + + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) + + log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + + default: + ctx.NotFound("", nil) + } +} + +func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.RepoSettingForm) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsProtocolInvalid: + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_protocol_invalid"), tplSettingsOptions, form) + case addrErr.IsURLError: + ctx.RenderWithErr(ctx.Tr("form.url_error", addrErr.Host), tplSettingsOptions, form) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tplSettingsOptions, form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tplSettingsOptions, form) + } + case addrErr.IsInvalidPath: + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tplSettingsOptions, form) + default: + ctx.ServerError("Unknown error", err) + } + return + } + ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form) +} + +func selectPushMirrorByForm(ctx *context.Context, form *forms.RepoSettingForm, repo *repo_model.Repository) (*repo_model.PushMirror, error) { + id, err := strconv.ParseInt(form.PushMirrorID, 10, 64) + if err != nil { + return nil, err + } + + pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{}) + if err != nil { + return nil, err + } + + for _, m := range pushMirrors { + if m.ID == id { + m.Repo = repo + return m, nil + } + } + + return nil, fmt.Errorf("PushMirror[%v] not associated to repository %v", id, repo) +} diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go new file mode 100644 index 0000000..0c8553f --- /dev/null +++ b/routers/web/repo/setting/settings_test.go @@ -0,0 +1,412 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createSSHAuthorizedKeysTmpPath(t *testing.T) func() { + tmpDir := t.TempDir() + + oldPath := setting.SSH.RootPath + setting.SSH.RootPath = tmpDir + + return func() { + setting.SSH.RootPath = oldPath + } +} + +func TestAddReadOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") + + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) + + addKeyForm := forms.AddKeyForm{ + Title: "read-only", + Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + } + web.SetForm(ctx, &addKeyForm) + DeployKeysPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: perm.AccessModeRead, + }) +} + +func TestAddReadWriteOnlyDeployKey(t *testing.T) { + if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { + defer deferable() + } else { + return + } + + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") + + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) + + addKeyForm := forms.AddKeyForm{ + Title: "read-write", + Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + IsWritable: true, + } + web.SetForm(ctx, &addKeyForm) + DeployKeysPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + Name: addKeyForm.Title, + Content: addKeyForm.Content, + Mode: perm.AccessModeWrite, + }) +} + +func TestCollaborationPost(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &user_model.User{ + ID: 2, + LowerName: "user2", + Type: user_model.UserTypeIndividual, + } + + re := &repo_model.Repository{ + ID: 2, + Owner: u, + OwnerID: u.ID, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) + require.NoError(t, err) + assert.True(t, exists) +} + +func TestCollaborationPost_InactiveUser(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 9) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user9") + + repo := &context.Repository{ + Owner: &user_model.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user4") + + u := &user_model.User{ + ID: 2, + LowerName: "user2", + Type: user_model.UserTypeIndividual, + } + + re := &repo_model.Repository{ + ID: 2, + Owner: u, + OwnerID: u.ID, + } + + repo := &context.Repository{ + Owner: u, + Repository: re, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + + exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) + require.NoError(t, err) + assert.True(t, exists) + + // Try adding the same collaborator again + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestCollaborationPost_NonExistentUser(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + ctx.Req.Form.Set("collaborator", "user34") + + repo := &context.Repository{ + Owner: &user_model.User{ + LowerName: "user2", + }, + } + + ctx.Repo = repo + + CollaborationPost(ctx) + + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Empty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NotAllowed(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: false, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_AddTeamTwice(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team11") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 11, + OrgID: 26, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + + AddTeamPost(ctx) + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestAddTeamPost_NonExistentTeam(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org26/repo43") + + ctx.Req.Form.Set("team", "team-non-existent") + + org := &user_model.User{ + LowerName: "org26", + Type: user_model.UserTypeOrganization, + } + + re := &repo_model.Repository{ + ID: 43, + Owner: org, + OwnerID: 26, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 26, + LowerName: "org26", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + AddTeamPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.NotEmpty(t, ctx.Flash.ErrorMsg) +} + +func TestDeleteTeam(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "org3/team1/repo3") + + ctx.Req.Form.Set("id", "2") + + org := &user_model.User{ + LowerName: "org3", + Type: user_model.UserTypeOrganization, + } + + team := &organization.Team{ + ID: 2, + OrgID: 3, + } + + re := &repo_model.Repository{ + ID: 3, + Owner: org, + OwnerID: 3, + } + + repo := &context.Repository{ + Owner: &user_model.User{ + ID: 3, + LowerName: "org3", + RepoAdminChangeTeamAccess: true, + }, + Repository: re, + } + + ctx.Repo = repo + + DeleteTeam(ctx) + + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) +} diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go new file mode 100644 index 0000000..45b6c0f --- /dev/null +++ b/routers/web/repo/setting/variables.go @@ -0,0 +1,140 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplRepoVariables base.TplName = "repo/settings/actions" + tplOrgVariables base.TplName = "org/settings/actions" + tplUserVariables base.TplName = "user/settings/actions" + tplAdminVariables base.TplName = "admin/actions" +) + +type variablesCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + IsGlobal bool + VariablesTemplate base.TplName + RedirectLink string +} + +func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + VariablesTemplate: tplRepoVariables, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } + return &variablesCtx{ + OwnerID: ctx.ContextUser.ID, + RepoID: 0, + IsOrg: true, + VariablesTemplate: tplOrgVariables, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &variablesCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + VariablesTemplate: tplUserVariables, + RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: 0, + IsGlobal: true, + VariablesTemplate: tplAdminVariables, + RedirectLink: setting.AppSubURL + "/admin/actions/variables", + }, nil + } + + return nil, errors.New("unable to set Variables context") +} + +func Variables(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.variables") + ctx.Data["PageType"] = "variables" + ctx.Data["PageIsSharedSettingsVariables"] = true + + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) +} + +func VariableCreate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink) +} + +func VariableUpdate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.UpdateVariable(ctx, vCtx.RedirectLink) +} + +func VariableDelete(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + shared.DeleteVariable(ctx, vCtx.RedirectLink) +} diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go new file mode 100644 index 0000000..eee493e --- /dev/null +++ b/routers/web/repo/setting/webhook.go @@ -0,0 +1,485 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "path" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web/middleware" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + webhook_service "code.gitea.io/gitea/services/webhook" + + "gitea.com/go-chi/binding" +) + +const ( + tplHooks base.TplName = "repo/settings/webhook/base" + tplHookNew base.TplName = "repo/settings/webhook/new" + tplOrgHookNew base.TplName = "org/settings/hook_new" + tplUserHookNew base.TplName = "user/settings/hook_new" + tplAdminHookNew base.TplName = "admin/hook_new" +) + +// WebhookList render web hooks list page +func WebhookList(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.hooks") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = ctx.Repo.RepoLink + "/settings/hooks" + ctx.Data["BaseLinkNew"] = ctx.Repo.RepoLink + "/settings/hooks" + ctx.Data["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("repo.settings.hooks_desc", "https://forgejo.org/docs/latest/user/webhooks/") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("GetWebhooksByRepoID", err) + return + } + ctx.Data["Webhooks"] = ws + + ctx.HTML(http.StatusOK, tplHooks) +} + +type ownerRepoCtx struct { + OwnerID int64 + RepoID int64 + IsAdmin bool + IsSystemWebhook bool + Link string + LinkNew string + NewTemplate base.TplName +} + +// getOwnerRepoCtx determines whether this is a repo, owner, or admin (both default and system) context. +func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &ownerRepoCtx{ + RepoID: ctx.Repo.Repository.ID, + Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"), + LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"), + NewTemplate: tplHookNew, + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + return &ownerRepoCtx{ + OwnerID: ctx.ContextUser.ID, + Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), + LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), + NewTemplate: tplOrgHookNew, + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &ownerRepoCtx{ + OwnerID: ctx.Doer.ID, + Link: path.Join(setting.AppSubURL, "/user/settings/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"), + NewTemplate: tplUserHookNew, + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &ownerRepoCtx{ + IsAdmin: true, + IsSystemWebhook: ctx.Params(":configType") == "system-hooks", + Link: path.Join(setting.AppSubURL, "/admin/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/admin/", ctx.Params(":configType")), + NewTemplate: tplAdminHookNew, + }, nil + } + + return nil, errors.New("unable to set OwnerRepo context") +} + +// WebhookNew render creating webhook page +func WebhookNew(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", err) + return + } + + if orCtx.IsAdmin && orCtx.IsSystemWebhook { + ctx.Data["PageIsAdminSystemHooks"] = true + ctx.Data["PageIsAdminSystemHooksNew"] = true + } else if orCtx.IsAdmin { + ctx.Data["PageIsAdminDefaultHooks"] = true + ctx.Data["PageIsAdminDefaultHooksNew"] = true + } else { + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + } + + hookType := ctx.Params(":type") + handler := webhook_service.GetWebhookHandler(hookType) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + ctx.Data["HookType"] = hookType + ctx.Data["WebhookHandler"] = handler + ctx.Data["BaseLink"] = orCtx.LinkNew + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + ctx.HTML(http.StatusOK, orCtx.NewTemplate) +} + +// ParseHookEvent convert web form content to webhook.HookEvent +func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent { + return &webhook_module.HookEvent{ + PushOnly: form.PushOnly(), + SendEverything: form.SendEverything(), + ChooseEvents: form.ChooseEvents(), + HookEvents: webhook_module.HookEvents{ + Create: form.Create, + Delete: form.Delete, + Fork: form.Fork, + Issues: form.Issues, + IssueAssign: form.IssueAssign, + IssueLabel: form.IssueLabel, + IssueMilestone: form.IssueMilestone, + IssueComment: form.IssueComment, + Release: form.Release, + Push: form.Push, + PullRequest: form.PullRequest, + PullRequestAssign: form.PullRequestAssign, + PullRequestLabel: form.PullRequestLabel, + PullRequestMilestone: form.PullRequestMilestone, + PullRequestComment: form.PullRequestComment, + PullRequestReview: form.PullRequestReview, + PullRequestSync: form.PullRequestSync, + PullRequestReviewRequest: form.PullRequestReviewRequest, + Wiki: form.Wiki, + Repository: form.Repository, + Package: form.Package, + }, + BranchFilter: form.BranchFilter, + } +} + +func WebhookCreate(ctx *context.Context) { + hookType := ctx.Params(":type") + handler := webhook_service.GetWebhookHandler(hookType) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + + fields := handler.UnmarshalForm(func(form any) { + errs := binding.Bind(ctx.Req, form) + middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError + }) + + ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + ctx.Data["HookType"] = hookType + ctx.Data["WebhookHandler"] = handler + + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", err) + return + } + ctx.Data["BaseLink"] = orCtx.LinkNew + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + if ctx.HasError() { + // pre-fill the form with the submitted data + var w webhook.Webhook + w.URL = fields.URL + w.ContentType = fields.ContentType + w.Secret = fields.Secret + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active + w.HTTPMethod = fields.HTTPMethod + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + ctx.Data["Webhook"] = w + ctx.Data["HookMetadata"] = fields.Metadata + + ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate) + return + } + + var meta []byte + if fields.Metadata != nil { + meta, err = json.Marshal(fields.Metadata) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + } + + w := &webhook.Webhook{ + RepoID: orCtx.RepoID, + URL: fields.URL, + HTTPMethod: fields.HTTPMethod, + ContentType: fields.ContentType, + Secret: fields.Secret, + HookEvent: ParseHookEvent(fields.WebhookCoreForm), + IsActive: fields.Active, + Type: hookType, + Meta: string(meta), + OwnerID: orCtx.OwnerID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + err = w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.CreateWebhook(ctx, w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +func WebhookUpdate(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + handler := webhook_service.GetWebhookHandler(w.Type) + if handler == nil { + ctx.NotFound("GetWebhookHandler", nil) + return + } + + fields := handler.UnmarshalForm(func(form any) { + errs := binding.Bind(ctx.Req, form) + middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError + }) + + // pre-fill the form with the submitted data + w.URL = fields.URL + w.ContentType = fields.ContentType + w.Secret = fields.Secret + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active + w.HTTPMethod = fields.HTTPMethod + + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) + if err != nil { + ctx.ServerError("SetHeaderAuthorization", err) + return + } + + if ctx.HasError() { + ctx.Data["HookMetadata"] = fields.Metadata + ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate) + return + } + + var meta []byte + if fields.Metadata != nil { + meta, err = json.Marshal(fields.Metadata) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + } + + w.Meta = string(meta) + + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.UpdateWebhook(ctx, w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { + orCtx, err := getOwnerRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOwnerRepoCtx", err) + return nil, nil + } + ctx.Data["BaseLink"] = orCtx.Link + ctx.Data["BaseLinkNew"] = orCtx.LinkNew + ctx.Data["WebhookList"] = webhook_service.List() + + var w *webhook.Webhook + if orCtx.RepoID > 0 { + w, err = webhook.GetWebhookByRepoID(ctx, orCtx.RepoID, ctx.ParamsInt64(":id")) + } else if orCtx.OwnerID > 0 { + w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.OwnerID, ctx.ParamsInt64(":id")) + } else if orCtx.IsAdmin { + w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id")) + } + if err != nil || w == nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound("GetWebhookByID", nil) + } else { + ctx.ServerError("GetWebhookByID", err) + } + return nil, nil + } + + ctx.Data["HookType"] = w.Type + + if handler := webhook_service.GetWebhookHandler(w.Type); handler != nil { + ctx.Data["HookMetadata"] = handler.Metadata(w) + ctx.Data["WebhookHandler"] = handler + } + + ctx.Data["History"], err = w.History(ctx, 1) + if err != nil { + ctx.ServerError("History", err) + } + return orCtx, w +} + +// WebhookEdit render editing web hook page +func WebhookEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + ctx.HTML(http.StatusOK, orCtx.NewTemplate) +} + +// WebhookTest test if web hook is work fine +func WebhookTest(ctx *context.Context) { + hookID := ctx.ParamsInt64(":id") + w, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hookID) + if err != nil { + ctx.Flash.Error("GetWebhookByRepoID: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + return + } + + // Grab latest commit or fake one if it's empty repository. + commit := ctx.Repo.Commit + if commit == nil { + ghost := user_model.NewGhostUser() + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) + commit = &git.Commit{ + ID: objectFormat.EmptyObjectID(), + Author: ghost.NewGitSig(), + Committer: ghost.NewGitSig(), + CommitMessage: "This is a fake commit", + } + } + + apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone) + + apiCommit := &api.PayloadCommit{ + ID: commit.ID.String(), + Message: commit.Message(), + URL: ctx.Repo.Repository.HTMLURL() + "/commit/" + url.PathEscape(commit.ID.String()), + Author: &api.PayloadUser{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Committer: &api.PayloadUser{ + Name: commit.Committer.Name, + Email: commit.Committer.Email, + }, + } + + commitID := commit.ID.String() + p := &api.PushPayload{ + Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch, + Before: commitID, + After: commitID, + CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID), + Commits: []*api.PayloadCommit{apiCommit}, + TotalCommits: 1, + HeadCommit: apiCommit, + Repo: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Pusher: apiUser, + Sender: apiUser, + } + if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil { + ctx.Flash.Error("PrepareWebhook: " + err.Error()) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success")) + ctx.Status(http.StatusOK) + } +} + +// WebhookReplay replays a webhook +func WebhookReplay(ctx *context.Context) { + hookTaskUUID := ctx.Params(":uuid") + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + + if err := webhook_service.ReplayHookTask(ctx, w, hookTaskUUID); err != nil { + if webhook.IsErrHookTaskNotExist(err) { + ctx.NotFound("ReplayHookTask", nil) + } else { + ctx.ServerError("ReplayHookTask", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.webhook.delivery.success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// WebhookDelete delete a webhook +func WebhookDelete(ctx *context.Context) { + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/hooks") +} diff --git a/routers/web/repo/topic.go b/routers/web/repo/topic.go new file mode 100644 index 0000000..d81a695 --- /dev/null +++ b/routers/web/repo/topic.go @@ -0,0 +1,60 @@ +// Copyright 2018 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" + "code.gitea.io/gitea/services/context" +) + +// TopicsPost response for creating repository +func TopicsPost(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]any{ + "message": "Only owners could change the topics.", + }) + return + } + + topics := make([]string, 0) + topicsStr := ctx.FormTrim("topics") + if len(topicsStr) > 0 { + topics = strings.Split(topicsStr, ",") + } + + validTopics, invalidTopics := repo_model.SanitizeAndValidateTopics(topics) + + if len(validTopics) > 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": nil, + "message": ctx.Tr("repo.topic.count_prompt"), + }) + return + } + + if len(invalidTopics) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": invalidTopics, + "message": ctx.Tr("repo.topic.format_prompt"), + }) + return + } + + err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...) + if err != nil { + log.Error("SaveTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "message": "Save topics failed.", + }) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "status": "ok", + }) +} diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go new file mode 100644 index 0000000..d11af46 --- /dev/null +++ b/routers/web/repo/treelist.go @@ -0,0 +1,54 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" + + "github.com/go-enry/go-enry/v2" +) + +// TreeList get all files' entries of a repository +func TreeList(ctx *context.Context) { + tree, err := ctx.Repo.Commit.SubTree("/") + if err != nil { + ctx.ServerError("Repo.Commit.SubTree", err) + return + } + + entries, err := tree.ListEntriesRecursiveFast() + if err != nil { + ctx.ServerError("ListEntriesRecursiveFast", err) + return + } + entries.CustomSort(base.NaturalSortLess) + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !isExcludedEntry(entry) { + files = append(files, entry.Name()) + } + } + ctx.JSON(http.StatusOK, files) +} + +func isExcludedEntry(entry *git.TreeEntry) bool { + if entry.IsDir() { + return true + } + + if entry.IsSubModule() { + return true + } + + if enry.IsVendor(entry.Name()) { + return true + } + + return false +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go new file mode 100644 index 0000000..f1445c5 --- /dev/null +++ b/routers/web/repo/view.go @@ -0,0 +1,1258 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + gocontext "context" + "encoding/base64" + "errors" + "fmt" + "html/template" + "image" + "io" + "net/http" + "net/url" + "path" + "slices" + "strings" + "time" + + _ "image/gif" // for processing gif images + _ "image/jpeg" // for processing jpeg images + _ "image/png" // for processing png images + + activities_model "code.gitea.io/gitea/models/activities" + admin_model "code.gitea.io/gitea/models/admin" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issue_model "code.gitea.io/gitea/models/issues" + 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/actions" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + files_service "code.gitea.io/gitea/services/repository/files" + + "github.com/nektos/act/pkg/model" + + _ "golang.org/x/image/bmp" // for processing bmp images + _ "golang.org/x/image/webp" // for processing webp images +) + +const ( + tplRepoEMPTY base.TplName = "repo/empty" + tplRepoHome base.TplName = "repo/home" + tplRepoViewList base.TplName = "repo/view_list" + tplWatchers base.TplName = "repo/watchers" + tplForks base.TplName = "repo/forks" + tplMigrating base.TplName = "repo/migrate/migrating" +) + +// locate a README for a tree in one of the supported paths. +// +// entries is passed to reduce calls to ListEntries(), so +// this has precondition: +// +// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() +// +// FIXME: There has to be a more efficient way of doing this +func FindReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { + // Create a list of extensions in priority order + // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md + // 2. Org-Mode files - with and without localisation - e.g. README.en-us.org or README.org + // 3. Txt files - e.g. README.txt + // 4. No extension - e.g. README + exts := append(append(localizedExtensions(".md", ctx.Locale.Language()), localizedExtensions(".org", ctx.Locale.Language())...), ".txt", "") // sorted by priority + extCount := len(exts) + readmeFiles := make([]*git.TreeEntry, extCount+1) + + docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) + for _, entry := range entries { + if tryWellKnownDirs && entry.IsDir() { + // as a special case for the top-level repo introduction README, + // fall back to subfolders, looking for e.g. docs/README.md, .gitea/README.zh-CN.txt, .github/README.txt, ... + // (note that docsEntries is ignored unless we are at the root) + lowerName := strings.ToLower(entry.Name()) + switch lowerName { + case "docs": + if entry.Name() == "docs" || docsEntries[0] == nil { + docsEntries[0] = entry + } + case ".forgejo": + if entry.Name() == ".forgejo" || docsEntries[1] == nil { + docsEntries[1] = entry + } + case ".gitea": + if entry.Name() == ".gitea" || docsEntries[1] == nil { + docsEntries[1] = entry + } + case ".github": + if entry.Name() == ".github" || docsEntries[2] == nil { + docsEntries[2] = entry + } + } + continue + } + if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { + log.Debug("Potential readme file: %s", entry.Name()) + if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { + if entry.IsLink() { + target, _, err := entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + return "", nil, err + } else if target != nil && (target.IsExecutable() || target.IsRegular()) { + readmeFiles[i] = entry + } + } else { + readmeFiles[i] = entry + } + } + } + } + var readmeFile *git.TreeEntry + for _, f := range readmeFiles { + if f != nil { + readmeFile = f + break + } + } + + if ctx.Repo.TreePath == "" && readmeFile == nil { + for _, subTreeEntry := range docsEntries { + if subTreeEntry == nil { + continue + } + subTree := subTreeEntry.Tree() + if subTree == nil { + // this should be impossible; if subTreeEntry exists so should this. + continue + } + var err error + childEntries, err := subTree.ListEntries() + if err != nil { + return "", nil, err + } + + subfolder, readmeFile, err := FindReadmeFileInEntries(ctx, childEntries, false) + if err != nil && !git.IsErrNotExist(err) { + return "", nil, err + } + if readmeFile != nil { + return path.Join(subTreeEntry.Name(), subfolder), readmeFile, nil + } + } + } + + return "", readmeFile, nil +} + +func renderDirectory(ctx *context.Context) { + entries := renderDirectoryFiles(ctx, 1*time.Second) + if ctx.Written() { + return + } + + if ctx.Repo.TreePath != "" { + ctx.Data["HideRepoInfo"] = true + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName) + } + + subfolder, readmeFile, err := FindReadmeFileInEntries(ctx, entries, true) + if err != nil { + ctx.ServerError("findReadmeFileInEntries", err) + return + } + + renderReadmeFile(ctx, subfolder, readmeFile) +} + +// localizedExtensions prepends the provided language code with and without a +// regional identifier to the provided extension. +// Note: the language code will always be lower-cased, if a region is present it must be separated with a `-` +// Note: ext should be prefixed with a `.` +func localizedExtensions(ext, languageCode string) (localizedExts []string) { + if len(languageCode) < 1 { + return []string{ext} + } + + lowerLangCode := "." + strings.ToLower(languageCode) + + if strings.Contains(lowerLangCode, "-") { + underscoreLangCode := strings.ReplaceAll(lowerLangCode, "-", "_") + indexOfDash := strings.Index(lowerLangCode, "-") + // e.g. [.zh-cn.md, .zh_cn.md, .zh.md, _zh.md, .md] + return []string{lowerLangCode + ext, underscoreLangCode + ext, lowerLangCode[:indexOfDash] + ext, "_" + lowerLangCode[1:indexOfDash] + ext, ext} + } + + // e.g. [.en.md, .md] + return []string{lowerLangCode + ext, ext} +} + +type fileInfo struct { + isTextFile bool + isLFSFile bool + fileSize int64 + lfsMeta *lfs.Pointer + st typesniffer.SniffedType +} + +func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) { + dataRc, err := blob.DataAsync() + if err != nil { + return nil, nil, nil, err + } + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + isTextFile := st.IsText() + + // FIXME: what happens when README file is an image? + if !isTextFile || !setting.LFS.StartServer { + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + pointer, _ := lfs.ReadPointerFromBuffer(buf) + if !pointer.IsValid() { // fallback to plain file + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid) + if err != nil { // fallback to plain file + log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err) + return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + } + + dataRc.Close() + + dataRc, err = lfs.ReadMetaObject(pointer) + if err != nil { + return nil, nil, nil, err + } + + buf = make([]byte, 1024) + n, err = util.ReadAtMost(dataRc, buf) + if err != nil { + dataRc.Close() + return nil, nil, nil, err + } + buf = buf[:n] + + st = typesniffer.DetectContentType(buf) + + return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil +} + +func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { + target := readmeFile + if readmeFile != nil && readmeFile.IsLink() { + target, _, _ = readmeFile.FollowLinks() + } + if target == nil { + // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) + // simply skip rendering the README + return + } + + ctx.Data["RawFileLink"] = "" + ctx.Data["ReadmeInList"] = true + ctx.Data["ReadmeExist"] = true + ctx.Data["FileIsSymlink"] = readmeFile.IsLink() + + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["FileIsText"] = fInfo.isTextFile + ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + + if fInfo.isLFSFile { + filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) + ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) + } + + if !fInfo.isTextFile { + return + } + + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + // Pretend that this is a normal text file to display 'This file is too large to be shown' + ctx.Data["IsFileTooLarge"] = true + ctx.Data["IsTextFile"] = true + ctx.Data["FileSize"] = fInfo.fileSize + return + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + if markupType := markup.Type(readmeFile.Name()); markupType != "" { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + RelativePath: path.Join(ctx.Repo.TreePath, readmeFile.Name()), // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Join(ctx.Repo.TreePath, subfolder), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) + delete(ctx.Data, "IsMarkup") + } + } + + if ctx.Data["IsMarkup"] != true { + ctx.Data["IsPlainText"] = true + content, err := io.ReadAll(rd) + if err != nil { + log.Error("Read readme content failed: %v", err) + } + contentEscaped := template.HTMLEscapeString(util.UnsafeBytesToString(content)) + ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale, charset.FileviewContext) + } + + if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + ctx.Data["CanEditReadmeFile"] = true + } +} + +func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { + // Show latest commit info of repository in table header, + // or of directory if not in root directory. + ctx.Data["LatestCommit"] = latestCommit + if latestCommit != nil { + verification := asymkey_model.ParseCommitWithSignature(ctx, latestCommit) + + if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) + }, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return false + } + ctx.Data["LatestCommitVerification"] = verification + ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + if !ctx.Repo.CanRead(unit_model.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, statuses) + } + + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses) + ctx.Data["LatestCommitStatuses"] = statuses + } + + return true +} + +func renderFile(ctx *context.Context, entry *git.TreeEntry) { + ctx.Data["IsViewFile"] = true + ctx.Data["HideRepoInfo"] = true + blob := entry.Blob() + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefName) + ctx.Data["FileIsSymlink"] = entry.IsLink() + ctx.Data["FileName"] = blob.Name() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + if entry.IsLink() { + _, link, err := entry.FollowLinks() + // Errors should be allowed, because this shouldn't + // block rendering invalid symlink files. + if err == nil { + ctx.Data["SymlinkURL"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(link) + } + } + + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + + if !loadLatestCommitData(ctx, commit) { + return + } + + if ctx.Repo.TreePath == ".editorconfig" { + _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) + if editorconfigWarning != nil { + ctx.Data["FileWarning"] = strings.TrimSpace(editorconfigWarning.Error()) + } + if editorconfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error()) + } + } else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) { + _, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit) + if issueConfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error()) + } + } else if actions.IsWorkflow(ctx.Repo.TreePath) { + content, err := actions.GetContentFromEntry(entry) + if err != nil { + log.Error("actions.GetContentFromEntry: %v", err) + } + _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) + if workFlowErr != nil { + ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) + } + } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { + if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { + _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) + if len(warnings) > 0 { + ctx.Data["FileWarning"] = strings.Join(warnings, "\n") + } + } + } + + isDisplayingSource := ctx.FormString("display") == "source" + isDisplayingRendered := !isDisplayingSource + + if fInfo.isLFSFile { + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + + isRepresentableAsText := fInfo.st.IsRepresentableAsText() + if !isRepresentableAsText { + // If we can't show plain text, always try to render. + isDisplayingSource = false + isDisplayingRendered = true + } + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["FileSize"] = fInfo.fileSize + ctx.Data["IsTextFile"] = fInfo.isTextFile + ctx.Data["IsRepresentableAsText"] = isRepresentableAsText + ctx.Data["IsDisplayingSource"] = isDisplayingSource + ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsExecutable"] = entry.IsExecutable() + + isTextSource := fInfo.isTextFile || isDisplayingSource + ctx.Data["IsTextSource"] = isTextSource + if isTextSource { + ctx.Data["CanCopyContent"] = true + } + + // Check LFS Lock + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") + } + + // Assume file is not editable first. + if fInfo.isLFSFile { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if !isRepresentableAsText { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") + } + + switch { + case isRepresentableAsText: + if fInfo.st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + ctx.Data["HasSourceRenderedToggle"] = true + } + + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + shouldRenderSource := ctx.FormString("display") == "source" + readmeExist := util.IsReadmeFileName(blob.Name()) + ctx.Data["ReadmeExist"] = readmeExist + + markupType := markup.Type(blob.Name()) + // If the markup is detected by custom markup renderer it should not be reset later on + // to not pass it down to the render context. + detected := false + if markupType == "" { + detected = true + markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) + } + if markupType != "" { + ctx.Data["HasSourceRenderedToggle"] = true + } + + if markupType != "" && !shouldRenderSource { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + if !detected { + markupType = "" + } + metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) + metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + Type: markupType, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: metas, + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + // to prevent iframe load third-party url + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + } else { + buf, _ := io.ReadAll(rd) + + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Forgejo uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 1 line; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in U and isn't counted towards the number of lines. + // To tell users that the file not contains a trailing EOL, text with a tooltip is displayed in the file header. + // Trailing EOL is only considered if the file has content. + // This NumLines is only used for the display on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + hasNoTrailingEOL := !bytes.HasSuffix(buf, []byte{'\n'}) + ctx.Data["HasNoTrailingEOL"] = hasNoTrailingEOL + + numLines := bytes.Count(buf, []byte{'\n'}) + if hasNoTrailingEOL { + numLines++ + } + ctx.Data["NumLines"] = numLines + } + ctx.Data["NumLinesSet"] = true + + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) + } + + fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) + ctx.Data["LexerName"] = lexerName + if err != nil { + log.Error("highlight.File failed, fallback to plain text: %v", err) + fileContent = highlight.PlainText(buf) + } + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(fileContent)) + for i, line := range fileContent { + statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale, charset.FileviewContext) + status = status.Or(statuses[i]) + } + ctx.Data["EscapeStatus"] = status + ctx.Data["FileContent"] = fileContent + ctx.Data["LineEscapeStatus"] = statuses + } + if !fInfo.isLFSFile { + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.Data["CanEditFile"] = false + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + } + } + + case fInfo.st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case fInfo.st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case fInfo.st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + default: + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + if markupType := markup.Type(blob.Name()); markupType != "" { + rd := io.MultiReader(bytes.NewReader(buf), dataRc) + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ + Ctx: ctx, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + }, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + } + } + + if ctx.Repo.GitRepo != nil { + attrs, err := ctx.Repo.GitRepo.GitAttributes(ctx.Repo.CommitID, ctx.Repo.TreePath, "linguist-vendored", "linguist-generated") + if err != nil { + log.Error("GitAttributes(%s, %s) failed: %v", ctx.Repo.CommitID, ctx.Repo.TreePath, err) + } else { + ctx.Data["IsVendored"] = attrs["linguist-vendored"].Bool().Value() + ctx.Data["IsGenerated"] = attrs["linguist-generated"].Bool().Value() + } + } + + if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { + img, _, err := image.DecodeConfig(bytes.NewReader(buf)) + if err == nil { + // There are Image formats go can't decode + // Instead of throwing an error in that case, we show the size only when we can decode + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.Data["CanDeleteFile"] = false + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanDeleteFile"] = true + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + } +} + +func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) { + markupRd, markupWr := io.Pipe() + defer markupWr.Close() + done := make(chan struct{}) + go func() { + sb := &strings.Builder{} + // We allow NBSP here this is rendered + escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.FileviewContext, charset.RuneNBSP) + output = template.HTML(sb.String()) + close(done) + }() + err = markup.Render(renderCtx, input, markupWr) + _ = markupWr.CloseWithError(err) + <-done + return escaped, output, err +} + +func checkHomeCodeViewable(ctx *context.Context) { + if len(ctx.Repo.Units) > 0 { + if ctx.Repo.Repository.IsBeingCreated() { + task, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) + if err != nil { + if admin_model.IsErrTaskDoesNotExist(err) { + ctx.Data["Repo"] = ctx.Repo + ctx.Data["CloneAddr"] = "" + ctx.Data["Failed"] = true + ctx.HTML(http.StatusOK, tplMigrating) + return + } + ctx.ServerError("models.GetMigratingTask", err) + return + } + cfg, err := task.MigrateConfig() + if err != nil { + ctx.ServerError("task.MigrateConfig", err) + return + } + + ctx.Data["Repo"] = ctx.Repo + ctx.Data["MigrateTask"] = task + ctx.Data["CloneAddr"], _ = util.SanitizeURL(cfg.CloneAddr) + ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed + ctx.HTML(http.StatusOK, tplMigrating) + return + } + + if ctx.IsSigned { + // Set repo notification-status read if unread + if err := activities_model.SetRepoReadBy(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var firstUnit *unit_model.Unit + for _, repoUnit := range ctx.Repo.Units { + if repoUnit.Type == unit_model.TypeCode { + return + } + + unit, ok := unit_model.Units[repoUnit.Type] + if ok && (firstUnit == nil || !firstUnit.IsLessThan(unit)) && repoUnit.Type.CanBeDefault() { + firstUnit = &unit + } + } + + if firstUnit != nil { + ctx.Redirect(ctx.Repo.Repository.Link() + firstUnit.URI) + return + } + } + + ctx.NotFound("Home", errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) +} + +func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { + if entry.Name() != "" { + return + } + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.SubTree", err) + return + } + allEntries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + for _, entry := range allEntries { + if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { + // Read Citation file contents + if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("checkCitationFile: GetBlobContent: %v", err) + } else { + ctx.Data["CitationExist"] = true + ctx.Data["CitationFile"] = entry.Name() + ctx.PageData["citationFileContent"] = content + break + } + } + } +} + +// Home render repository home page +func Home(ctx *context.Context) { + if setting.Other.EnableFeed { + isFeed, _, showFeedType := feed.GetFeedType(ctx.Params(":reponame"), ctx.Req) + if isFeed { + if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) { + feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) + return + } + + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound("MustBeNotEmpty", nil) + return + } + + if ctx.Repo.TreePath == "" { + feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) + } else { + feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) + } + return + } + } + + checkHomeCodeViewable(ctx) + if ctx.Written() { + return + } + + renderHomeCode(ctx) +} + +// LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body +func LastCommit(ctx *context.Context) { + checkHomeCodeViewable(ctx) + if ctx.Written() { + return + } + + renderDirectoryFiles(ctx, 0) + if ctx.Written() { + return + } + + var treeNames []string + paths := make([]string, 0, 5) + if len(ctx.Repo.TreePath) > 0 { + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] + } + } + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["BranchLink"] = branchLink + + ctx.HTML(http.StatusOK, tplRepoViewList) +} + +func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries { + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.SubTree", err) + return nil + } + + ctx.Data["LastCommitLoaderURL"] = ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return nil + } + + if !entry.IsDir() { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return nil + } + + allEntries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return nil + } + allEntries.CustomSort(base.NaturalSortLess) + + commitInfoCtx := gocontext.Context(ctx) + if timeout > 0 { + var cancel gocontext.CancelFunc + commitInfoCtx, cancel = gocontext.WithTimeout(ctx, timeout) + defer cancel() + } + + files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitsInfo", err) + return nil + } + ctx.Data["Files"] = files + for _, f := range files { + if f.Commit == nil { + ctx.Data["HasFilesWithoutLatestCommit"] = true + break + } + } + + if !loadLatestCommitData(ctx, latestCommit) { + return nil + } + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + + ctx.Data["TreeLink"] = treeLink + ctx.Data["SSHDomain"] = setting.SSH.Domain + + return allEntries +} + +func renderLanguageStats(ctx *context.Context) { + langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5) + if err != nil { + ctx.ServerError("Repo.GetTopLanguageStats", err) + return + } + + ctx.Data["LanguageStats"] = langs +} + +func renderRepoTopics(ctx *context.Context) { + topics, _, err := repo_model.FindTopics(ctx, &repo_model.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("models.FindTopics", err) + return + } + ctx.Data["Topics"] = topics +} + +func prepareOpenWithEditorApps(ctx *context.Context) { + var tmplApps []map[string]any + apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) + if len(apps) == 0 { + apps = setting.DefaultOpenWithEditorApps() + } + for _, app := range apps { + schema, _, _ := strings.Cut(app.OpenURL, ":") + var iconHTML template.HTML + if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { + iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-open-with-%s", schema), 16, "tw-mr-2") + } else { + iconHTML = svg.RenderHTML("gitea-git", 16, "tw-mr-2") // TODO: it could support user's customized icon in the future + } + tmplApps = append(tmplApps, map[string]any{ + "DisplayName": app.DisplayName, + "OpenURL": app.OpenURL, + "IconHTML": iconHTML, + }) + } + ctx.Data["OpenWithEditorApps"] = tmplApps +} + +func renderHomeCode(ctx *context.Context) { + ctx.Data["PageIsViewCode"] = true + ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled + prepareOpenWithEditorApps(ctx) + + if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { + showEmpty := true + var err error + if ctx.Repo.GitRepo != nil { + showEmpty, err = ctx.Repo.GitRepo.IsEmpty() + if err != nil { + log.Error("GitRepo.IsEmpty: %v", err) + ctx.Repo.Repository.Status = repo_model.RepositoryBroken + showEmpty = true + ctx.Flash.Error(ctx.Tr("error.occurred"), true) + } + } + if showEmpty { + ctx.HTML(http.StatusOK, tplRepoEMPTY) + return + } + + // the repo is not really empty, so we should update the modal in database + // such problem may be caused by: + // 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually + // and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos. + // it's possible for a repository to be non-empty by that flag but still 500 + // because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed. + ctx.Repo.Repository.IsEmpty = false + if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil { + ctx.ServerError("UpdateRepositoryCols", err) + return + } + if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateRepoSize", err) + return + } + + // the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values + link := ctx.Link + if ctx.Req.URL.RawQuery != "" { + link += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(link) + return + } + + title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name + if len(ctx.Repo.Repository.Description) > 0 { + title += ": " + ctx.Repo.Repository.Description + } + ctx.Data["Title"] = title + + // Get Topics of this repo + renderRepoTopics(ctx) + if ctx.Written() { + return + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + + checkOutdatedBranch(ctx) + + checkCitationFile(ctx, entry) + if ctx.Written() { + return + } + + renderLanguageStats(ctx) + if ctx.Written() { + return + } + + if entry.IsDir() { + renderDirectory(ctx) + } else { + renderFile(ctx, entry) + } + if ctx.Written() { + return + } + + if ctx.Doer != nil { + if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + + // If the repo is a mirror, don't display recently pushed branches. + if ctx.Repo.Repository.IsMirror { + goto PostRecentBranchCheck + } + + // If pull requests aren't enabled for either the current repo, or its + // base, don't display recently pushed branches. + if !(ctx.Repo.Repository.AllowsPulls(ctx) || + (ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.AllowsPulls(ctx))) { + goto PostRecentBranchCheck + } + + // Find recently pushed new branches to *this* repo. + branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID, ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.ServerError("FindRecentlyPushedBranches", err) + return + } + + // If this is not a fork, check if the signed in user has a fork, and + // check branches there. + if !ctx.Repo.Repository.IsFork { + repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if repo != nil { + baseBranches, err := git_model.FindRecentlyPushedNewBranches(ctx, repo.ID, ctx.Doer.ID, repo.DefaultBranch) + if err != nil { + ctx.ServerError("FindRecentlyPushedBranches", err) + return + } + branches = append(branches, baseBranches...) + } + } + + // Filter out branches that have no relation to the default branch of + // the repository. + var filteredBranches []*git_model.Branch + for _, branch := range branches { + repo, err := branch.GetRepo(ctx) + if err != nil { + continue + } + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { + continue + } + defer gitRepo.Close() + head, err := gitRepo.GetCommit(branch.CommitID) + if err != nil { + continue + } + defaultBranch, err := gitrepo.GetDefaultBranch(ctx, repo) + if err != nil { + continue + } + defaultBranchHead, err := gitRepo.GetCommit(defaultBranch) + if err != nil { + continue + } + + hasMergeBase, err := head.HasPreviousCommit(defaultBranchHead.ID) + if err != nil { + continue + } + + if hasMergeBase { + filteredBranches = append(filteredBranches, branch) + } + } + + ctx.Data["RecentlyPushedNewBranches"] = filteredBranches + } + +PostRecentBranchCheck: + var treeNames []string + paths := make([]string, 0, 5) + if len(ctx.Repo.TreePath) > 0 { + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] + } + } + + ctx.Data["Paths"] = paths + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink + ctx.Data["CodeIndexerDisabled"] = !setting.Indexer.RepoIndexerEnabled + ctx.HTML(http.StatusOK, tplRepoHome) +} + +func checkOutdatedBranch(ctx *context.Context) { + if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { + return + } + + // get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName` + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranch: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + if dbBranch.CommitID != commit.ID.String() { + ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true) + } +} + +// RenderUserCards render a page show users according the input template +func RenderUserCards(ctx *context.Context, total int, getter func(opts db.ListOptions) ([]*user_model.User, error), tpl base.TplName) { + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + pager := context.NewPagination(total, setting.MaxUserCardsPerPage, page, 5) + ctx.Data["Page"] = pager + + items, err := getter(db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.MaxUserCardsPerPage, + }) + if err != nil { + ctx.ServerError("getter", err) + return + } + ctx.Data["Cards"] = items + + ctx.HTML(http.StatusOK, tpl) +} + +// Watchers render repository's watch users +func Watchers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.watchers") + ctx.Data["CardsTitle"] = ctx.Tr("repo.watchers") + ctx.Data["PageIsWatchers"] = true + + RenderUserCards(ctx, ctx.Repo.Repository.NumWatches, func(opts db.ListOptions) ([]*user_model.User, error) { + return repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, opts) + }, tplWatchers) +} + +// Stars render repository's starred users +func Stars(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.stargazers") + ctx.Data["CardsTitle"] = ctx.Tr("repo.stargazers") + ctx.Data["PageIsStargazers"] = true + RenderUserCards(ctx, ctx.Repo.Repository.NumStars, func(opts db.ListOptions) ([]*user_model.User, error) { + return repo_model.GetStargazers(ctx, ctx.Repo.Repository, opts) + }, tplWatchers) +} + +// Forks render repository's forked users +func Forks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.forks") + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.MaxForksPerPage, page, 5) + ctx.Data["Page"] = pager + + forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.MaxForksPerPage, + }) + if err != nil { + ctx.ServerError("GetForks", err) + return + } + + for _, fork := range forks { + if err = fork.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + } + + ctx.Data["Forks"] = forks + + ctx.HTML(http.StatusOK, tplForks) +} diff --git a/routers/web/repo/view_test.go b/routers/web/repo/view_test.go new file mode 100644 index 0000000..73ba118 --- /dev/null +++ b/routers/web/repo/view_test.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "reflect" + "testing" +) + +func Test_localizedExtensions(t *testing.T) { + tests := []struct { + name string + ext string + languageCode string + wantLocalizedExts []string + }{ + { + name: "empty language", + ext: ".md", + wantLocalizedExts: []string{".md"}, + }, + { + name: "No region - lowercase", + languageCode: "en", + ext: ".csv", + wantLocalizedExts: []string{".en.csv", ".csv"}, + }, + { + name: "No region - uppercase", + languageCode: "FR", + ext: ".txt", + wantLocalizedExts: []string{".fr.txt", ".txt"}, + }, + { + name: "With region - lowercase", + languageCode: "en-us", + ext: ".md", + wantLocalizedExts: []string{".en-us.md", ".en_us.md", ".en.md", "_en.md", ".md"}, + }, + { + name: "With region - uppercase", + languageCode: "en-CA", + ext: ".MD", + wantLocalizedExts: []string{".en-ca.MD", ".en_ca.MD", ".en.MD", "_en.MD", ".MD"}, + }, + { + name: "With region - all uppercase", + languageCode: "ZH-TW", + ext: ".md", + wantLocalizedExts: []string{".zh-tw.md", ".zh_tw.md", ".zh.md", "_zh.md", ".md"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotLocalizedExts := localizedExtensions(tt.ext, tt.languageCode); !reflect.DeepEqual(gotLocalizedExts, tt.wantLocalizedExts) { + t.Errorf("localizedExtensions() = %v, want %v", gotLocalizedExts, tt.wantLocalizedExts) + } + }) + } +} diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go new file mode 100644 index 0000000..1fd0800 --- /dev/null +++ b/routers/web/repo/wiki.go @@ -0,0 +1,816 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + 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/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + notify_service "code.gitea.io/gitea/services/notify" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +const ( + tplWikiStart base.TplName = "repo/wiki/start" + tplWikiView base.TplName = "repo/wiki/view" + tplWikiRevision base.TplName = "repo/wiki/revision" + tplWikiNew base.TplName = "repo/wiki/new" + tplWikiPages base.TplName = "repo/wiki/pages" + tplWikiSearch base.TplName = "repo/wiki/search" +) + +// MustEnableWiki check if wiki is enabled, if external then redirect +func MustEnableWiki(ctx *context.Context) { + if !ctx.Repo.CanRead(unit.TypeWiki) && + !ctx.Repo.CanRead(unit.TypeExternalWiki) { + if log.IsTrace() { + log.Trace("Permission Denied: User %-v cannot read %-v or %-v of repo %-v\n"+ + "User in repo has Permissions: %-+v", + ctx.Doer, + unit.TypeWiki, + unit.TypeExternalWiki, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + ctx.NotFound("MustEnableWiki", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki) + if err == nil { + ctx.Redirect(unit.ExternalWikiConfig().ExternalWikiURL) + return + } +} + +// PageMeta wiki page meta information +type PageMeta struct { + Name string + SubURL string + GitEntryName string + UpdatedUnix timeutil.TimeStamp +} + +// 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 && !git.IsErrNotExist(err) { + return nil, err + } + if entry != nil { + return entry, nil + } + + // Then the unescaped, the shortest alternative + var unescapedTarget string + if unescapedTarget, err = url.QueryUnescape(target); err != nil { + return nil, err + } + return commit.GetTreeEntryByPath(unescapedTarget) +} + +func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) { + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("OpenRepository", err) + return nil, nil, err + } + + commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.GetWikiBranchName()) + if err != nil { + return wikiRepo, nil, err + } + return wikiRepo, commit, nil +} + +// wikiContentsByEntry returns the contents of the wiki page referenced by the +// given tree entry. Writes to ctx if an error occurs. +func wikiContentsByEntry(ctx *context.Context, entry *git.TreeEntry) []byte { + reader, err := entry.Blob().DataAsync() + if err != nil { + ctx.ServerError("Blob.Data", err) + return nil + } + defer reader.Close() + content, err := io.ReadAll(reader) + if err != nil { + ctx.ServerError("ReadAll", err) + return nil + } + 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.Context, commit *git.Commit, wikiName wiki_service.WebPath) ([]byte, *git.TreeEntry, string, bool) { + gitFilename := wiki_service.WebPathToGitPath(wikiName) + entry, err := findEntryForFile(commit, gitFilename) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findEntryForFile", err) + return nil, nil, "", false + } else if entry == nil { + return nil, nil, "", true + } + return wikiContentsByEntry(ctx, entry), entry, gitFilename, false +} + +func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return nil, nil + } + + // Get page list. + entries, err := commit.ListEntries() + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("ListEntries", err) + return nil, nil + } + pages := make([]PageMeta, 0, len(entries)) + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + wikiName, err := wiki_service.GitPathToWebPath(entry.Name()) + if err != nil { + if repo_model.IsErrWikiInvalidFileName(err) { + continue + } + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("WikiFilenameToName", err) + return nil, nil + } else if wikiName == "_Sidebar" || wikiName == "_Footer" { + continue + } + _, displayName := wiki_service.WebPathToUserTitle(wikiName) + pages = append(pages, PageMeta{ + Name: displayName, + SubURL: wiki_service.WebPathToURLPath(wikiName), + GitEntryName: entry.Name(), + }) + } + ctx.Data["Pages"] = pages + + // get requested page name + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + isSideBar := pageName == "_Sidebar" + isFooter := pageName == "_Footer" + + // lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") + } + if entry == nil || ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + var sidebarContent []byte + if !isSideBar { + sidebarContent, _, _, _ = wikiContentsByName(ctx, commit, "_Sidebar") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + } else { + sidebarContent = data + } + + var footerContent []byte + if !isFooter { + footerContent, _, _, _ = wikiContentsByName(ctx, commit, "_Footer") + if ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + } else { + footerContent = data + } + + rctx := &markup.RenderContext{ + Ctx: ctx, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + IsWiki: true, + } + buf := &strings.Builder{} + + renderFn := func(data []byte) (escaped *charset.EscapeStatus, output string, err error) { + markupRd, markupWr := io.Pipe() + defer markupWr.Close() + done := make(chan struct{}) + go func() { + // We allow NBSP here this is rendered + escaped, _ = charset.EscapeControlReader(markupRd, buf, ctx.Locale, charset.WikiContext, charset.RuneNBSP) + output = buf.String() + buf.Reset() + close(done) + }() + + err = markdown.Render(rctx, bytes.NewReader(data), markupWr) + _ = markupWr.CloseWithError(err) + <-done + return escaped, output, err + } + + ctx.Data["EscapeStatus"], ctx.Data["content"], err = renderFn(data) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + + if rctx.SidebarTocNode != nil { + sb := &strings.Builder{} + err = markdown.SpecializedMarkdown().Renderer().Render(sb, nil, rctx.SidebarTocNode) + if err != nil { + log.Error("Failed to render wiki sidebar TOC: %v", err) + } else { + ctx.Data["sidebarTocContent"] = sb.String() + } + } + + if !isSideBar { + buf.Reset() + ctx.Data["sidebarEscapeStatus"], ctx.Data["sidebarContent"], err = renderFn(sidebarContent) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["sidebarPresent"] = sidebarContent != nil + } else { + ctx.Data["sidebarPresent"] = false + } + + if !isFooter { + buf.Reset() + ctx.Data["footerEscapeStatus"], ctx.Data["footerContent"], err = renderFn(footerContent) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("Render", err) + return nil, nil + } + ctx.Data["footerPresent"] = footerContent != nil + } else { + ctx.Data["footerPresent"] = false + } + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename) + ctx.Data["CommitCount"] = commitsCount + + return wikiRepo, entry +} + +func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return nil, nil + } + + // get requested pagename + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + + // lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") + } + if entry == nil || ctx.Written() { + if wikiRepo != nil { + wikiRepo.Close() + } + return nil, nil + } + + ctx.Data["content"] = string(data) + ctx.Data["sidebarPresent"] = false + ctx.Data["sidebarContent"] = "" + ctx.Data["footerPresent"] = false + ctx.Data["footerContent"] = "" + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename) + ctx.Data["CommitCount"] = commitsCount + + // get page + 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 { + if wikiRepo != nil { + wikiRepo.Close() + } + ctx.ServerError("CommitsByFileAndRange", err) + return nil, nil + } + ctx.Data["Commits"] = git_model.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) + + pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParamString("action", "_revision") + ctx.Data["Page"] = pager + + return wikiRepo, entry +} + +func renderEditPage(ctx *context.Context) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + if !git.IsErrNotExist(err) { + ctx.ServerError("GetBranchCommit", err) + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + // get requested pagename + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(pageName) == 0 { + pageName = "Home" + } + + _, displayName := wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["old_title"] = displayName + ctx.Data["Title"] = displayName + ctx.Data["title"] = displayName + + // lookup filename in wiki - get filecontent, gitTree entry , real filename + data, entry, _, noEntry := wikiContentsByName(ctx, commit, pageName) + if noEntry { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") + } + if entry == nil || ctx.Written() { + return + } + + ctx.Data["content"] = string(data) + ctx.Data["sidebarPresent"] = false + ctx.Data["sidebarContent"] = "" + ctx.Data["footerPresent"] = false + ctx.Data["footerContent"] = "" +} + +// WikiPost renders post of wiki page +func WikiPost(ctx *context.Context) { + switch ctx.FormString("action") { + case "_new": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + NewWikiPost(ctx) + return + case "_delete": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + DeleteWikiPagePost(ctx) + return + } + + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + EditWikiPost(ctx) +} + +// Wiki renders single wiki page +func Wiki(ctx *context.Context) { + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + + switch ctx.FormString("action") { + case "_pages": + WikiPages(ctx) + return + case "_revision": + WikiRevision(ctx) + return + case "_edit": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + EditWiki(ctx) + return + case "_new": + if !ctx.Repo.CanWrite(unit.TypeWiki) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + NewWiki(ctx) + return + } + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiRepo, entry := renderViewPage(ctx) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + if ctx.Written() { + return + } + if entry == nil { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiPath := entry.Name() + if markup.Type(wikiPath) != markdown.MarkupName { + ext := strings.ToUpper(filepath.Ext(wikiPath)) + ctx.Data["FormatWarning"] = fmt.Sprintf("%s rendering is not supported at the moment. Rendered as Markdown.", ext) + } + // Get last change information. + lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + ctx.Data["Author"] = lastCommit.Author + + ctx.HTML(http.StatusOK, tplWikiView) +} + +// WikiRevision renders file revision list of wiki page +func WikiRevision(ctx *context.Context) { + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + wikiRepo, entry := renderRevisionPage(ctx) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + if ctx.Written() { + return + } + if entry == nil { + ctx.Data["Title"] = ctx.Tr("repo.wiki") + ctx.HTML(http.StatusOK, tplWikiStart) + return + } + + // Get last change information. + wikiPath := entry.Name() + lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + ctx.Data["Author"] = lastCommit.Author + + ctx.HTML(http.StatusOK, tplWikiRevision) +} + +// WikiPages render wiki pages list page +func WikiPages(ctx *context.Context) { + if !ctx.Repo.Repository.HasWiki() { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki") + return + } + + ctx.Data["Title"] = ctx.Tr("repo.wiki.pages") + ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + + wikiRepo, commit, err := findWikiRepoCommit(ctx) + if err != nil { + if wikiRepo != nil { + wikiRepo.Close() + } + return + } + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + entries, err := commit.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + pages := make([]PageMeta, 0, len(entries)) + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + c, err := wikiRepo.GetCommitByPath(entry.Name()) + if err != nil { + ctx.ServerError("GetCommit", err) + return + } + wikiName, err := wiki_service.GitPathToWebPath(entry.Name()) + if err != nil { + if repo_model.IsErrWikiInvalidFileName(err) { + continue + } + ctx.ServerError("WikiFilenameToName", err) + return + } + _, displayName := wiki_service.WebPathToUserTitle(wikiName) + pages = append(pages, PageMeta{ + Name: displayName, + SubURL: wiki_service.WebPathToURLPath(wikiName), + GitEntryName: entry.Name(), + UpdatedUnix: timeutil.TimeStamp(c.Author.When.Unix()), + }) + } + ctx.Data["Pages"] = pages + + ctx.HTML(http.StatusOK, tplWikiPages) +} + +// WikiRaw outputs raw blob requested by user (image for example) +func WikiRaw(ctx *context.Context) { + wikiRepo, commit, err := findWikiRepoCommit(ctx) + defer func() { + if wikiRepo != nil { + wikiRepo.Close() + } + }() + + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("findEntryForFile", nil) + return + } + ctx.ServerError("findEntryForfile", err) + return + } + + providedWebPath := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + providedGitPath := wiki_service.WebPathToGitPath(providedWebPath) + var entry *git.TreeEntry + if commit != nil { + // Try to find a file with that name + entry, err = findEntryForFile(commit, providedGitPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + + if entry == nil { + // Try to find a wiki page with that name + providedGitPath = strings.TrimSuffix(providedGitPath, ".md") + entry, err = findEntryForFile(commit, providedGitPath) + if err != nil && !git.IsErrNotExist(err) { + ctx.ServerError("findFile", err) + return + } + } + } + + if entry != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, entry.Blob(), nil); err != nil { + ctx.ServerError("ServeBlob", err) + } + return + } + + ctx.NotFound("findEntryForFile", nil) +} + +// NewWiki render wiki create page +func NewWiki(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + + if !ctx.Repo.Repository.HasWiki() { + ctx.Data["title"] = "Home" + } + if ctx.FormString("title") != "" { + ctx.Data["title"] = ctx.FormString("title") + } + + ctx.HTML(http.StatusOK, tplWikiNew) +} + +// NewWikiPost response for wiki create request +func NewWikiPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWikiForm) + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplWikiNew) + return + } + + if util.IsEmptyString(form.Title) { + ctx.RenderWithErr(ctx.Tr("repo.issues.new.title_empty"), tplWikiNew, form) + return + } + + wikiName := wiki_service.UserTitleToWebPath("", form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Locale.TrString("repo.editor.add", form.Title) + } + + if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Content, form.Message); err != nil { + if repo_model.IsErrWikiReservedName(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.reserved_page", wikiName), tplWikiNew, &form) + } else if repo_model.IsErrWikiAlreadyExist(err) { + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("repo.wiki.page_already_exists"), tplWikiNew, &form) + } else { + ctx.ServerError("AddWikiPage", err) + } + return + } + + notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(wikiName)) +} + +// EditWiki render wiki modify page +func EditWiki(ctx *context.Context) { + ctx.Data["PageIsWikiEdit"] = true + + if !ctx.Repo.Repository.HasWiki() { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki") + return + } + + renderEditPage(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplWikiNew) +} + +// EditWikiPost response for wiki modify request +func EditWikiPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewWikiForm) + ctx.Data["Title"] = ctx.Tr("repo.wiki.new_page") + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplWikiNew) + return + } + + oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + newWikiName := wiki_service.UserTitleToWebPath("", form.Title) + + if len(form.Message) == 0 { + form.Message = ctx.Locale.TrString("repo.editor.update", form.Title) + } + + if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.Content, form.Message); err != nil { + ctx.ServerError("EditWikiPage", err) + return + } + + notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) + + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName)) +} + +// DeleteWikiPagePost delete wiki page +func DeleteWikiPagePost(ctx *context.Context) { + wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) + if len(wikiName) == 0 { + wikiName = "Home" + } + + if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil { + ctx.ServerError("DeleteWikiPage", err) + return + } + + notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/wiki/") +} + +func WikiSearchContent(ctx *context.Context) { + keyword := ctx.FormTrim("q") + if keyword == "" { + ctx.HTML(http.StatusOK, tplWikiSearch) + return + } + + res, err := wiki_service.SearchWikiContents(ctx, ctx.Repo.Repository, keyword) + if err != nil { + ctx.ServerError("SearchWikiContents", err) + return + } + + ctx.Data["Results"] = res + ctx.HTML(http.StatusOK, tplWikiSearch) +} diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go new file mode 100644 index 0000000..00a35a5 --- /dev/null +++ b/routers/web/repo/wiki_test.go @@ -0,0 +1,224 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "io" + "net/http" + "net/url" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + wiki_service "code.gitea.io/gitea/services/wiki" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + content = "Wiki contents for unit tests" + message = "Wiki commit message for unit tests" +) + +func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) *git.TreeEntry { + wikiRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer wikiRepo.Close() + commit, err := wikiRepo.GetBranchCommit("master") + require.NoError(t, err) + entries, err := commit.ListEntries() + require.NoError(t, err) + for _, entry := range entries { + if entry.Name() == wiki_service.WebPathToGitPath(wikiName) { + return entry + } + } + return nil +} + +func wikiContent(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) string { + entry := wikiEntry(t, repo, wikiName) + if !assert.NotNil(t, entry) { + return "" + } + reader, err := entry.Blob().DataAsync() + require.NoError(t, err) + defer reader.Close() + bytes, err := io.ReadAll(reader) + require.NoError(t, err) + return string(bytes) +} + +func assertWikiExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) { + assert.NotNil(t, wikiEntry(t, repo, wikiName)) +} + +func assertWikiNotExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) { + assert.Nil(t, wikiEntry(t, repo, wikiName)) +} + +func assertPagesMetas(t *testing.T, expectedNames []string, metas any) { + pageMetas, ok := metas.([]PageMeta) + if !assert.True(t, ok) { + return + } + if !assert.Len(t, pageMetas, len(expectedNames)) { + return + } + for i, pageMeta := range pageMetas { + assert.EqualValues(t, expectedNames[i], pageMeta.Name) + } +} + +func TestWiki(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") + ctx.SetParams("*", "Home") + contexttest.LoadRepo(t, ctx, 1) + Wiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, "Home", ctx.Data["Title"]) + assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) +} + +func TestWikiPages(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_pages") + contexttest.LoadRepo(t, ctx, 1) + WikiPages(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) +} + +func TestNewWiki(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + NewWiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, ctx.Tr("repo.wiki.new_page"), ctx.Data["Title"]) +} + +func TestNewWikiPost(t *testing.T) { + for _, title := range []string{ + "New page", + "&&&&", + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + NewWikiPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) + assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) + } +} + +func TestNewWikiPost_ReservedName(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: "_edit", + Content: content, + Message: message, + }) + NewWikiPost(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg) + assertWikiNotExists(t, ctx.Repo.Repository, "_edit") +} + +func TestEditWiki(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_edit") + ctx.SetParams("*", "Home") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + EditWiki(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, "Home", ctx.Data["Title"]) + assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["content"]) +} + +func TestEditWikiPost(t *testing.T) { + for _, title := range []string{ + "Home", + "New/", + } { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_new") + ctx.SetParams("*", "Home") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.NewWikiForm{ + Title: title, + Content: content, + Message: message, + }) + EditWikiPost(ctx) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) + assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) + if title != "Home" { + assertWikiNotExists(t, ctx.Repo.Repository, "Home") + } + } +} + +func TestDeleteWikiPagePost(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + DeleteWikiPagePost(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assertWikiNotExists(t, ctx.Repo.Repository, "Home") +} + +func TestWikiRaw(t *testing.T) { + for filepath, filetype := range map[string]string{ + "jpeg.jpg": "image/jpeg", + "images/jpeg.jpg": "image/jpeg", + "Page With Spaced Name": "text/plain; charset=utf-8", + "Page-With-Spaced-Name": "text/plain; charset=utf-8", + "Page With Spaced Name.md": "", // there is no "Page With Spaced Name.md" in repo + "Page-With-Spaced-Name.md": "text/plain; charset=utf-8", + } { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/raw/"+url.PathEscape(filepath)) + ctx.SetParams("*", filepath) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + WikiRaw(ctx) + if filetype == "" { + assert.EqualValues(t, http.StatusNotFound, ctx.Resp.Status(), "filepath: %s", filepath) + } else { + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status(), "filepath: %s", filepath) + assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type"), "filepath: %s", filepath) + } + } +} diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go new file mode 100644 index 0000000..f389332 --- /dev/null +++ b/routers/web/shared/actions/runners.go @@ -0,0 +1,161 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// RunnersList prepares data for runners list +func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { + runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts) + if err != nil { + ctx.ServerError("CountRunners", err) + return + } + + if err := actions_model.RunnerList(runners).LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + // ownid=0,repo_id=0,means this token is used for global + var token *actions_model.ActionRunnerToken + token, err = actions_model.GetLatestRunnerToken(ctx, opts.OwnerID, opts.RepoID) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { + token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID) + if err != nil { + ctx.ServerError("CreateRunnerToken", err) + return + } + } else if err != nil { + ctx.ServerError("GetLatestRunnerToken", err) + return + } + + ctx.Data["Keyword"] = opts.Filter + ctx.Data["Runners"] = runners + ctx.Data["Total"] = count + ctx.Data["RegistrationToken"] = token.Token + ctx.Data["RunnerOwnerID"] = opts.OwnerID + ctx.Data["RunnerRepoID"] = opts.RepoID + ctx.Data["SortType"] = opts.Sort + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + + ctx.Data["Page"] = pager +} + +// RunnerDetails prepares data for runners edit page +func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int64) { + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + ctx.ServerError("GetRunnerByID", err) + return + } + if err := runner.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + if !runner.Editable(ownerID, repoID) { + err = errors.New("no permission to edit this runner") + ctx.NotFound("RunnerDetails", err) + return + } + + ctx.Data["Runner"] = runner + + opts := actions_model.FindTaskOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: 30, + }, + Status: actions_model.StatusUnknown, // Unknown means all + RunnerID: runner.ID, + } + + tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts) + if err != nil { + ctx.ServerError("CountTasks", err) + return + } + + if err = actions_model.TaskList(tasks).LoadAttributes(ctx); err != nil { + ctx.ServerError("TasksLoadAttributes", err) + return + } + + ctx.Data["Tasks"] = tasks + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + ctx.Data["Page"] = pager +} + +// RunnerDetailsEditPost response for edit runner details +func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) { + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL) + ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err) + return + } + if !runner.Editable(ownerID, repoID) { + ctx.NotFound("RunnerDetailsEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner")) + return + } + + form := web.GetForm(ctx).(*forms.EditRunnerForm) + runner.Description = form.Description + + err = actions_model.UpdateRunner(ctx, runner, "description") + if err != nil { + log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed")) + ctx.Redirect(redirectTo) + return + } + + log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL) + + ctx.Flash.Success(ctx.Tr("actions.runners.update_runner_success")) + ctx.Redirect(redirectTo) +} + +// RunnerResetRegistrationToken reset registration token +func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) { + _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID) + if err != nil { + ctx.ServerError("ResetRunnerRegistrationToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("actions.runners.reset_registration_token_success")) + ctx.Redirect(redirectTo) +} + +// RunnerDeletePost response for deleting a runner +func RunnerDeletePost(ctx *context.Context, runnerID int64, + successRedirectTo, failedRedirectTo string, +) { + if err := actions_model.DeleteRunner(ctx, runnerID); err != nil { + log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed")) + + ctx.JSONRedirect(failedRedirectTo) + return + } + + log.Info("DeleteRunnerPost success: %s", ctx.Req.URL) + + ctx.Flash.Success(ctx.Tr("actions.runners.delete_runner_success")) + + ctx.JSONRedirect(successRedirectTo) +} diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go new file mode 100644 index 0000000..79c03e4 --- /dev/null +++ b/routers/web/shared/actions/variables.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { + variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + RepoID: repoID, + }) + if err != nil { + ctx.ServerError("FindVariables", err) + return + } + ctx.Data["Variables"] = variables +} + +func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + form := web.GetForm(ctx).(*forms.EditVariableForm) + + v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data) + if err != nil { + log.Error("CreateVariable: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) + ctx.JSONRedirect(redirectURL) +} + +func UpdateVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + form := web.GetForm(ctx).(*forms.EditVariableForm) + + if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok { + log.Error("UpdateVariable: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.update.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.update.success")) + ctx.JSONRedirect(redirectURL) +} + +func DeleteVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + + if err := actions_service.DeleteVariableByID(ctx, id); err != nil { + log.Error("Delete variable [%d] failed: %v", id, err) + ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/shared/packages/packages.go b/routers/web/shared/packages/packages.go new file mode 100644 index 0000000..af960f1 --- /dev/null +++ b/routers/web/shared/packages/packages.go @@ -0,0 +1,260 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "errors" + "fmt" + "net/http" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + cargo_service "code.gitea.io/gitea/services/packages/cargo" + container_service "code.gitea.io/gitea/services/packages/container" +) + +func SetPackagesContext(ctx *context.Context, owner *user_model.User) { + pcrs, err := packages_model.GetCleanupRulesByOwner(ctx, owner.ID) + if err != nil { + ctx.ServerError("GetCleanupRulesByOwner", err) + return + } + + ctx.Data["CleanupRules"] = pcrs + + ctx.Data["CargoIndexExists"], err = repo_model.IsRepositoryModelExist(ctx, owner, cargo_service.IndexRepositoryName) + if err != nil { + ctx.ServerError("IsRepositoryModelExist", err) + return + } +} + +func SetRuleAddContext(ctx *context.Context) { + setRuleEditContext(ctx, nil) +} + +func SetRuleEditContext(ctx *context.Context, owner *user_model.User) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + setRuleEditContext(ctx, pcr) +} + +func setRuleEditContext(ctx *context.Context, pcr *packages_model.PackageCleanupRule) { + ctx.Data["IsEditRule"] = pcr != nil + + if pcr == nil { + pcr = &packages_model.PackageCleanupRule{} + } + ctx.Data["CleanupRule"] = pcr + ctx.Data["AvailableTypes"] = packages_model.TypeList +} + +func PerformRuleAddPost(ctx *context.Context, owner *user_model.User, redirectURL string, template base.TplName) { + performRuleEditPost(ctx, owner, nil, redirectURL, template) +} + +func PerformRuleEditPost(ctx *context.Context, owner *user_model.User, redirectURL string, template base.TplName) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + form := web.GetForm(ctx).(*forms.PackageCleanupRuleForm) + + if form.Action == "remove" { + if err := packages_model.DeleteCleanupRuleByID(ctx, pcr.ID); err != nil { + ctx.ServerError("DeleteCleanupRuleByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cleanuprules.success.delete")) + ctx.Redirect(redirectURL) + } else { + performRuleEditPost(ctx, owner, pcr, redirectURL, template) + } +} + +func performRuleEditPost(ctx *context.Context, owner *user_model.User, pcr *packages_model.PackageCleanupRule, redirectURL string, template base.TplName) { + isEditRule := pcr != nil + + if pcr == nil { + pcr = &packages_model.PackageCleanupRule{} + } + + form := web.GetForm(ctx).(*forms.PackageCleanupRuleForm) + + pcr.Enabled = form.Enabled + pcr.OwnerID = owner.ID + pcr.KeepCount = form.KeepCount + pcr.KeepPattern = form.KeepPattern + pcr.RemoveDays = form.RemoveDays + pcr.RemovePattern = form.RemovePattern + pcr.MatchFullName = form.MatchFullName + + ctx.Data["IsEditRule"] = isEditRule + ctx.Data["CleanupRule"] = pcr + ctx.Data["AvailableTypes"] = packages_model.TypeList + + if ctx.HasError() { + ctx.HTML(http.StatusOK, template) + return + } + + if isEditRule { + if err := packages_model.UpdateCleanupRule(ctx, pcr); err != nil { + ctx.ServerError("UpdateCleanupRule", err) + return + } + } else { + pcr.Type = packages_model.Type(form.Type) + + if has, err := packages_model.HasOwnerCleanupRuleForPackageType(ctx, owner.ID, pcr.Type); err != nil { + ctx.ServerError("HasOwnerCleanupRuleForPackageType", err) + return + } else if has { + ctx.Data["Err_Type"] = true + ctx.HTML(http.StatusOK, template) + return + } + + var err error + if pcr, err = packages_model.InsertCleanupRule(ctx, pcr); err != nil { + ctx.ServerError("InsertCleanupRule", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cleanuprules.success.update")) + ctx.Redirect(fmt.Sprintf("%s/rules/%d", redirectURL, pcr.ID)) +} + +func SetRulePreviewContext(ctx *context.Context, owner *user_model.User) { + pcr := getCleanupRuleByContext(ctx, owner) + if pcr == nil { + return + } + + if err := pcr.CompiledPattern(); err != nil { + ctx.ServerError("CompiledPattern", err) + return + } + + olderThan := time.Now().AddDate(0, 0, -pcr.RemoveDays) + + packages, err := packages_model.GetPackagesByType(ctx, pcr.OwnerID, pcr.Type) + if err != nil { + ctx.ServerError("GetPackagesByType", err) + return + } + + versionsToRemove := make([]*packages_model.PackageDescriptor, 0, 10) + + for _, p := range packages { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + Paginator: db.NewAbsoluteListOptions(pcr.KeepCount, 200), + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + for _, pv := range pvs { + if skip, err := container_service.ShouldBeSkipped(ctx, pcr, p, pv); err != nil { + ctx.ServerError("ShouldBeSkipped", err) + return + } else if skip { + continue + } + + toMatch := pv.LowerVersion + if pcr.MatchFullName { + toMatch = p.LowerName + "/" + pv.LowerVersion + } + + if pcr.KeepPatternMatcher != nil && pcr.KeepPatternMatcher.MatchString(toMatch) { + continue + } + if pv.CreatedUnix.AsLocalTime().After(olderThan) { + continue + } + if pcr.RemovePatternMatcher != nil && !pcr.RemovePatternMatcher.MatchString(toMatch) { + continue + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + ctx.ServerError("GetPackageDescriptor", err) + return + } + versionsToRemove = append(versionsToRemove, pd) + } + } + + ctx.Data["CleanupRule"] = pcr + ctx.Data["VersionsToRemove"] = versionsToRemove +} + +func getCleanupRuleByContext(ctx *context.Context, owner *user_model.User) *packages_model.PackageCleanupRule { + id := ctx.FormInt64("id") + if id == 0 { + id = ctx.ParamsInt64("id") + } + + pcr, err := packages_model.GetCleanupRuleByID(ctx, id) + if err != nil { + if err == packages_model.ErrPackageCleanupRuleNotExist { + ctx.NotFound("", err) + } else { + ctx.ServerError("GetCleanupRuleByID", err) + } + return nil + } + + if pcr != nil && pcr.OwnerID == owner.ID { + return pcr + } + + ctx.NotFound("", fmt.Errorf("PackageCleanupRule[%v] not associated to owner %v", id, owner)) + + return nil +} + +func InitializeCargoIndex(ctx *context.Context, owner *user_model.User) { + err := cargo_service.InitializeIndexRepository(ctx, owner, owner) + if err != nil { + log.Error("InitializeIndexRepository failed: %v", err) + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.initialize.error", err)) + } else { + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cargo.initialize.success")) + } +} + +func RebuildCargoIndex(ctx *context.Context, owner *user_model.User) { + err := cargo_service.RebuildIndex(ctx, owner, owner) + if err != nil { + log.Error("RebuildIndex failed: %v", err) + if errors.Is(err, util.ErrNotExist) { + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.rebuild.no_index")) + } else { + ctx.Flash.Error(ctx.Tr("packages.owner.settings.cargo.rebuild.error", err)) + } + } else { + ctx.Flash.Success(ctx.Tr("packages.owner.settings.cargo.rebuild.success")) + } +} diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go new file mode 100644 index 0000000..599842e --- /dev/null +++ b/routers/web/shared/project/column.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go new file mode 100644 index 0000000..3bd421f --- /dev/null +++ b/routers/web/shared/secrets/secrets.go @@ -0,0 +1,53 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + secret_service "code.gitea.io/gitea/services/secrets" +) + +func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { + secrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{OwnerID: ownerID, RepoID: repoID}) + if err != nil { + ctx.ServerError("FindSecrets", err) + return + } + + ctx.Data["Secrets"] = secrets +} + +func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + form := web.GetForm(ctx).(*forms.AddSecretForm) + + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) + if err != nil { + log.Error("CreateOrUpdateSecret failed: %v", err) + ctx.JSONError(ctx.Tr("secrets.creation.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) + ctx.JSONRedirect(redirectURL) +} + +func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + id := ctx.FormInt64("id") + + err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) + if err != nil { + log.Error("DeleteSecretByID(%d) failed: %v", id, err) + ctx.JSONError(ctx.Tr("secrets.deletion.failed")) + return + } + + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + ctx.JSONRedirect(redirectURL) +} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go new file mode 100644 index 0000000..fd7605c --- /dev/null +++ b/routers/web/shared/user/header.go @@ -0,0 +1,163 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/url" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + 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/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu) +// It is designed to be fast and safe to be called multiple times in one request +func prepareContextForCommonProfile(ctx *context.Context) { + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["EnableFeed"] = setting.Other.EnableFeed + ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink() +} + +// PrepareContextForProfileBigAvatar set the context for big avatar view on the profile page +func PrepareContextForProfileBigAvatar(ctx *context.Context) { + prepareContextForCommonProfile(ctx) + + ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate + if setting.Service.UserLocationMapURL != "" { + ctx.Data["ContextUserLocationMapURL"] = setting.Service.UserLocationMapURL + url.QueryEscape(ctx.ContextUser.Location) + } + // Show OpenID URIs + openIDs, err := user_model.GetUserOpenIDs(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openIDs + if len(ctx.ContextUser.Description) != 0 { + content, err := markdown.RenderString(&markup.RenderContext{ + Metas: map[string]string{"mode": "document"}, + Ctx: ctx, + }, ctx.ContextUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = content + } + + showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ + UserID: ctx.ContextUser.ID, + IncludePrivate: showPrivate, + }) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(ctx, orgs, ctx.Doer) + + badges, _, err := user_model.GetUserBadges(ctx, ctx.ContextUser) + if err != nil { + ctx.ServerError("GetUserBadges", err) + return + } + ctx.Data["Badges"] = badges + + // in case the numbers are already provided by other functions, no need to query again (which is slow) + if _, ok := ctx.Data["NumFollowers"]; !ok { + _, ctx.Data["NumFollowers"], _ = user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) + } + if _, ok := ctx.Data["NumFollowing"]; !ok { + _, ctx.Data["NumFollowing"], _ = user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) + } +} + +func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { + profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") + if err == nil { + perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) + if err == nil && !profileDbRepo.IsEmpty && perm.CanRead(unit.TypeCode) { + if profileGitRepo, err = gitrepo.OpenRepository(ctx, profileDbRepo); err != nil { + log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) + } else { + if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { + log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) + } else { + profileReadmeBlob, _ = commit.GetBlobByFoldedPath("README.md") + } + } + } + } else if !repo_model.IsErrRepoNotExist(err) { + log.Error("FindUserProfileReadme failed to GetRepositoryByName: %v", err) + } + return profileDbRepo, profileGitRepo, profileReadmeBlob, func() { + if profileGitRepo != nil { + _ = profileGitRepo.Close() + } + } +} + +func RenderUserHeader(ctx *context.Context) { + prepareContextForCommonProfile(ctx) + + _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil +} + +func LoadHeaderCount(ctx *context.Context) error { + prepareContextForCommonProfile(ctx) + + var err error + + ctx.Data["RepoCount"], err = repo_model.CountRepository(ctx, &repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: ctx.ContextUser.ID, + Private: ctx.IsSigned, + Collaborate: optional.Some(false), + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + return err + } + + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + ctx.Data["ProjectCount"], err = db.Count[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(false), + Type: projectType, + }) + if err != nil { + return err + } + ctx.Data["PackageCount"], err = packages_model.CountOwnerPackages(ctx, ctx.ContextUser.ID) + if err != nil { + return err + } + + return nil +} diff --git a/routers/web/swagger_json.go b/routers/web/swagger_json.go new file mode 100644 index 0000000..fc39b50 --- /dev/null +++ b/routers/web/swagger_json.go @@ -0,0 +1,13 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "code.gitea.io/gitea/services/context" +) + +// SwaggerV1Json render swagger v1 json +func SwaggerV1Json(ctx *context.Context) { + ctx.JSONTemplate("swagger/v1_json") +} diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go new file mode 100644 index 0000000..04f5101 --- /dev/null +++ b/routers/web/user/avatar.go @@ -0,0 +1,57 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "strings" + "time" + + "code.gitea.io/gitea/models/avatars" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/services/context" +) + +func cacheableRedirect(ctx *context.Context, location string) { + // here we should not use `setting.StaticCacheTime`, it is pretty long (default: 6 hours) + // we must make sure the redirection cache time is short enough, otherwise a user won't see the updated avatar in 6 hours + // it's OK to make the cache time short, it is only a redirection, and doesn't cost much to make a new request + httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 5*time.Minute) + ctx.Redirect(location) +} + +// AvatarByUserName redirect browser to user avatar of requested size +func AvatarByUserName(ctx *context.Context) { + userName := ctx.Params(":username") + size := int(ctx.ParamsInt64(":size")) + + var user *user_model.User + if strings.ToLower(userName) != user_model.GhostUserLowerName { + var err error + if user, err = user_model.GetUserByName(ctx, userName); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName", err) + return + } + ctx.ServerError("Invalid user: "+userName, err) + return + } + } else { + user = user_model.NewGhostUser() + } + + cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, size)) +} + +// AvatarByEmailHash redirects the browser to the email avatar link +func AvatarByEmailHash(ctx *context.Context) { + hash := ctx.Params(":hash") + email, err := avatars.GetEmailForHash(ctx, hash) + if err != nil { + ctx.ServerError("invalid avatar hash: "+hash, err) + return + } + size := ctx.FormInt("size") + cacheableRedirect(ctx, avatars.GenerateEmailAvatarFinalLink(ctx, email, size)) +} diff --git a/routers/web/user/code.go b/routers/web/user/code.go new file mode 100644 index 0000000..e2e8f25 --- /dev/null +++ b/routers/web/user/code.go @@ -0,0 +1,129 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplUserCode base.TplName = "user/code" +) + +// CodeSearch render user/organization code search page +func CodeSearch(ctx *context.Context) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.Redirect(ctx.ContextUser.HomeLink()) + return + } + shared_user.PrepareContextForProfileBigAvatar(ctx) + shared_user.RenderUserHeader(ctx) + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["Title"] = ctx.Tr("explore.code") + + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + ctx.Data["Keyword"] = keyword + ctx.Data["Language"] = language + ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["IsCodePage"] = true + + if keyword == "" { + ctx.HTML(http.StatusOK, tplUserCode) + return + } + + var ( + repoIDs []int64 + err error + ) + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + repoIDs, err = repo_model.FindUserCodeAccessibleOwnerRepoIDs(ctx, ctx.ContextUser.ID, ctx.Doer) + if err != nil { + ctx.ServerError("FindUserCodeAccessibleOwnerRepoIDs", err) + return + } + + var ( + total int + searchResults []*code_indexer.Result + searchResultLanguages []*code_indexer.SearchResultLanguages + ) + + if len(repoIDs) > 0 { + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + } + + loadRepoIDs := make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + ctx.Data["RepoMaps"] = repoMaps + } + ctx.Data["SearchResults"] = searchResults + ctx.Data["SearchResultLanguages"] = searchResultLanguages + + pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "l", "Language") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplUserCode) +} diff --git a/routers/web/user/home.go b/routers/web/user/home.go new file mode 100644 index 0000000..4b249e9 --- /dev/null +++ b/routers/web/user/home.go @@ -0,0 +1,883 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "bytes" + "fmt" + "net/http" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + 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/container" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "xorm.io/builder" +) + +const ( + tplDashboard base.TplName = "user/dashboard/dashboard" + tplIssues base.TplName = "user/dashboard/issues" + tplMilestones base.TplName = "user/dashboard/milestones" + tplProfile base.TplName = "user/profile" +) + +// getDashboardContextUser finds out which context user dashboard is being viewed as . +func getDashboardContextUser(ctx *context.Context) *user_model.User { + ctxUser := ctx.Doer + orgName := ctx.Params(":org") + if len(orgName) > 0 { + ctxUser = ctx.Org.Organization.AsUser() + ctx.Data["Teams"] = ctx.Org.Teams + } + ctx.Data["ContextUser"] = ctxUser + + orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserOrgsList", err) + return nil + } + ctx.Data["Orgs"] = orgs + + return ctxUser +} + +// Dashboard render the dashboard page +func Dashboard(ctx *context.Context) { + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + var ( + date = ctx.FormString("date") + page = ctx.FormInt("page") + ) + + // Make sure page number is at least 1. Will be posted to ctx.Data. + if page <= 1 { + page = 1 + } + + ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard") + ctx.Data["PageIsDashboard"] = true + ctx.Data["PageIsNews"] = true + cnt, _ := organization.GetOrganizationCount(ctx, ctxUser) + ctx.Data["UserOrgsCount"] = cnt + ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled + ctx.Data["Date"] = date + + var uid int64 + if ctxUser != nil { + uid = ctxUser.ID + } + + ctx.PageData["dashboardRepoList"] = map[string]any{ + "searchLimit": setting.UI.User.RepoPagingNum, + "uid": uid, + } + + if setting.Service.EnableUserHeatmap { + data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUserTeam", err) + return + } + ctx.Data["HeatmapData"] = data + ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) + } + + feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctxUser, + RequestedTeam: ctx.Org.Team, + Actor: ctx.Doer, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: false, + Date: ctx.FormString("date"), + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.FeedPagingNum, + }, + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + ctx.Data["Feeds"] = feeds + + pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5) + pager.AddParam(ctx, "date", "Date") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplDashboard) +} + +// Milestones render the user milestones page +func Milestones(ctx *context.Context) { + if unit.TypeIssues.UnitGlobalDisabled() && unit.TypePullRequests.UnitGlobalDisabled() { + log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("milestones") + ctx.Data["PageIsMilestonesDashboard"] = true + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + repoOpts := repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: ctxUser.ID, + Private: true, + AllPublic: false, // Include also all public repositories of users and public organisations + AllLimited: false, // Include also all public repositories of limited organisations + Archived: optional.Some(false), + HasMilestones: optional.Some(true), // Just needs display repos has milestones + } + + if ctxUser.IsOrganization() && ctx.Org.Team != nil { + repoOpts.TeamID = ctx.Org.Team.ID + } + + var ( + userRepoCond = repo_model.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit + repoCond = userRepoCond + repoIDs []int64 + + reposQuery = ctx.FormString("repos") + isShowClosed = ctx.FormString("state") == "closed" + sortType = ctx.FormString("sort") + page = ctx.FormInt("page") + keyword = ctx.FormTrim("q") + ) + + if page <= 1 { + page = 1 + } + + if len(reposQuery) != 0 { + if issueReposQueryPattern.MatchString(reposQuery) { + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + // for each ID (delimiter ",") add to int to repoIDs + + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + rIDint64, err := strconv.ParseInt(rID, 10, 64) + // If the repo id specified by query is not parseable or not accessible by user, just ignore it. + if err == nil { + repoIDs = append(repoIDs, rIDint64) + } + } + } + if len(repoIDs) > 0 { + // Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs + // But the original repoCond has a limitation + repoCond = repoCond.And(builder.In("id", repoIDs)) + } + } else { + log.Warn("issueReposQueryPattern not match with query") + } + } + + counts, err := issues_model.CountMilestonesMap(ctx, issues_model.FindMilestoneOptions{ + RepoCond: userRepoCond, + Name: keyword, + IsClosed: optional.Some(isShowClosed), + }) + if err != nil { + ctx.ServerError("CountMilestonesByRepoIDs", err) + return + } + + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoCond: repoCond, + IsClosed: optional.Some(isShowClosed), + SortType: sortType, + Name: keyword, + }) + if err != nil { + ctx.ServerError("SearchMilestones", err) + return + } + + showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, &repoOpts, userRepoCond, false) + if err != nil { + ctx.ServerError("SearchRepositoryByCondition", err) + return + } + sort.Sort(showRepos) + + for i := 0; i < len(milestones); { + for _, repo := range showRepos { + if milestones[i].RepoID == repo.ID { + milestones[i].Repo = repo + break + } + } + if milestones[i].Repo == nil { + log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID) + milestones = append(milestones[:i], milestones[i+1:]...) + continue + } + + milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: milestones[i].Repo.Link(), + }, + Metas: milestones[i].Repo.ComposeMetas(ctx), + Ctx: ctx, + }, milestones[i].Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + if milestones[i].Repo.IsTimetrackerEnabled(ctx) { + err := milestones[i].LoadTotalTrackedTime(ctx) + if err != nil { + ctx.ServerError("LoadTotalTrackedTime", err) + return + } + } + i++ + } + + milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, repoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + var totalMilestoneStats *issues_model.MilestonesStats + if len(repoIDs) == 0 { + totalMilestoneStats = milestoneStats + } else { + totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, userRepoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + } + + showRepoIDs := make(container.Set[int64], len(showRepos)) + for _, repo := range showRepos { + if repo.ID > 0 { + showRepoIDs.Add(repo.ID) + } + } + if len(repoIDs) == 0 { + repoIDs = showRepoIDs.Values() + } + repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool { + return !showRepoIDs.Contains(v) + }) + + var pagerCount int + if isShowClosed { + ctx.Data["State"] = "closed" + ctx.Data["Total"] = totalMilestoneStats.ClosedCount + pagerCount = int(milestoneStats.ClosedCount) + } else { + ctx.Data["State"] = "open" + ctx.Data["Total"] = totalMilestoneStats.OpenCount + pagerCount = int(milestoneStats.OpenCount) + } + + ctx.Data["Milestones"] = milestones + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = counts + ctx.Data["MilestoneStats"] = milestoneStats + ctx.Data["SortType"] = sortType + ctx.Data["Keyword"] = keyword + ctx.Data["RepoIDs"] = repoIDs + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "repos", "RepoIDs") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplMilestones) +} + +// Pulls renders the user's pull request overview page +func Pulls(ctx *context.Context) { + if unit.TypePullRequests.UnitGlobalDisabled() { + log.Debug("Pull request overview page not available as it is globally disabled.") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("pull_requests") + ctx.Data["PageIsPulls"] = true + buildIssueOverview(ctx, unit.TypePullRequests) +} + +// Issues renders the user's issues overview page +func Issues(ctx *context.Context) { + if unit.TypeIssues.UnitGlobalDisabled() { + log.Debug("Issues overview page not available as it is globally disabled.") + ctx.Status(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("issues") + ctx.Data["PageIsIssues"] = true + buildIssueOverview(ctx, unit.TypeIssues) +} + +// Regexp for repos query +var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) + +func buildIssueOverview(ctx *context.Context, unitType unit.Type) { + // ---------------------------------------------------- + // Determine user; can be either user or organization. + // Return with NotFound or ServerError if unsuccessful. + // ---------------------------------------------------- + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + var ( + viewType string + sortType = ctx.FormString("sort") + filterMode int + ) + + // Default to recently updated, unlike repository issues list + if sortType == "" { + sortType = "recentupdate" + } + + // -------------------------------------------------------------------------------- + // Distinguish User from Organization. + // Org: + // - Remember pre-determined viewType string for later. Will be posted to ctx.Data. + // Organization does not have view type and filter mode. + // User: + // - Use ctx.FormString("type") to determine filterMode. + // The type is set when clicking for example "assigned to me" on the overview page. + // - Remember either this or a fallback. Will be posted to ctx.Data. + // -------------------------------------------------------------------------------- + + // TODO: distinguish during routing + + viewType = ctx.FormString("type") + switch viewType { + case "assigned": + filterMode = issues_model.FilterModeAssign + case "mentioned": + filterMode = issues_model.FilterModeMention + case "review_requested": + filterMode = issues_model.FilterModeReviewRequested + case "reviewed_by": + filterMode = issues_model.FilterModeReviewed + case "your_repositories": + filterMode = issues_model.FilterModeYourRepositories + case "created_by": + fallthrough + default: + filterMode = issues_model.FilterModeCreate + viewType = "created_by" + } + + // -------------------------------------------------------------------------- + // Build opts (IssuesOptions), which contains filter information. + // Will eventually be used to retrieve issues relevant for the overview page. + // Note: Non-final states of opts are used in-between, namely for: + // - Keyword search + // - Count Issues by repo + // -------------------------------------------------------------------------- + + // Get repository IDs where User/Org/Team has access. + var team *organization.Team + var org *organization.Organization + if ctx.Org != nil { + org = ctx.Org.Organization + team = ctx.Org.Team + } + + isPullList := unitType == unit.TypePullRequests + opts := &issues_model.IssuesOptions{ + IsPull: optional.Some(isPullList), + SortType: sortType, + IsArchived: optional.Some(false), + Org: org, + Team: team, + User: ctx.Doer, + } + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + // Search all repositories which + // + // As user: + // - Owns the repository. + // - Have collaborator permissions in repository. + // + // As org: + // - Owns the repository. + // + // As team: + // - Team org's owns the repository. + // - Team has read permission to repository. + repoOpts := &repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + OwnerID: ctxUser.ID, + Private: true, + AllPublic: false, + AllLimited: false, + Collaborate: optional.None[bool](), + UnitType: unitType, + Archived: optional.Some(false), + } + if team != nil { + repoOpts.TeamID = team.ID + } + accessibleRepos := container.Set[int64]{} + { + ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts) + if err != nil { + ctx.ServerError("SearchRepositoryIDs", err) + return + } + accessibleRepos.AddMultiple(ids...) + opts.RepoIDs = ids + if len(opts.RepoIDs) == 0 { + // no repos found, don't let the indexer return all repos + opts.RepoIDs = []int64{0} + } + } + if ctx.Doer.ID == ctxUser.ID && filterMode != issues_model.FilterModeYourRepositories { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + opts.AllPublic = true + } + + switch filterMode { + case issues_model.FilterModeAll: + case issues_model.FilterModeYourRepositories: + case issues_model.FilterModeAssign: + opts.AssigneeID = ctx.Doer.ID + case issues_model.FilterModeCreate: + opts.PosterID = ctx.Doer.ID + case issues_model.FilterModeMention: + opts.MentionedID = ctx.Doer.ID + case issues_model.FilterModeReviewRequested: + opts.ReviewRequestedID = ctx.Doer.ID + case issues_model.FilterModeReviewed: + opts.ReviewedID = ctx.Doer.ID + } + + // keyword holds the search term entered into the search field. + keyword := strings.Trim(ctx.FormString("q"), " ") + ctx.Data["Keyword"] = keyword + + // Educated guess: Do or don't show closed issues. + isShowClosed := ctx.FormString("state") == "closed" + opts.IsClosed = optional.Some(isShowClosed) + + // Make sure page number is at least 1. Will be posted to ctx.Data. + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + opts.Paginator = &db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + } + + // Get IDs for labels (a filter option for issues/pulls). + // Required for IssuesOptions. + selectedLabels := ctx.FormString("labels") + if len(selectedLabels) > 0 && selectedLabels != "0" { + var err error + opts.LabelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) + } + } + + if org != nil { + // Get Org Labels + labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByOrgID", err) + return + } + + // Get the exclusive scope for every label ID + labelExclusiveScopes := make([]string, 0, len(opts.LabelIDs)) + for _, labelID := range opts.LabelIDs { + foundExclusiveScope := false + for _, label := range labels { + if label.ID == labelID || label.ID == -labelID { + labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope()) + foundExclusiveScope = true + break + } + } + if !foundExclusiveScope { + labelExclusiveScopes = append(labelExclusiveScopes, "") + } + } + + for _, l := range labels { + l.LoadSelectedLabelsAfterClick(opts.LabelIDs, labelExclusiveScopes) + } + ctx.Data["Labels"] = labels + } + + // ------------------------------ + // Get issues as defined by opts. + // ------------------------------ + + // Slice of Issues that will be displayed on the overview page + // USING FINAL STATE OF opts FOR A QUERY. + var issues issues_model.IssueList + { + issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, + )) + if err != nil { + ctx.ServerError("issueIDsFromSearch", err) + return + } + issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return + } + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + + // ------------------------------- + // Fill stats to post to ctx.Data. + // ------------------------------- + issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy( + func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, + )) + if err != nil { + ctx.ServerError("getUserIssueStats", err) + return + } + + // Will be posted to ctx.Data. + var shownIssues int + if !isShowClosed { + shownIssues = int(issueStats.OpenCount) + } else { + shownIssues = int(issueStats.ClosedCount) + } + + ctx.Data["IsShowClosed"] = isShowClosed + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.FormString("RepoLink")) + + if err := issues.LoadAttributes(ctx); err != nil { + ctx.ServerError("issues.LoadAttributes", err) + return + } + ctx.Data["Issues"] = issues + + approvalCounts, err := issues.GetApprovalCounts(ctx) + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["IssueStats"] = issueStats + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["SelectLabels"] = selectedLabels + ctx.Data["PageIsOrgIssues"] = org != nil + ctx.Data["IsFuzzy"] = isFuzzy + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "type", "ViewType") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "labels", "SelectLabels") + pager.AddParam(ctx, "milestone", "MilestoneID") + pager.AddParam(ctx, "assignee", "AssigneeID") + pager.AddParam(ctx, "fuzzy", "IsFuzzy") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplIssues) +} + +// ShowSSHKeys output all the ssh keys of user by uid +func ShowSSHKeys(ctx *context.Context) { + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + + var buf bytes.Buffer + for i := range keys { + buf.WriteString(keys[i].OmitEmail()) + buf.WriteString("\n") + } + ctx.PlainTextBytes(http.StatusOK, buf.Bytes()) +} + +// ShowGPGKeys output all the public GPG keys of user by uid +func ShowGPGKeys(ctx *context.Context) { + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + + entities := make([]*openpgp.Entity, 0) + failedEntitiesID := make([]string, 0) + for _, k := range keys { + e, err := asymkey_model.GPGKeyToEntity(ctx, k) + if err != nil { + if asymkey_model.IsErrGPGKeyImportNotExist(err) { + failedEntitiesID = append(failedEntitiesID, k.KeyID) + continue // Skip previous import without backup of imported armored key + } + ctx.ServerError("ShowGPGKeys", err) + return + } + entities = append(entities, e) + } + var buf bytes.Buffer + + headers := make(map[string]string) + if len(failedEntitiesID) > 0 { // If some key need re-import to be exported + headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", ")) + } else if len(entities) == 0 { + headers["Note"] = "This user hasn't uploaded any GPG keys." + } + writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers) + for _, e := range entities { + err = e.Serialize(writer) // TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange) + if err != nil { + ctx.ServerError("ShowGPGKeys", err) + return + } + } + writer.Close() + ctx.PlainTextBytes(http.StatusOK, buf.Bytes()) +} + +func UsernameSubRoute(ctx *context.Context) { + // WORKAROUND to support usernames with "." in it + // https://github.com/go-chi/chi/issues/781 + username := ctx.Params("username") + reloadParam := func(suffix string) (success bool) { + ctx.SetParams("username", strings.TrimSuffix(username, suffix)) + context.UserAssignmentWeb()(ctx) + if ctx.Written() { + return false + } + // check view permissions + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound("User not visible", nil) + return false + } + return true + } + switch { + case strings.HasSuffix(username, ".png"): + if reloadParam(".png") { + AvatarByUserName(ctx) + } + case strings.HasSuffix(username, ".keys"): + if reloadParam(".keys") { + ShowSSHKeys(ctx) + } + case strings.HasSuffix(username, ".gpg"): + if reloadParam(".gpg") { + ShowGPGKeys(ctx) + } + case strings.HasSuffix(username, ".rss"): + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + if reloadParam(".rss") { + feed.ShowUserFeedRSS(ctx) + } + case strings.HasSuffix(username, ".atom"): + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + if reloadParam(".atom") { + feed.ShowUserFeedAtom(ctx) + } + default: + context.UserAssignmentWeb()(ctx) + if !ctx.Written() { + ctx.Data["EnableFeed"] = setting.Other.EnableFeed + OwnerProfile(ctx) + } + } +} + +func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) { + doerID := ctx.Doer.ID + + opts = opts.Copy(func(o *issue_indexer.SearchOptions) { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + o.AllPublic = doerID == ctxUser.ID + o.AssigneeID = nil + o.PosterID = nil + o.MentionID = nil + o.ReviewRequestedID = nil + o.ReviewedID = nil + }) + + var ( + err error + ret = &issues_model.IssueStats{} + ) + + { + openClosedOpts := opts.Copy() + switch filterMode { + case issues_model.FilterModeAll: + // no-op + case issues_model.FilterModeYourRepositories: + openClosedOpts.AllPublic = false + case issues_model.FilterModeAssign: + openClosedOpts.AssigneeID = optional.Some(doerID) + case issues_model.FilterModeCreate: + openClosedOpts.PosterID = optional.Some(doerID) + case issues_model.FilterModeMention: + openClosedOpts.MentionID = optional.Some(doerID) + case issues_model.FilterModeReviewRequested: + openClosedOpts.ReviewRequestedID = optional.Some(doerID) + case issues_model.FilterModeReviewed: + openClosedOpts.ReviewedID = optional.Some(doerID) + } + openClosedOpts.IsClosed = optional.Some(false) + ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) + if err != nil { + return nil, err + } + openClosedOpts.IsClosed = optional.Some(true) + ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) + if err != nil { + return nil, err + } + } + + ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false })) + if err != nil { + return nil, err + } + ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = optional.Some(doerID) })) + if err != nil { + return nil, err + } + return ret, nil +} diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go new file mode 100644 index 0000000..e1c8ca9 --- /dev/null +++ b/routers/web/user/home_test.go @@ -0,0 +1,169 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArchivedIssues(t *testing.T) { + // Arrange + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 30) + ctx.Req.Form.Set("state", "open") + ctx.Req.Form.Set("type", "your_repositories") + + // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. + repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, &repo_model.SearchRepoOptions{Actor: ctx.Doer}) + assert.Len(t, repos, 3) + IsArchived := make(map[int64]bool) + NumIssues := make(map[int64]int) + for _, repo := range repos { + IsArchived[repo.ID] = repo.IsArchived + NumIssues[repo.ID] = repo.NumIssues + } + assert.False(t, IsArchived[50]) + assert.EqualValues(t, 1, NumIssues[50]) + assert.True(t, IsArchived[51]) + assert.EqualValues(t, 1, NumIssues[51]) + + // Act + Issues(ctx) + + // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.Len(t, ctx.Data["Issues"], 1) +} + +func TestIssues(t *testing.T) { + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "closed") + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.Len(t, ctx.Data["Issues"], 1) +} + +func TestPulls(t *testing.T) { + setting.UI.IssuePagingNum = 20 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "pulls") + contexttest.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "open") + ctx.Req.Form.Set("type", "your_repositories") + Pulls(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.Len(t, ctx.Data["Issues"], 5) +} + +func TestMilestones(t *testing.T) { + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} + +func TestMilestonesForSpecificRepo(t *testing.T) { + setting.UI.IssuePagingNum = 1 + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.SetParams("repo", "1") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} + +func TestDashboardPagination(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + page := context.NewPagination(10, 3, 1, 3) + + setting.AppSubURL = "/SubPath" + out, err := ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + require.NoError(t, err) + assert.Contains(t, out, ``) + + setting.AppSubURL = "" + out, err = ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + require.NoError(t, err) + assert.Contains(t, out, ``) +} + +func TestOrgLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + ctx, _ := contexttest.MockContext(t, "org/org3/issues") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadOrganization(t, ctx, 3) + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.True(t, ctx.Data["PageIsOrgIssues"].(bool)) + + orgLabels := []struct { + ID int64 + OrgID int64 + Name string + }{ + {3, 3, "orglabel3"}, + {4, 3, "orglabel4"}, + } + + labels, ok := ctx.Data["Labels"].([]*issues_model.Label) + + assert.True(t, ok) + + if assert.Len(t, labels, len(orgLabels)) { + for i, label := range labels { + assert.EqualValues(t, orgLabels[i].OrgID, label.OrgID) + assert.EqualValues(t, orgLabels[i].ID, label.ID) + assert.EqualValues(t, orgLabels[i].Name, label.Name) + } + } +} diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go new file mode 100644 index 0000000..8b6ae69 --- /dev/null +++ b/routers/web/user/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go new file mode 100644 index 0000000..dfcaf58 --- /dev/null +++ b/routers/web/user/notification.go @@ -0,0 +1,485 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + goctx "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" +) + +const ( + tplNotification base.TplName = "user/notification/notification" + tplNotificationDiv base.TplName = "user/notification/notification_div" + tplNotificationSubscriptions base.TplName = "user/notification/notification_subscriptions" +) + +// GetNotificationCount is the middleware that sets the notification count in the context +func GetNotificationCount(ctx *context.Context) { + if strings.HasPrefix(ctx.Req.URL.Path, "/api") { + return + } + + if !ctx.IsSigned { + return + } + + ctx.Data["NotificationUnreadCount"] = func() int64 { + count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + if err != goctx.Canceled { + log.Error("Unable to GetNotificationCount for user:%-v: %v", ctx.Doer, err) + } + return -1 + } + + return count + } +} + +// Notifications is the notifications page +func Notifications(ctx *context.Context) { + getNotifications(ctx) + if ctx.Written() { + return + } + if ctx.FormBool("div-only") { + ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number") + ctx.HTML(http.StatusOK, tplNotificationDiv) + return + } + ctx.HTML(http.StatusOK, tplNotification) +} + +func getNotifications(ctx *context.Context) { + var ( + keyword = ctx.FormTrim("q") + status activities_model.NotificationStatus + page = ctx.FormInt("page") + perPage = ctx.FormInt("perPage") + ) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + + switch keyword { + case "read": + status = activities_model.NotificationStatusRead + default: + status = activities_model.NotificationStatusUnread + } + + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{status}, + }) + if err != nil { + ctx.ServerError("ErrGetNotificationCount", err) + return + } + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(int(total), perPage, page, 5) + if pager.Paginater.Current() < page { + ctx.Redirect(fmt.Sprintf("%s/notifications?q=%s&page=%d", setting.AppSubURL, url.QueryEscape(ctx.FormString("q")), pager.Paginater.Current())) + return + } + + statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned} + nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + ListOptions: db.ListOptions{ + PageSize: perPage, + Page: page, + }, + UserID: ctx.Doer.ID, + Status: statuses, + }) + if err != nil { + ctx.ServerError("db.Find[activities_model.Notification]", err) + return + } + + notifications := activities_model.NotificationList(nls) + + failCount := 0 + + repos, failures, err := notifications.LoadRepos(ctx) + if err != nil { + ctx.ServerError("LoadRepos", err) + return + } + notifications = notifications.Without(failures) + if err := repos.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + failCount += len(failures) + + failures, err = notifications.LoadIssues(ctx) + if err != nil { + ctx.ServerError("LoadIssues", err) + return + } + + if err = notifications.LoadIssuePullRequests(ctx); err != nil { + ctx.ServerError("LoadIssuePullRequests", err) + return + } + + notifications = notifications.Without(failures) + failCount += len(failures) + + failures, err = notifications.LoadComments(ctx) + if err != nil { + ctx.ServerError("LoadComments", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + if failCount > 0 { + ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) + } + + ctx.Data["Title"] = ctx.Tr("notifications") + ctx.Data["Keyword"] = keyword + ctx.Data["Status"] = status + ctx.Data["Notifications"] = notifications + + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager +} + +// NotificationStatusPost is a route for changing the status of a notification +func NotificationStatusPost(ctx *context.Context) { + var ( + notificationID = ctx.FormInt64("notification_id") + statusStr = ctx.FormString("status") + status activities_model.NotificationStatus + ) + + switch statusStr { + case "read": + status = activities_model.NotificationStatusRead + case "unread": + status = activities_model.NotificationStatusUnread + case "pinned": + status = activities_model.NotificationStatusPinned + default: + ctx.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status")) + return + } + + if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, status); err != nil { + ctx.ServerError("SetNotificationStatus", err) + return + } + + if !ctx.FormBool("noredirect") { + url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, url.QueryEscape(ctx.FormString("page"))) + ctx.Redirect(url, http.StatusSeeOther) + } + + getNotifications(ctx) + if ctx.Written() { + return + } + ctx.Data["Link"] = setting.AppSubURL + "/notifications" + ctx.Data["SequenceNumber"] = ctx.Req.PostFormValue("sequence-number") + + ctx.HTML(http.StatusOK, tplNotificationDiv) +} + +// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read +func NotificationPurgePost(ctx *context.Context) { + err := activities_model.UpdateNotificationStatuses(ctx, ctx.Doer, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead) + if err != nil { + ctx.ServerError("UpdateNotificationStatuses", err) + return + } + + ctx.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther) +} + +// NotificationSubscriptions returns the list of subscribed issues +func NotificationSubscriptions(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + sortType := ctx.FormString("sort") + ctx.Data["SortType"] = sortType + + state := ctx.FormString("state") + if !util.SliceContainsString([]string{"all", "open", "closed"}, state, true) { + state = "all" + } + + ctx.Data["State"] = state + // default state filter is "all" + showClosed := optional.None[bool]() + switch state { + case "closed": + showClosed = optional.Some(true) + case "open": + showClosed = optional.Some(false) + } + + issueType := ctx.FormString("issueType") + // default issue type is no filter + issueTypeBool := optional.None[bool]() + switch issueType { + case "issues": + issueTypeBool = optional.Some(false) + case "pulls": + issueTypeBool = optional.Some(true) + } + ctx.Data["IssueType"] = issueType + + var labelIDs []int64 + selectedLabels := ctx.FormString("labels") + ctx.Data["Labels"] = selectedLabels + if len(selectedLabels) > 0 && selectedLabels != "0" { + var err error + labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) + } + } + + count, err := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{ + SubscriberID: ctx.Doer.ID, + IsClosed: showClosed, + IsPull: issueTypeBool, + LabelIDs: labelIDs, + }) + if err != nil { + ctx.ServerError("CountIssues", err) + return + } + issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.IssuePagingNum, + Page: page, + }, + SubscriberID: ctx.Doer.ID, + SortType: sortType, + IsClosed: showClosed, + IsPull: issueTypeBool, + LabelIDs: labelIDs, + }) + if err != nil { + ctx.ServerError("Issues", err) + return + } + + commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesAllCommitStatus", err) + return + } + if !ctx.Repo.CanRead(unit.TypeActions) { + for key := range commitStatuses { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) + } + } + ctx.Data["CommitLastStatus"] = lastStatus + ctx.Data["CommitStatuses"] = commitStatuses + ctx.Data["Issues"] = issues + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "") + + commitStatus, err := pull_service.GetIssuesLastCommitStatus(ctx, issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + ctx.Data["CommitStatus"] = commitStatus + + approvalCounts, err := issues.GetApprovalCounts(ctx) + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := issues_model.ReviewTypeApprove + if typ == "reject" { + reviewTyp = issues_model.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = issues_model.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + + ctx.Data["Status"] = 1 + ctx.Data["Title"] = ctx.Tr("notification.subscriptions") + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(int(count), setting.UI.IssuePagingNum, page, 5) + if pager.Paginater.Current() < page { + ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current())) + return + } + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplNotificationSubscriptions) +} + +// NotificationWatching returns the list of watching repos +func NotificationWatching(ctx *context.Context) { + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + var orderBy db.SearchOrderBy + ctx.Data["SortType"] = ctx.FormString("sort") + switch ctx.FormString("sort") { + case "newest": + orderBy = db.SearchOrderByNewest + case "oldest": + orderBy = db.SearchOrderByOldest + case "recentupdate": + orderBy = db.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = db.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = db.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = db.SearchOrderByAlphabetically + case "moststars": + orderBy = db.SearchOrderByStarsReverse + case "feweststars": + orderBy = db.SearchOrderByStars + case "mostforks": + orderBy = db.SearchOrderByForksReverse + case "fewestforks": + orderBy = db.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = db.SearchOrderByRecentUpdated + } + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctx.Doer.ID, + Collaborate: optional.Some(false), + TopicOnly: ctx.FormBool("topic"), + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + total := int(count) + ctx.Data["Total"] = total + ctx.Data["Repos"] = repos + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager + + ctx.Data["Status"] = 2 + ctx.Data["Title"] = ctx.Tr("notification.watching") + + ctx.HTML(http.StatusOK, tplNotificationSubscriptions) +} + +// NewAvailable returns the notification counts +func NewAvailable(ctx *context.Context) { + 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 { + log.Error("db.Count[activities_model.Notification]", err) + ctx.JSON(http.StatusOK, structs.NotificationCount{New: 0}) + return + } + + ctx.JSON(http.StatusOK, structs.NotificationCount{New: total}) +} diff --git a/routers/web/user/package.go b/routers/web/user/package.go new file mode 100644 index 0000000..d47a36e --- /dev/null +++ b/routers/web/user/package.go @@ -0,0 +1,513 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + "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/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + alpine_module "code.gitea.io/gitea/modules/packages/alpine" + arch_model "code.gitea.io/gitea/modules/packages/arch" + debian_module "code.gitea.io/gitea/modules/packages/debian" + rpm_module "code.gitea.io/gitea/modules/packages/rpm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + packages_helper "code.gitea.io/gitea/routers/api/packages/helper" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + packages_service "code.gitea.io/gitea/services/packages" +) + +const ( + tplPackagesList base.TplName = "user/overview/packages" + tplPackagesView base.TplName = "package/view" + tplPackageVersionList base.TplName = "user/overview/package_versions" + tplPackagesSettings base.TplName = "package/settings" +) + +// ListPackages displays a list of all packages of the context user +func ListPackages(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + query := ctx.FormTrim("q") + packageType := ctx.FormTrim("type") + + pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + }, + OwnerID: ctx.ContextUser.ID, + Type: packages_model.Type(packageType), + Name: packages_model.SearchValue{Value: query}, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + repositoryAccessMap := make(map[int64]bool) + for _, pd := range pds { + if pd.Repository == nil { + continue + } + if _, has := repositoryAccessMap[pd.Repository.ID]; has { + continue + } + + permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + repositoryAccessMap[pd.Repository.ID] = permission.HasAccess() + } + + hasPackages, err := packages_model.HasOwnerPackages(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("HasOwnerPackages", err) + return + } + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["Query"] = query + ctx.Data["PackageType"] = packageType + ctx.Data["AvailableTypes"] = packages_model.TypeList + ctx.Data["HasPackages"] = hasPackages + ctx.Data["PackageDescriptors"] = pds + ctx.Data["Total"] = total + ctx.Data["RepositoryAccessMap"] = repositoryAccessMap + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + // TODO: context/org -> HandleOrgAssignment() can not be used + if ctx.ContextUser.IsOrganization() { + org := org_model.OrgFromUser(ctx.ContextUser) + ctx.Data["Org"] = org + ctx.Data["OrgLink"] = ctx.ContextUser.OrganisationLink() + + if ctx.Doer != nil { + ctx.Data["IsOrganizationMember"], _ = org_model.IsOrganizationMember(ctx, org.ID, ctx.Doer.ID) + ctx.Data["IsOrganizationOwner"], _ = org_model.IsOrganizationOwner(ctx, org.ID, ctx.Doer.ID) + } else { + ctx.Data["IsOrganizationMember"] = false + ctx.Data["IsOrganizationOwner"] = false + } + } + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParam(ctx, "q", "Query") + pager.AddParam(ctx, "type", "PackageType") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackagesList) +} + +// RedirectToLastVersion redirects to the latest package version +func RedirectToLastVersion(ctx *context.Context) { + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + ctx.NotFound("GetPackageByName", err) + } else { + ctx.ServerError("GetPackageByName", err) + } + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("GetPackageByName", err) + return + } + if len(pvs) == 0 { + ctx.NotFound("", err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0]) + if err != nil { + ctx.ServerError("GetPackageDescriptor", err) + return + } + + ctx.Redirect(pd.VersionWebLink()) +} + +// ViewPackageVersion displays a single package version +func ViewPackageVersion(ctx *context.Context) { + pd := ctx.Package.Descriptor + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = pd.Package.Name + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = pd + + switch pd.Package.Type { + case packages_model.TypeContainer: + ctx.Data["RegistryHost"] = setting.Packages.RegistryHost + case packages_model.TypeAlpine: + branches := make(container.Set[string]) + repositories := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case alpine_module.PropertyBranch: + branches.Add(pp.Value) + case alpine_module.PropertyRepository: + repositories.Add(pp.Value) + case alpine_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Branches"] = util.Sorted(branches.Values()) + ctx.Data["Repositories"] = util.Sorted(repositories.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + case packages_model.TypeArch: + ctx.Data["RegistryHost"] = setting.Packages.RegistryHost + ctx.Data["SignMail"] = fmt.Sprintf("%s@noreply.%s", ctx.Package.Owner.Name, setting.Packages.RegistryHost) + groups := make(container.Set[string]) + for _, f := range pd.Files { + for _, pp := range f.Properties { + if pp.Name == arch_model.PropertyDistribution { + groups.Add(pp.Value) + } + } + } + ctx.Data["Groups"] = util.Sorted(groups.Values()) + case packages_model.TypeDebian: + distributions := make(container.Set[string]) + components := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case debian_module.PropertyDistribution: + distributions.Add(pp.Value) + case debian_module.PropertyComponent: + components.Add(pp.Value) + case debian_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Distributions"] = util.Sorted(distributions.Values()) + ctx.Data["Components"] = util.Sorted(components.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + case packages_model.TypeRpm: + groups := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case rpm_module.PropertyGroup: + groups.Add(pp.Value) + case rpm_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Groups"] = util.Sorted(groups.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + } + + var ( + total int64 + pvs []*packages_model.PackageVersion + err error + ) + switch pd.Package.Type { + case packages_model.TypeContainer: + pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ + Paginator: db.NewAbsoluteListOptions(0, 5), + PackageID: pd.Package.ID, + IsTagged: true, + }) + default: + pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: db.NewAbsoluteListOptions(0, 5), + PackageID: pd.Package.ID, + IsInternal: optional.Some(false), + }) + } + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["LatestVersions"] = pvs + ctx.Data["TotalVersionCount"] = total + + ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + + hasRepositoryAccess := false + if pd.Repository != nil { + permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + hasRepositoryAccess = permission.HasAccess() + } + ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplPackagesView) +} + +// ListPackageVersions lists all versions of a package +func ListPackageVersions(ctx *context.Context) { + shared_user.PrepareContextForProfileBigAvatar(ctx) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + ctx.NotFound("GetPackageByName", err) + } else { + ctx.ServerError("GetPackageByName", err) + } + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + pagination := &db.ListOptions{ + PageSize: setting.UI.PackagesPagingNum, + Page: page, + } + + query := ctx.FormTrim("q") + sort := ctx.FormTrim("sort") + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ + Package: p, + Owner: ctx.Package.Owner, + } + ctx.Data["Query"] = query + ctx.Data["Sort"] = sort + + pagerParams := map[string]string{ + "q": query, + "sort": sort, + } + + var ( + total int64 + pvs []*packages_model.PackageVersion + ) + switch p.Type { + case packages_model.TypeContainer: + tagged := ctx.FormTrim("tagged") + + pagerParams["tagged"] = tagged + ctx.Data["Tagged"] = tagged + + pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ + Paginator: pagination, + PackageID: p.ID, + Query: query, + IsTagged: tagged == "" || tagged == "tagged", + Sort: sort, + }) + if err != nil { + ctx.ServerError("SearchImageTags", err) + return + } + default: + pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + Paginator: pagination, + PackageID: p.ID, + Version: packages_model.SearchValue{ + ExactMatch: false, + Value: query, + }, + IsInternal: optional.Some(false), + Sort: sort, + }) + if err != nil { + ctx.ServerError("SearchVersions", err) + return + } + } + + ctx.Data["PackageDescriptors"], err = packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.ServerError("GetPackageDescriptors", err) + return + } + + ctx.Data["Total"] = total + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) + for k, v := range pagerParams { + pager.AddParamString(k, v) + } + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplPackageVersionList) +} + +// PackageSettings displays the package settings page +func PackageSettings(ctx *context.Context) { + pd := ctx.Package.Descriptor + + shared_user.RenderUserHeader(ctx) + + ctx.Data["Title"] = pd.Package.Name + ctx.Data["IsPackagesPage"] = true + ctx.Data["PackageDescriptor"] = pd + + repos, _, _ := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: pd.Owner, + Private: true, + }) + ctx.Data["Repos"] = repos + ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + ctx.HTML(http.StatusOK, tplPackagesSettings) +} + +// PackageSettingsPost updates the package settings +func PackageSettingsPost(ctx *context.Context) { + pd := ctx.Package.Descriptor + + form := web.GetForm(ctx).(*forms.PackageSettingForm) + switch form.Action { + case "link": + success := func() bool { + repoID := int64(0) + if form.RepoID != 0 { + repo, err := repo_model.GetRepositoryByID(ctx, form.RepoID) + if err != nil { + log.Error("Error getting repository: %v", err) + return false + } + + if repo.OwnerID != pd.Owner.ID { + return false + } + + repoID = repo.ID + } + + if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, repoID); err != nil { + log.Error("Error updating package: %v", err) + return false + } + + return true + }() + + if success { + ctx.Flash.Success(ctx.Tr("packages.settings.link.success")) + } else { + ctx.Flash.Error(ctx.Tr("packages.settings.link.error")) + } + + ctx.Redirect(ctx.Link) + return + case "delete": + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) + if err != nil { + log.Error("Error deleting package: %v", err) + ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) + } else { + ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) + } + + redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" + // redirect to the package if there are still versions available + if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has { + redirectURL = ctx.Package.Descriptor.PackageWebLink() + } + + ctx.Redirect(redirectURL) + return + } +} + +// DownloadPackageFile serves the content of a package file +func DownloadPackageFile(ctx *context.Context) { + pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.ParamsInt64(":fileid")) + if err != nil { + if err == packages_model.ErrPackageFileNotExist { + ctx.NotFound("", err) + } else { + ctx.ServerError("GetFileForVersionByID", err) + } + return + } + + s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + ctx.ServerError("GetPackageFileStream", err) + return + } + + packages_helper.ServePackageFile(ctx, s, u, pf) +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go new file mode 100644 index 0000000..9cb392d --- /dev/null +++ b/routers/web/user/profile.go @@ -0,0 +1,385 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "fmt" + "net/http" + "path" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/routers/web/org" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplProfileBigAvatar base.TplName = "shared/user/profile_big_avatar" + tplFollowUnfollow base.TplName = "org/follow_unfollow" +) + +// OwnerProfile render profile page for a user or a organization (aka, repo owner) +func OwnerProfile(ctx *context.Context) { + if strings.Contains(ctx.Req.Header.Get("Accept"), "application/rss+xml") { + feed.ShowUserFeedRSS(ctx) + return + } + if strings.Contains(ctx.Req.Header.Get("Accept"), "application/atom+xml") { + feed.ShowUserFeedAtom(ctx) + return + } + + if ctx.ContextUser.IsOrganization() { + org.Home(ctx) + } else { + userProfile(ctx) + } +} + +func userProfile(ctx *context.Context) { + // check view permissions + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound("User not visible", nil) + return + } + + ctx.Data["Title"] = ctx.ContextUser.DisplayName() + ctx.Data["PageIsUserProfile"] = true + + // prepare heatmap data + if setting.Service.EnableUserHeatmap { + data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUser", err) + return + } + ctx.Data["HeatmapData"] = data + ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) + } + + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + + showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileGitRepo, profileReadmeBlob) + // call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.HTML(http.StatusOK, tplProfile) +} + +func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadme *git.Blob) { + // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page + // if there is not a profile readme, the overview tab should be treated as the repositories tab + tab := ctx.FormString("tab") + if tab == "" || tab == "overview" { + if profileReadme != nil { + tab = "overview" + } else { + tab = "repositories" + } + } + ctx.Data["TabName"] = tab + ctx.Data["HasProfileReadme"] = profileReadme != nil + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + pagingNum := setting.UI.User.RepoPagingNum + topicOnly := ctx.FormBool("topic") + var ( + repos []*repo_model.Repository + count int64 + total int + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok { + sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for user home? + } + ctx.Data["SortType"] = sortOrder + orderBy = repo_model.OrderByFlatMap[sortOrder] + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + followers, numFollowers, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{ + PageSize: pagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + ctx.Data["NumFollowers"] = numFollowers + following, numFollowing, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{ + PageSize: pagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetUserFollowing", err) + return + } + ctx.Data["NumFollowing"] = numFollowing + + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + + switch tab { + case "followers": + ctx.Data["Cards"] = followers + total = int(numFollowers) + ctx.Data["CardsTitle"] = ctx.TrN(total, "user.followers.title.one", "user.followers.title.few") + case "following": + ctx.Data["Cards"] = following + total = int(numFollowing) + ctx.Data["CardsTitle"] = ctx.TrN(total, "user.following.title.one", "user.following.title.few") + case "activity": + date := ctx.FormString("date") + pagingNum = setting.UI.FeedPagingNum + items, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: showPrivate, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: date, + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + }) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + ctx.Data["Feeds"] = items + ctx.Data["Date"] = date + + total = int(count) + case "stars": + ctx.Data["PageIsProfileStarList"] = true + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + StarredByID: ctx.ContextUser.ID, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "watching": + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctx.ContextUser.ID, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "overview": + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) + } else { + if profileContent, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + GitRepo: profileGitRepo, + Links: markup.Links{ + // Give the repo link to the markdown render for the full link of media element. + // the media link usually be like /[user]/[repoName]/media/branch/[branchName], + // Eg. /Tom/.profile/media/branch/main + // The branch shown on the profile page is the default branch, this need to be in sync with doc, see: + // https://docs.gitea.com/usage/profile-readme + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, + }, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadme"] = profileContent + } + } + default: // default to "repositories" + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: pagingNum, + Page: page, + }, + Actor: ctx.Doer, + Keyword: keyword, + OwnerID: ctx.ContextUser.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Collaborate: optional.Some(false), + TopicOnly: topicOnly, + Language: language, + IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + } + ctx.Data["Repos"] = repos + ctx.Data["Total"] = total + + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + pager := context.NewPagination(total, pagingNum, page, 5) + pager.SetDefaultParams(ctx) + pager.AddParam(ctx, "tab", "TabName") + if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" { + pager.AddParam(ctx, "language", "Language") + } + if tab == "activity" { + pager.AddParam(ctx, "date", "Date") + } + if archived.Has() { + pager.AddParamString("archived", fmt.Sprint(archived.Value())) + } + if fork.Has() { + pager.AddParamString("fork", fmt.Sprint(fork.Value())) + } + if mirror.Has() { + pager.AddParamString("mirror", fmt.Sprint(mirror.Value())) + } + if template.Has() { + pager.AddParamString("template", fmt.Sprint(template.Value())) + } + if private.Has() { + pager.AddParamString("private", fmt.Sprint(private.Value())) + } + ctx.Data["Page"] = pager +} + +// Action response for follow/unfollow user request +func Action(ctx *context.Context) { + var err error + action := ctx.FormString("action") + + if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") { + log.Error("Cannot perform this action on an organization %q", ctx.FormString("action")) + ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + switch action { + case "follow": + err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "unfollow": + err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "block": + err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "unblock": + err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + } + + if err != nil { + if !errors.Is(err, user_model.ErrBlockedByUser) { + log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + if ctx.ContextUser.IsOrganization() { + ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true) + } else { + ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"), true) + } + } + + if ctx.ContextUser.IsIndividual() { + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.Data["IsHTMX"] = true + ctx.HTML(http.StatusOK, tplProfileBigAvatar) + return + } else if ctx.ContextUser.IsOrganization() { + ctx.Data["Org"] = ctx.ContextUser + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.HTML(http.StatusOK, tplFollowUnfollow) + return + } + log.Error("Failed to apply action %q: unsupported context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) +} diff --git a/routers/web/user/search.go b/routers/web/user/search.go new file mode 100644 index 0000000..fb7729b --- /dev/null +++ b/routers/web/user/search.go @@ -0,0 +1,44 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// Search search users +func Search(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + UID: ctx.FormInt64("uid"), + Type: user_model.UserTypeIndividual, + IsActive: ctx.FormOptionalBool("active"), + ListOptions: listOptions, + }) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": err.Error(), + }) + return + } + + ctx.SetTotalCountHeader(maxResults) + + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": convert.ToUsers(ctx, ctx.Doer, users), + }) +} diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go new file mode 100644 index 0000000..3a2527c --- /dev/null +++ b/routers/web/user/setting/account.go @@ -0,0 +1,344 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "time" + + "code.gitea.io/gitea/models" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/db" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + "code.gitea.io/gitea/services/user" +) + +const ( + tplSettingsAccount base.TplName = "user/settings/account" +) + +// Account renders change user's password, user's email and user suicide page +func Account(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.account") + ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.Doer.Email + ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail + + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) +} + +// AccountPost response for change user's password +func AccountPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + if ctx.Doer.IsPasswordSet() && !ctx.Doer.ValidatePassword(form.OldPassword) { + ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) + } else if form.Password != form.Retype { + ctx.Flash.Error(ctx.Tr("form.password_not_match")) + } else { + opts := &user.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), + } + if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + case errors.Is(err, password.ErrComplexity): + ctx.Flash.Error(password.BuildComplexityError(ctx.Locale)) + case errors.Is(err, password.ErrIsPwned): + ctx.Flash.Error(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")) + case password.IsErrIsPwnedRequest(err): + ctx.Flash.Error(ctx.Tr("auth.password_pwned_err")) + default: + ctx.ServerError("UpdateAuth", err) + return + } + } else { + // Re-generate LTA cookie. + if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 { + if err := ctx.SetLTACookie(ctx.Doer); err != nil { + ctx.ServerError("SetLTACookie", err) + return + } + } + + log.Trace("User password updated: %s", ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + } + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// EmailPost response for change user's email +func EmailPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddEmailForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + // Make emailaddress primary. + if ctx.FormString("_method") == "PRIMARY" { + id := ctx.FormInt64("id") + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil { + ctx.ServerError("MakeEmailPrimary", err) + return + } + + log.Trace("Email made primary: %s", ctx.Doer.Name) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Send activation Email + if ctx.FormString("_method") == "SENDACTIVATION" { + var address string + if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) { + log.Error("Send activation: activation still pending") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + id := ctx.FormInt64("id") + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email == nil { + log.Warn("Send activation failed: EmailAddress[%d] not found for user: %-v", id, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsActivated { + log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsPrimary { + if ctx.Doer.IsActive && !setting.Service.RegisterEmailConfirm { + log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Only fired when the primary email is inactive (Wrong state) + mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) + } else { + mailer.SendActivateEmailMail(ctx.Doer, email.Email) + } + address = email.Email + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Set Email Notification Preference + if ctx.FormString("_method") == "NOTIFICATION" { + preference := ctx.FormString("preference") + if !(preference == user_model.EmailNotificationsEnabled || + preference == user_model.EmailNotificationsOnMention || + preference == user_model.EmailNotificationsDisabled || + preference == user_model.EmailNotificationsAndYourOwn) { + log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) + ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + return + } + opts := &user.UpdateOptions{ + EmailNotificationsPreference: optional.Some(preference), + } + if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { + log.Error("Set Email Notifications failed: %v", err) + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil { + if user_model.IsErrEmailAlreadyUsed(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) + } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form) + } else { + ctx.ServerError("AddEmailAddresses", err) + } + return + } + + // Send confirmation email + if setting.Service.RegisterEmailConfirm { + mailer.SendActivateEmailMail(ctx.Doer, form.Email) + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", form.Email) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteEmail response for delete user's email +func DeleteEmail(ctx *context.Context) { + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) + if err != nil || email == nil { + ctx.ServerError("GetEmailAddressByID", err) + return + } + + if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil { + ctx.ServerError("DeleteEmailAddresses", err) + return + } + log.Trace("Email address deleted: %s", ctx.Doer.Name) + + ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteAccount render user suicide page and response for delete user himself +func DeleteAccount(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) { + ctx.Error(http.StatusNotFound) + return + } + + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { + switch { + case user_model.IsErrUserNotExist(err): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil) + case errors.Is(err, smtp.ErrUnsupportedLoginType): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordNotSet{}): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unset_password"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordInvalid{}): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) + default: + ctx.ServerError("UserSignIn", err) + } + return + } + + // admin should not delete themself + if ctx.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("form.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("form.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrUserOwnPackages(err): + ctx.Flash.Error(ctx.Tr("form.still_own_packages")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + default: + ctx.ServerError("DeleteUser", err) + } + } else { + log.Trace("Account deleted: %s", ctx.Doer.Name) + ctx.Redirect(setting.AppSubURL + "/") + } +} + +func loadAccountData(ctx *context.Context) { + emlist, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + type UserEmail struct { + user_model.EmailAddress + CanBePrimary bool + } + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) + emails := make([]*UserEmail, len(emlist)) + for i, em := range emlist { + var email UserEmail + email.EmailAddress = *em + email.CanBePrimary = em.IsActivated + emails[i] = &email + } + ctx.Data["Emails"] = emails + ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference + ctx.Data["ActivationsPending"] = pendingActivation + ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) + + if setting.Service.UserDeleteWithCommentsMaxTime != 0 { + ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String() + ctx.Data["UserDeleteWithComments"] = ctx.Doer.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now()) + } +} diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go new file mode 100644 index 0000000..9fdc5e4 --- /dev/null +++ b/routers/web/user/setting/account_test.go @@ -0,0 +1,101 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func TestChangePassword(t *testing.T) { + oldPassword := "password" + setting.MinPasswordLength = 6 + pcALL := []string{"lower", "upper", "digit", "spec"} + pcLUN := []string{"lower", "upper", "digit"} + pcLU := []string{"lower", "upper"} + + for _, req := range []struct { + OldPassword string + NewPassword string + Retype string + Message string + PasswordComplexity []string + }{ + { + OldPassword: oldPassword, + NewPassword: "Qwerty123456-", + Retype: "Qwerty123456-", + Message: "", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "12345", + Retype: "12345", + Message: "auth.password_too_short", + PasswordComplexity: pcALL, + }, + { + OldPassword: "12334", + NewPassword: "123456", + Retype: "123456", + Message: "settings.password_incorrect", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "123456", + Retype: "12345", + Message: "form.password_not_match", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcLUN, + }, + { + OldPassword: oldPassword, + NewPassword: "QWERTY", + Retype: "QWERTY", + Message: "form.password_complexity", + PasswordComplexity: pcLU, + }, + } { + t.Run(req.OldPassword+"__"+req.NewPassword, func(t *testing.T) { + unittest.PrepareTestEnv(t) + setting.PasswordComplexity = req.PasswordComplexity + ctx, _ := contexttest.MockContext(t, "user/settings/security") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.ChangePasswordForm{ + OldPassword: req.OldPassword, + Password: req.NewPassword, + Retype: req.Retype, + }) + AccountPost(ctx) + + assert.Contains(t, ctx.Flash.ErrorMsg, req.Message) + assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + }) + } +} diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go new file mode 100644 index 0000000..171c193 --- /dev/null +++ b/routers/web/user/setting/adopt.go @@ -0,0 +1,64 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "path/filepath" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.adopt") + ctx.Data["PageIsSettingsRepos"] = true + allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowAdopt"] = allowAdopt + allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + ctx.Data["allowDelete"] = allowDelete + + dir := ctx.FormString("id") + action := ctx.FormString("action") + + ctxUser := ctx.Doer + root := user_model.UserPath(ctxUser.LowerName) + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + + isDir, err := util.IsDir(filepath.Join(root, dir+".git")) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // Fallthrough to failure mode + } else if action == "adopt" && allowAdopt { + if _, err := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_service.CreateRepoOptions{ + Name: dir, + IsPrivate: true, + }); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir)) + } else if action == "delete" && allowDelete { + if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir)) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/repos") +} diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go new file mode 100644 index 0000000..24ebf9b --- /dev/null +++ b/routers/web/user/setting/applications.go @@ -0,0 +1,115 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsApplications base.TplName = "user/settings/applications" +) + +// Applications render manage access token page +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.applications") + ctx.Data["PageIsSettingsApplications"] = true + + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewAccessTokenForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + + scope, err := form.GetScope() + if err != nil { + ctx.ServerError("GetScope", err) + return + } + t := &auth_model.AccessToken{ + UID: ctx.Doer.ID, + Name: form.Name, + Scope: scope, + } + + exist, err := auth_model.AccessTokenByNameExists(ctx, t) + if err != nil { + ctx.ServerError("AccessTokenByNameExists", err) + return + } + if exist { + ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name)) + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") + return + } + + if err := auth_model.NewAccessToken(ctx, t); err != nil { + ctx.ServerError("NewAccessToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Token) + + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") +} + +// DeleteApplication response for delete user access token +func DeleteApplication(ctx *context.Context) { + if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") +} + +func loadApplicationsData(ctx *context.Context) { + ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled + ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + if setting.OAuth2.Enabled { + ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Doer.ID, + }) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Grants"], err = auth_model.GetOAuth2GrantsByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOAuth2GrantsByUserID", err) + return + } + ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes + } +} diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go new file mode 100644 index 0000000..3f35b2e --- /dev/null +++ b/routers/web/user/setting/blocked_users.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +// BlockedUsers render the blocked users list page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsBlockedUsers"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users" + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +// UnblockUser unblocks a particular user for the doer. +func UnblockUser(ctx *context.Context) { + if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go new file mode 100644 index 0000000..9462be7 --- /dev/null +++ b/routers/web/user/setting/keys.go @@ -0,0 +1,338 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + + 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/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsKeys base.TplName = "user/settings/keys" +) + +// Keys render user's SSH/GPG public keys page +func Keys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.ssh_gpg_keys") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) +} + +// KeysPost response for change user's SSH/GPG keys +func KeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + if ctx.HasError() { + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) + return + } + switch form.Type { + case "principal": + content, err := asymkey_model.CheckPrincipalKeyString(ctx, ctx.Doer, form.Content) + if err != nil { + if db.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if _, err = asymkey_model.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { + ctx.Data["HasPrincipalError"] = true + switch { + case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPrincipalKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "gpg": + 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, ctx.Doer.ID, form.Content, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + keys, err = asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case asymkey_model.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case asymkey_model.IsErrGPGKeyIDAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case asymkey_model.IsErrGPGInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["Err_Content"] = true + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form) + case asymkey_model.IsErrGPGNoEmailFound(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGNoEmailFound).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + keyIDs := "" + for _, key := range keys { + keyIDs += key.KeyID + keyIDs += ", " + } + if len(keyIDs) > 0 { + keyIDs = keyIDs[:len(keyIDs)-2] + } + ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", keyIDs)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "verify_gpg": + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + keyID, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + keyID, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasGPGVerifyError"] = true + switch { + case asymkey_model.IsErrGPGInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["VerifyingID"] = form.KeyID + ctx.Data["Err_Signature"] = true + keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID + ctx.Data["KeyID"] = keyID + ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID) + ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form) + default: + ctx.ServerError("VerifyGPG", err) + } + } + ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + 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.Content) + if err != nil { + if db.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if asymkey_model.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else if err == asymkey_model.ErrKeyIsPrivate { + ctx.Flash.Error(ctx.Tr("form.must_use_public_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + + if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case asymkey_model.IsErrKeyAlreadyExist(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case asymkey_model.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + case asymkey_model.IsErrKeyUnableVerify(err): + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "verify_ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + fingerprint, err := asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, token, form.Signature) + if err != nil && asymkey_model.IsErrSSHInvalidTokenSignature(err) { + fingerprint, err = asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature) + } + if err != nil { + ctx.Data["HasSSHVerifyError"] = true + switch { + case asymkey_model.IsErrSSHInvalidTokenSignature(err): + loadKeysData(ctx) + ctx.Data["Err_Signature"] = true + ctx.Data["Fingerprint"] = err.(asymkey_model.ErrSSHInvalidTokenSignature).Fingerprint + ctx.RenderWithErr(ctx.Tr("settings.ssh_invalid_token_signature"), tplSettingsKeys, &form) + default: + ctx.ServerError("VerifySSH", err) + } + } + ctx.Flash.Success(ctx.Tr("settings.verify_ssh_key_success", fingerprint)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } +} + +// DeleteKey response for delete user's SSH/GPG key +func DeleteKey(ctx *context.Context) { + switch ctx.FormString("type") { + case "gpg": + 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.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + keyID := ctx.FormInt64("id") + external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID) + if err != nil { + ctx.ServerError("sshKeysExternalManaged", err) + return + } + if external { + ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + case "principal": + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success")) + } + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/keys") +} + +func loadKeysData(ctx *context.Context) { + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.Doer.ID, + NotKeytype: asymkey_model.KeyTypePrincipal, + }) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(ctx, keys) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["ExternalKeys"] = externalKeys + + gpgkeys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + }) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + if err := asymkey_model.GPGKeyList(gpgkeys).LoadSubKeys(ctx); err != nil { + ctx.ServerError("LoadSubKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + tokenToSign := asymkey_model.VerificationToken(ctx.Doer, 1) + + // generate a new aes cipher using the csrfToken + ctx.Data["TokenToSign"] = tokenToSign + + principals, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypePrincipal}, + }) + if err != nil { + ctx.ServerError("ListPrincipalKeys", err) + return + } + ctx.Data["Principals"] = principals + + ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg") + ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh") + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) +} diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go new file mode 100644 index 0000000..e398208 --- /dev/null +++ b/routers/web/user/setting/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go new file mode 100644 index 0000000..1f485e0 --- /dev/null +++ b/routers/web/user/setting/oauth2.go @@ -0,0 +1,68 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsOAuthApplicationEdit base.TplName = "user/settings/applications_oauth2_edit" +) + +func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers { + return &OAuth2CommonHandlers{ + OwnerID: userID, + BasePathList: setting.AppSubURL + "/user/settings/applications", + BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2", + TplAppEdit: tplSettingsOAuthApplicationEdit, + } +} + +// OAuthApplicationsPost response for adding a oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.AddApp(ctx) +} + +// OAuthApplicationsEdit response for editing oauth2 application +func OAuthApplicationsEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.EditSave(ctx) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.RegenerateSecret(ctx) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.EditShow(ctx) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.DeleteApp(ctx) +} + +// RevokeOAuth2Grant revokes the grant with the given id +func RevokeOAuth2Grant(ctx *context.Context) { + oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa.RevokeGrant(ctx) +} diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go new file mode 100644 index 0000000..85d1e82 --- /dev/null +++ b/routers/web/user/setting/oauth2_common.go @@ -0,0 +1,163 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +type OAuth2CommonHandlers struct { + OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID + BasePathList string // the base URL for the application list page, eg: "/user/setting/applications" + BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2" + TplAppEdit base.TplName // the template for the application edit page +} + +func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) { + app := ctx.Data["App"].(*auth.OAuth2Application) + ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID) + + if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() { + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + } + + ctx.HTML(http.StatusOK, oa.TplAppEdit) +} + +// AddApp adds an oauth2 application +func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + // go to the application list page + ctx.Redirect(oa.BasePathList) + return + } + + // TODO validate redirect URI + app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ + Name: form.Name, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), + UserID: oa.OwnerID, + ConfidentialClient: form.ConfidentialClient, + }) + if err != nil { + ctx.ServerError("CreateOAuth2Application", err) + return + } + + // render the edit page with secret + ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true) + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + + oa.renderEditPage(ctx) +} + +// EditShow displays the given application +func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id")) + if err != nil { + if auth.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != oa.OwnerID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + oa.renderEditPage(ctx) +} + +// EditSave saves the oauth2 application +func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + + if ctx.HasError() { + oa.renderEditPage(ctx) + return + } + + // TODO validate redirect URI + var err error + if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ + ID: ctx.ParamsInt64("id"), + Name: form.Name, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), + UserID: oa.OwnerID, + ConfidentialClient: form.ConfidentialClient, + }); err != nil { + ctx.ServerError("UpdateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.Redirect(oa.BasePathList) +} + +// RegenerateSecret regenerates the secret +func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id")) + if err != nil { + if auth.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != oa.OwnerID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true) + oa.renderEditPage(ctx) +} + +// DeleteApp deletes the given oauth2 application +func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) { + if err := auth.DeleteOAuth2Application(ctx, ctx.ParamsInt64("id"), oa.OwnerID); err != nil { + ctx.ServerError("DeleteOAuth2Application", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success")) + ctx.JSONRedirect(oa.BasePathList) +} + +// RevokeGrant revokes the grant +func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) { + if err := auth.RevokeOAuth2Grant(ctx, ctx.ParamsInt64("grantId"), oa.OwnerID); err != nil { + ctx.ServerError("RevokeOAuth2Grant", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) + ctx.JSONRedirect(oa.BasePathList) +} diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go new file mode 100644 index 0000000..4132659 --- /dev/null +++ b/routers/web/user/setting/packages.go @@ -0,0 +1,119 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + shared "code.gitea.io/gitea/routers/web/shared/packages" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsPackages base.TplName = "user/settings/packages" + tplSettingsPackagesRuleEdit base.TplName = "user/settings/packages_cleanup_rules_edit" + tplSettingsPackagesRulePreview base.TplName = "user/settings/packages_cleanup_rules_preview" +) + +func Packages(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetPackagesContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackages) +} + +func PackagesRuleAdd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRuleAddContext(ctx) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleEdit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRuleEditContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) +} + +func PackagesRuleAddPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleAddPost( + ctx, + ctx.Doer, + setting.AppSubURL+"/user/settings/packages", + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRuleEditPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.PerformRuleEditPost( + ctx, + ctx.Doer, + setting.AppSubURL+"/user/settings/packages", + tplSettingsPackagesRuleEdit, + ) +} + +func PackagesRulePreview(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.SetRulePreviewContext(ctx, ctx.Doer) + + ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview) +} + +func InitializeCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.InitializeCargoIndex(ctx, ctx.Doer) + + ctx.Redirect(setting.AppSubURL + "/user/settings/packages") +} + +func RebuildCargoIndex(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("packages.title") + ctx.Data["PageIsSettingsPackages"] = true + + shared.RebuildCargoIndex(ctx, ctx.Doer) + + ctx.Redirect(setting.AppSubURL + "/user/settings/packages") +} + +func RegenerateChefKeyPair(ctx *context.Context) { + priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits) + if err != nil { + ctx.ServerError("GenerateKeyPair", err) + return + } + + if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil { + ctx.ServerError("SetUserSetting", err) + return + } + + ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{ + ContentType: "application/x-pem-file", + Filename: ctx.Doer.Name + ".priv", + }) +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go new file mode 100644 index 0000000..907f0f5 --- /dev/null +++ b/routers/web/user/setting/profile.go @@ -0,0 +1,433 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "fmt" + "io" + "math/big" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "code.gitea.io/gitea/models/avatars" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplSettingsProfile base.TplName = "user/settings/profile" + tplSettingsAppearance base.TplName = "user/settings/appearance" + tplSettingsOrganization base.TplName = "user/settings/organization" + tplSettingsRepositories base.TplName = "user/settings/repos" +) + +// must be kept in sync with `web_src/js/features/user-settings.js` +var recognisedPronouns = []string{"", "he/him", "she/her", "they/them", "it/its", "any pronouns"} + +// Profile render user's profile page +func Profile(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.profile") + ctx.Data["PageIsSettingsProfile"] = true + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) + + ctx.HTML(http.StatusOK, tplSettingsProfile) +} + +// ProfilePost response for change user's profile +func ProfilePost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsProfile) + return + } + + form := web.GetForm(ctx).(*forms.UpdateProfileForm) + + if form.Name != "" { + if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + case user_model.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + case db.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name)) + case db.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name)) + case db.IsErrNameCharsNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name)) + default: + ctx.ServerError("RenameUser", err) + return + } + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + KeepEmailPrivate: optional.Some(form.KeepEmailPrivate), + Description: optional.Some(form.Biography), + Pronouns: optional.Some(form.Pronouns), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.Some(form.Visibility), + KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// UpdateAvatarSetting update user's avatar +// FIXME: limit size. +func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *user_model.User) error { + ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal + if len(form.Gravatar) > 0 { + if form.Avatar != nil { + ctxUser.Avatar = avatars.HashEmail(form.Gravatar) + } else { + ctxUser.Avatar = "" + } + ctxUser.AvatarEmail = form.Gravatar + } + + if form.Avatar != nil && form.Avatar.Filename != "" { + fr, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %w", err) + } + defer fr.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + } + + data, err := io.ReadAll(fr) + if err != nil { + return fmt.Errorf("io.ReadAll: %w", err) + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) + } + if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil { + return fmt.Errorf("UploadAvatar: %w", err) + } + } else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" { + // No avatar is uploaded but setting has been changed to enable, + // generate a random one when needed. + if err := user_model.GenerateRandomAvatar(ctx, ctxUser); err != nil { + log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) + } + } + + if err := user_model.UpdateUserCols(ctx, ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { + return fmt.Errorf("UpdateUserCols: %w", err) + } + + return nil +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + if err := UpdateAvatarSetting(ctx, form, ctx.Doer); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + if err := user_service.DeleteAvatar(ctx, ctx.Doer); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings") +} + +// Organization render all the organization of the user +func Organization(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.organization") + ctx.Data["PageIsSettingsOrganization"] = true + + opts := organization.FindOrgOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + }, + UserID: ctx.Doer.ID, + IncludePrivate: ctx.IsSigned, + } + + if opts.Page <= 0 { + opts.Page = 1 + } + + orgs, total, err := db.FindAndCount[organization.Organization](ctx, opts) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + + ctx.Data["Orgs"] = orgs + pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsOrganization) +} + +// Repos display a list of all repositories of the user +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.repos") + ctx.Data["PageIsSettingsRepos"] = true + ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + + opts := db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.FormInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + start := (opts.Page - 1) * opts.PageSize + end := start + opts.PageSize + + adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories) + + ctxUser := ctx.Doer + count := 0 + + if adoptOrDelete { + repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum) + repos := map[string]*repo_model.Repository{} + // We're going to iterate by pagesize. + root := user_model.UserPath(ctxUser.Name) + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !d.IsDir() || path == root { + return nil + } + name := d.Name() + if !strings.HasSuffix(name, ".git") { + return filepath.SkipDir + } + name = name[:len(name)-4] + if repo_model.IsUsableRepoName(name) != nil || strings.ToLower(name) != name { + return filepath.SkipDir + } + if count >= start && count < end { + repoNames = append(repoNames, name) + } + count++ + return filepath.SkipDir + }); err != nil { + ctx.ServerError("filepath.WalkDir", err) + return + } + + userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + Actor: ctxUser, + Private: true, + ListOptions: db.ListOptions{ + Page: 1, + PageSize: setting.UI.Admin.UserPagingNum, + }, + LowerNames: repoNames, + }) + if err != nil { + ctx.ServerError("GetUserRepositories", err) + return + } + for _, repo := range userRepos { + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + repos[repo.LowerName] = repo + } + ctx.Data["Dirs"] = repoNames + ctx.Data["ReposMap"] = repos + } else { + repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + if err != nil { + ctx.ServerError("GetUserRepositories", err) + return + } + count = int(count64) + + for i := range repos { + if repos[i].IsFork { + if err := repos[i].GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + } + + ctx.Data["Repos"] = repos + } + ctx.Data["ContextUser"] = ctxUser + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsRepositories) +} + +// Appearance render user's appearance settings +func Appearance(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.appearance") + ctx.Data["PageIsSettingsAppearance"] = true + + var hiddenCommentTypes *big.Int + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + + ctx.Data["IsCommentTypeGroupChecked"] = func(commentTypeGroup string) bool { + return forms.IsUserHiddenCommentTypeGroupChecked(commentTypeGroup, hiddenCommentTypes) + } + + ctx.HTML(http.StatusOK, tplSettingsAppearance) +} + +// UpdateUIThemePost is used to update users' specific theme +func UpdateUIThemePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateThemeForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAppearance"] = true + + if ctx.HasError() { + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + + if !form.IsThemeExists() { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + + opts := &user_service.UpdateOptions{ + Theme: optional.Some(form.Theme), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + } else { + ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserLang update a user's language +func UpdateUserLang(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateLanguageForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAppearance"] = true + + if form.Language != "" { + if !util.SliceContainsString(setting.Langs, form.Language) { + ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language)) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") + return + } + } + + opts := &user_service.UpdateOptions{ + Language: optional.Some(form.Language), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + // Update the language to the one we just set + middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0) + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserHints updates a user's hints settings +func UpdateUserHints(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateHintsForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAppearance"] = true + + opts := &user_service.UpdateOptions{ + EnableRepoUnitHints: optional.Some(form.EnableRepoUnitHints), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_hints_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} + +// UpdateUserHiddenComments update a user's shown comment types +func UpdateUserHiddenComments(ctx *context.Context) { + err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String()) + if err != nil { + ctx.ServerError("SetUserSetting", err) + return + } + + log.Trace("User settings updated: %s", ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") +} diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go new file mode 100644 index 0000000..2bb10cc --- /dev/null +++ b/routers/web/user/setting/runner.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners") +} diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go new file mode 100644 index 0000000..a145867 --- /dev/null +++ b/routers/web/user/setting/security/2fa.go @@ -0,0 +1,260 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + "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/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. +func RegenerateScratchTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err) + return + } + + if err = auth.UpdateTwoFactor(ctx, t); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DisableTwoFactor deletes the user's 2FA settings. +func DisableTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil { + if auth.IsErrTwoFactorNotEnrolled(err) { + // There is a potential DB race here - we must have been disabled by another request in the intervening period + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) + return + } + + if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendDisabledTOTP", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func twofaGenerateSecretAndQr(ctx *context.Context) bool { + var otpKey *otp.Key + var err error + uri := ctx.Session.Get("twofaUri") + if uri != nil { + otpKey, err = otp.NewKeyFromURL(uri.(string)) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err) + return false + } + } + // Filter unsafe character ':' in issuer + issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "") + if otpKey == nil { + otpKey, err = totp.Generate(totp.GenerateOpts{ + SecretSize: 40, + Issuer: issuer, + AccountName: ctx.Doer.Name, + }) + if err != nil { + ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err) + return false + } + } + + ctx.Data["TwofaSecret"] = otpKey.Secret() + img, err := otpKey.Image(320, 240) + if err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err) + return false + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, img); err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err) + return false + } + + ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + + if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err) + return false + } + + if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err) + return false + } + + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + return true +} + +// EnrollTwoFactor shows the page where the user can enroll into 2FA. +func EnrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled - we should redirect back! + log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer) + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) +} + +// EnrollTwoFactorPost handles enrolling the user into 2FA. +func EnrollTwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if t != nil { + // already enrolled + ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err) + return + } + + if ctx.HasError() { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) + return + } + + secretRaw := ctx.Session.Get("twofaSecret") + if secretRaw == nil { + ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + secret := secretRaw.(string) + if !totp.Validate(form.Passcode, secret) { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + t = &auth.TwoFactor{ + UID: ctx.Doer.ID, + } + err = t.SetSecret(secret) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set secret", err) + return + } + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err) + return + } + + // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used + // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor + if err := ctx.Session.Delete("twofaSecret"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaSecret from the session: Error: %v", err) + } + if err := ctx.Session.Delete("twofaUri"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaUri from the session: Error: %v", err) + } + if err := ctx.Session.Release(); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to save changes to the session: %v", err) + } + + if err := mailer.SendTOTPEnrolled(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendTOTPEnrolled", err) + return + } + + if err = auth.NewTwoFactor(ctx, t); err != nil { + // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. + // If there is a unique constraint fail we should just tolerate the error + ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go new file mode 100644 index 0000000..8f788e1 --- /dev/null +++ b/routers/web/user/setting/security/openid.go @@ -0,0 +1,126 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/openid" + "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/forms" +) + +// OpenIDPost response for change user's openid +func OpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddOpenIDForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.HasError() { + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) + return + } + + // WARNING: specifying a wrong OpenID here could lock + // a user out of her account, would be better to + // verify/confirm the new OpenID before storing it + + // Also, consider allowing for multiple OpenID URIs + + id, err := openid.Normalize(form.Openid) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + form.Openid = id + log.Trace("Normalized id: " + id) + + oids, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + // Check that the OpenID is not already used + for _, obj := range oids { + if obj.URI == id { + loadSecurityData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form) + return + } + } + + redirectTo := setting.AppURL + "user/settings/security" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + ctx.Redirect(url) +} + +func settingsOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.AddOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + oid := &user_model.UserOpenID{UID: ctx.Doer.ID, URI: id} + if err = user_model.AddUserOpenID(ctx, oid); err != nil { + if user_model.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id}) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DeleteOpenID response for delete user's openid +func DeleteOpenID(ctx *context.Context) { + if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { + ctx.ServerError("DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.Doer.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +// ToggleOpenIDVisibility response for toggle visibility of user's openid +func ToggleOpenIDVisibility(ctx *context.Context) { + if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { + ctx.ServerError("ToggleUserOpenIDVisibility", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go new file mode 100644 index 0000000..8d6859a --- /dev/null +++ b/routers/web/user/setting/security/security.go @@ -0,0 +1,148 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + "sort" + + auth_model "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/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsSecurity base.TplName = "user/settings/security/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/security/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.security") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.FormString("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) +} + +// DeleteAccountLink delete a single account link +func DeleteAccountLink(ctx *context.Context) { + id := ctx.FormInt64("id") + if id <= 0 { + ctx.Flash.Error("Account link id is not given") + } else { + if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} + +func loadSecurityData(ctx *context.Context) { + enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + ctx.Data["TOTPEnrolled"] = enrolled + + credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + ctx.Data["WebAuthnCredentials"] = credentials + + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{ + UserID: ctx.Doer.ID, + OrderBy: "login_source_id DESC", + }) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the AuthSource + sources := make(map[*auth_model.Source]string) + for _, externalAccount := range accountLinks { + if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + + type DisplayNamed interface { + DisplayName() string + } + + type Named interface { + Name() string + } + + if displayNamed, ok := authSource.Cfg.(DisplayNamed); ok { + providerDisplayName = displayNamed.DisplayName() + } else if named, ok := authSource.Cfg.(Named); ok { + providerDisplayName = named.Name() + } else { + providerDisplayName = authSource.Name + } + sources[authSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{ + IsActive: optional.None[bool](), + LoginType: auth_model.OAuth2, + }) + if err != nil { + ctx.ServerError("FindSources", err) + return + } + + var orderedOAuth2Names []string + oauth2Providers := make(map[string]oauth2.Provider) + for _, source := range authSources { + provider, err := oauth2.CreateProviderFromSource(source) + if err != nil { + ctx.ServerError("CreateProviderFromSource", err) + return + } + oauth2Providers[source.Name] = provider + if source.IsActive { + orderedOAuth2Names = append(orderedOAuth2Names, source.Name) + } + } + + sort.Strings(orderedOAuth2Names) + + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + + openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid +} diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go new file mode 100644 index 0000000..bfbc06c --- /dev/null +++ b/routers/web/user/setting/security/webauthn.go @@ -0,0 +1,137 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "errors" + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/models/auth" + wa "code.gitea.io/gitea/modules/auth/webauthn" + "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/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// WebAuthnRegister initializes the webauthn registration procedure +func WebAuthnRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) + if form.Name == "" { + // Set name to the hexadecimal of the current time + form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) + } + + cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if cred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + _ = ctx.Session.Delete("webauthnRegistration") + if err := ctx.Session.Set("webauthnName", form.Name); err != nil { + ctx.ServerError("Unable to set session key for webauthnName", err) + return + } + + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer)) + if err != nil { + ctx.ServerError("Unable to BeginRegistration", err) + return + } + + // Save the session data as marshaled JSON + if err = ctx.Session.Set("webauthnRegistration", sessionData); err != nil { + ctx.ServerError("Unable to set session", err) + return + } + + ctx.JSON(http.StatusOK, credentialOptions) +} + +// WebauthnRegisterPost receives the response of the security key +func WebauthnRegisterPost(ctx *context.Context) { + name, ok := ctx.Session.Get("webauthnName").(string) + if !ok || name == "" { + ctx.ServerError("Get webauthnName", errors.New("no webauthnName")) + return + } + + // Load the session data + sessionData, ok := ctx.Session.Get("webauthnRegistration").(*webauthn.SessionData) + if !ok || sessionData == nil { + ctx.ServerError("Get registration", errors.New("no registration")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnRegistration") + }() + + // Verify that the challenge succeeded + cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req) + if err != nil { + if pErr, ok := err.(*protocol.Error); ok { + log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo) + } + ctx.ServerError("CreateCredential", err) + return + } + + dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name) + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + if dbCred != nil { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + + // Create the credential + _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) + if err != nil { + ctx.ServerError("CreateCredential", err) + return + } + _ = ctx.Session.Delete("webauthnName") + + ctx.JSON(http.StatusCreated, cred) +} + +// WebauthnDelete deletes an security key by id +func WebauthnDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) + cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID) + if err != nil || cred.UserID != ctx.Doer.ID { + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { + log.Error("GetWebAuthnCredentialByID: %v", err) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") + return + } + + if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("GetWebAuthnCredentialByID", err) + return + } + + if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil { + ctx.ServerError("SendRemovedSecurityKey", err) + return + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go new file mode 100644 index 0000000..3cc67d9 --- /dev/null +++ b/routers/web/user/setting/webhooks.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + tplSettingsHooks base.TplName = "user/settings/hooks" +) + +// Webhooks render webhook list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["WebhookList"] = webhook_service.List() + ctx.Data["Description"] = ctx.Tr("settings.hooks.desc") + + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListWebhooksByOpts", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/hooks") +} diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go new file mode 100644 index 0000000..38f74ea --- /dev/null +++ b/routers/web/user/stop_watch.go @@ -0,0 +1,40 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "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" +) + +// GetStopwatches get all stopwatches +func GetStopwatches(ctx *context.Context) { + sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + apiSWs, err := convert.ToStopWatches(ctx, sws) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSWs) +} diff --git a/routers/web/user/task.go b/routers/web/user/task.go new file mode 100644 index 0000000..8476767 --- /dev/null +++ b/routers/web/user/task.go @@ -0,0 +1,53 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "strconv" + + admin_model "code.gitea.io/gitea/models/admin" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +// TaskStatus returns task's status +func TaskStatus(ctx *context.Context) { + task, opts, err := admin_model.GetMigratingTaskByID(ctx, ctx.ParamsInt64("task"), ctx.Doer.ID) + if err != nil { + if admin_model.IsErrTaskDoesNotExist(err) { + ctx.JSON(http.StatusNotFound, map[string]any{ + "error": "task `" + strconv.FormatInt(ctx.ParamsInt64("task"), 10) + "` does not exist", + }) + return + } + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "err": err, + }) + return + } + + message := task.Message + + if task.Message != "" && task.Message[0] == '{' { + // assume message is actually a translatable string + var translatableMessage admin_model.TranslatableMessage + if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil { + translatableMessage = admin_model.TranslatableMessage{ + Format: "migrate.migrating_failed.error", + Args: []any{task.Message}, + } + } + message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "status": task.Status, + "message": message, + "repo-id": task.RepoID, + "repo-name": opts.RepoName, + "start": task.StartTime, + "end": task.EndTime, + }) +} diff --git a/routers/web/web.go b/routers/web/web.go new file mode 100644 index 0000000..c268f72 --- /dev/null +++ b/routers/web/web.go @@ -0,0 +1,1658 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + gocontext "context" + "net/http" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/metrics" + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/routers/web/admin" + "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/routers/web/devtest" + "code.gitea.io/gitea/routers/web/events" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/routers/web/misc" + "code.gitea.io/gitea/routers/web/org" + org_setting "code.gitea.io/gitea/routers/web/org/setting" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/routers/web/repo/actions" + "code.gitea.io/gitea/routers/web/repo/badges" + repo_flags "code.gitea.io/gitea/routers/web/repo/flags" + repo_setting "code.gitea.io/gitea/routers/web/repo/setting" + "code.gitea.io/gitea/routers/web/shared/project" + "code.gitea.io/gitea/routers/web/user" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/routers/web/user/setting/security" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/lfs" + + _ "code.gitea.io/gitea/modules/session" // to registers all internal adapters + + "code.forgejo.org/go-chi/captcha" + chi_middleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/klauspost/compress/gzhttp" + "github.com/prometheus/client_golang/prometheus" +) + +var GzipMinSize = gzhttp.DefaultMinSize + +// optionsCorsHandler return a http handler which sets CORS options if enabled by config, it blocks non-CORS OPTIONS requests. +func optionsCorsHandler() func(next http.Handler) http.Handler { + var corsHandler func(next http.Handler) http.Handler + if setting.CORSConfig.Enabled { + corsHandler = cors.Handler(cors.Options{ + AllowedOrigins: setting.CORSConfig.AllowDomain, + AllowedMethods: setting.CORSConfig.Methods, + AllowCredentials: setting.CORSConfig.AllowCredentials, + AllowedHeaders: setting.CORSConfig.Headers, + MaxAge: int(setting.CORSConfig.MaxAge.Seconds()), + }) + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + if corsHandler != nil && r.Header.Get("Access-Control-Request-Method") != "" { + corsHandler(next).ServeHTTP(w, r) + } else { + // it should explicitly deny OPTIONS requests if CORS handler is not executed, to avoid the next GET/POST handler being incorrectly called by the OPTIONS request + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + // for non-OPTIONS requests, call the CORS handler to add some related headers like "Vary" + if corsHandler != nil { + corsHandler(next).ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) + } +} + +// The OAuth2 plugin is expected to be executed first, as it must ignore the user id stored +// in the session (if there is a user id stored in session other plugins might return the user +// object for that id). +// +// The Session plugin is expected to be executed second, in order to skip authentication +// for users that have already signed in. +func buildAuthGroup() *auth_service.Group { + group := auth_service.NewGroup() + group.Add(&auth_service.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers + group.Add(&auth_service.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers + + if setting.Service.EnableReverseProxyAuth { + group.Add(&auth_service.ReverseProxy{}) // reverseproxy should before Session, otherwise the header will be ignored if user has login + } + group.Add(&auth_service.Session{}) + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI + } + + return group +} + +func webAuth(authMethod auth_service.Method) func(*context.Context) { + return func(ctx *context.Context) { + ar, err := common.AuthShared(ctx.Base, ctx.Session, authMethod) + if err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.unauthorized_credentials", "https://codeberg.org/forgejo/forgejo/issues/2809")) + return + } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth + if ctx.Doer == nil { + // ensure the session uid is deleted + _ = ctx.Session.Delete("uid") + } + + ctx.Csrf.PrepareForSessionUser(ctx) + } +} + +// verifyAuthWithOptions checks authentication according to options +func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) { + return func(ctx *context.Context) { + // Check prohibit login users. + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + + if ctx.Doer.MustChangePassword { + if ctx.Req.URL.Path != "/user/settings/change_password" { + if strings.HasPrefix(ctx.Req.UserAgent(), "git") { + ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) + return + } + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + return + } + } else if ctx.Req.URL.Path == "/user/settings/change_password" { + // make sure that the form cannot be accessed by users who don't need this + ctx.Redirect(setting.AppSubURL + "/") + return + } + } + + // Redirect to dashboard (or alternate location) if user tries to visit any non-login page. + if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { + ctx.RedirectToFirst(ctx.FormString("redirect_to")) + return + } + + if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" { + ctx.Csrf.Validate(ctx) + if ctx.Written() { + return + } + } + + if options.SignInRequired { + if !ctx.IsSigned { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + } + + // Redirect to log in page if auto-signin info is provided and has not signed in. + if !options.SignOutRequired && !ctx.IsSigned && + ctx.GetSiteCookie(setting.CookieRememberName) != "" { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + if options.AdminRequired { + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + ctx.Data["PageIsAdmin"] = true + } + } +} + +func ctxDataSet(args ...any) func(ctx *context.Context) { + return func(ctx *context.Context) { + for i := 0; i < len(args); i += 2 { + ctx.Data[args[i].(string)] = args[i+1] + } + } +} + +// Routes returns all web routes +func Routes() *web.Route { + routes := web.NewRoute() + + routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler + routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc()) + routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) + routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) + routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) + routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) + routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png")) + + _ = templates.HTMLRenderer() + + var mid []any + + if setting.EnableGzip { + wrapper, err := gzhttp.NewWrapper(gzhttp.RandomJitter(32, 0, false), gzhttp.MinSize(GzipMinSize)) + if err != nil { + log.Fatal("gzhttp.NewWrapper failed: %v", err) + } + mid = append(mid, wrapper) + } + + if setting.Service.EnableCaptcha { + // The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url + routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Server(captcha.StdWidth, captcha.StdHeight).ServeHTTP)...) + } + + if setting.Metrics.Enabled { + prometheus.MustRegister(metrics.NewCollector()) + routes.Get("/metrics", append(mid, Metrics)...) + } + + routes.Methods("GET,HEAD", "/robots.txt", append(mid, misc.RobotsTxt)...) + routes.Get("/ssh_info", misc.SSHInfo) + routes.Get("/api/healthz", healthcheck.Check) + + mid = append(mid, common.Sessioner(), context.Contexter()) + + // Get user from session if logged in. + mid = append(mid, webAuth(buildAuthGroup())) + + // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route + mid = append(mid, chi_middleware.GetHead) + + if setting.API.EnableSwagger { + // Note: The route is here but no in API routes because it renders a web page + routes.Get("/api/swagger", append(mid, misc.Swagger)...) // Render V1 by default + routes.Get("/api/forgejo/swagger", append(mid, misc.SwaggerForgejo)...) + } + + // TODO: These really seem like things that could be folded into Contexter or as helper functions + mid = append(mid, user.GetNotificationCount) + mid = append(mid, repo.GetActiveStopwatch) + mid = append(mid, goGet) + + others := web.NewRoute() + others.Use(mid...) + registerRoutes(others) + routes.Mount("", others) + return routes +} + +var ignSignInAndCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) + +// registerRoutes register routes +func registerRoutes(m *web.Route) { + reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) + reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true}) + // TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView) + ignSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) + ignExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) + + validation.AddBindingRules() + + linkAccountEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled { + ctx.Error(http.StatusForbidden) + return + } + } + + openIDSignInEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignIn { + ctx.Error(http.StatusForbidden) + return + } + } + + openIDSignUpEnabled := func(ctx *context.Context) { + if !setting.Service.EnableOpenIDSignUp { + ctx.Error(http.StatusForbidden) + return + } + } + + reqMilestonesDashboardPageEnabled := func(ctx *context.Context) { + if !setting.Service.ShowMilestonesDashboardPage { + ctx.Error(http.StatusForbidden) + return + } + } + + // webhooksEnabled requires webhooks to be enabled by admin. + webhooksEnabled := func(ctx *context.Context) { + if setting.DisableWebhooks { + ctx.Error(http.StatusForbidden) + return + } + } + + lfsServerEnabled := func(ctx *context.Context) { + if !setting.LFS.StartServer { + ctx.Error(http.StatusNotFound) + return + } + } + + federationEnabled := func(ctx *context.Context) { + if !setting.Federation.Enabled { + ctx.Error(http.StatusNotFound) + return + } + } + + dlSourceEnabled := func(ctx *context.Context) { + if setting.Repository.DisableDownloadSourceArchives { + ctx.Error(http.StatusNotFound) + return + } + } + + sitemapEnabled := func(ctx *context.Context) { + if !setting.Other.EnableSitemap { + ctx.Error(http.StatusNotFound) + return + } + } + + packagesEnabled := func(ctx *context.Context) { + if !setting.Packages.Enabled { + ctx.Error(http.StatusForbidden) + return + } + } + + feedEnabled := func(ctx *context.Context) { + if !setting.Other.EnableFeed { + ctx.Error(http.StatusNotFound) + return + } + } + + reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) { + return func(ctx *context.Context) { + // only check global disabled units when ignoreGlobal is false + if !ignoreGlobal && unitType.UnitGlobalDisabled() { + ctx.NotFound(unitType.String(), nil) + return + } + + if ctx.ContextUser == nil { + ctx.NotFound(unitType.String(), nil) + return + } + + if ctx.ContextUser.IsOrganization() { + if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode { + ctx.NotFound(unitType.String(), nil) + return + } + } + } + } + + addSettingsVariablesRoutes := func() { + m.Group("/variables", func() { + m.Get("", repo_setting.Variables) + m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate) + m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate) + m.Post("/{variable_id}/delete", repo_setting.VariableDelete) + }) + } + + addSettingsSecretsRoutes := func() { + m.Group("/secrets", func() { + m.Get("", repo_setting.Secrets) + m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.SecretsPost) + m.Post("/delete", repo_setting.SecretsDelete) + }) + } + + addSettingsRunnersRoutes := func() { + m.Group("/runners", func() { + m.Get("", repo_setting.Runners) + m.Combo("/{runnerid}").Get(repo_setting.RunnersEdit). + Post(web.Bind(forms.EditRunnerForm{}), repo_setting.RunnersEditPost) + m.Post("/{runnerid}/delete", repo_setting.RunnerDeletePost) + m.Get("/reset_registration_token", repo_setting.ResetRunnerRegistrationToken) + }) + } + + // FIXME: not all routes need go through same middleware. + // Especially some AJAX requests, we can reduce middleware number to improve performance. + + m.Get("/", Home) + m.Get("/sitemap.xml", sitemapEnabled, ignExploreSignIn, HomeSitemap) + m.Group("/.well-known", func() { + m.Get("/openid-configuration", auth.OIDCWellKnown) + m.Group("", func() { + m.Get("/nodeinfo", NodeInfoLinks) + m.Get("/webfinger", WebfingerQuery) + }, federationEnabled) + m.Get("/change-password", func(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + }) + m.Methods("GET, HEAD", "/*", public.FileHandlerFunc()) + }, optionsCorsHandler()) + + m.Group("/explore", func() { + m.Get("", func(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/explore/repos") + }) + m.Get("/repos", explore.Repos) + m.Get("/repos/sitemap-{idx}.xml", sitemapEnabled, explore.Repos) + m.Get("/users", explore.Users) + m.Get("/users/sitemap-{idx}.xml", sitemapEnabled, explore.Users) + m.Get("/organizations", explore.Organizations) + m.Get("/code", func(ctx *context.Context) { + if unit.TypeCode.UnitGlobalDisabled() { + ctx.NotFound(unit.TypeCode.String(), nil) + return + } + }, explore.Code) + m.Get("/topics/search", explore.TopicSearch) + }, ignExploreSignIn) + m.Group("/issues", func() { + m.Get("", user.Issues) + m.Get("/search", repo.SearchIssues) + }, reqSignIn) + + m.Get("/pulls", reqSignIn, user.Pulls) + m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) + + // ***** START: User ***** + // "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..." + m.Get("/user/login", auth.SignIn) + m.Group("/user", func() { + m.Post("/login", web.Bind(forms.SignInForm{}), auth.SignInPost) + m.Group("", func() { + m.Combo("/login/openid"). + Get(auth.SignInOpenID). + Post(web.Bind(forms.SignInOpenIDForm{}), auth.SignInOpenIDPost) + }, openIDSignInEnabled) + m.Group("/openid", func() { + m.Combo("/connect"). + Get(auth.ConnectOpenID). + Post(web.Bind(forms.ConnectOpenIDForm{}), auth.ConnectOpenIDPost) + m.Group("/register", func() { + m.Combo(""). + Get(auth.RegisterOpenID, openIDSignUpEnabled). + Post(web.Bind(forms.SignUpOpenIDForm{}), auth.RegisterOpenIDPost) + }, openIDSignUpEnabled) + }, openIDSignInEnabled) + m.Get("/sign_up", auth.SignUp) + m.Post("/sign_up", web.Bind(forms.RegisterForm{}), auth.SignUpPost) + m.Get("/link_account", linkAccountEnabled, auth.LinkAccount) + m.Post("/link_account_signin", linkAccountEnabled, web.Bind(forms.SignInForm{}), auth.LinkAccountPostSignIn) + m.Post("/link_account_signup", linkAccountEnabled, web.Bind(forms.RegisterForm{}), auth.LinkAccountPostRegister) + m.Group("/two_factor", func() { + m.Get("", auth.TwoFactor) + m.Post("", web.Bind(forms.TwoFactorAuthForm{}), auth.TwoFactorPost) + m.Get("/scratch", auth.TwoFactorScratch) + m.Post("/scratch", web.Bind(forms.TwoFactorScratchAuthForm{}), auth.TwoFactorScratchPost) + }) + m.Group("/webauthn", func() { + m.Get("", auth.WebAuthn) + m.Get("/assertion", auth.WebAuthnLoginAssertion) + m.Post("/assertion", auth.WebAuthnLoginAssertionPost) + }) + }, reqSignOut) + + m.Any("/user/events", routing.MarkLongPolling, events.Events) + + m.Group("/login/oauth", func() { + m.Get("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) + m.Post("/grant", web.Bind(forms.GrantApplicationForm{}), auth.GrantApplicationOAuth) + // TODO manage redirection + m.Post("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) + }, ignSignInAndCsrf, reqSignIn) + + m.Methods("GET, OPTIONS", "/login/oauth/userinfo", optionsCorsHandler(), ignSignInAndCsrf, auth.InfoOAuth) + m.Methods("POST, OPTIONS", "/login/oauth/access_token", optionsCorsHandler(), web.Bind(forms.AccessTokenForm{}), ignSignInAndCsrf, auth.AccessTokenOAuth) + m.Methods("GET, OPTIONS", "/login/oauth/keys", optionsCorsHandler(), ignSignInAndCsrf, auth.OIDCKeys) + m.Methods("POST, OPTIONS", "/login/oauth/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), ignSignInAndCsrf, auth.IntrospectOAuth) + + m.Group("/user/settings", func() { + m.Get("", user_setting.Profile) + m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost) + m.Get("/change_password", auth.MustChangePassword) + m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost) + m.Post("/avatar/delete", user_setting.DeleteAvatar) + m.Group("/account", func() { + m.Combo("").Get(user_setting.Account).Post(web.Bind(forms.ChangePasswordForm{}), user_setting.AccountPost) + m.Post("/email", web.Bind(forms.AddEmailForm{}), user_setting.EmailPost) + m.Post("/email/delete", user_setting.DeleteEmail) + m.Post("/delete", user_setting.DeleteAccount) + }) + m.Group("/appearance", func() { + m.Get("", user_setting.Appearance) + m.Post("/language", web.Bind(forms.UpdateLanguageForm{}), user_setting.UpdateUserLang) + m.Post("/hints", web.Bind(forms.UpdateHintsForm{}), user_setting.UpdateUserHints) + m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments) + m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost) + }) + m.Group("/security", func() { + m.Get("", security.Security) + m.Group("/two_factor", func() { + m.Post("/regenerate_scratch", security.RegenerateScratchTwoFactor) + m.Post("/disable", security.DisableTwoFactor) + m.Get("/enroll", security.EnrollTwoFactor) + m.Post("/enroll", web.Bind(forms.TwoFactorAuthForm{}), security.EnrollTwoFactorPost) + }) + m.Group("/webauthn", func() { + m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister) + m.Post("/register", security.WebauthnRegisterPost) + m.Post("/delete", web.Bind(forms.WebauthnDeleteForm{}), security.WebauthnDelete) + }) + m.Group("/openid", func() { + m.Post("", web.Bind(forms.AddOpenIDForm{}), security.OpenIDPost) + m.Post("/delete", security.DeleteOpenID) + m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) + }, openIDSignInEnabled) + m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink) + }) + m.Group("/applications/oauth2", func() { + m.Get("/{id}", user_setting.OAuth2ApplicationShow) + m.Post("/{id}", web.Bind(forms.EditOAuth2ApplicationForm{}), user_setting.OAuthApplicationsEdit) + m.Post("/{id}/regenerate_secret", user_setting.OAuthApplicationsRegenerateSecret) + m.Post("", web.Bind(forms.EditOAuth2ApplicationForm{}), user_setting.OAuthApplicationsPost) + m.Post("/{id}/delete", user_setting.DeleteOAuth2Application) + m.Post("/{id}/revoke/{grantId}", user_setting.RevokeOAuth2Grant) + }) + m.Combo("/applications").Get(user_setting.Applications). + Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost) + m.Post("/applications/delete", user_setting.DeleteApplication) + m.Combo("/keys").Get(user_setting.Keys). + Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) + m.Post("/keys/delete", user_setting.DeleteKey) + m.Group("/packages", func() { + m.Get("", user_setting.Packages) + m.Group("/rules", func() { + m.Group("/add", func() { + m.Get("", user_setting.PackagesRuleAdd) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), user_setting.PackagesRuleAddPost) + }) + m.Group("/{id}", func() { + m.Get("", user_setting.PackagesRuleEdit) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), user_setting.PackagesRuleEditPost) + m.Get("/preview", user_setting.PackagesRulePreview) + }) + }) + m.Group("/cargo", func() { + m.Post("/initialize", user_setting.InitializeCargoIndex) + m.Post("/rebuild", user_setting.RebuildCargoIndex) + }) + m.Post("/chef/regenerate_keypair", user_setting.RegenerateChefKeyPair) + }, packagesEnabled) + + m.Group("/actions", func() { + m.Get("", user_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + + m.Get("/organization", user_setting.Organization) + m.Get("/repos", user_setting.Repos) + m.Post("/repos/unadopted", user_setting.AdoptOrDeleteRepository) + + m.Group("/hooks", func() { + m.Get("", user_setting.Webhooks) + m.Post("/delete", user_setting.DeleteWebhook) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("/unblock", user_setting.UnblockUser) + }) + }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) + + m.Group("/user", func() { + m.Get("/activate", auth.Activate) + m.Post("/activate", auth.ActivatePost) + m.Any("/activate_email", auth.ActivateEmail) + m.Get("/avatar/{username}/{size}", user.AvatarByUserName) + m.Get("/recover_account", auth.ResetPasswd) + m.Post("/recover_account", auth.ResetPasswdPost) + m.Get("/forgot_password", auth.ForgotPasswd) + m.Post("/forgot_password", auth.ForgotPasswdPost) + m.Post("/logout", auth.SignOut) + m.Get("/task/{task}", reqSignIn, user.TaskStatus) + m.Get("/stopwatches", reqSignIn, user.GetStopwatches) + m.Get("/search", ignExploreSignIn, user.Search) + m.Group("/oauth2", func() { + m.Get("/{provider}", auth.SignInOAuth) + m.Get("/{provider}/callback", auth.SignInOAuthCallback) + }) + }) + // ***** END: User ***** + + m.Get("/avatar/{hash}", user.AvatarByEmailHash) + + adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true}) + + // ***** START: Admin ***** + m.Group("/admin", func() { + m.Get("", admin.Dashboard) + m.Get("/system_status", admin.SystemStatus) + m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + + if setting.Database.Type.IsMySQL() { + m.Get("/self_check", admin.SelfCheck) + } + + m.Group("/config", func() { + m.Get("", admin.Config) + m.Post("", admin.ChangeConfig) + m.Post("/test_mail", admin.SendTestMail) + m.Post("/test_cache", admin.TestCache) + m.Get("/settings", admin.ConfigSettings) + }) + + m.Group("/monitor", func() { + m.Get("/stats", admin.MonitorStats) + m.Get("/cron", admin.CronTasks) + m.Get("/stacktrace", admin.Stacktrace) + m.Post("/stacktrace/cancel/{pid}", admin.StacktraceCancel) + m.Get("/queue", admin.Queues) + m.Group("/queue/{qid}", func() { + m.Get("", admin.QueueManage) + m.Post("/set", admin.QueueSet) + m.Post("/remove-all-items", admin.QueueRemoveAllItems) + }) + m.Get("/diagnosis", admin.MonitorDiagnosis) + }) + + m.Group("/users", func() { + m.Get("", admin.Users) + m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost) + m.Get("/{userid}", admin.ViewUser) + m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost) + m.Post("/{userid}/delete", admin.DeleteUser) + m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost) + m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) + }) + + m.Group("/emails", func() { + m.Get("", admin.Emails) + m.Post("/activate", admin.ActivateEmail) + m.Post("/delete", admin.DeleteEmail) + }) + + m.Group("/orgs", func() { + m.Get("", admin.Organizations) + }) + + m.Group("/repos", func() { + m.Get("", admin.Repos) + m.Combo("/unadopted").Get(admin.UnadoptedRepos).Post(admin.AdoptOrDeleteRepository) + m.Post("/delete", admin.DeleteRepo) + }) + + m.Group("/packages", func() { + m.Get("", admin.Packages) + m.Post("/delete", admin.DeletePackageVersion) + m.Post("/cleanup", admin.CleanupExpiredData) + }, packagesEnabled) + + m.Group("/hooks", func() { + m.Get("", admin.DefaultOrSystemWebhooks) + m.Post("/delete", admin.DeleteDefaultOrSystemWebhook) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/{configType:default-hooks|system-hooks}", func() { + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + }) + + m.Group("/auths", func() { + m.Get("", admin.Authentications) + m.Combo("/new").Get(admin.NewAuthSource).Post(web.Bind(forms.AuthenticationForm{}), admin.NewAuthSourcePost) + m.Combo("/{authid}").Get(admin.EditAuthSource). + Post(web.Bind(forms.AuthenticationForm{}), admin.EditAuthSourcePost) + m.Post("/{authid}/delete", admin.DeleteAuthSource) + }) + + m.Group("/notices", func() { + m.Get("", admin.Notices) + m.Post("/delete", admin.DeleteNotices) + m.Post("/empty", admin.EmptyNotices) + }) + + m.Group("/applications", func() { + m.Get("", admin.Applications) + m.Post("/oauth2", web.Bind(forms.EditOAuth2ApplicationForm{}), admin.ApplicationsPost) + m.Group("/oauth2/{id}", func() { + m.Combo("").Get(admin.EditApplication).Post(web.Bind(forms.EditOAuth2ApplicationForm{}), admin.EditApplicationPost) + m.Post("/regenerate_secret", admin.ApplicationsRegenerateSecret) + m.Post("/delete", admin.DeleteApplication) + }) + }, func(ctx *context.Context) { + if !setting.OAuth2.Enabled { + ctx.Error(http.StatusForbidden) + return + } + }) + + m.Group("/actions", func() { + m.Get("", admin.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsVariablesRoutes() + }) + }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) + // ***** END: Admin ***** + + m.Group("", func() { + m.Get("/{username}", user.UsernameSubRoute) + m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment) + }, ignSignIn) + + m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action) + + reqRepoAdmin := context.RequireRepoAdmin() + reqRepoCodeWriter := context.RequireRepoWriter(unit.TypeCode) + canEnableEditor := context.CanEnableEditor() + reqRepoCodeReader := context.RequireRepoReader(unit.TypeCode) + reqRepoReleaseWriter := context.RequireRepoWriter(unit.TypeReleases) + reqRepoReleaseReader := context.RequireRepoReader(unit.TypeReleases) + reqRepoWikiWriter := context.RequireRepoWriter(unit.TypeWiki) + reqRepoIssueReader := context.RequireRepoReader(unit.TypeIssues) + reqRepoPullsReader := context.RequireRepoReader(unit.TypePullRequests) + reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(unit.TypeIssues, unit.TypePullRequests) + reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(unit.TypeIssues, unit.TypePullRequests) + reqRepoProjectsReader := context.RequireRepoReader(unit.TypeProjects) + reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects) + reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions) + reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions) + + reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { + ctx.NotFound("", nil) + } + } + } + + individualPermsChecker := func(ctx *context.Context) { + // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. + if ctx.ContextUser.IsIndividual() { + switch { + case ctx.ContextUser.Visibility == structs.VisibleTypePrivate: + if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { + ctx.NotFound("Visit Project", nil) + return + } + case ctx.ContextUser.Visibility == structs.VisibleTypeLimited: + if ctx.Doer == nil { + ctx.NotFound("Visit Project", nil) + return + } + } + } + } + + // ***** START: Organization ***** + m.Group("/org", func() { + m.Group("/{org}", func() { + m.Get("/members", org.Members) + }, context.OrgAssignment()) + }, ignSignIn) + + m.Group("/org", func() { + m.Group("", func() { + m.Get("/create", org.Create) + m.Post("/create", web.Bind(forms.CreateOrgForm{}), org.CreatePost) + }) + + m.Group("/invite/{token}", func() { + m.Get("", org.TeamInvite) + m.Post("", org.TeamInvitePost) + }) + + m.Group("/{org}", func() { + m.Get("/dashboard", user.Dashboard) + m.Get("/dashboard/{team}", user.Dashboard) + m.Get("/issues", user.Issues) + m.Get("/issues/{team}", user.Issues) + m.Get("/pulls", user.Pulls) + m.Get("/pulls/{team}", user.Pulls) + m.Get("/milestones", reqMilestonesDashboardPageEnabled, user.Milestones) + m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones) + m.Post("/members/action/{action}", org.MembersAction) + m.Get("/teams", org.Teams) + }, context.OrgAssignment(true, false, true)) + + m.Group("/{org}", func() { + m.Get("/teams/{team}", org.TeamMembers) + m.Get("/teams/{team}/repositories", org.TeamRepositories) + m.Post("/teams/{team}/action/{action}", org.TeamsAction) + m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction) + }, context.OrgAssignment(true, false, true)) + + // require admin permission + m.Group("/{org}", func() { + m.Get("/teams/-/search", org.SearchTeam) + }, context.OrgAssignment(true, false, false, true)) + + // require owner permission + m.Group("/{org}", func() { + m.Get("/teams/new", org.NewTeam) + m.Post("/teams/new", web.Bind(forms.CreateTeamForm{}), org.NewTeamPost) + m.Get("/teams/{team}/edit", org.EditTeam) + m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) + m.Post("/teams/{team}/delete", org.DeleteTeam) + + m.Group("/settings", func() { + m.Combo("").Get(org.Settings). + Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), org.SettingsAvatar) + m.Post("/avatar/delete", org.SettingsDeleteAvatar) + m.Group("/applications", func() { + m.Get("", org.Applications) + m.Post("/oauth2", web.Bind(forms.EditOAuth2ApplicationForm{}), org.OAuthApplicationsPost) + m.Group("/oauth2/{id}", func() { + m.Combo("").Get(org.OAuth2ApplicationShow).Post(web.Bind(forms.EditOAuth2ApplicationForm{}), org.OAuth2ApplicationEdit) + m.Post("/regenerate_secret", org.OAuthApplicationsRegenerateSecret) + m.Post("/delete", org.DeleteOAuth2Application) + }) + }, func(ctx *context.Context) { + if !setting.OAuth2.Enabled { + ctx.Error(http.StatusForbidden) + return + } + }) + + m.Group("/hooks", func() { + m.Get("", org.Webhooks) + m.Post("/delete", org.DeleteWebhook) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/labels", func() { + m.Get("", org.RetrieveLabels, org.Labels) + m.Post("/new", web.Bind(forms.CreateLabelForm{}), org.NewLabel) + m.Post("/edit", web.Bind(forms.CreateLabelForm{}), org.UpdateLabel) + m.Post("/delete", org.DeleteLabel) + m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels) + }) + + m.Group("/actions", func() { + m.Get("", org_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + + m.Methods("GET,POST", "/delete", org.SettingsDelete) + + m.Group("/blocked_users", func() { + m.Get("", org_setting.BlockedUsers) + m.Post("/block", org_setting.BlockedUsersBlock) + m.Post("/unblock", org_setting.BlockedUsersUnblock) + }) + + m.Group("/packages", func() { + m.Get("", org.Packages) + m.Group("/rules", func() { + m.Group("/add", func() { + m.Get("", org.PackagesRuleAdd) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), org.PackagesRuleAddPost) + }) + m.Group("/{id}", func() { + m.Get("", org.PackagesRuleEdit) + m.Post("", web.Bind(forms.PackageCleanupRuleForm{}), org.PackagesRuleEditPost) + m.Get("/preview", org.PackagesRulePreview) + }) + }) + m.Group("/cargo", func() { + m.Post("/initialize", org.InitializeCargoIndex) + m.Post("/rebuild", org.RebuildCargoIndex) + }) + }, packagesEnabled) + }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) + }, context.OrgAssignment(true, true)) + }, reqSignIn) + // ***** END: Organization ***** + + // ***** START: Repository ***** + m.Group("/repo", func() { + m.Get("/create", repo.Create) + m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost) + m.Get("/migrate", repo.Migrate) + m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost) + if !setting.Repository.DisableForks { + m.Get("/fork/{repoid}", context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader, repo.ForkByID) + } + m.Get("/search", repo.SearchRepo) + }, reqSignIn) + + m.Group("/{username}/-", func() { + if setting.Packages.Enabled { + m.Group("/packages", func() { + m.Get("", user.ListPackages) + m.Group("/{type}/{name}", func() { + m.Get("", user.RedirectToLastVersion) + m.Get("/versions", user.ListPackageVersions) + m.Group("/{version}", func() { + m.Get("", user.ViewPackageVersion) + m.Get("/files/{fileid}", user.DownloadPackageFile) + m.Group("/settings", func() { + m.Get("", user.PackageSettings) + m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + }) + }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + } + + m.Group("/projects", func() { + m.Group("", func() { + m.Get("", org.Projects) + m.Get("/{id}", org.ViewProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { //nolint:dupl + m.Get("/new", org.RenderNewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) + m.Post("/move", project.MoveColumns) + m.Post("/delete", org.DeleteProject) + + m.Get("/edit", org.RenderEditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) + m.Delete("", org.DeleteProjectColumn) + m.Post("/default", org.SetDefaultProjectColumn) + + m.Post("/move", org.MoveIssues) + }) + }) + }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + + m.Group("", func() { + m.Get("/code", user.CodeSearch) + }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker) + }, ignSignIn, context.UserAssignmentWeb(), context.OrgAssignment()) // for "/{username}/-" (packages, projects, code) + + m.Group("/{username}/{reponame}", func() { + m.Group("/settings", func() { + m.Group("", func() { + m.Combo("").Get(repo_setting.Settings). + Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost) + }, repo_setting.SettingsCtxData) + m.Combo("/units").Get(repo_setting.Units). + Post(web.Bind(forms.RepoUnitSettingForm{}), repo_setting.UnitsPost) + m.Post("/avatar", web.Bind(forms.AvatarForm{}), repo_setting.SettingsAvatar) + m.Post("/avatar/delete", repo_setting.SettingsDeleteAvatar) + + m.Group("/collaboration", func() { + m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost) + m.Post("/access_mode", repo_setting.ChangeCollaborationAccessMode) + m.Post("/delete", repo_setting.DeleteCollaboration) + m.Group("/team", func() { + m.Post("", repo_setting.AddTeamPost) + m.Post("/delete", repo_setting.DeleteTeam) + }) + }) + + m.Group("/branches", func() { + m.Post("/", repo_setting.SetDefaultBranchPost) + }, repo.MustBeNotEmpty) + + m.Group("/branches", func() { + m.Get("/", repo_setting.ProtectedBranchRules) + m.Combo("/edit").Get(repo_setting.SettingsProtectedBranch). + Post(web.Bind(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo_setting.SettingsProtectedBranchPost) + m.Post("/{id}/delete", repo_setting.DeleteProtectedBranchRulePost) + }, repo.MustBeNotEmpty) + + m.Post("/rename_branch", web.Bind(forms.RenameBranchForm{}), context.RepoMustNotBeArchived(), repo_setting.RenameBranchPost) + + m.Group("/tags", func() { + m.Get("", repo_setting.ProtectedTags) + m.Post("", web.Bind(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo_setting.NewProtectedTagPost) + m.Post("/delete", context.RepoMustNotBeArchived(), repo_setting.DeleteProtectedTagPost) + m.Get("/{id}", repo_setting.EditProtectedTag) + m.Post("/{id}", web.Bind(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo_setting.EditProtectedTagPost) + }) + + m.Group("/hooks/git", func() { + m.Get("", repo_setting.GitHooks) + m.Combo("/{name}").Get(repo_setting.GitHooksEdit). + Post(repo_setting.GitHooksEditPost) + }, context.GitHookService()) + + m.Group("/hooks", func() { + m.Get("", repo_setting.WebhookList) + m.Post("/delete", repo_setting.WebhookDelete) + m.Get("/{type}/new", repo_setting.WebhookNew) + m.Post("/{type}/new", repo_setting.WebhookCreate) + m.Group("/{id}", func() { + m.Get("", repo_setting.WebhookEdit) + m.Post("", repo_setting.WebhookUpdate) + m.Post("/test", repo_setting.WebhookTest) + m.Post("/replay/{uuid}", repo_setting.WebhookReplay) + }) + }, webhooksEnabled) + + m.Group("/keys", func() { + m.Combo("").Get(repo_setting.DeployKeys). + Post(web.Bind(forms.AddKeyForm{}), repo_setting.DeployKeysPost) + m.Post("/delete", repo_setting.DeleteDeployKey) + }) + + m.Group("/lfs", func() { + m.Get("/", repo_setting.LFSFiles) + m.Get("/show/{oid}", repo_setting.LFSFileGet) + m.Post("/delete/{oid}", repo_setting.LFSDelete) + m.Get("/pointers", repo_setting.LFSPointerFiles) + m.Post("/pointers/associate", repo_setting.LFSAutoAssociate) + m.Get("/find", repo_setting.LFSFileFind) + m.Group("/locks", func() { + m.Get("/", repo_setting.LFSLocks) + m.Post("/", repo_setting.LFSLockFile) + m.Post("/{lid}/unlock", repo_setting.LFSUnlock) + }) + }) + m.Group("/actions", func() { + m.Get("", repo_setting.RedirectToDefaultSetting) + addSettingsRunnersRoutes() + addSettingsSecretsRoutes() + addSettingsVariablesRoutes() + }, actions.MustEnableActions) + // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed + m.Group("/migrate", func() { + m.Post("/retry", repo.MigrateRetryPost) + m.Post("/cancel", repo.MigrateCancelPost) + }) + }, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer)) + }, reqSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoAdmin, context.RepoRef()) + + m.Group("/{username}/{reponame}/action", func() { + m.Post("/watch", repo.ActionWatch(true)) + m.Post("/unwatch", repo.ActionWatch(false)) + m.Post("/accept_transfer", repo.ActionTransfer(true)) + m.Post("/reject_transfer", repo.ActionTransfer(false)) + if !setting.Repository.DisableStars { + m.Post("/star", repo.ActionStar(true)) + m.Post("/unstar", repo.ActionStar(false)) + } + }, reqSignIn, context.RepoAssignment, context.UnitTypes()) + + // Grouping for those endpoints not requiring authentication (but should respect ignSignIn) + m.Group("/{username}/{reponame}", func() { + m.Group("/milestone", func() { + m.Get("/{id}", repo.MilestoneIssuesAndPulls) + }, reqRepoIssuesOrPullsReader, context.RepoRef()) + m.Get("/find/*", repo.FindFiles) + m.Group("/tree-list", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.TreeList) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.TreeList) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.TreeList) + }) + m.Get("/compare", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists, ignSignIn, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) + m.Combo("/compare/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.SetEditorconfigIfExists). + Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). + Post(reqSignIn, context.RepoMustNotBeArchived(), reqRepoPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + m.Group("/{type:issues|pulls}", func() { + m.Group("/{index}", func() { + m.Get("/info", repo.GetIssueInfo) + }) + }) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication + + // Grouping for those endpoints that do require authentication + m.Group("/{username}/{reponame}", func() { + if !setting.Repository.DisableForks { + m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork). + Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost) + } + m.Group("/issues", func() { + m.Group("/new", func() { + m.Combo("").Get(context.RepoRef(), repo.NewIssue). + Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost) + m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) + }) + m.Get("/search", repo.ListIssues) + }, context.RepoMustNotBeArchived(), reqRepoIssueReader) + // FIXME: should use different URLs but mostly same logic for comments of issue and pull request. + // So they can apply their own enable/disable logic on routers. + m.Group("/{type:issues|pulls}", func() { + m.Group("/{index}", func() { + m.Post("/title", repo.UpdateIssueTitle) + m.Post("/content", repo.UpdateIssueContent) + m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) + m.Post("/watch", repo.IssueWatch) + m.Post("/ref", repo.UpdateIssueRef) + m.Post("/pin", reqRepoAdmin, repo.IssuePinOrUnpin) + m.Post("/viewed-files", repo.UpdateViewedFiles) + m.Group("/dependency", func() { + m.Post("/add", repo.AddDependency) + m.Post("/delete", repo.RemoveDependency) + }) + m.Combo("/comments").Post(repo.MustAllowUserComment, web.Bind(forms.CreateCommentForm{}), repo.NewComment) + m.Group("/times", func() { + m.Post("/add", web.Bind(forms.AddTimeManuallyForm{}), repo.AddTimeManually) + m.Post("/{timeid}/delete", repo.DeleteTime) + m.Group("/stopwatch", func() { + m.Post("/toggle", repo.IssueStopwatch) + m.Post("/cancel", repo.CancelStopwatch) + }) + }) + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction) + m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) + m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) + m.Post("/delete", reqRepoAdmin, repo.DeleteIssue) + }, context.RepoMustNotBeArchived()) + m.Group("/{index}", func() { + m.Get("/attachments", repo.GetIssueAttachments) + m.Get("/attachments/{uuid}", repo.GetAttachment) + }) + m.Group("/{index}", func() { + m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory) + }) + + m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) + m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) + m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject) + m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) + m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) + m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) + m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) + m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) + m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.SetShowOutdatedComments, repo.UpdateResolveConversation) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.UploadIssueAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) + m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Post("", repo.UpdateCommentContent) + m.Post("/delete", repo.DeleteComment) + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommentReaction) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Get("/attachments", repo.GetCommentAttachments) + }) + m.Post("/markup", web.Bind(structs.MarkupOption{}), misc.Markup) + m.Group("/labels", func() { + m.Post("/new", web.Bind(forms.CreateLabelForm{}), repo.NewLabel) + m.Post("/edit", web.Bind(forms.CreateLabelForm{}), repo.UpdateLabel) + m.Post("/delete", repo.DeleteLabel) + m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), repo.InitializeLabels) + }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) + m.Group("/milestones", func() { + m.Combo("/new").Get(repo.NewMilestone). + Post(web.Bind(forms.CreateMilestoneForm{}), repo.NewMilestonePost) + m.Get("/{id}/edit", repo.EditMilestone) + m.Post("/{id}/edit", web.Bind(forms.CreateMilestoneForm{}), repo.EditMilestonePost) + m.Post("/{id}/{action}", repo.ChangeMilestoneStatus) + m.Post("/delete", repo.DeleteMilestone) + }, context.RepoMustNotBeArchived(), reqRepoIssuesOrPullsWriter, context.RepoRef()) + m.Group("/pull", func() { + m.Post("/{index}/target_branch", repo.UpdatePullRequestTarget) + }, context.RepoMustNotBeArchived()) + + m.Group("", func() { + m.Group("", func() { + m.Combo("/_edit/*").Get(repo.EditFile). + Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) + m.Combo("/_new/*").Get(repo.NewFile). + Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost) + m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) + m.Combo("/_delete/*").Get(repo.DeleteFile). + Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) + m.Combo("/_upload/*", repo.MustBeAbleToUpload). + Get(repo.UploadFile). + Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) + m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). + Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) + m.Combo("/_cherrypick/{sha:([a-f0-9]{4,64})}/*").Get(repo.CherryPick). + Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) + }, repo.MustBeEditable, repo.CommonEditorData, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + m.Group("", func() { + m.Post("/upload-file", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.UploadFileToServer) + m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) + }, repo.MustBeEditable, repo.MustBeAbleToUpload) + }, context.RepoRef(), canEnableEditor, context.RepoMustNotBeArchived()) + + m.Group("/branches", func() { + m.Group("/_new", func() { + m.Post("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.CreateBranch) + m.Post("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.CreateBranch) + m.Post("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.CreateBranch) + }, web.Bind(forms.NewBranchForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + m.Post("/delete", repo.DeleteBranchPost) + m.Post("/restore", repo.RestoreBranchPost) + }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + }, reqSignIn, context.RepoAssignment, context.UnitTypes()) + + // Tags + m.Group("/{username}/{reponame}", func() { + m.Group("/tags", func() { + m.Get("", repo.TagsList) + m.Get("/list", repo.GetTagList) + m.Get(".rss", feedEnabled, repo.TagsListFeedRSS) + m.Get(".atom", feedEnabled, repo.TagsListFeedAtom) + }, ctxDataSet("EnableFeed", setting.Other.EnableFeed), + repo.MustBeNotEmpty, reqRepoCodeReader, context.RepoRefByType(context.RepoRefTag, true)) + m.Post("/tags/delete", repo.DeleteTag, reqSignIn, + repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoCodeWriter, context.RepoRef()) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + // Releases + m.Group("/{username}/{reponame}", func() { + m.Group("/releases", func() { + m.Get("/", repo.Releases) + m.Get("/tag/*", repo.SingleRelease) + m.Get("/latest", repo.LatestRelease) + m.Get(".rss", feedEnabled, repo.ReleasesFeedRSS) + m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom) + }, ctxDataSet("EnableFeed", setting.Other.EnableFeed), + repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag, true)) + m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment) + m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload) + m.Group("/releases", func() { + m.Combo("/new", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)). + Get(repo.NewRelease). + Post(web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Post("/delete", repo.DeleteRelease) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsReleases, context.QuotaTargetRepo), repo.UploadReleaseAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) + m.Group("/releases", func() { + m.Get("/edit/*", repo.EditRelease) + m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) + }, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader) + + // to maintain compatibility with old attachments + m.Group("/{username}/{reponame}", func() { + m.Get("/attachments/{uuid}", repo.GetAttachment) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + m.Group("/{username}/{reponame}", func() { + m.Post("/topics", repo.TopicsPost) + }, context.RepoAssignment, context.RepoMustNotBeArchived(), reqRepoAdmin) + + m.Group("/{username}/{reponame}", func() { + m.Group("", func() { + m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because other routes like "/pulls/{index}" has higher priority + m.Get("/{type:issues|pulls}", repo.Issues) + m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) + m.Group("/{type:issues|pulls}/{index}/content-history", func() { + m.Get("/overview", repo.GetContentHistoryOverview) + m.Get("/list", repo.GetContentHistoryList) + m.Get("/detail", repo.GetContentHistoryDetail) + }) + m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) + m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) + }, context.RepoRef()) + + if setting.Packages.Enabled { + m.Get("/packages", repo.Packages) + } + + if setting.Badges.Enabled { + m.Group("/badges", func() { + m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge) + m.Group("/issues", func() { + m.Get(".svg", badges.GetTotalIssuesBadge) + m.Get("/open.svg", badges.GetOpenIssuesBadge) + m.Get("/closed.svg", badges.GetClosedIssuesBadge) + }) + m.Group("/pulls", func() { + m.Get(".svg", badges.GetTotalPullsBadge) + m.Get("/open.svg", badges.GetOpenPullsBadge) + m.Get("/closed.svg", badges.GetClosedPullsBadge) + }) + if !setting.Repository.DisableStars { + m.Get("/stars.svg", badges.GetStarsBadge) + } + m.Get("/release.svg", badges.GetLatestReleaseBadge) + }) + } + + m.Group("/projects", func() { + m.Get("", repo.Projects) + m.Get("/{id}", repo.ViewProject) + m.Group("", func() { //nolint:dupl + m.Get("/new", repo.RenderNewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectColumnForm{}), repo.AddColumnToProjectPost) + m.Post("/move", project.MoveColumns) + m.Post("/delete", repo.DeleteProject) + + m.Get("/edit", repo.RenderEditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), repo.EditProjectPost) + m.Post("/{action:open|close}", repo.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", web.Bind(forms.EditProjectColumnForm{}), repo.EditProjectColumn) + m.Delete("", repo.DeleteProjectColumn) + m.Post("/default", repo.SetDefaultProjectColumn) + + m.Post("/move", repo.MoveIssues) + }) + }) + }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) + }, reqRepoProjectsReader, repo.MustEnableProjects) + + m.Group("/actions", func() { + m.Get("", actions.List) + m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) + m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) + m.Post("/manual", reqRepoAdmin, actions.ManualRunWorkflow) + + m.Group("/runs", func() { + m.Get("/latest", actions.ViewLatest) + m.Group("/{run}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Group("/jobs/{job}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) + m.Get("/logs", actions.Logs) + }) + m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) + m.Post("/approve", reqRepoActionsWriter, actions.Approve) + m.Get("/artifacts", actions.ArtifactsView) + m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) + m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) + m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) + }) + }) + + m.Group("/workflows/{workflow_name}", func() { + m.Get("/badge.svg", badges.GetWorkflowBadge) + m.Get("/runs/latest", actions.ViewLatestWorkflowRun) + }) + }, reqRepoActionsReader, actions.MustEnableActions) + + m.Group("/wiki", func() { + m.Combo("/"). + Get(repo.Wiki). + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) + m.Combo("/*"). + Get(repo.Wiki). + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) + m.Get("/commit/{sha:[a-f0-9]{4,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:[a-f0-9]{4,64}}.{ext:patch|diff}", repo.RawDiff) + }, repo.MustEnableWiki, func(ctx *context.Context) { + ctx.Data["PageIsWiki"] = true + ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink() + }) + + m.Group("/wiki", func() { + m.Get("/search", repo.WikiSearchContent) + m.Get("/raw/*", repo.WikiRaw) + }, repo.MustEnableWiki) + + m.Group("/activity", func() { + m.Get("", repo.Activity) + m.Get("/{period}", repo.Activity) + m.Group("/contributors", func() { + m.Get("", repo.Contributors) + m.Get("/data", repo.ContributorsData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + m.Group("/code-frequency", func() { + m.Get("", repo.CodeFrequency) + m.Get("/data", repo.CodeFrequencyData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + m.Group("/recent-commits", func() { + m.Get("", repo.RecentCommits) + m.Get("/data", repo.RecentCommitsData) + }, repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + }, context.RepoRef(), context.RequireRepoReaderOr(unit.TypeCode, unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) + + m.Group("/activity_author_data", func() { + m.Get("", repo.ActivityAuthors) + m.Get("/{period}", repo.ActivityAuthors) + }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode)) + + m.Group("/archive", func() { + m.Get("/*", repo.Download) + m.Post("/*", repo.InitiateDownload) + }, repo.MustBeNotEmpty, dlSourceEnabled, reqRepoCodeReader) + + m.Group("/branches", func() { + m.Get("/list", repo.GetBranchesList) + m.Get("", repo.Branches) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Group("/blob_excerpt", func() { + m.Get("/{sha}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.ExcerptBlob) + }, func(ctx *context.Context) gocontext.CancelFunc { + if ctx.FormBool("wiki") { + ctx.Data["PageIsWiki"] = true + repo.MustEnableWiki(ctx) + return nil + } + + reqRepoCodeReader(ctx) + if ctx.Written() { + return nil + } + cancel := context.RepoRef()(ctx) + if ctx.Written() { + return cancel + } + + repo.MustBeNotEmpty(ctx) + return cancel + }) + + m.Get("/pulls/posters", repo.PullPosters) + m.Group("/pulls/{index}", func() { + m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) + m.Get(".diff", repo.DownloadPullDiff) + m.Get(".patch", repo.DownloadPullPatch) + m.Group("/commits", func() { + m.Get("", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) + m.Get("/list", context.RepoRef(), repo.GetPullCommits) + m.Get("/{sha:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) + }) + m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MergePullRequest) + m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) + m.Post("/update", repo.UpdatePullRequest) + m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) + m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) + m.Group("/files", func() { + m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr) + m.Get("/{sha:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit) + m.Get("/{shaFrom:[a-f0-9]{4,40}}..{shaTo:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) + m.Group("/reviews", func() { + m.Get("/new_comment", repo.RenderNewCodeCommentForm) + m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) + m.Post("/submit", web.Bind(forms.SubmitReviewForm{}), repo.SubmitReview) + }, context.RepoMustNotBeArchived()) + }) + }, repo.MustAllowPulls) + + m.Group("/media", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownloadOrLFS) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownloadOrLFS) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownloadOrLFS) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByIDOrLFS) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownloadOrLFS) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/raw", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownload) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownload) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownload) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByID) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownload) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/render", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RenderFile) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RenderFile) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RenderFile) + m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.RenderFile) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/commits", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RefCommits) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RefCommits) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RefCommits) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.RefCommits) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("/blame", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RefBlame) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RefBlame) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RefBlame) + }, repo.MustBeNotEmpty, reqRepoCodeReader) + + m.Group("", func() { + m.Get("/graph", repo.Graph) + m.Get("/commit/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:([a-f0-9]{4,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Get("/cherry-pick/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick) + }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) + + m.Get("/rss/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("rss")) + m.Get("/atom/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("atom")) + + m.Group("/src", func() { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Home) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.Home) + m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.Home) + // "/*" route is deprecated, and kept for backward compatibility + m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.Home) + }, repo.SetEditorconfigIfExists) + + if !setting.Repository.DisableForks { + m.Group("", func() { + m.Get("/forks", repo.Forks) + }, context.RepoRef(), reqRepoCodeReader) + } + m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit) + + m.Group("/{username}/{reponame}", func() { + if !setting.Repository.DisableStars { + m.Get("/stars", context.RepoRef(), repo.Stars) + } + m.Get("/watchers", context.RepoRef(), repo.Watchers) + m.Group("/search", func() { + m.Get("", context.RepoRef(), repo.Search) + if !setting.Indexer.RepoIndexerEnabled { + m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Search) + m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.Search) + } + }, reqRepoCodeReader) + }, ignSignIn, context.RepoAssignment, context.UnitTypes()) + + m.Group("/{username}", func() { + m.Group("/{reponame}", func() { + m.Get("", repo.SetEditorconfigIfExists, repo.Home) + }, ignSignIn, context.RepoAssignment, context.RepoRef(), context.UnitTypes()) + + m.Group("/{reponame}", func() { + m.Group("/info/lfs", func() { + m.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler) + m.Put("/objects/{oid}/{size}", lfs.UploadHandler) + m.Get("/objects/{oid}/{filename}", lfs.DownloadHandler) + m.Get("/objects/{oid}", lfs.DownloadHandler) + m.Post("/verify", lfs.CheckAcceptMediaType, lfs.VerifyHandler) + m.Group("/locks", func() { + m.Get("/", lfs.GetListLockHandler) + m.Post("/", lfs.PostLockHandler) + m.Post("/verify", lfs.VerifyLockHandler) + m.Post("/{lid}/unlock", lfs.UnLockHandler) + }, lfs.CheckAcceptMediaType) + m.Any("/*", func(ctx *context.Context) { + ctx.NotFound("", nil) + }) + }, ignSignInAndCsrf, lfsServerEnabled) + + gitHTTPRouters(m) + }) + }) + + if setting.Repository.EnableFlags { + m.Group("/{username}/{reponame}/flags", func() { + m.Get("", repo_flags.Manage) + m.Post("", repo_flags.ManagePost) + }, adminReq, context.RepoAssignment, context.UnitTypes()) + } + // ***** END: Repository ***** + + m.Group("/notifications", func() { + m.Get("", user.Notifications) + m.Get("/subscriptions", user.NotificationSubscriptions) + m.Get("/watching", user.NotificationWatching) + m.Post("/status", user.NotificationStatusPost) + m.Post("/purge", user.NotificationPurgePost) + m.Get("/new", user.NewAvailable) + }, reqSignIn) + + if setting.API.EnableSwagger { + m.Get("/swagger.v1.json", SwaggerV1Json) + } + + if !setting.IsProd { + m.Any("/devtest", devtest.List) + m.Any("/devtest/fetch-action-test", devtest.FetchActionTest) + m.Any("/devtest/{sub}", devtest.Tmpl) + } + + m.NotFound(func(w http.ResponseWriter, req *http.Request) { + ctx := context.GetWebContext(req) + ctx.NotFound("", nil) + }) +} diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go new file mode 100644 index 0000000..1f3de70 --- /dev/null +++ b/routers/web/webfinger.go @@ -0,0 +1,167 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4 + +type webfingerJRD struct { + Subject string `json:"subject,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Links []*webfingerLink `json:"links,omitempty"` +} + +type webfingerLink struct { + Rel string `json:"rel,omitempty"` + Type string `json:"type,omitempty"` + Href string `json:"href,omitempty"` + Titles map[string]string `json:"titles,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// WebfingerQuery returns information about a resource +// https://datatracker.ietf.org/doc/html/rfc7565 +func WebfingerQuery(ctx *context.Context) { + appURL, _ := url.Parse(setting.AppURL) + + resource, err := url.Parse(ctx.FormTrim("resource")) + if err != nil { + ctx.Error(http.StatusBadRequest) + return + } + + var u *user_model.User + + switch resource.Scheme { + case "acct": + // allow only the current host + parts := strings.SplitN(resource.Opaque, "@", 2) + if len(parts) != 2 { + ctx.Error(http.StatusBadRequest) + return + } + if parts[1] != appURL.Host { + ctx.Error(http.StatusBadRequest) + return + } + + u, err = user_model.GetUserByName(ctx, parts[0]) + case "mailto": + u, err = user_model.GetUserByEmail(ctx, resource.Opaque) + if u != nil && u.KeepEmailPrivate { + err = user_model.ErrUserNotExist{} + } + case "https", "http": + if resource.Host != appURL.Host { + ctx.Error(http.StatusBadRequest) + return + } + + p := strings.Trim(resource.Path, "/") + if len(p) == 0 { + ctx.Error(http.StatusNotFound) + return + } + + parts := strings.Split(p, "/") + + switch len(parts) { + case 1: // user + u, err = user_model.GetUserByName(ctx, parts[0]) + case 2: // repository + ctx.Error(http.StatusNotFound) + return + + case 3: + switch parts[2] { + case "issues": + ctx.Error(http.StatusNotFound) + return + + case "pulls": + ctx.Error(http.StatusNotFound) + return + + case "projects": + ctx.Error(http.StatusNotFound) + return + + default: + ctx.Error(http.StatusNotFound) + return + } + + default: + ctx.Error(http.StatusNotFound) + return + } + + default: + ctx.Error(http.StatusBadRequest) + return + } + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + log.Error("Error getting user: %s Error: %v", resource.Opaque, err) + ctx.Error(http.StatusInternalServerError) + } + return + } + + if !user_model.IsUserVisibleToViewer(ctx, u, ctx.Doer) { + ctx.Error(http.StatusNotFound) + return + } + + aliases := []string{ + u.HTMLURL(), + appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + } + if !u.KeepEmailPrivate { + aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) + } + + links := []*webfingerLink{ + { + Rel: "http://webfinger.net/rel/profile-page", + Type: "text/html", + Href: u.HTMLURL(), + }, + { + Rel: "http://webfinger.net/rel/avatar", + Href: u.AvatarLink(ctx), + }, + { + Rel: "self", + Type: "application/activity+json", + Href: appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + }, + { + Rel: "http://openid.net/specs/connect/1.0/issuer", + Href: appURL.String(), + }, + } + + ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*") + ctx.JSON(http.StatusOK, &webfingerJRD{ + Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host), + Aliases: aliases, + Links: links, + }) + ctx.Resp.Header().Set("Content-Type", "application/jrd+json") +} -- cgit v1.2.3