diff --git a/.golangci.yml b/.golangci.yml index 342fe97837bf4..22de387facb29 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -86,6 +86,7 @@ linters-settings: - io/ioutil: "use os or io instead" - golang.org/x/exp: "it's experimental and unreliable." - code.gitea.io/gitea/modules/git/internal: "do not use the internal package, use AddXxx function instead" + - gopkg.in/ini.v1: "do not use the ini package, use gitea's config system instead" issues: max-issues-per-linter: 0 diff --git a/build/backport-locales.go b/build/backport-locales.go index 054b623d698e8..0346215348596 100644 --- a/build/backport-locales.go +++ b/build/backport-locales.go @@ -12,7 +12,7 @@ import ( "path/filepath" "strings" - "gopkg.in/ini.v1" + "code.gitea.io/gitea/modules/setting" ) func main() { @@ -22,14 +22,13 @@ func main() { os.Exit(1) } - ini.PrettyFormat = false mustNoErr := func(err error) { if err != nil { panic(err) } } - collectInis := func(ref string) map[string]*ini.File { - inis := map[string]*ini.File{} + collectInis := func(ref string) map[string]setting.ConfigProvider { + inis := map[string]setting.ConfigProvider{} err := filepath.WalkDir("options/locale", func(path string, d os.DirEntry, err error) error { if err != nil { return err @@ -37,10 +36,7 @@ func main() { if d.IsDir() || !strings.HasSuffix(d.Name(), ".ini") { return nil } - cfg, err := ini.LoadSources(ini.LoadOptions{ - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, path) + cfg, err := setting.NewConfigProviderForLocale(path) mustNoErr(err) inis[path] = cfg fmt.Printf("collecting: %s @ %s\n", path, ref) diff --git a/contrib/environment-to-ini/environment-to-ini.go b/contrib/environment-to-ini/environment-to-ini.go index ae8535d89128e..3405d7d429b49 100644 --- a/contrib/environment-to-ini/environment-to-ini.go +++ b/contrib/environment-to-ini/environment-to-ini.go @@ -9,10 +9,8 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "github.com/urfave/cli" - "gopkg.in/ini.v1" ) // EnvironmentPrefix environment variables prefixed with this represent ini values to write @@ -97,19 +95,10 @@ func runEnvironmentToIni(c *cli.Context) error { providedWorkPath := c.String("work-path") setting.SetCustomPathAndConf(providedCustom, providedConf, providedWorkPath) - cfg := ini.Empty() - confFileExists, err := util.IsFile(setting.CustomConf) + cfg, err := setting.NewConfigProviderFromFile(&setting.Options{CustomConf: setting.CustomConf, AllowEmpty: true}) if err != nil { - log.Fatal("Unable to check if %s is a file. Error: %v", setting.CustomConf, err) + log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) } - if confFileExists { - if err := cfg.Append(setting.CustomConf); err != nil { - log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } - } else { - log.Warn("Custom config '%s' not found, ignore this if you're running first time", setting.CustomConf) - } - cfg.NameMapper = ini.SnackCase prefixGitea := c.String("prefix") + "__" suffixFile := "__FILE" diff --git a/docs/content/doc/development/oauth2-provider.en-us.md b/docs/content/doc/development/oauth2-provider.en-us.md index cf045ac2fe677..03833b5ac0182 100644 --- a/docs/content/doc/development/oauth2-provider.en-us.md +++ b/docs/content/doc/development/oauth2-provider.en-us.md @@ -1,5 +1,5 @@ --- -date: "2019-04-19:44:00+01:00" +date: "2023-06-01T08:40:00+08:00" title: "OAuth2 provider" slug: "oauth2-provider" weight: 41 @@ -40,46 +40,47 @@ At the moment Gitea only supports the [**Authorization Code Grant**](https://too - [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) - [OpenID Connect (OIDC)](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) -To use the Authorization Code Grant as a third party application it is required to register a new application via the "Settings" (`/user/settings/applications`) section of the settings. +To use the Authorization Code Grant as a third party application it is required to register a new application via the "Settings" (`/user/settings/applications`) section of the settings. To test or debug you can use the web-tool https://oauthdebugger.com/. ## Scopes -Gitea supports the following scopes for tokens: - -| Name | Description | -| ---- | ----------- | -| **(no scope)** | Grants read-only access to public user profile and public repositories. | -| **repo** | Full control over all repositories. | -|     **repo:status** | Grants read/write access to commit status in all repositories. | -|     **public_repo** | Grants read/write access to public repositories only. | -| **admin:repo_hook** | Grants access to repository hooks of all repositories. This is included in the `repo` scope. | -|     **write:repo_hook** | Grants read/write access to repository hooks | -|     **read:repo_hook** | Grants read-only access to repository hooks | -| **admin:org** | Grants full access to organization settings | -|     **write:org** | Grants read/write access to organization settings | -|     **read:org** | Grants read-only access to organization settings | -| **admin:public_key** | Grants full access for managing public keys | -|     **write:public_key** | Grant read/write access to public keys | -|     **read:public_key** | Grant read-only access to public keys | -| **admin:org_hook** | Grants full access to organizational-level hooks | -| **admin:user_hook** | Grants full access to user-level hooks | -| **notification** | Grants full access to notifications | -| **user** | Grants full access to user profile info | -|     **read:user** | Grants read access to user's profile | -|     **user:email** | Grants read access to user's email addresses | -|     **user:follow** | Grants access to follow/un-follow a user | -| **delete_repo** | Grants access to delete repositories as an admin | -| **package** | Grants full access to hosted packages | -|     **write:package** | Grants read/write access to packages | -|     **read:package** | Grants read access to packages | -|     **delete:package** | Grants delete access to packages | -| **admin:gpg_key** | Grants full access for managing GPG keys | -|     **write:gpg_key** | Grants read/write access to GPG keys | -|     **read:gpg_key** | Grants read-only access to GPG keys | -| **admin:application** | Grants full access to manage applications | -|     **write:application** | Grants read/write access for managing applications | -|     **read:application** | Grants read access for managing applications | -| **sudo** | Allows to perform actions as the site admin. | +Gitea supports scoped access tokens, which allow users the ability to restrict tokens to operate only on selected url routes. Scopes are grouped by high-level API routes, and further refined to the following: + +- `read`: `GET` routes +- `write`: `POST`, `PUT`, `PATCH`, and `DELETE` routes (in addition to `GET`) + +Gitea token scopes are as follows: + +| Name | Description | +| ---- |--------------------------------------------------------------------------------------------------------------------------------------------------| +| **(no scope)** | Not supported. A scope is required even for public repositories. | +| **activitypub** | `activitypub` API routes: ActivityPub related operations. | +|     **read:activitypub** | Grants read access for ActivityPub operations. | +|     **write:activitypub** | Grants read/write/delete access for ActivityPub operations. | +| **admin** | `/admin/*` API routes: Site-wide administrative operations (hidden for non-admin accounts). | +|     **read:admin** | Grants read access for admin operations, such as getting cron jobs or registered user emails. | +|     **write:admin** | Grants read/write/delete access for admin operations, such as running cron jobs or updating user accounts. | | +| **issue** | `issues/*`, `labels/*`, `milestones/*` API routes: Issue-related operations. | +|     **read:issue** | Grants read access for issues operations, such as getting issue comments, issue attachments, and milestones. | +|     **write:issue** | Grants read/write/delete access for issues operations, such as posting or editing an issue comment or attachment, and updating milestones. | +| **misc** | miscellaneous and settings top-level API routes. | +|     **read:misc** | Grants read access to miscellaneous operations, such as getting label and gitignore templates. | +|     **write:misc** | Grants read/write/delete access to miscellaneous operations, such as markup utility operations. | +| **notification** | `notification/*` API routes: user notification operations. | +|     **read:notification** | Grants read access to user notifications, such as which notifications users are subscribed to and read new notifications. | +|     **write:notification** | Grants read/write/delete access to user notifications, such as marking notifications as read. | +| **organization** | `orgs/*` and `teams/*` API routes: Organization and team management operations. | +|     **read:organization** | Grants read access to org and team status, such as listing all orgs a user has visibility to, teams, and team members. | +|     **write:organization** | Grants read/write/delete access to org and team status, such as creating and updating teams and updating org settings. | +| **package** | `/packages/*` API routes: Packages operations | +|     **read:package** | Grants read access to package operations, such as reading and downloading available packages. | +|     **write:package** | Grants read/write/delete access to package operations. Currently the same as `read:package`. | +| **repository** | `/repos/*` API routes except `/repos/issues/*`: Repository file, pull-request, and release operations. | +|     **read:repository** | Grants read access to repository operations, such as getting repository files, releases, collaborators. | +|     **write:repository** | Grants read/write/delete access to repository operations, such as getting updating repository files, creating pull requests, updating collaborators. | +| **user** | `/user/*` and `/users/*` API routes: User-related operations. | +|     **read:user** | Grants read access to user operations, such as getting user repo subscriptions and user settings. | +|     **write:user** | Grants read/write/delete access to user operations, such as updating user repo subscriptions, followed users, and user settings. | ## Client types @@ -87,17 +88,19 @@ Gitea supports both confidential and public client types, [as defined by RFC 674 For public clients, a redirect URI of a loopback IP address such as `http://127.0.0.1/` allows any port. Avoid using `localhost`, [as recommended by RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252#section-8.3). -## Example +## Examples + +### Confidential client **Note:** This example does not use PKCE. -1. Redirect to user to the authorization endpoint in order to get their consent for accessing the resources: +1. Redirect the user to the authorization endpoint in order to get their consent for accessing the resources: ```curl https://[YOUR-GITEA-URL]/login/oauth/authorize?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE ``` - The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be send back to your application after the user authorizes. The `state` parameter is optional but should be used to prevent CSRF attacks. + The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be sent back to your application after the user authorizes. The `state` parameter is optional, but should be used to prevent CSRF attacks. ![Authorization Page](/authorize.png) @@ -107,7 +110,7 @@ For public clients, a redirect URI of a loopback IP address such as `http://127. https://[REDIRECT_URI]?code=RETURNED_CODE&state=STATE ``` -2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoints accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: +2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoint accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: ```curl POST https://[YOUR-GITEA-URL]/login/oauth/access_token @@ -134,7 +137,69 @@ For public clients, a redirect URI of a loopback IP address such as `http://127. } ``` - The `CLIENT_SECRET` is the unique secret code generated for this application. Please note that the secret will only be visible after you created/registered the application with Gitea and cannot be recovered. If you lose the secret you must regenerate the secret via the application's settings. + The `CLIENT_SECRET` is the unique secret code generated for this application. Please note that the secret will only be visible after you created/registered the application with Gitea and cannot be recovered. If you lose the secret, you must regenerate the secret via the application's settings. + + The `REDIRECT_URI` in the `access_token` request must match the `REDIRECT_URI` in the `authorize` request. + +3. Use the `access_token` to make [API requests](https://docs.gitea.io/en-us/api-usage#oauth2) to access the user's resources. + +### Public client (PKCE) + +PKCE (Proof Key for Code Exchange) is an extension to the OAuth flow which allows for a secure credential exchange without the requirement to provide a client secret. + +**Note**: Please ensure you have registered your OAuth application as a public client. + +To achieve this, you have to provide a `code_verifier` for every authorization request. A `code_verifier` has to be a random string with a minimum length of 43 characters and a maximum length of 128 characters. It can contain alphanumeric characters as well as the characters `-`, `.`, `_` and `~`. + +Using this `code_verifier` string, a new one called `code_challenge` is created by using one of two methods: + +- If you have the required functionality on your client, set `code_challenge` to be a URL-safe base64-encoded string of the SHA256 hash of `code_verifier`. In that case, your `code_challenge_method` becomes `S256`. +- If you are unable to do so, you can provide your `code_verifier` as a plain string to `code_challenge`. Then you have to set your `code_challenge_method` as `plain`. + +After you have generated this values, you can continue with your request. + +1. Redirect the user to the authorization endpoint in order to get their consent for accessing the resources: + + ```curl + https://[YOUR-GITEA-URL]/login/oauth/authorize?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&response_type=code&code_challenge_method=CODE_CHALLENGE_METHOD&code_challenge=CODE_CHALLENGE&state=STATE + ``` + + The `CLIENT_ID` can be obtained by registering an application in the settings. The `STATE` is a random string that will be sent back to your application after the user authorizes. The `state` parameter is optional, but should be used to prevent CSRF attacks. + + ![Authorization Page](/authorize.png) + + The user will now be asked to authorize your application. If they authorize it, the user will be redirected to the `REDIRECT_URL`, for example: + + ```curl + https://[REDIRECT_URI]?code=RETURNED_CODE&state=STATE + ``` + +2. Using the provided `code` from the redirect, you can request a new application and refresh token. The access token endpoint accepts POST requests with `application/json` and `application/x-www-form-urlencoded` body, for example: + + ```curl + POST https://[YOUR-GITEA-URL]/login/oauth/access_token + ``` + + ```json + { + "client_id": "YOUR_CLIENT_ID", + "code": "RETURNED_CODE", + "grant_type": "authorization_code", + "redirect_uri": "REDIRECT_URI", + "code_verifier": "CODE_VERIFIER", + } + ``` + + Response: + + ```json + { + "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJnbnQiOjIsInR0IjowLCJleHAiOjE1NTUxNzk5MTIsImlhdCI6MTU1NTE3NjMxMn0.0-iFsAwBtxuckA0sNZ6QpBQmywVPz129u75vOM7wPJecw5wqGyBkmstfJHAjEOqrAf_V5Z-1QYeCh_Cz4RiKug", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJnbnQiOjIsInR0IjoxLCJjbnQiOjEsImV4cCI6MTU1NzgwNDMxMiwiaWF0IjoxNTU1MTc2MzEyfQ.S_HZQBy4q9r5SEzNGNIoFClT43HPNDbUdHH-GYNYYdkRfft6XptJBkUQscZsGxOW975Yk6RbgtGvq1nkEcklOw" + } + ``` The `REDIRECT_URI` in the `access_token` request must match the `REDIRECT_URI` in the `authorize` request. diff --git a/docs/content/doc/installation/from-source.en-us.md b/docs/content/doc/installation/from-source.en-us.md index e0be7f2f14463..dfded3d9fd1b4 100644 --- a/docs/content/doc/installation/from-source.en-us.md +++ b/docs/content/doc/installation/from-source.en-us.md @@ -132,6 +132,8 @@ If pre-built frontend files are present it is possible to only build the backend TAGS="bindata" make backend ``` +Webpack source maps are by default enabled in development builds and disabled in production builds. They can be enabled by setting the `ENABLE_SOURCEMAP=true` environment variable. + ## Test After following the steps above, a `gitea` binary will be available in the working directory. diff --git a/docs/content/doc/usage/actions/act-runner.en-us.md b/docs/content/doc/usage/actions/act-runner.en-us.md index ef8ed35019ffa..b654fe2efa4e5 100644 --- a/docs/content/doc/usage/actions/act-runner.en-us.md +++ b/docs/content/doc/usage/actions/act-runner.en-us.md @@ -172,6 +172,40 @@ It is because the act runner will run jobs in docker containers, so it needs to As mentioned, you can remove it if you want to run jobs in the host directly. To be clear, the "host" actually means the container which is running the act runner now, instead of the host machine. +### Configuring cache when starting a Runner using docker image + +If you do not intend to use `actions/cache` in workflow, you can ignore this section. + +If you use `actions/cache` without any additional configuration, it will return the following error: +> Failed to restore: getCacheEntry failed: connect ETIMEDOUT IP:PORT + +The error occurs because the runner container and job container are on different networks, so the job container cannot access the runner container. + +Therefore, it is essential to configure the cache action to ensure its proper functioning. Follow these steps: + +- 1.Obtain the LAN IP address of the host machine where the runner container is running. +- 2.Find an available port number on the host machine where the runner container is running. +- 3.Configure the following settings in the configuration file: + +```yaml +cache: + enabled: true + dir: "" + # Use the LAN IP obtained in step 1 + host: "192.168.8.17" + # Use the port number obtained in step 2 + port: 8088 +``` + +- 4.When starting the container, map the cache port to the host machine: + +```bash +docker run \ + --name gitea-docker-runner \ + -p 8088:8088 \ + -d gitea/act_runner:nightly +``` + ### Labels The labels of a runner are used to determine which jobs the runner can run, and how to run them. diff --git a/docs/content/doc/usage/actions/act-runner.zh-cn.md b/docs/content/doc/usage/actions/act-runner.zh-cn.md index dc73f4cd6fb89..8d0899feac7e8 100644 --- a/docs/content/doc/usage/actions/act-runner.zh-cn.md +++ b/docs/content/doc/usage/actions/act-runner.zh-cn.md @@ -169,6 +169,39 @@ docker run \ 如前所述,如果要在主机上直接运行Job,可以将其移除。 需要明确的是,这里的 "主机" 实际上指的是当前运行 Act Runner的容器,而不是主机机器本身。 +### 当您使用 Docker 镜像启动 Runner,如何配置 Cache + +如果你不打算在工作流中使用 `actions/cache`,你可以忽略本段。 + +如果您在使用 `actions/cache` 时没有进行额外的配置,将会返回以下错误信息: +> Failed to restore: getCacheEntry failed: connect ETIMEDOUT IP:PORT + +这个错误的原因是 runner 容器和作业容器位于不同的网络中,因此作业容器无法访问 runner 容器。 +因此,配置 cache 动作以确保其正常运行是非常重要的。请按照以下步骤操作: + +- 1.获取 Runner 容器所在主机的 LAN(本地局域网) IP 地址。 +- 2.获取一个 Runner 容器所在主机的空闲端口号。 +- 3.在配置文件中如下配置: + +```yaml +cache: +enabled: true +dir: "" +# 使用步骤 1. 获取的 LAN IP +host: "192.168.8.17" +# 使用步骤 2. 获取的端口号 +port: 8088 +``` + +- 4.启动容器时, 将 Cache 端口映射至主机。 + +```bash +docker run \ + --name gitea-docker-runner \ + -p 8088:8088 \ + -d gitea/act_runner:nightly +``` + ### 标签 Runner的标签用于确定Runner可以运行哪些Job以及如何运行它们。 diff --git a/docs/content/doc/usage/labels.en-us.md b/docs/content/doc/usage/labels.en-us.md index bf60951d8c679..8467f7e037243 100644 --- a/docs/content/doc/usage/labels.en-us.md +++ b/docs/content/doc/usage/labels.en-us.md @@ -27,7 +27,7 @@ For organizations, you can define organization-wide labels that are shared with Labels have a mandatory name, a mandatory color, an optional description, and must either be exclusive or not (see `Scoped Labels` below). -When you create a repository, you can ensure certain labels exist by using the `Issue Labels` option. This option lists a number of available label sets that are [configured globally on your instance](../customizing-gitea/#labels). Its contained labels will all be created as well while creating the repository. +When you create a repository, you can ensure certain labels exist by using the `Issue Labels` option. This option lists a number of available label sets that are [configured globally on your instance](../administration/customizing-gitea/#labels). Its contained labels will all be created as well while creating the repository. ## Scoped Labels diff --git a/docs/content/doc/usage/labels.zh-cn.md b/docs/content/doc/usage/labels.zh-cn.md index 10fef72e75024..07dd2bf854371 100644 --- a/docs/content/doc/usage/labels.zh-cn.md +++ b/docs/content/doc/usage/labels.zh-cn.md @@ -27,7 +27,7 @@ menu: 标签具有必填的名称和颜色,可选的描述,以及必须是独占的或非独占的(见下面的“作用域标签”)。 -当您创建一个仓库时,可以通过使用 `工单标签(Issue Labels)` 选项来选择标签集。该选项列出了一些在您的实例上 [全局配置的可用标签集](../customizing-gitea/#labels)。在创建仓库时,这些标签也将被创建。 +当您创建一个仓库时,可以通过使用 `工单标签(Issue Labels)` 选项来选择标签集。该选项列出了一些在您的实例上 [全局配置的可用标签集](../administration/customizing-gitea/#labels)。在创建仓库时,这些标签也将被创建。 ## 作用域标签 diff --git a/docs/content/doc/usage/permissions.en-us.md b/docs/content/doc/usage/permissions.en-us.md index 0778f6943f7fe..655c67de86c1e 100644 --- a/docs/content/doc/usage/permissions.en-us.md +++ b/docs/content/doc/usage/permissions.en-us.md @@ -25,7 +25,7 @@ Gitea supports permissions for repository so that you can give different access ## Unit -In Gitea, we call a sub module of a repository `Unit`. Now we have following units. +In Gitea, we call a sub module of a repository `Unit`. Now we have following possible units. | Name | Description | Permissions | | --------------- | ---------------------------------------------------- | ----------- | @@ -37,6 +37,8 @@ In Gitea, we call a sub module of a repository `Unit`. Now we have following uni | ExternalWiki | Link to an external wiki | Read | | ExternalTracker | Link to an external issue tracker | Read | | Projects | The URL to the template repository | Read Write | +| Packages | Packages which linked to this repository | Read Write | +| Actions | Review actions logs or restart/cacnel pipelines | Read Write | | Settings | Manage the repository | Admin | With different permissions, people could do different things with these units. @@ -51,6 +53,8 @@ With different permissions, people could do different things with these units. | ExternalWiki | Link to an external wiki | - | - | | ExternalTracker | Link to an external issue tracker | - | - | | Projects | View the boards | Change issues across boards | - | +| Packages | View the packages | Upload/Delete packages | - | +| Actions | View the Actions logs | Approve / Cancel / Restart | - | | Settings | - | - | Manage the repository | And there are some differences for permissions between individual repositories and organization repositories. @@ -60,16 +64,27 @@ And there are some differences for permissions between individual repositories a For individual repositories, the creators are the only owners of repositories and have no limit to change anything of this repository or delete it. Repositories owners could add collaborators to help maintain the repositories. Collaborators could have `Read`, `Write` and `Admin` permissions. +For a private repository, the experience is similar to visiting an anonymous public repository. You have access to all the available content within the repository, including the ability to clone the code, create issues, respond to issue comments, submit pull requests, and more. If you have 'Write' permission, you can push code to specific branches of the repository, provided it's permitted by the branch protection rules. Additionally, you can make changes to the wiki pages. With 'Admin' permission, you have the ability to modify the repository's settings. + +But you cannot delete or transfer this repository if you are not that repository's owner. + ## Organization Repository -Different from individual repositories, the owner of organization repositories are the owner team of this organization. +For individual repositories, the owner is the user who created it. For organization repositories, the owners are the members of the owner team on this organization. All the permissions depends on the team permission settings. + +### Owner Team + +The owner team will be created when the organization is created, and the creator will become the first member of the owner team. The owner team cannot be deleted and there is at least one member. + +### Admin Team + +When creating teams, there are two types of teams. One is the admin team, another is the general team. An admin team can be created to manage some of the repositories, whose members can do anything with these repositories. Only members of the owner or admin team can create a new team. + +### General Team -### Team +A general team in an organization has unit permissions settings. It can have members and repositories scope. -A team in an organization has unit permissions settings. It can have members and repositories scope. A team could access all the repositories in this organization or special repositories changed by the owner team. A team could also be allowed to create new -repositories. +- A team could access all the repositories in this organization or special repositories. +- A team could also be allowed to create new repositories or not. -The owner team will be created when the organization is created, and the creator will become the first member of the owner team. -Every member of an organization must be in at least one team. The owner team cannot be deleted and only -members of the owner team can create a new team. An admin team can be created to manage some of the repositories, whose members can do anything with these repositories. -The Generate team can be created by the owner team to do the operations allowed by their permissions. +The General team can be created to do the operations allowed by their permissions. One member could join multiple teams. diff --git a/models/activities/statistic.go b/models/activities/statistic.go index 138f4d8fe9e92..9d379cd0c4f1a 100644 --- a/models/activities/statistic.go +++ b/models/activities/statistic.go @@ -21,7 +21,7 @@ import ( type Statistic struct { Counter struct { User, Org, PublicKey, - Repo, Watch, Star, Action, Access, + Repo, Watch, Star, Access, Issue, IssueClosed, IssueOpen, Comment, Oauth, Follow, Mirror, Release, AuthSource, Webhook, @@ -55,7 +55,6 @@ func GetStatistic() (stats Statistic) { stats.Counter.Repo, _ = repo_model.CountRepositories(db.DefaultContext, repo_model.CountRepositoryOptions{}) stats.Counter.Watch, _ = e.Count(new(repo_model.Watch)) stats.Counter.Star, _ = e.Count(new(repo_model.Star)) - stats.Counter.Action, _ = db.EstimateCount(db.DefaultContext, new(Action)) stats.Counter.Access, _ = e.Count(new(access_model.Access)) type IssueCount struct { @@ -83,7 +82,7 @@ func GetStatistic() (stats Statistic) { Find(&stats.Counter.IssueByRepository) } - issueCounts := []IssueCount{} + var issueCounts []IssueCount _ = e.Select("COUNT(*) AS count, is_closed").Table("issue").GroupBy("is_closed").Find(&issueCounts) for _, c := range issueCounts { diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index bda0668c45f55..53a5c28b4a59e 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -51,14 +51,6 @@ func (app *OAuth2Application) TableName() string { return "oauth2_application" } -// PrimaryRedirectURI returns the first redirect uri or an empty string if empty -func (app *OAuth2Application) PrimaryRedirectURI() string { - if len(app.RedirectURIs) == 0 { - return "" - } - return app.RedirectURIs[0] -} - // ContainsRedirectURI checks if redirectURI is allowed for app func (app *OAuth2Application) ContainsRedirectURI(redirectURI string) bool { if !app.ConfidentialClient { diff --git a/models/auth/token.go b/models/auth/token.go index 3f9f117f73377..9374fe38c227c 100644 --- a/models/auth/token.go +++ b/models/auth/token.go @@ -112,6 +112,15 @@ func NewAccessToken(t *AccessToken) error { return err } +// DisplayPublicOnly whether to display this as a public-only token. +func (t *AccessToken) DisplayPublicOnly() bool { + publicOnly, err := t.Scope.PublicOnly() + if err != nil { + return false + } + return publicOnly +} + func getAccessTokenIDFromCache(token string) int64 { if successfulAccessTokenCache == nil { return 0 diff --git a/models/auth/token_scope.go b/models/auth/token_scope.go index 06c89fecc2e4d..61e684ea272bd 100644 --- a/models/auth/token_scope.go +++ b/models/auth/token_scope.go @@ -6,113 +6,122 @@ package auth import ( "fmt" "strings" + + "code.gitea.io/gitea/models/perm" ) -// AccessTokenScope represents the scope for an access token. -type AccessTokenScope string +// AccessTokenScopeCategory represents the scope category for an access token +type AccessTokenScopeCategory int const ( - AccessTokenScopeAll AccessTokenScope = "all" + AccessTokenScopeCategoryActivityPub = iota + AccessTokenScopeCategoryAdmin + AccessTokenScopeCategoryMisc + AccessTokenScopeCategoryNotification + AccessTokenScopeCategoryOrganization + AccessTokenScopeCategoryPackage + AccessTokenScopeCategoryIssue + AccessTokenScopeCategoryRepository + AccessTokenScopeCategoryUser +) - AccessTokenScopeRepo AccessTokenScope = "repo" - AccessTokenScopeRepoStatus AccessTokenScope = "repo:status" - AccessTokenScopePublicRepo AccessTokenScope = "public_repo" +// AllAccessTokenScopeCategories contains all access token scope categories +var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{ + AccessTokenScopeCategoryActivityPub, + AccessTokenScopeCategoryAdmin, + AccessTokenScopeCategoryMisc, + AccessTokenScopeCategoryNotification, + AccessTokenScopeCategoryOrganization, + AccessTokenScopeCategoryPackage, + AccessTokenScopeCategoryIssue, + AccessTokenScopeCategoryRepository, + AccessTokenScopeCategoryUser, +} - AccessTokenScopeAdminOrg AccessTokenScope = "admin:org" - AccessTokenScopeWriteOrg AccessTokenScope = "write:org" - AccessTokenScopeReadOrg AccessTokenScope = "read:org" +// AccessTokenScopeLevel represents the access levels without a given scope category +type AccessTokenScopeLevel int - AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key" - AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key" - AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key" +const ( + NoAccess AccessTokenScopeLevel = iota + Read + Write +) + +// AccessTokenScope represents the scope for an access token. +type AccessTokenScope string - AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook" - AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook" - AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook" +// for all categories, write implies read +const ( + AccessTokenScopeAll AccessTokenScope = "all" + AccessTokenScopePublicOnly AccessTokenScope = "public-only" // limited to public orgs/repos - AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook" + AccessTokenScopeReadActivityPub AccessTokenScope = "read:activitypub" + AccessTokenScopeWriteActivityPub AccessTokenScope = "write:activitypub" - AccessTokenScopeAdminUserHook AccessTokenScope = "admin:user_hook" + AccessTokenScopeReadAdmin AccessTokenScope = "read:admin" + AccessTokenScopeWriteAdmin AccessTokenScope = "write:admin" - AccessTokenScopeNotification AccessTokenScope = "notification" + AccessTokenScopeReadMisc AccessTokenScope = "read:misc" + AccessTokenScopeWriteMisc AccessTokenScope = "write:misc" - AccessTokenScopeUser AccessTokenScope = "user" - AccessTokenScopeReadUser AccessTokenScope = "read:user" - AccessTokenScopeUserEmail AccessTokenScope = "user:email" - AccessTokenScopeUserFollow AccessTokenScope = "user:follow" + AccessTokenScopeReadNotification AccessTokenScope = "read:notification" + AccessTokenScopeWriteNotification AccessTokenScope = "write:notification" - AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo" + AccessTokenScopeReadOrganization AccessTokenScope = "read:organization" + AccessTokenScopeWriteOrganization AccessTokenScope = "write:organization" - AccessTokenScopePackage AccessTokenScope = "package" - AccessTokenScopeWritePackage AccessTokenScope = "write:package" - AccessTokenScopeReadPackage AccessTokenScope = "read:package" - AccessTokenScopeDeletePackage AccessTokenScope = "delete:package" + AccessTokenScopeReadPackage AccessTokenScope = "read:package" + AccessTokenScopeWritePackage AccessTokenScope = "write:package" - AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key" - AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key" - AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key" + AccessTokenScopeReadIssue AccessTokenScope = "read:issue" + AccessTokenScopeWriteIssue AccessTokenScope = "write:issue" - AccessTokenScopeAdminApplication AccessTokenScope = "admin:application" - AccessTokenScopeWriteApplication AccessTokenScope = "write:application" - AccessTokenScopeReadApplication AccessTokenScope = "read:application" + AccessTokenScopeReadRepository AccessTokenScope = "read:repository" + AccessTokenScopeWriteRepository AccessTokenScope = "write:repository" - AccessTokenScopeSudo AccessTokenScope = "sudo" + AccessTokenScopeReadUser AccessTokenScope = "read:user" + AccessTokenScopeWriteUser AccessTokenScope = "write:user" ) -// AccessTokenScopeBitmap represents a bitmap of access token scopes. -type AccessTokenScopeBitmap uint64 +// accessTokenScopeBitmap represents a bitmap of access token scopes. +type accessTokenScopeBitmap uint64 // Bitmap of each scope, including the child scopes. const ( - // AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`. - AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits | - AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits | AccessTokenScopeAdminUserHookBits | - AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits | - AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits - - AccessTokenScopeRepoBits AccessTokenScopeBitmap = 1< 64 scopes, @@ -120,61 +129,110 @@ const ( ) // allAccessTokenScopes contains all access token scopes. -// The order is important: parent scope must precedes child scopes. +// The order is important: parent scope must precede child scopes. var allAccessTokenScopes = []AccessTokenScope{ - AccessTokenScopeRepo, AccessTokenScopeRepoStatus, AccessTokenScopePublicRepo, - AccessTokenScopeAdminOrg, AccessTokenScopeWriteOrg, AccessTokenScopeReadOrg, - AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey, - AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook, - AccessTokenScopeAdminOrgHook, - AccessTokenScopeAdminUserHook, - AccessTokenScopeNotification, - AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow, - AccessTokenScopeDeleteRepo, - AccessTokenScopePackage, AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, AccessTokenScopeDeletePackage, - AccessTokenScopeAdminGPGKey, AccessTokenScopeWriteGPGKey, AccessTokenScopeReadGPGKey, - AccessTokenScopeAdminApplication, AccessTokenScopeWriteApplication, AccessTokenScopeReadApplication, - AccessTokenScopeSudo, + AccessTokenScopePublicOnly, + AccessTokenScopeWriteActivityPub, AccessTokenScopeReadActivityPub, + AccessTokenScopeWriteAdmin, AccessTokenScopeReadAdmin, + AccessTokenScopeWriteMisc, AccessTokenScopeReadMisc, + AccessTokenScopeWriteNotification, AccessTokenScopeReadNotification, + AccessTokenScopeWriteOrganization, AccessTokenScopeReadOrganization, + AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, + AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, + AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, + AccessTokenScopeWriteUser, AccessTokenScopeReadUser, } // allAccessTokenScopeBits contains all access token scopes. -var allAccessTokenScopeBits = map[AccessTokenScope]AccessTokenScopeBitmap{ - AccessTokenScopeRepo: AccessTokenScopeRepoBits, - AccessTokenScopeRepoStatus: AccessTokenScopeRepoStatusBits, - AccessTokenScopePublicRepo: AccessTokenScopePublicRepoBits, - AccessTokenScopeAdminOrg: AccessTokenScopeAdminOrgBits, - AccessTokenScopeWriteOrg: AccessTokenScopeWriteOrgBits, - AccessTokenScopeReadOrg: AccessTokenScopeReadOrgBits, - AccessTokenScopeAdminPublicKey: AccessTokenScopeAdminPublicKeyBits, - AccessTokenScopeWritePublicKey: AccessTokenScopeWritePublicKeyBits, - AccessTokenScopeReadPublicKey: AccessTokenScopeReadPublicKeyBits, - AccessTokenScopeAdminRepoHook: AccessTokenScopeAdminRepoHookBits, - AccessTokenScopeWriteRepoHook: AccessTokenScopeWriteRepoHookBits, - AccessTokenScopeReadRepoHook: AccessTokenScopeReadRepoHookBits, - AccessTokenScopeAdminOrgHook: AccessTokenScopeAdminOrgHookBits, - AccessTokenScopeAdminUserHook: AccessTokenScopeAdminUserHookBits, - AccessTokenScopeNotification: AccessTokenScopeNotificationBits, - AccessTokenScopeUser: AccessTokenScopeUserBits, - AccessTokenScopeReadUser: AccessTokenScopeReadUserBits, - AccessTokenScopeUserEmail: AccessTokenScopeUserEmailBits, - AccessTokenScopeUserFollow: AccessTokenScopeUserFollowBits, - AccessTokenScopeDeleteRepo: AccessTokenScopeDeleteRepoBits, - AccessTokenScopePackage: AccessTokenScopePackageBits, - AccessTokenScopeWritePackage: AccessTokenScopeWritePackageBits, - AccessTokenScopeReadPackage: AccessTokenScopeReadPackageBits, - AccessTokenScopeDeletePackage: AccessTokenScopeDeletePackageBits, - AccessTokenScopeAdminGPGKey: AccessTokenScopeAdminGPGKeyBits, - AccessTokenScopeWriteGPGKey: AccessTokenScopeWriteGPGKeyBits, - AccessTokenScopeReadGPGKey: AccessTokenScopeReadGPGKeyBits, - AccessTokenScopeAdminApplication: AccessTokenScopeAdminApplicationBits, - AccessTokenScopeWriteApplication: AccessTokenScopeWriteApplicationBits, - AccessTokenScopeReadApplication: AccessTokenScopeReadApplicationBits, - AccessTokenScopeSudo: AccessTokenScopeSudoBits, +var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{ + AccessTokenScopeAll: accessTokenScopeAllBits, + AccessTokenScopePublicOnly: accessTokenScopePublicOnlyBits, + AccessTokenScopeReadActivityPub: accessTokenScopeReadActivityPubBits, + AccessTokenScopeWriteActivityPub: accessTokenScopeWriteActivityPubBits, + AccessTokenScopeReadAdmin: accessTokenScopeReadAdminBits, + AccessTokenScopeWriteAdmin: accessTokenScopeWriteAdminBits, + AccessTokenScopeReadMisc: accessTokenScopeReadMiscBits, + AccessTokenScopeWriteMisc: accessTokenScopeWriteMiscBits, + AccessTokenScopeReadNotification: accessTokenScopeReadNotificationBits, + AccessTokenScopeWriteNotification: accessTokenScopeWriteNotificationBits, + AccessTokenScopeReadOrganization: accessTokenScopeReadOrganizationBits, + AccessTokenScopeWriteOrganization: accessTokenScopeWriteOrganizationBits, + AccessTokenScopeReadPackage: accessTokenScopeReadPackageBits, + AccessTokenScopeWritePackage: accessTokenScopeWritePackageBits, + AccessTokenScopeReadIssue: accessTokenScopeReadIssueBits, + AccessTokenScopeWriteIssue: accessTokenScopeWriteIssueBits, + AccessTokenScopeReadRepository: accessTokenScopeReadRepositoryBits, + AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits, + AccessTokenScopeReadUser: accessTokenScopeReadUserBits, + AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits, +} + +// readAccessTokenScopes maps a scope category to the read permission scope +var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]AccessTokenScope{ + Read: { + AccessTokenScopeCategoryActivityPub: AccessTokenScopeReadActivityPub, + AccessTokenScopeCategoryAdmin: AccessTokenScopeReadAdmin, + AccessTokenScopeCategoryMisc: AccessTokenScopeReadMisc, + AccessTokenScopeCategoryNotification: AccessTokenScopeReadNotification, + AccessTokenScopeCategoryOrganization: AccessTokenScopeReadOrganization, + AccessTokenScopeCategoryPackage: AccessTokenScopeReadPackage, + AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue, + AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository, + AccessTokenScopeCategoryUser: AccessTokenScopeReadUser, + }, + Write: { + AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub, + AccessTokenScopeCategoryAdmin: AccessTokenScopeWriteAdmin, + AccessTokenScopeCategoryMisc: AccessTokenScopeWriteMisc, + AccessTokenScopeCategoryNotification: AccessTokenScopeWriteNotification, + AccessTokenScopeCategoryOrganization: AccessTokenScopeWriteOrganization, + AccessTokenScopeCategoryPackage: AccessTokenScopeWritePackage, + AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, + AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, + AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, + }, +} + +// GetRequiredScopes gets the specific scopes for a given level and categories +func GetRequiredScopes(level AccessTokenScopeLevel, scopeCategories ...AccessTokenScopeCategory) []AccessTokenScope { + scopes := make([]AccessTokenScope, 0, len(scopeCategories)) + for _, cat := range scopeCategories { + scopes = append(scopes, accessTokenScopes[level][cat]) + } + return scopes } -// Parse parses the scope string into a bitmap, thus removing possible duplicates. -func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) { - var bitmap AccessTokenScopeBitmap +// ContainsCategory checks if a list of categories contains a specific category +func ContainsCategory(categories []AccessTokenScopeCategory, category AccessTokenScopeCategory) bool { + for _, c := range categories { + if c == category { + return true + } + } + return false +} + +// GetScopeLevelFromAccessMode converts permission access mode to scope level +func GetScopeLevelFromAccessMode(mode perm.AccessMode) AccessTokenScopeLevel { + switch mode { + case perm.AccessModeNone: + return NoAccess + case perm.AccessModeRead: + return Read + case perm.AccessModeWrite: + return Write + case perm.AccessModeAdmin: + return Write + case perm.AccessModeOwner: + return Write + default: + return NoAccess + } +} + +// parse the scope string into a bitmap, thus removing possible duplicates. +func (s AccessTokenScope) parse() (accessTokenScopeBitmap, error) { + var bitmap accessTokenScopeBitmap // The following is the more performant equivalent of 'for _, v := range strings.Split(remainingScope, ",")' as this is hot code remainingScopes := string(s) @@ -196,7 +254,7 @@ func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) { continue } if singleScope == AccessTokenScopeAll { - bitmap |= AccessTokenScopeAllBits + bitmap |= accessTokenScopeAllBits continue } @@ -217,26 +275,42 @@ func (s AccessTokenScope) StringSlice() []string { // Normalize returns a normalized scope string without any duplicates. func (s AccessTokenScope) Normalize() (AccessTokenScope, error) { - bitmap, err := s.Parse() + bitmap, err := s.parse() if err != nil { return "", err } - return bitmap.ToScope(), nil + return bitmap.toScope(), nil } -// HasScope returns true if the string has the given scope -func (s AccessTokenScope) HasScope(scope AccessTokenScope) (bool, error) { - bitmap, err := s.Parse() +// PublicOnly checks if this token scope is limited to public resources +func (s AccessTokenScope) PublicOnly() (bool, error) { + bitmap, err := s.parse() if err != nil { return false, err } - return bitmap.HasScope(scope) + return bitmap.hasScope(AccessTokenScopePublicOnly) } // HasScope returns true if the string has the given scope -func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, error) { +func (s AccessTokenScope) HasScope(scopes ...AccessTokenScope) (bool, error) { + bitmap, err := s.parse() + if err != nil { + return false, err + } + + for _, s := range scopes { + if has, err := bitmap.hasScope(s); !has || err != nil { + return has, err + } + } + + return true, nil +} + +// hasScope returns true if the string has the given scope +func (bitmap accessTokenScopeBitmap) hasScope(scope AccessTokenScope) (bool, error) { expectedBits, ok := allAccessTokenScopeBits[scope] if !ok { return false, fmt.Errorf("invalid access token scope: %s", scope) @@ -245,17 +319,17 @@ func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, err return bitmap&expectedBits == expectedBits, nil } -// ToScope returns a normalized scope string without any duplicates. -func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope { +// toScope returns a normalized scope string without any duplicates. +func (bitmap accessTokenScopeBitmap) toScope() AccessTokenScope { var scopes []string // iterate over all scopes, and reconstruct the bitmap // if the reconstructed bitmap doesn't change, then the scope is already included - var reconstruct AccessTokenScopeBitmap + var reconstruct accessTokenScopeBitmap for _, singleScope := range allAccessTokenScopes { // no need for error checking here, since we know the scope is valid - if ok, _ := bitmap.HasScope(singleScope); ok { + if ok, _ := bitmap.hasScope(singleScope); ok { current := reconstruct | allAccessTokenScopeBits[singleScope] if current == reconstruct { continue @@ -269,7 +343,7 @@ func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope { scope := AccessTokenScope(strings.Join(scopes, ",")) scope = AccessTokenScope(strings.ReplaceAll( string(scope), - "repo,admin:org,admin:public_key,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", + "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", )) return scope diff --git a/models/auth/token_scope_test.go b/models/auth/token_scope_test.go index b96a5fd469479..a6097e45d7f59 100644 --- a/models/auth/token_scope_test.go +++ b/models/auth/token_scope_test.go @@ -4,44 +4,35 @@ package auth import ( + "fmt" "testing" "github.com/stretchr/testify/assert" ) +type scopeTestNormalize struct { + in AccessTokenScope + out AccessTokenScope + err error +} + func TestAccessTokenScope_Normalize(t *testing.T) { - tests := []struct { - in AccessTokenScope - out AccessTokenScope - err error - }{ + tests := []scopeTestNormalize{ {"", "", nil}, - {"repo", "repo", nil}, - {"repo,repo:status", "repo", nil}, - {"repo,public_repo", "repo", nil}, - {"admin:public_key,write:public_key", "admin:public_key", nil}, - {"admin:public_key,read:public_key", "admin:public_key", nil}, - {"write:public_key,read:public_key", "write:public_key", nil}, // read is include in write - {"admin:repo_hook,write:repo_hook", "admin:repo_hook", nil}, - {"admin:repo_hook,read:repo_hook", "admin:repo_hook", nil}, - {"repo,admin:repo_hook,read:repo_hook", "repo", nil}, // admin:repo_hook is a child scope of repo - {"repo,read:repo_hook", "repo", nil}, // read:repo_hook is a child scope of repo - {"user", "user", nil}, - {"user,read:user", "user", nil}, - {"user,admin:org,write:org", "admin:org,user", nil}, - {"admin:org,write:org,user", "admin:org,user", nil}, - {"package", "package", nil}, - {"package,write:package", "package", nil}, - {"package,write:package,delete:package", "package", nil}, - {"write:package,read:package", "write:package", nil}, // read is include in write - {"write:package,delete:package", "write:package,delete:package", nil}, // write and delete are not include in each other - {"admin:gpg_key", "admin:gpg_key", nil}, - {"admin:gpg_key,write:gpg_key", "admin:gpg_key", nil}, - {"admin:gpg_key,write:gpg_key,user", "user,admin:gpg_key", nil}, - {"admin:application,write:application,user", "user,admin:application", nil}, + {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, {"all", "all", nil}, - {"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil}, - {"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", nil}, + {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil}, + {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil}, + } + + for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + tests = append(tests, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%s", scope)), AccessTokenScope(fmt.Sprintf("read:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("write:%s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("write:%[1]s,read:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%[1]s,write:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%[1]s,write:%[1]s,write:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + ) } for _, test := range tests { @@ -53,31 +44,46 @@ func TestAccessTokenScope_Normalize(t *testing.T) { } } +type scopeTestHasScope struct { + in AccessTokenScope + scope AccessTokenScope + out bool + err error +} + func TestAccessTokenScope_HasScope(t *testing.T) { - tests := []struct { - in AccessTokenScope - scope AccessTokenScope - out bool - err error - }{ - {"repo", "repo", true, nil}, - {"repo", "repo:status", true, nil}, - {"repo", "public_repo", true, nil}, - {"repo", "admin:org", false, nil}, - {"repo", "admin:public_key", false, nil}, - {"repo:status", "repo", false, nil}, - {"repo:status", "public_repo", false, nil}, - {"admin:org", "write:org", true, nil}, - {"admin:org", "read:org", true, nil}, - {"admin:org", "admin:org", true, nil}, - {"user", "read:user", true, nil}, - {"package", "write:package", true, nil}, + tests := []scopeTestHasScope{ + {"read:admin", "write:package", false, nil}, + {"all", "write:package", true, nil}, + {"write:package", "all", false, nil}, + {"public-only", "read:issue", false, nil}, + } + + for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + tests = append(tests, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("read:%s", scope)), + AccessTokenScope(fmt.Sprintf("read:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("write:%s", scope)), + AccessTokenScope(fmt.Sprintf("write:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("write:%s", scope)), + AccessTokenScope(fmt.Sprintf("read:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("read:%s", scope)), + AccessTokenScope(fmt.Sprintf("write:%s", scope)), false, nil, + }, + ) } for _, test := range tests { t.Run(string(test.in), func(t *testing.T) { - scope, err := test.in.HasScope(test.scope) - assert.Equal(t, test.out, scope) + hasScope, err := test.in.HasScope(test.scope) + assert.Equal(t, test.out, hasScope) assert.Equal(t, test.err, err) }) } diff --git a/models/db/context.go b/models/db/context.go index 670f6272aa9a9..59be1e138914a 100644 --- a/models/db/context.go +++ b/models/db/context.go @@ -9,7 +9,6 @@ import ( "xorm.io/builder" "xorm.io/xorm" - "xorm.io/xorm/schemas" ) // DefaultContext is the default context to run xorm queries in @@ -241,30 +240,6 @@ func TableName(bean interface{}) string { return x.TableName(bean) } -// EstimateCount returns an estimate of total number of rows in table -func EstimateCount(ctx context.Context, bean interface{}) (int64, error) { - e := GetEngine(ctx) - e.Context(ctx) - - var rows int64 - var err error - tablename := TableName(bean) - switch x.Dialect().URI().DBType { - case schemas.MYSQL: - _, err = e.Context(ctx).SQL("SELECT table_rows FROM information_schema.tables WHERE tables.table_name = ? AND tables.table_schema = ?;", tablename, x.Dialect().URI().DBName).Get(&rows) - case schemas.POSTGRES: - // the table can live in multiple schemas of a postgres database - // See https://wiki.postgresql.org/wiki/Count_estimate - tablename = x.TableName(bean, true) - _, err = e.Context(ctx).SQL("SELECT reltuples::bigint AS estimate FROM pg_class WHERE oid = ?::regclass;", tablename).Get(&rows) - case schemas.MSSQL: - _, err = e.Context(ctx).SQL("sp_spaceused ?;", tablename).Get(&rows) - default: - return e.Context(ctx).Count(tablename) - } - return rows, err -} - // InTransaction returns true if the engine is in a transaction otherwise return false func InTransaction(ctx context.Context) bool { _, ok := inTransaction(ctx) diff --git a/models/db/index.go b/models/db/index.go index 7609d8fb6e6c2..259ddd6ade7ac 100644 --- a/models/db/index.go +++ b/models/db/index.go @@ -71,10 +71,31 @@ func postgresGetNextResourceIndex(ctx context.Context, tableName string, groupID return strconv.ParseInt(string(res[0]["max_index"]), 10, 64) } +func mysqlGetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) { + if _, err := GetEngine(ctx).Exec(fmt.Sprintf("INSERT INTO %s (group_id, max_index) "+ + "VALUES (?,1) ON DUPLICATE KEY UPDATE max_index = max_index+1", + tableName), groupID); err != nil { + return 0, err + } + + var idx int64 + _, err := GetEngine(ctx).SQL(fmt.Sprintf("SELECT max_index FROM %s WHERE group_id = ?", tableName), groupID).Get(&idx) + if err != nil { + return 0, err + } + if idx == 0 { + return 0, errors.New("cannot get the correct index") + } + return idx, nil +} + // GetNextResourceIndex generates a resource index, it must run in the same transaction where the resource is created func GetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) { - if setting.Database.Type.IsPostgreSQL() { + switch { + case setting.Database.Type.IsPostgreSQL(): return postgresGetNextResourceIndex(ctx, tableName, groupID) + case setting.Database.Type.IsMySQL(): + return mysqlGetNextResourceIndex(ctx, tableName, groupID) } e := GetEngine(ctx) diff --git a/models/git/commit_status.go b/models/git/commit_status.go index 6028e46649325..a018bb0553a46 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -64,10 +64,32 @@ func postgresGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) return strconv.ParseInt(string(res[0]["max_index"]), 10, 64) } +func mysqlGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) { + if _, err := db.GetEngine(ctx).Exec("INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+ + "VALUES (?,?,1) ON DUPLICATE KEY UPDATE max_index = max_index+1", + repoID, sha); err != nil { + return 0, err + } + + var idx int64 + _, err := db.GetEngine(ctx).SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id = ? AND sha = ?", + repoID, sha).Get(&idx) + if err != nil { + return 0, err + } + if idx == 0 { + return 0, errors.New("cannot get the correct index") + } + return idx, nil +} + // GetNextCommitStatusIndex retried 3 times to generate a resource index func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) { - if setting.Database.Type.IsPostgreSQL() { + switch { + case setting.Database.Type.IsPostgreSQL(): return postgresGetCommitStatusIndex(ctx, repoID, sha) + case setting.Database.Type.IsMySQL(): + return mysqlGetCommitStatusIndex(ctx, repoID, sha) } e := db.GetEngine(ctx) @@ -75,7 +97,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in // try to update the max_index to next value, and acquire the write-lock for the record res, err := e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha) if err != nil { - return 0, err + return 0, fmt.Errorf("update failed: %w", err) } affected, err := res.RowsAffected() if err != nil { @@ -86,18 +108,18 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in _, errIns := e.Exec("INSERT INTO `commit_status_index` (repo_id, sha, max_index) VALUES (?, ?, 0)", repoID, sha) res, err = e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha) if err != nil { - return 0, err + return 0, fmt.Errorf("update2 failed: %w", err) } affected, err = res.RowsAffected() if err != nil { - return 0, err + return 0, fmt.Errorf("RowsAffected failed: %w", err) } // if the update still can not update any records, the record must not exist and there must be some errors (insert error) if affected == 0 { if errIns == nil { return 0, errors.New("impossible error when GetNextCommitStatusIndex, insert and update both succeeded but no record is updated") } - return 0, errIns + return 0, fmt.Errorf("insert failed: %w", errIns) } } @@ -105,7 +127,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in var newIdx int64 has, err := e.SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id=? AND sha=?", repoID, sha).Get(&newIdx) if err != nil { - return 0, err + return 0, fmt.Errorf("select failed: %w", err) } if !has { return 0, errors.New("impossible error when GetNextCommitStatusIndex, upsert succeeded but no record can be selected") diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 6ddadd27ed442..dad21c14776f0 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -34,7 +34,7 @@ func (issues IssueList) getRepoIDs() []int64 { } // LoadRepositories loads issues' all repositories -func (issues IssueList) LoadRepositories(ctx context.Context) ([]*repo_model.Repository, error) { +func (issues IssueList) LoadRepositories(ctx context.Context) (repo_model.RepositoryList, error) { if len(issues) == 0 { return nil, nil } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 231c93cc74bb4..d96c17bfb5524 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -495,6 +495,8 @@ var migrations = []Migration{ NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable), // v258 -> 259 NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue), + // v259 -> 260 + NewMigration("Convert scoped access tokens", v1_20.ConvertScopedAccessTokens), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/main_test.go b/models/migrations/v1_20/main_test.go new file mode 100644 index 0000000000000..92a1a9f622659 --- /dev/null +++ b/models/migrations/v1_20/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_20/v259.go b/models/migrations/v1_20/v259.go new file mode 100644 index 0000000000000..5b8ced4ad7b41 --- /dev/null +++ b/models/migrations/v1_20/v259.go @@ -0,0 +1,360 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/modules/log" + + "xorm.io/xorm" +) + +// unknownAccessTokenScope represents the scope for an access token that isn't +// known be an old token or a new token. +type unknownAccessTokenScope string + +// AccessTokenScope represents the scope for an access token. +type AccessTokenScope string + +// for all categories, write implies read +const ( + AccessTokenScopeAll AccessTokenScope = "all" + AccessTokenScopePublicOnly AccessTokenScope = "public-only" // limited to public orgs/repos + + AccessTokenScopeReadActivityPub AccessTokenScope = "read:activitypub" + AccessTokenScopeWriteActivityPub AccessTokenScope = "write:activitypub" + + AccessTokenScopeReadAdmin AccessTokenScope = "read:admin" + AccessTokenScopeWriteAdmin AccessTokenScope = "write:admin" + + AccessTokenScopeReadMisc AccessTokenScope = "read:misc" + AccessTokenScopeWriteMisc AccessTokenScope = "write:misc" + + AccessTokenScopeReadNotification AccessTokenScope = "read:notification" + AccessTokenScopeWriteNotification AccessTokenScope = "write:notification" + + AccessTokenScopeReadOrganization AccessTokenScope = "read:organization" + AccessTokenScopeWriteOrganization AccessTokenScope = "write:organization" + + AccessTokenScopeReadPackage AccessTokenScope = "read:package" + AccessTokenScopeWritePackage AccessTokenScope = "write:package" + + AccessTokenScopeReadIssue AccessTokenScope = "read:issue" + AccessTokenScopeWriteIssue AccessTokenScope = "write:issue" + + AccessTokenScopeReadRepository AccessTokenScope = "read:repository" + AccessTokenScopeWriteRepository AccessTokenScope = "write:repository" + + AccessTokenScopeReadUser AccessTokenScope = "read:user" + AccessTokenScopeWriteUser AccessTokenScope = "write:user" +) + +// accessTokenScopeBitmap represents a bitmap of access token scopes. +type accessTokenScopeBitmap uint64 + +// Bitmap of each scope, including the child scopes. +const ( + // AccessTokenScopeAllBits is the bitmap of all access token scopes + accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits | + accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits | + accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits | + accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits + + accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota + + accessTokenScopeReadActivityPubBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteActivityPubBits accessTokenScopeBitmap = 1< 64 scopes, + // refactoring the whole implementation in this file (and only this file) is needed. +) + +// allAccessTokenScopes contains all access token scopes. +// The order is important: parent scope must precede child scopes. +var allAccessTokenScopes = []AccessTokenScope{ + AccessTokenScopePublicOnly, + AccessTokenScopeWriteActivityPub, AccessTokenScopeReadActivityPub, + AccessTokenScopeWriteAdmin, AccessTokenScopeReadAdmin, + AccessTokenScopeWriteMisc, AccessTokenScopeReadMisc, + AccessTokenScopeWriteNotification, AccessTokenScopeReadNotification, + AccessTokenScopeWriteOrganization, AccessTokenScopeReadOrganization, + AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, + AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, + AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, + AccessTokenScopeWriteUser, AccessTokenScopeReadUser, +} + +// allAccessTokenScopeBits contains all access token scopes. +var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{ + AccessTokenScopeAll: accessTokenScopeAllBits, + AccessTokenScopePublicOnly: accessTokenScopePublicOnlyBits, + AccessTokenScopeReadActivityPub: accessTokenScopeReadActivityPubBits, + AccessTokenScopeWriteActivityPub: accessTokenScopeWriteActivityPubBits, + AccessTokenScopeReadAdmin: accessTokenScopeReadAdminBits, + AccessTokenScopeWriteAdmin: accessTokenScopeWriteAdminBits, + AccessTokenScopeReadMisc: accessTokenScopeReadMiscBits, + AccessTokenScopeWriteMisc: accessTokenScopeWriteMiscBits, + AccessTokenScopeReadNotification: accessTokenScopeReadNotificationBits, + AccessTokenScopeWriteNotification: accessTokenScopeWriteNotificationBits, + AccessTokenScopeReadOrganization: accessTokenScopeReadOrganizationBits, + AccessTokenScopeWriteOrganization: accessTokenScopeWriteOrganizationBits, + AccessTokenScopeReadPackage: accessTokenScopeReadPackageBits, + AccessTokenScopeWritePackage: accessTokenScopeWritePackageBits, + AccessTokenScopeReadIssue: accessTokenScopeReadIssueBits, + AccessTokenScopeWriteIssue: accessTokenScopeWriteIssueBits, + AccessTokenScopeReadRepository: accessTokenScopeReadRepositoryBits, + AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits, + AccessTokenScopeReadUser: accessTokenScopeReadUserBits, + AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits, +} + +// hasScope returns true if the string has the given scope +func (bitmap accessTokenScopeBitmap) hasScope(scope AccessTokenScope) (bool, error) { + expectedBits, ok := allAccessTokenScopeBits[scope] + if !ok { + return false, fmt.Errorf("invalid access token scope: %s", scope) + } + + return bitmap&expectedBits == expectedBits, nil +} + +// toScope returns a normalized scope string without any duplicates. +func (bitmap accessTokenScopeBitmap) toScope(unknownScopes *[]unknownAccessTokenScope) AccessTokenScope { + var scopes []string + + // Preserve unknown scopes, and put them at the beginning so that it's clear + // when debugging. + if unknownScopes != nil { + for _, unknownScope := range *unknownScopes { + scopes = append(scopes, string(unknownScope)) + } + } + + // iterate over all scopes, and reconstruct the bitmap + // if the reconstructed bitmap doesn't change, then the scope is already included + var reconstruct accessTokenScopeBitmap + + for _, singleScope := range allAccessTokenScopes { + // no need for error checking here, since we know the scope is valid + if ok, _ := bitmap.hasScope(singleScope); ok { + current := reconstruct | allAccessTokenScopeBits[singleScope] + if current == reconstruct { + continue + } + + reconstruct = current + scopes = append(scopes, string(singleScope)) + } + } + + scope := AccessTokenScope(strings.Join(scopes, ",")) + scope = AccessTokenScope(strings.ReplaceAll( + string(scope), + "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", + "all", + )) + return scope +} + +// parse the scope string into a bitmap, thus removing possible duplicates. +func (s AccessTokenScope) parse() (accessTokenScopeBitmap, *[]unknownAccessTokenScope) { + var bitmap accessTokenScopeBitmap + var unknownScopes []unknownAccessTokenScope + + // The following is the more performant equivalent of 'for _, v := range strings.Split(remainingScope, ",")' as this is hot code + remainingScopes := string(s) + for len(remainingScopes) > 0 { + i := strings.IndexByte(remainingScopes, ',') + var v string + if i < 0 { + v = remainingScopes + remainingScopes = "" + } else if i+1 >= len(remainingScopes) { + v = remainingScopes[:i] + remainingScopes = "" + } else { + v = remainingScopes[:i] + remainingScopes = remainingScopes[i+1:] + } + singleScope := AccessTokenScope(v) + if singleScope == "" { + continue + } + if singleScope == AccessTokenScopeAll { + bitmap |= accessTokenScopeAllBits + continue + } + + bits, ok := allAccessTokenScopeBits[singleScope] + if !ok { + unknownScopes = append(unknownScopes, unknownAccessTokenScope(string(singleScope))) + } + bitmap |= bits + } + + return bitmap, &unknownScopes +} + +// NormalizePreservingUnknown returns a normalized scope string without any +// duplicates. Unknown scopes are included. +func (s AccessTokenScope) NormalizePreservingUnknown() AccessTokenScope { + bitmap, unknownScopes := s.parse() + + return bitmap.toScope(unknownScopes) +} + +// OldAccessTokenScope represents the scope for an access token. +type OldAccessTokenScope string + +const ( + OldAccessTokenScopeAll OldAccessTokenScope = "all" + + OldAccessTokenScopeRepo OldAccessTokenScope = "repo" + OldAccessTokenScopeRepoStatus OldAccessTokenScope = "repo:status" + OldAccessTokenScopePublicRepo OldAccessTokenScope = "public_repo" + + OldAccessTokenScopeAdminOrg OldAccessTokenScope = "admin:org" + OldAccessTokenScopeWriteOrg OldAccessTokenScope = "write:org" + OldAccessTokenScopeReadOrg OldAccessTokenScope = "read:org" + + OldAccessTokenScopeAdminPublicKey OldAccessTokenScope = "admin:public_key" + OldAccessTokenScopeWritePublicKey OldAccessTokenScope = "write:public_key" + OldAccessTokenScopeReadPublicKey OldAccessTokenScope = "read:public_key" + + OldAccessTokenScopeAdminRepoHook OldAccessTokenScope = "admin:repo_hook" + OldAccessTokenScopeWriteRepoHook OldAccessTokenScope = "write:repo_hook" + OldAccessTokenScopeReadRepoHook OldAccessTokenScope = "read:repo_hook" + + OldAccessTokenScopeAdminOrgHook OldAccessTokenScope = "admin:org_hook" + + OldAccessTokenScopeNotification OldAccessTokenScope = "notification" + + OldAccessTokenScopeUser OldAccessTokenScope = "user" + OldAccessTokenScopeReadUser OldAccessTokenScope = "read:user" + OldAccessTokenScopeUserEmail OldAccessTokenScope = "user:email" + OldAccessTokenScopeUserFollow OldAccessTokenScope = "user:follow" + + OldAccessTokenScopeDeleteRepo OldAccessTokenScope = "delete_repo" + + OldAccessTokenScopePackage OldAccessTokenScope = "package" + OldAccessTokenScopeWritePackage OldAccessTokenScope = "write:package" + OldAccessTokenScopeReadPackage OldAccessTokenScope = "read:package" + OldAccessTokenScopeDeletePackage OldAccessTokenScope = "delete:package" + + OldAccessTokenScopeAdminGPGKey OldAccessTokenScope = "admin:gpg_key" + OldAccessTokenScopeWriteGPGKey OldAccessTokenScope = "write:gpg_key" + OldAccessTokenScopeReadGPGKey OldAccessTokenScope = "read:gpg_key" + + OldAccessTokenScopeAdminApplication OldAccessTokenScope = "admin:application" + OldAccessTokenScopeWriteApplication OldAccessTokenScope = "write:application" + OldAccessTokenScopeReadApplication OldAccessTokenScope = "read:application" + + OldAccessTokenScopeSudo OldAccessTokenScope = "sudo" +) + +var accessTokenScopeMap = map[OldAccessTokenScope][]AccessTokenScope{ + OldAccessTokenScopeAll: {AccessTokenScopeAll}, + OldAccessTokenScopeRepo: {AccessTokenScopeWriteRepository}, + OldAccessTokenScopeRepoStatus: {AccessTokenScopeWriteRepository}, + OldAccessTokenScopePublicRepo: {AccessTokenScopePublicOnly, AccessTokenScopeWriteRepository}, + OldAccessTokenScopeAdminOrg: {AccessTokenScopeWriteOrganization}, + OldAccessTokenScopeWriteOrg: {AccessTokenScopeWriteOrganization}, + OldAccessTokenScopeReadOrg: {AccessTokenScopeReadOrganization}, + OldAccessTokenScopeAdminPublicKey: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeWritePublicKey: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeReadPublicKey: {AccessTokenScopeReadUser}, + OldAccessTokenScopeAdminRepoHook: {AccessTokenScopeWriteRepository}, + OldAccessTokenScopeWriteRepoHook: {AccessTokenScopeWriteRepository}, + OldAccessTokenScopeReadRepoHook: {AccessTokenScopeReadRepository}, + OldAccessTokenScopeAdminOrgHook: {AccessTokenScopeWriteOrganization}, + OldAccessTokenScopeNotification: {AccessTokenScopeWriteNotification}, + OldAccessTokenScopeUser: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeReadUser: {AccessTokenScopeReadUser}, + OldAccessTokenScopeUserEmail: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeUserFollow: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeDeleteRepo: {AccessTokenScopeWriteRepository}, + OldAccessTokenScopePackage: {AccessTokenScopeWritePackage}, + OldAccessTokenScopeWritePackage: {AccessTokenScopeWritePackage}, + OldAccessTokenScopeReadPackage: {AccessTokenScopeReadPackage}, + OldAccessTokenScopeDeletePackage: {AccessTokenScopeWritePackage}, + OldAccessTokenScopeAdminGPGKey: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeWriteGPGKey: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeReadGPGKey: {AccessTokenScopeReadUser}, + OldAccessTokenScopeAdminApplication: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeWriteApplication: {AccessTokenScopeWriteUser}, + OldAccessTokenScopeReadApplication: {AccessTokenScopeReadUser}, + OldAccessTokenScopeSudo: {AccessTokenScopeWriteAdmin}, +} + +type AccessToken struct { + ID int64 `xorm:"pk autoincr"` + Scope string +} + +func ConvertScopedAccessTokens(x *xorm.Engine) error { + var tokens []*AccessToken + + if err := x.Find(&tokens); err != nil { + return err + } + + for _, token := range tokens { + var scopes []string + allNewScopesMap := make(map[AccessTokenScope]bool) + for _, oldScope := range strings.Split(token.Scope, ",") { + if newScopes, exists := accessTokenScopeMap[OldAccessTokenScope(oldScope)]; exists { + for _, newScope := range newScopes { + allNewScopesMap[newScope] = true + } + } else { + log.Debug("access token scope not recognized as old token scope %s; preserving it", oldScope) + scopes = append(scopes, oldScope) + } + } + + for s := range allNewScopesMap { + scopes = append(scopes, string(s)) + } + scope := AccessTokenScope(strings.Join(scopes, ",")) + + // normalize the scope + normScope := scope.NormalizePreservingUnknown() + + token.Scope = string(normScope) + + // update the db entry with the new scope + if _, err := x.Cols("scope").ID(token.ID).Update(token); err != nil { + return err + } + } + + return nil +} diff --git a/models/migrations/v1_20/v259_test.go b/models/migrations/v1_20/v259_test.go new file mode 100644 index 0000000000000..5bc9a71391e1d --- /dev/null +++ b/models/migrations/v1_20/v259_test.go @@ -0,0 +1,110 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "sort" + "strings" + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" +) + +type testCase struct { + Old OldAccessTokenScope + New AccessTokenScope +} + +func createOldTokenScope(scopes ...OldAccessTokenScope) OldAccessTokenScope { + s := make([]string, 0, len(scopes)) + for _, os := range scopes { + s = append(s, string(os)) + } + return OldAccessTokenScope(strings.Join(s, ",")) +} + +func createNewTokenScope(scopes ...AccessTokenScope) AccessTokenScope { + s := make([]string, 0, len(scopes)) + for _, os := range scopes { + s = append(s, string(os)) + } + return AccessTokenScope(strings.Join(s, ",")) +} + +func Test_ConvertScopedAccessTokens(t *testing.T) { + tests := []testCase{ + { + createOldTokenScope(OldAccessTokenScopeRepo, OldAccessTokenScopeUserFollow), + createNewTokenScope(AccessTokenScopeWriteRepository, AccessTokenScopeWriteUser), + }, + { + createOldTokenScope(OldAccessTokenScopeUser, OldAccessTokenScopeWritePackage, OldAccessTokenScopeSudo), + createNewTokenScope(AccessTokenScopeWriteAdmin, AccessTokenScopeWritePackage, AccessTokenScopeWriteUser), + }, + { + createOldTokenScope(), + createNewTokenScope(), + }, + { + createOldTokenScope(OldAccessTokenScopeReadGPGKey, OldAccessTokenScopeReadOrg, OldAccessTokenScopeAll), + createNewTokenScope(AccessTokenScopeAll), + }, + { + createOldTokenScope(OldAccessTokenScopeReadGPGKey, "invalid"), + createNewTokenScope("invalid", AccessTokenScopeReadUser), + }, + } + + // add a test for each individual mapping + for oldScope, newScope := range accessTokenScopeMap { + tests = append(tests, testCase{ + oldScope, + createNewTokenScope(newScope...), + }) + } + + x, deferable := base.PrepareTestEnv(t, 0, new(AccessToken)) + defer deferable() + if x == nil || t.Failed() { + t.Skip() + return + } + + // verify that no fixtures were loaded + count, err := x.Count(&AccessToken{}) + assert.NoError(t, err) + assert.Equal(t, int64(0), count) + + for _, tc := range tests { + _, err = x.Insert(&AccessToken{ + Scope: string(tc.Old), + }) + assert.NoError(t, err) + } + + // migrate the scopes + err = ConvertScopedAccessTokens(x) + assert.NoError(t, err) + + // migrate the scopes again (migration should be idempotent) + err = ConvertScopedAccessTokens(x) + assert.NoError(t, err) + + tokens := make([]AccessToken, 0) + err = x.Find(&tokens) + assert.NoError(t, err) + assert.Equal(t, len(tests), len(tokens)) + + // sort the tokens (insertion order by auto-incrementing primary key) + sort.Slice(tokens, func(i, j int) bool { + return tokens[i].ID < tokens[j].ID + }) + + // verify that the converted scopes are equal to the expected test result + for idx, newToken := range tokens { + assert.Equal(t, string(tests[idx].New), newToken.Scope) + } +} diff --git a/models/organization/org.go b/models/organization/org.go index 30b76fb1a0633..05db6ba15f8ab 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -710,8 +710,8 @@ func (org *Organization) GetUserTeams(userID int64) ([]*Team, error) { type AccessibleReposEnvironment interface { CountRepos() (int64, error) RepoIDs(page, pageSize int) ([]int64, error) - Repos(page, pageSize int) ([]*repo_model.Repository, error) - MirrorRepos() ([]*repo_model.Repository, error) + Repos(page, pageSize int) (repo_model.RepositoryList, error) + MirrorRepos() (repo_model.RepositoryList, error) AddKeyword(keyword string) SetSort(db.SearchOrderBy) } @@ -813,7 +813,7 @@ func (env *accessibleReposEnv) RepoIDs(page, pageSize int) ([]int64, error) { Find(&repoIDs) } -func (env *accessibleReposEnv) Repos(page, pageSize int) ([]*repo_model.Repository, error) { +func (env *accessibleReposEnv) Repos(page, pageSize int) (repo_model.RepositoryList, error) { repoIDs, err := env.RepoIDs(page, pageSize) if err != nil { return nil, fmt.Errorf("GetUserRepositoryIDs: %w", err) @@ -842,7 +842,7 @@ func (env *accessibleReposEnv) MirrorRepoIDs() ([]int64, error) { Find(&repoIDs) } -func (env *accessibleReposEnv) MirrorRepos() ([]*repo_model.Repository, error) { +func (env *accessibleReposEnv) MirrorRepos() (repo_model.RepositoryList, error) { repoIDs, err := env.MirrorRepoIDs() if err != nil { return nil, fmt.Errorf("MirrorRepoIDs: %w", err) diff --git a/models/organization/org_repo.go b/models/organization/org_repo.go index 99638916b0b52..f7e59928f42ee 100644 --- a/models/organization/org_repo.go +++ b/models/organization/org_repo.go @@ -11,7 +11,7 @@ import ( ) // GetOrgRepositories get repos belonging to the given organization -func GetOrgRepositories(ctx context.Context, orgID int64) ([]*repo_model.Repository, error) { +func GetOrgRepositories(ctx context.Context, orgID int64) (repo_model.RepositoryList, error) { var orgRepos []*repo_model.Repository return orgRepos, db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos) } diff --git a/models/organization/org_test.go b/models/organization/org_test.go index 27e124a62bc68..27a173d497c52 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -346,7 +346,7 @@ func TestAccessibleReposEnv_Repos(t *testing.T) { assert.NoError(t, err) repos, err := env.Repos(1, 100) assert.NoError(t, err) - expectedRepos := make([]*repo_model.Repository, len(expectedRepoIDs)) + expectedRepos := make(repo_model.RepositoryList, len(expectedRepoIDs)) for i, repoID := range expectedRepoIDs { expectedRepos[i] = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) @@ -365,7 +365,7 @@ func TestAccessibleReposEnv_MirrorRepos(t *testing.T) { assert.NoError(t, err) repos, err := env.MirrorRepos() assert.NoError(t, err) - expectedRepos := make([]*repo_model.Repository, len(expectedRepoIDs)) + expectedRepos := make(repo_model.RepositoryList, len(expectedRepoIDs)) for i, repoID := range expectedRepoIDs { expectedRepos[i] = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go index e6b50ecff7e41..1184e39263635 100644 --- a/models/organization/team_repo.go +++ b/models/organization/team_repo.go @@ -37,7 +37,7 @@ type SearchTeamRepoOptions struct { } // GetRepositories returns paginated repositories in team of organization. -func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) ([]*repo_model.Repository, error) { +func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (repo_model.RepositoryList, error) { sess := db.GetEngine(ctx) if opts.TeamID > 0 { sess = sess.In("id", diff --git a/models/repo/star.go b/models/repo/star.go index b3d3d795f8f52..89bdb7ac05cf5 100644 --- a/models/repo/star.go +++ b/models/repo/star.go @@ -85,3 +85,17 @@ func GetStargazers(repo *Repository, opts db.ListOptions) ([]*user_model.User, e users := make([]*user_model.User, 0, 8) return users, sess.Find(&users) } + +// ClearRepoStars clears all stars for a repository and from the user that starred it. +// Used when a repository is set to private. +func ClearRepoStars(ctx context.Context, repoID int64) error { + if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars=num_stars-1 WHERE id IN (SELECT `uid` FROM `star` WHERE repo_id = ?)", repoID); err != nil { + return err + } + + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = 0 WHERE id = ?", repoID); err != nil { + return err + } + + return db.DeleteBeans(ctx, Star{RepoID: repoID}) +} diff --git a/models/repo/star_test.go b/models/repo/star_test.go index 7221a6c8ebba0..f15ac12ebea65 100644 --- a/models/repo/star_test.go +++ b/models/repo/star_test.go @@ -51,3 +51,21 @@ func TestRepository_GetStargazers2(t *testing.T) { assert.NoError(t, err) assert.Len(t, gazers, 0) } + +func TestClearRepoStars(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + const userID = 2 + const repoID = 1 + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + assert.NoError(t, repo_model.StarRepo(userID, repoID, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + assert.NoError(t, repo_model.StarRepo(userID, repoID, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + assert.NoError(t, repo_model.ClearRepoStars(db.DefaultContext, repoID)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gazers, err := repo_model.GetStargazers(repo, db.ListOptions{Page: 0}) + assert.NoError(t, err) + assert.Len(t, gazers, 0) +} diff --git a/modules/context/permission.go b/modules/context/permission.go index cc53fb99ed747..0f72b8e244d4c 100644 --- a/modules/context/permission.go +++ b/modules/context/permission.go @@ -111,28 +111,36 @@ func RequireRepoReaderOr(unitTypes ...unit.Type) func(ctx *Context) { } } -// RequireRepoScopedToken check whether personal access token has repo scope -func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository) { +// CheckRepoScopedToken check whether personal access token has repo scope +func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) { if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true { return } - var err error scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) if ok { // it's a personal access token but not oauth2 token var scopeMatched bool - scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeRepo) + + requiredScopes := auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository) + + // check if scope only applies to public resources + publicOnly, err := scope.PublicOnly() if err != nil { ctx.ServerError("HasScope", err) return } - if !scopeMatched && !repo.IsPrivate { - scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopePublicRepo) - if err != nil { - ctx.ServerError("HasScope", err) - return - } + + if publicOnly && repo.IsPrivate { + ctx.Error(http.StatusForbidden) + return } + + scopeMatched, err = scope.HasScope(requiredScopes...) + if err != nil { + ctx.ServerError("HasScope", err) + return + } + if !scopeMatched { ctx.Error(http.StatusForbidden) return diff --git a/modules/metrics/collector.go b/modules/metrics/collector.go index 94699c161cd56..33678256c3cb3 100755 --- a/modules/metrics/collector.go +++ b/modules/metrics/collector.go @@ -18,7 +18,6 @@ const namespace = "gitea_" // exposes gitea metrics for prometheus type Collector struct { Accesses *prometheus.Desc - Actions *prometheus.Desc Attachments *prometheus.Desc BuildInfo *prometheus.Desc Comments *prometheus.Desc @@ -56,11 +55,6 @@ func NewCollector() Collector { "Number of Accesses", nil, nil, ), - Actions: prometheus.NewDesc( - namespace+"actions", - "Number of Actions", - nil, nil, - ), Attachments: prometheus.NewDesc( namespace+"attachments", "Number of Attachments", @@ -207,7 +201,6 @@ func NewCollector() Collector { // Describe returns all possible prometheus.Desc func (c Collector) Describe(ch chan<- *prometheus.Desc) { ch <- c.Accesses - ch <- c.Actions ch <- c.Attachments ch <- c.BuildInfo ch <- c.Comments @@ -246,11 +239,6 @@ func (c Collector) Collect(ch chan<- prometheus.Metric) { prometheus.GaugeValue, float64(stats.Counter.Access), ) - ch <- prometheus.MustNewConstMetric( - c.Actions, - prometheus.GaugeValue, - float64(stats.Counter.Action), - ) ch <- prometheus.MustNewConstMetric( c.Attachments, prometheus.GaugeValue, diff --git a/modules/repository/create.go b/modules/repository/create.go index e1f0bdcdf995e..0558d7f1c0068 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -397,6 +397,10 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili if err != nil { return err } + + if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil { + return err + } } // Create/Remove git-daemon-export-ok for git-daemon... diff --git a/modules/repository/repo.go b/modules/repository/repo.go index 62e1f31b9e5e6..bcb43f15e1d1f 100644 --- a/modules/repository/repo.go +++ b/modules/repository/repo.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net/http" - "path" "strings" "time" @@ -26,8 +25,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" - - "gopkg.in/ini.v1" ) /* @@ -240,14 +237,14 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, // cleanUpMigrateGitConfig removes mirror info which prevents "push --all". // This also removes possible user credentials. -func cleanUpMigrateGitConfig(configPath string) error { - cfg, err := ini.Load(configPath) - if err != nil { - return fmt.Errorf("open config file: %w", err) - } - cfg.DeleteSection("remote \"origin\"") - if err = cfg.SaveToIndent(configPath, "\t"); err != nil { - return fmt.Errorf("save config file: %w", err) +func cleanUpMigrateGitConfig(ctx context.Context, repoPath string) error { + cmd := git.NewCommand(ctx, "remote", "rm", "origin") + // if the origin does not exist + _, stderr, err := cmd.RunStdString(&git.RunOpts{ + Dir: repoPath, + }) + if err != nil && !strings.HasPrefix(stderr, "fatal: No such remote") { + return err } return nil } @@ -270,7 +267,7 @@ func CleanUpMigrateInfo(ctx context.Context, repo *repo_model.Repository) (*repo } if repo.HasWiki() { - if err := cleanUpMigrateGitConfig(path.Join(repo.WikiPath(), "config")); err != nil { + if err := cleanUpMigrateGitConfig(ctx, repo.WikiPath()); err != nil { return repo, fmt.Errorf("cleanUpMigrateGitConfig (wiki): %w", err) } } diff --git a/modules/setting/config_env.go b/modules/setting/config_env.go index dca9f2bb47bb5..63488037059ab 100644 --- a/modules/setting/config_env.go +++ b/modules/setting/config_env.go @@ -10,8 +10,6 @@ import ( "strings" "code.gitea.io/gitea/modules/log" - - "gopkg.in/ini.v1" ) const escapeRegexpString = "_0[xX](([0-9a-fA-F][0-9a-fA-F])+)_" @@ -89,7 +87,7 @@ func decodeEnvironmentKey(prefixGitea, suffixFile, envKey string) (ok bool, sect return ok, section, key, useFileValue } -func EnvironmentToConfig(cfg *ini.File, prefixGitea, suffixFile string, envs []string) (changed bool) { +func EnvironmentToConfig(cfg ConfigProvider, prefixGitea, suffixFile string, envs []string) (changed bool) { for _, kv := range envs { idx := strings.IndexByte(kv, '=') if idx < 0 { diff --git a/modules/setting/config_env_test.go b/modules/setting/config_env_test.go index d49464ecf785e..d574554bcc049 100644 --- a/modules/setting/config_env_test.go +++ b/modules/setting/config_env_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "gopkg.in/ini.v1" ) func TestDecodeEnvSectionKey(t *testing.T) { @@ -71,15 +70,15 @@ func TestDecodeEnvironmentKey(t *testing.T) { } func TestEnvironmentToConfig(t *testing.T) { - cfg := ini.Empty() + cfg, _ := NewConfigProviderFromData("") changed := EnvironmentToConfig(cfg, "GITEA__", "__FILE", nil) assert.False(t, changed) - cfg, err := ini.Load([]byte(` + cfg, err := NewConfigProviderFromData(` [sec] key = old -`)) +`) assert.NoError(t, err) changed = EnvironmentToConfig(cfg, "GITEA__", "__FILE", []string{"GITEA__sec__key=new"}) diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go index 37f5754ffdb0c..8b317d94e32c7 100644 --- a/modules/setting/config_provider.go +++ b/modules/setting/config_provider.go @@ -8,36 +8,71 @@ import ( "os" "path/filepath" "strings" + "time" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" - ini "gopkg.in/ini.v1" + "gopkg.in/ini.v1" //nolint:depguard ) +type ConfigKey interface { + Name() string + Value() string + SetValue(v string) + + In(defaultVal string, candidates []string) string + String() string + Strings(delim string) []string + + MustString(defaultVal string) string + MustBool(defaultVal ...bool) bool + MustInt(defaultVal ...int) int + MustInt64(defaultVal ...int64) int64 + MustDuration(defaultVal ...time.Duration) time.Duration +} + type ConfigSection interface { Name() string - MapTo(interface{}) error + MapTo(any) error HasKey(key string) bool - NewKey(name, value string) (*ini.Key, error) - Key(key string) *ini.Key - Keys() []*ini.Key - ChildSections() []*ini.Section + NewKey(name, value string) (ConfigKey, error) + Key(key string) ConfigKey + Keys() []ConfigKey + ChildSections() []ConfigSection } // ConfigProvider represents a config provider type ConfigProvider interface { Section(section string) ConfigSection + Sections() []ConfigSection NewSection(name string) (ConfigSection, error) GetSection(name string) (ConfigSection, error) Save() error + SaveTo(filename string) error +} + +type iniConfigProvider struct { + opts *Options + ini *ini.File + newFile bool // whether the file has not existed previously +} + +type iniConfigSection struct { + sec *ini.Section } +var ( + _ ConfigProvider = (*iniConfigProvider)(nil) + _ ConfigSection = (*iniConfigSection)(nil) + _ ConfigKey = (*ini.Key)(nil) +) + // ConfigSectionKey only searches the keys in the given section, but it is O(n). // ini package has a special behavior: with "[sec] a=1" and an empty "[sec.sub]", // then in "[sec.sub]", Key()/HasKey() can always see "a=1" because it always tries parent sections. // It returns nil if the key doesn't exist. -func ConfigSectionKey(sec ConfigSection, key string) *ini.Key { +func ConfigSectionKey(sec ConfigSection, key string) ConfigKey { if sec == nil { return nil } @@ -64,7 +99,7 @@ func ConfigSectionKeyString(sec ConfigSection, key string, def ...string) string // and the returned key is safe to be used with "MustXxx", it doesn't change the parent's values. // Otherwise, ini.Section.Key().MustXxx would pollute the parent section's keys. // It never returns nil. -func ConfigInheritedKey(sec ConfigSection, key string) *ini.Key { +func ConfigInheritedKey(sec ConfigSection, key string) ConfigKey { k := sec.Key(key) if k != nil && k.String() != "" { newKey, _ := sec.NewKey(k.Name(), k.String()) @@ -85,41 +120,64 @@ func ConfigInheritedKeyString(sec ConfigSection, key string, def ...string) stri return "" } -type iniFileConfigProvider struct { - opts *Options - *ini.File - newFile bool // whether the file has not existed previously +func (s *iniConfigSection) Name() string { + return s.sec.Name() +} + +func (s *iniConfigSection) MapTo(v any) error { + return s.sec.MapTo(v) +} + +func (s *iniConfigSection) HasKey(key string) bool { + return s.sec.HasKey(key) +} + +func (s *iniConfigSection) NewKey(name, value string) (ConfigKey, error) { + return s.sec.NewKey(name, value) +} + +func (s *iniConfigSection) Key(key string) ConfigKey { + return s.sec.Key(key) +} + +func (s *iniConfigSection) Keys() (keys []ConfigKey) { + for _, k := range s.sec.Keys() { + keys = append(keys, k) + } + return keys } -// NewConfigProviderFromData this function is only for testing +func (s *iniConfigSection) ChildSections() (sections []ConfigSection) { + for _, s := range s.sec.ChildSections() { + sections = append(sections, &iniConfigSection{s}) + } + return sections +} + +// NewConfigProviderFromData this function is mainly for testing purpose func NewConfigProviderFromData(configContent string) (ConfigProvider, error) { - var cfg *ini.File - var err error - if configContent == "" { - cfg = ini.Empty() - } else { - cfg, err = ini.Load(strings.NewReader(configContent)) - if err != nil { - return nil, err - } + cfg, err := ini.Load(strings.NewReader(configContent)) + if err != nil { + return nil, err } cfg.NameMapper = ini.SnackCase - return &iniFileConfigProvider{ - File: cfg, + return &iniConfigProvider{ + ini: cfg, newFile: true, }, nil } type Options struct { - CustomConf string // the ini file path - AllowEmpty bool // whether not finding configuration files is allowed (only true for the tests) - ExtraConfig string - DisableLoadCommonSettings bool + CustomConf string // the ini file path + AllowEmpty bool // whether not finding configuration files is allowed + ExtraConfig string + + DisableLoadCommonSettings bool // only used by "Init()", not used by "NewConfigProvider()" } -// newConfigProviderFromFile load configuration from file. +// NewConfigProviderFromFile load configuration from file. // NOTE: do not print any log except error. -func newConfigProviderFromFile(opts *Options) (*iniFileConfigProvider, error) { +func NewConfigProviderFromFile(opts *Options) (ConfigProvider, error) { cfg := ini.Empty() newFile := true @@ -147,61 +205,77 @@ func newConfigProviderFromFile(opts *Options) (*iniFileConfigProvider, error) { } cfg.NameMapper = ini.SnackCase - return &iniFileConfigProvider{ + return &iniConfigProvider{ opts: opts, - File: cfg, + ini: cfg, newFile: newFile, }, nil } -func (p *iniFileConfigProvider) Section(section string) ConfigSection { - return p.File.Section(section) +func (p *iniConfigProvider) Section(section string) ConfigSection { + return &iniConfigSection{sec: p.ini.Section(section)} +} + +func (p *iniConfigProvider) Sections() (sections []ConfigSection) { + for _, s := range p.ini.Sections() { + sections = append(sections, &iniConfigSection{s}) + } + return sections } -func (p *iniFileConfigProvider) NewSection(name string) (ConfigSection, error) { - return p.File.NewSection(name) +func (p *iniConfigProvider) NewSection(name string) (ConfigSection, error) { + sec, err := p.ini.NewSection(name) + if err != nil { + return nil, err + } + return &iniConfigSection{sec: sec}, nil } -func (p *iniFileConfigProvider) GetSection(name string) (ConfigSection, error) { - return p.File.GetSection(name) +func (p *iniConfigProvider) GetSection(name string) (ConfigSection, error) { + sec, err := p.ini.GetSection(name) + if err != nil { + return nil, err + } + return &iniConfigSection{sec: sec}, nil } -// Save save the content into file -func (p *iniFileConfigProvider) Save() error { - if p.opts.CustomConf == "" { +// Save saves the content into file +func (p *iniConfigProvider) Save() error { + filename := p.opts.CustomConf + if filename == "" { if !p.opts.AllowEmpty { return fmt.Errorf("custom config path must not be empty") } return nil } - if p.newFile { - if err := os.MkdirAll(filepath.Dir(CustomConf), os.ModePerm); err != nil { - return fmt.Errorf("failed to create '%s': %v", CustomConf, err) + if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil { + return fmt.Errorf("failed to create '%s': %v", filename, err) } } - if err := p.SaveTo(p.opts.CustomConf); err != nil { - return fmt.Errorf("failed to save '%s': %v", p.opts.CustomConf, err) + if err := p.ini.SaveTo(filename); err != nil { + return fmt.Errorf("failed to save '%s': %v", filename, err) } // Change permissions to be more restrictive - fi, err := os.Stat(CustomConf) + fi, err := os.Stat(filename) if err != nil { return fmt.Errorf("failed to determine current conf file permissions: %v", err) } if fi.Mode().Perm() > 0o600 { - if err = os.Chmod(CustomConf, 0o600); err != nil { + if err = os.Chmod(filename, 0o600); err != nil { log.Warn("Failed changing conf file permissions to -rw-------. Consider changing them manually.") } } return nil } -// a file is an implementation ConfigProvider and other implementations are possible, i.e. from docker, k8s, … -var _ ConfigProvider = &iniFileConfigProvider{} +func (p *iniConfigProvider) SaveTo(filename string) error { + return p.ini.SaveTo(filename) +} -func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting interface{}) { +func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting any) { if err := rootCfg.Section(sectionName).MapTo(setting); err != nil { log.Fatal("Failed to map %s settings: %v", sectionName, err) } @@ -219,3 +293,23 @@ func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) { log.Error("Deprecated `[%s]` `%s` present which has been copied to database table sys_setting", oldSection, oldKey) } } + +// NewConfigProviderForLocale loads locale configuration from source and others. "string" if for a local file path, "[]byte" is for INI content +func NewConfigProviderForLocale(source any, others ...any) (ConfigProvider, error) { + iniFile, err := ini.LoadSources(ini.LoadOptions{ + IgnoreInlineComment: true, + UnescapeValueCommentSymbols: true, + }, source, others...) + if err != nil { + return nil, fmt.Errorf("unable to load locale ini: %w", err) + } + iniFile.BlockMode = false + return &iniConfigProvider{ + ini: iniFile, + newFile: true, + }, nil +} + +func init() { + ini.PrettyFormat = false +} diff --git a/modules/setting/config_provider_test.go b/modules/setting/config_provider_test.go index 76f7048d59c9e..17650edea404c 100644 --- a/modules/setting/config_provider_test.go +++ b/modules/setting/config_provider_test.go @@ -4,6 +4,7 @@ package setting import ( + "os" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +65,57 @@ key = 123 assert.Equal(t, "", ConfigSectionKeyString(sec, "empty")) assert.Equal(t, "def", ConfigSectionKeyString(secSub, "empty")) } + +func TestNewConfigProviderFromFile(t *testing.T) { + _, err := NewConfigProviderFromFile(&Options{CustomConf: "no-such.ini", AllowEmpty: false}) + assert.ErrorContains(t, err, "unable to find configuration file") + + // load non-existing file and save + testFile := t.TempDir() + "/test.ini" + testFile1 := t.TempDir() + "/test1.ini" + cfg, err := NewConfigProviderFromFile(&Options{CustomConf: testFile, AllowEmpty: true}) + assert.NoError(t, err) + + sec, _ := cfg.NewSection("foo") + _, _ = sec.NewKey("k1", "a") + assert.NoError(t, cfg.Save()) + _, _ = sec.NewKey("k2", "b") + assert.NoError(t, cfg.SaveTo(testFile1)) + + bs, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "[foo]\nk1=a\n", string(bs)) + + bs, err = os.ReadFile(testFile1) + assert.NoError(t, err) + assert.Equal(t, "[foo]\nk1=a\nk2=b\n", string(bs)) + + // load existing file and save + cfg, err = NewConfigProviderFromFile(&Options{CustomConf: testFile, AllowEmpty: true}) + assert.NoError(t, err) + assert.Equal(t, "a", cfg.Section("foo").Key("k1").String()) + sec, _ = cfg.NewSection("bar") + _, _ = sec.NewKey("k1", "b") + assert.NoError(t, cfg.Save()) + bs, err = os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "[foo]\nk1=a\n\n[bar]\nk1=b\n", string(bs)) +} + +func TestNewConfigProviderForLocale(t *testing.T) { + // load locale from file + localeFile := t.TempDir() + "/locale.ini" + _ = os.WriteFile(localeFile, []byte(`k1=a`), 0o644) + cfg, err := NewConfigProviderForLocale(localeFile) + assert.NoError(t, err) + assert.Equal(t, "a", cfg.Section("").Key("k1").String()) + + // load locale from bytes + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar")) + assert.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + cfg, err = NewConfigProviderForLocale([]byte("k1=foo\nk2=bar"), []byte("k2=xxx")) + assert.NoError(t, err) + assert.Equal(t, "foo", cfg.Section("").Key("k1").String()) + assert.Equal(t, "xxx", cfg.Section("").Key("k2").String()) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 71cd9a12a99fd..1967d9e79aa12 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -201,7 +201,7 @@ func Init(opts *Options) { opts.CustomConf = CustomConf } var err error - CfgProvider, err = newConfigProviderFromFile(opts) + CfgProvider, err = NewConfigProviderFromFile(opts) if err != nil { log.Fatal("Init[%v]: %v", opts, err) } diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go index 0664bcfd1a227..aa784e866fce4 100644 --- a/modules/translation/i18n/localestore.go +++ b/modules/translation/i18n/localestore.go @@ -7,8 +7,7 @@ import ( "fmt" "code.gitea.io/gitea/modules/log" - - "gopkg.in/ini.v1" + "code.gitea.io/gitea/modules/setting" ) // This file implements the static LocaleStore that will not watch for changes @@ -47,14 +46,10 @@ func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, more l := &locale{store: store, langName: langName, idxToMsgMap: make(map[int]string)} store.localeMap[l.langName] = l - iniFile, err := ini.LoadSources(ini.LoadOptions{ - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, source, moreSource) + iniFile, err := setting.NewConfigProviderForLocale(source, moreSource) if err != nil { return fmt.Errorf("unable to load ini: %w", err) } - iniFile.BlockMode = false for _, section := range iniFile.Sections() { for _, key := range section.Keys() { diff --git a/modules/util/truncate.go b/modules/util/truncate.go index f41d27d8b7432..77b116eeff27f 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -3,7 +3,10 @@ package util -import "unicode/utf8" +import ( + "strings" + "unicode/utf8" +) // in UTF8 "…" is 3 bytes so doesn't really gain us anything... const ( @@ -35,3 +38,17 @@ func SplitStringAtByteN(input string, n int) (left, right string) { return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:] } + +// SplitTrimSpace splits the string at given separator and trims leading and trailing space +func SplitTrimSpace(input, sep string) []string { + // replace CRLF with LF + input = strings.ReplaceAll(input, "\r\n", "\n") + + var stringList []string + for _, s := range strings.Split(input, sep) { + // trim leading and trailing space + stringList = append(stringList, strings.TrimSpace(s)) + } + + return stringList +} diff --git a/options/license/GPL-3.0-interface-exception b/options/license/GPL-3.0-interface-exception new file mode 100644 index 0000000000000..a86a7fffd79f1 --- /dev/null +++ b/options/license/GPL-3.0-interface-exception @@ -0,0 +1,7 @@ +Linking [name of library] statically or dynamically with other modules is making a combined work based on [name of library]. Thus, the terms and conditions of the GNU General Public License cover the whole combination. + +As a special exception, the copyright holders of [name of library] give you permission to combine [name of library] program with free software programs or libraries that are released under the GNU LGPL and with independent modules that communicate with [name of library] solely through the [name of library's interface] interface. You may copy and distribute such a system following the terms of the GNU GPL for [name of library] and the licenses of the other code concerned, provided that you include the source code of that other code when and as the GNU GPL requires distribution of source code and provided that you do not modify the [name of library's interface] interface. + +Note that people who make modified versions of [name of library] are not obligated to grant this special exception for their modified versions; it is their choice whether to do so. The GNU General Public License gives permission to release a modified version without this exception; this exception also makes it possible to release a modified version which carries forward this exception. If you modify the [name of library's interface] interface, this exception does not apply to your modified version of [name of library], and you must remove this exception when you distribute your modified version. + +This exception is an additional permission under section 7 of the GNU General Public License, version 3 ("GPLv3") diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bf6e4b75247f3..195252c47d176 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -796,7 +796,6 @@ unbind_success = The social account has been unlinked from your Gitea account. manage_access_token = Manage Access Tokens generate_new_token = Generate New Token tokens_desc = These tokens grant access to your account using the Gitea API. -new_token_desc = Applications using a token have full access to your account. token_name = Token Name generate_token = Generate Token generate_token_success = Your new token has been generated. Copy it now as it will not be shown again. @@ -807,8 +806,13 @@ access_token_deletion_cancel_action = Cancel access_token_deletion_confirm_action = Delete access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue? delete_token_success = The token has been deleted. Applications using it no longer have access to your account. -select_scopes = Select scopes -scopes_list = Scopes: +repo_and_org_access = Repository and Organization Access +permissions_public_only = Public only +permissions_access_all = All (public, private, and limited) +select_permissions = Select permissions +scoped_token_desc = Selected token scopes limit authentication only to the corresponding API routes. Read the documentation for more information. +at_least_one_permission = You must select at least one permission to create a token +permissions_list = Permissions: manage_oauth2_applications = Manage OAuth2 Applications edit_oauth2_application = Edit OAuth2 Application @@ -822,7 +826,7 @@ create_oauth2_application_success = You've successfully created a new OAuth2 app update_oauth2_application_success = You've successfully updated the OAuth2 application. oauth2_application_name = Application Name oauth2_confidential_client = Confidential Client. Select for apps that keep the secret confidential, such as web apps. Do not select for native apps including desktop and mobile apps. -oauth2_redirect_uri = Redirect URI +oauth2_redirect_uris = Redirect URIs. Please use a new line for every URI. save_application = Save oauth2_client_id = Client ID oauth2_client_secret = Client Secret @@ -968,6 +972,7 @@ mirror_password_blank_placeholder = (Unset) mirror_password_help = Change the username to erase a stored password. watchers = Watchers stargazers = Stargazers +stars_remove_warning = This will remove all stars from this repository. forks = Forks reactions_more = and %d more unit_disabled = The site administrator has disabled this repository section. @@ -1755,7 +1760,7 @@ milestones.no_due_date = No due date milestones.open = Open milestones.close = Close milestones.new_subheader = Milestones organize issues and track progress. -milestones.completeness = %d%% Completed +milestones.completeness = %d%% Completed milestones.create = Create Milestone milestones.title = Title milestones.desc = Description @@ -2619,7 +2624,6 @@ dashboard.new_version_hint = Gitea %s is now available, you are running %s. Chec dashboard.statistic = Summary dashboard.operations = Maintenance Operations dashboard.system_status = System Status -dashboard.statistic_info = The Gitea database holds %d users, %d organizations, %d public keys, %d repositories, %d watches, %d stars, ~%d actions, %d accesses, %d issues, %d comments, %d social accounts, %d follows, %d mirrors, %d releases, %d authentication sources, %d webhooks, %d milestones, %d labels, %d hook tasks, %d teams, %d update tasks, %d attachments. dashboard.operation_name = Operation Name dashboard.operation_switch = Switch dashboard.operation_run = Run @@ -3060,6 +3064,8 @@ config.xorm_log_sql = Log SQL config.get_setting_failed = Get setting %s failed config.set_setting_failed = Set setting %s failed +monitor.stats = Stats + monitor.cron = Cron Tasks monitor.name = Name monitor.schedule = Schedule diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 45e36e84fe788..37361a8b963bd 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -236,44 +236,85 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.APIContext) } } +// 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 + } + + ctx.Data["ApiTokenScopePublicRepoOnly"] = false + ctx.Data["ApiTokenScopePublicOrgOnly"] = false + + // 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...) + + // 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 + } + + // this context is used by the middleware in the specific route + ctx.Data["ApiTokenScopePublicRepoOnly"] = publicOnly && auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository) + ctx.Data["ApiTokenScopePublicOrgOnly"] = publicOnly && auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization) + + allow, err := scope.HasScope(requiredScopes...) + if err != nil { + ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error()) + return + } + + if allow { + return + } + + ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes)) + } +} + // Contexter middleware already checks token for user sign in process. -func reqToken(requiredScope auth_model.AccessTokenScope) func(ctx *context.APIContext) { +func reqToken() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { // If actions token is present if true == ctx.Data["IsActionsToken"] { return } - // If OAuth2 token is present - if _, ok := ctx.Data["ApiTokenScope"]; ctx.Data["IsApiToken"] == true && ok { - // no scope required - if requiredScope == "" { - return - } + if true == ctx.Data["IsApiToken"] { + publicRepo, pubRepoExists := ctx.Data["ApiTokenScopePublicRepoOnly"] + publicOrg, pubOrgExists := ctx.Data["ApiTokenScopePublicOrgOnly"] - // check scope - scope := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) - allow, err := scope.HasScope(requiredScope) - if err != nil { - ctx.Error(http.StatusForbidden, "reqToken", "parsing token failed: "+err.Error()) - return - } - if allow { + if pubRepoExists && publicRepo.(bool) && + ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos") return } - // if requires 'repo' scope, but only has 'public_repo' scope, allow it only if the repo is public - if requiredScope == auth_model.AccessTokenScopeRepo { - if allowPublicRepo, err := scope.HasScope(auth_model.AccessTokenScopePublicRepo); err == nil && allowPublicRepo { - if ctx.Repo.Repository != nil && !ctx.Repo.Repository.IsPrivate { - return - } - } + if pubOrgExists && publicOrg.(bool) && + ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs") + return } - ctx.Error(http.StatusForbidden, "reqToken", "token does not have required scope: "+requiredScope) return } + if ctx.IsBasicAuth { ctx.CheckForOTP() return @@ -700,7 +741,7 @@ func Routes(ctx gocontext.Context) *web.Route { ctx.Redirect(setting.AppSubURL + "/api/swagger") }) } - m.Get("/version", misc.Version) + if setting.Federation.Enabled { m.Get("/nodeinfo", misc.NodeInfo) m.Group("/activitypub", func() { @@ -713,37 +754,43 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context_service.UserIDAssignmentAPI()) - }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) } - m.Get("/signing-key.gpg", misc.SigningKey) - m.Post("/markup", bind(api.MarkupOption{}), misc.Markup) - m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown) - m.Post("/markdown/raw", 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 'notification' scope) + // Misc (requires 'misc' scope) + 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) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryMisc)) + + // Notifications (requires 'notifications' scope) m.Group("/notifications", func() { m.Combo(""). Get(notify.ListNotifications). - Put(notify.ReadNotifications) + Put(notify.ReadNotifications, reqToken()) m.Get("/new", notify.NewAvailable) m.Combo("/threads/{id}"). Get(notify.GetThread). - Patch(notify.ReadThread) - }, reqToken(auth_model.AccessTokenScopeNotification)) + Patch(notify.ReadThread, reqToken()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) - // Users (no scope required) + // Users (requires user scope) m.Group("/users", func() { m.Get("/search", reqExploreSignIn(), user.Search) @@ -754,18 +801,18 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("/heatmap", user.GetUserHeatmapData) } - m.Get("/repos", reqExploreSignIn(), user.ListUserRepos) + m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqExploreSignIn(), user.ListUserRepos) m.Group("/tokens", func() { m.Combo("").Get(user.ListAccessTokens). - Post(bind(api.CreateAccessTokenOption{}), user.CreateAccessToken) - m.Combo("/{id}").Delete(user.DeleteAccessToken) + Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken) + m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken) }, reqBasicAuth()) m.Get("/activities/feeds", user.ListUserActivityFeeds) }, context_service.UserAssignmentAPI()) - }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) - // (no scope required) + // Users (requires user scope) m.Group("/users", func() { m.Group("/{username}", func() { m.Get("/keys", user.ListPublicKeys) @@ -781,59 +828,61 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("/subscriptions", user.GetWatchedRepos) }, context_service.UserAssignmentAPI()) - }, reqToken("")) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + // Users (requires user scope) m.Group("/user", func() { m.Get("", user.GetAuthenticatedUser) m.Group("/settings", func() { - m.Get("", reqToken(auth_model.AccessTokenScopeReadUser), user.GetUserSettings) - m.Patch("", reqToken(auth_model.AccessTokenScopeUser), bind(api.UserSettingsOptions{}), user.UpdateUserSettings) - }) - m.Combo("/emails").Get(reqToken(auth_model.AccessTokenScopeReadUser), user.ListEmails). - Post(reqToken(auth_model.AccessTokenScopeUser), bind(api.CreateEmailOption{}), user.AddEmail). - Delete(reqToken(auth_model.AccessTokenScopeUser), bind(api.DeleteEmailOption{}), user.DeleteEmail) + 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) m.Get("/followers", user.ListMyFollowers) m.Group("/following", func() { m.Get("", user.ListMyFollowing) m.Group("/{username}", func() { m.Get("", user.CheckMyFollowing) - m.Put("", reqToken(auth_model.AccessTokenScopeUserFollow), user.Follow) // requires 'user:follow' scope - m.Delete("", reqToken(auth_model.AccessTokenScopeUserFollow), user.Unfollow) // requires 'user:follow' scope + m.Put("", user.Follow) + m.Delete("", user.Unfollow) }, context_service.UserAssignmentAPI()) }) // (admin:public_key scope) m.Group("/keys", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadPublicKey), user.ListMyPublicKeys). - Post(reqToken(auth_model.AccessTokenScopeWritePublicKey), bind(api.CreateKeyOption{}), user.CreatePublicKey) - m.Combo("/{id}").Get(reqToken(auth_model.AccessTokenScopeReadPublicKey), user.GetPublicKey). - Delete(reqToken(auth_model.AccessTokenScopeWritePublicKey), user.DeletePublicKey) + 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(reqToken(auth_model.AccessTokenScopeReadApplication), user.ListOauth2Applications). - Post(reqToken(auth_model.AccessTokenScopeWriteApplication), bind(api.CreateOAuth2ApplicationOptions{}), user.CreateOauth2Application) + Get(user.ListOauth2Applications). + Post(bind(api.CreateOAuth2ApplicationOptions{}), user.CreateOauth2Application) m.Combo("/oauth2/{id}"). - Delete(reqToken(auth_model.AccessTokenScopeWriteApplication), user.DeleteOauth2Application). - Patch(reqToken(auth_model.AccessTokenScopeWriteApplication), bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). - Get(reqToken(auth_model.AccessTokenScopeReadApplication), user.GetOauth2Application) + Delete(user.DeleteOauth2Application). + Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). + Get(user.GetOauth2Application) }) // (admin:gpg_key scope) m.Group("/gpg_keys", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadGPGKey), user.ListMyGPGKeys). - Post(reqToken(auth_model.AccessTokenScopeWriteGPGKey), bind(api.CreateGPGKeyOption{}), user.CreateGPGKey) - m.Combo("/{id}").Get(reqToken(auth_model.AccessTokenScopeReadGPGKey), user.GetGPGKey). - Delete(reqToken(auth_model.AccessTokenScopeWriteGPGKey), user.DeleteGPGKey) + 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", reqToken(auth_model.AccessTokenScopeReadGPGKey), user.GetVerificationToken) - m.Post("/gpg_key_verify", reqToken(auth_model.AccessTokenScopeReadGPGKey), bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) + m.Get("/gpg_key_token", user.GetVerificationToken) + m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) // (repo scope) - m.Combo("/repos", reqToken(auth_model.AccessTokenScopeRepo)).Get(user.ListMyRepos). + m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). Post(bind(api.CreateRepoOption{}), repo.Create) // (repo scope) @@ -844,64 +893,65 @@ func Routes(ctx gocontext.Context) *web.Route { m.Put("", user.Star) m.Delete("", user.Unstar) }, repoAssignment()) - }, reqToken(auth_model.AccessTokenScopeRepo)) - m.Get("/times", reqToken(auth_model.AccessTokenScopeRepo), repo.ListMyTrackedTimes) - m.Get("/stopwatches", reqToken(auth_model.AccessTokenScopeRepo), repo.GetStopwatches) - m.Get("/subscriptions", reqToken(auth_model.AccessTokenScopeRepo), user.GetMyWatchedRepos) - m.Get("/teams", reqToken(auth_model.AccessTokenScopeRepo), org.ListUserTeams) + }, 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) - }, reqToken(auth_model.AccessTokenScopeAdminUserHook), reqWebhooksEnabled()) - }, reqToken("")) + }, reqWebhooksEnabled()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) - // Repositories - m.Post("/org/{org}/repos", reqToken(auth_model.AccessTokenScopeAdminOrg), bind(api.CreateRepoOption{}), repo.CreateOrgRepoDeprecated) + // Repositories (requires repo scope, org scope) + m.Post("/org/{org}/repos", + tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), + reqToken(), + bind(api.CreateRepoOption{}), + repo.CreateOrgRepoDeprecated) - m.Combo("/repositories/{id}", reqToken(auth_model.AccessTokenScopeRepo)).Get(repo.GetByID) + // requires repo scope + 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) - m.Get("/issues/search", repo.SearchIssues) - // (repo scope) - m.Post("/migrate", reqToken(auth_model.AccessTokenScopeRepo), bind(api.MigrateRepoOptions{}), repo.Migrate) + m.Post("/migrate", reqToken(), bind(api.MigrateRepoOptions{}), repo.Migrate) m.Group("/{username}/{reponame}", func() { m.Combo("").Get(reqAnyRepoReader(), repo.Get). - Delete(reqToken(auth_model.AccessTokenScopeDeleteRepo), reqOwner(), repo.Delete). - Patch(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) - m.Post("/generate", reqToken(auth_model.AccessTokenScopeRepo), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) + 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(auth_model.AccessTokenScopeRepo)) - m.Combo("/notifications", reqToken(auth_model.AccessTokenScopeNotification)). - Get(notify.ListRepoNotifications). - Put(notify.ReadRepoNotifications) + }, reqToken()) m.Group("/hooks/git", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadRepoHook), repo.ListGitHooks) + m.Combo("").Get(repo.ListGitHooks) m.Group("/{id}", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadRepoHook), repo.GetGitHook). - Patch(reqToken(auth_model.AccessTokenScopeWriteRepoHook), bind(api.EditGitHookOption{}), repo.EditGitHook). - Delete(reqToken(auth_model.AccessTokenScopeWriteRepoHook), repo.DeleteGitHook) + m.Combo("").Get(repo.GetGitHook). + Patch(bind(api.EditGitHookOption{}), repo.EditGitHook). + Delete(repo.DeleteGitHook) }) - }, reqAdmin(), reqGitHook(), context.ReferencesGitRepo(true)) + }, reqToken(), reqAdmin(), reqGitHook(), context.ReferencesGitRepo(true)) m.Group("/hooks", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadRepoHook), repo.ListHooks). - Post(reqToken(auth_model.AccessTokenScopeWriteRepoHook), bind(api.CreateHookOption{}), repo.CreateHook) + m.Combo("").Get(repo.ListHooks). + Post(bind(api.CreateHookOption{}), repo.CreateHook) m.Group("/{id}", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadRepoHook), repo.GetHook). - Patch(reqToken(auth_model.AccessTokenScopeWriteRepoHook), bind(api.EditHookOption{}), repo.EditHook). - Delete(reqToken(auth_model.AccessTokenScopeWriteRepoHook), repo.DeleteHook) - m.Post("/tests", reqToken(auth_model.AccessTokenScopeReadRepoHook), context.ReferencesGitRepo(), context.RepoRefForAPI, repo.TestHook) + m.Combo("").Get(repo.GetHook). + Patch(bind(api.EditHookOption{}), repo.EditHook). + Delete(repo.DeleteHook) + m.Post("/tests", context.ReferencesGitRepo(), context.RepoRefForAPI, repo.TestHook) }) - }, reqAdmin(), reqWebhooksEnabled()) + }, reqToken(), reqAdmin(), reqWebhooksEnabled()) m.Group("/collaborators", func() { m.Get("", reqAnyRepoReader(), repo.ListCollaborators) m.Group("/{collaborator}", func() { @@ -910,25 +960,25 @@ func Routes(ctx gocontext.Context) *web.Route { Delete(reqAdmin(), repo.DeleteCollaborator) m.Get("/permission", repo.GetRepoPermissions) }) - }, reqToken(auth_model.AccessTokenScopeRepo)) - m.Get("/assignees", reqToken(auth_model.AccessTokenScopeRepo), reqAnyRepoReader(), repo.GetAssignees) - m.Get("/reviewers", reqToken(auth_model.AccessTokenScopeRepo), reqAnyRepoReader(), repo.GetReviewers) + }, reqToken()) + 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(auth_model.AccessTokenScopeRepo)) + }, reqToken()) m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive) m.Combo("/forks").Get(repo.ListForks). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) + 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(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeCode), repo.DeleteBranch) - m.Post("", reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeCode), bind(api.CreateBranchRepoOption{}), repo.CreateBranch) + m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), repo.DeleteBranch) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateBranchRepoOption{}), repo.CreateBranch) }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) m.Group("/branch_protections", func() { m.Get("", repo.ListBranchProtections) @@ -938,218 +988,112 @@ func Routes(ctx gocontext.Context) *web.Route { m.Patch("", bind(api.EditBranchProtectionOption{}), repo.EditBranchProtection) m.Delete("", repo.DeleteBranchProtection) }) - }, reqToken(auth_model.AccessTokenScopeRepo), reqAdmin()) + }, reqToken(), reqAdmin()) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) - m.Post("", reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeCode), bind(api.CreateTagOption{}), repo.CreateTag) - m.Delete("/*", reqToken(auth_model.AccessTokenScopeRepo), repo.DeleteTag) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateTagOption{}), repo.CreateTag) + m.Delete("/*", reqToken(), repo.DeleteTag) }, reqRepoReader(unit.TypeCode), 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(auth_model.AccessTokenScopeRepo), reqAdmin()) + }, reqToken(), reqAdmin()) m.Group("/times", func() { m.Combo("").Get(repo.ListTrackedTimesByRepository) m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser) - }, mustEnableIssues, reqToken(auth_model.AccessTokenScopeRepo)) + }, mustEnableIssues, reqToken()) m.Group("/wiki", func() { m.Combo("/page/{pageName}"). Get(repo.GetWikiPage). - Patch(mustNotBeArchived, reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.EditWikiPage). - Delete(mustNotBeArchived, reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeWiki), repo.DeleteWikiPage) + Patch(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.EditWikiPage). + Delete(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), repo.DeleteWikiPage) m.Get("/revisions/{pageName}", repo.ListPageRevisions) - m.Post("/new", mustNotBeArchived, reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage) + m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage) m.Get("/pages", repo.ListWikiPages) }, mustEnableWiki) - m.Group("/issues", func() { - m.Combo("").Get(repo.ListIssues). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue) - m.Get("/pinned", repo.ListPinnedIssues) - m.Group("/comments", func() { - m.Get("", repo.ListRepoIssueComments) - m.Group("/{id}", func() { - m.Combo(""). - Get(repo.GetIssueComment). - Patch(mustNotBeArchived, reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditIssueCommentOption{}), repo.EditIssueComment). - Delete(reqToken(auth_model.AccessTokenScopeRepo), repo.DeleteIssueComment) - m.Combo("/reactions"). - Get(repo.GetIssueCommentReactions). - Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditReactionOption{}), repo.PostIssueCommentReaction). - Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditReactionOption{}), repo.DeleteIssueCommentReaction) - m.Group("/assets", func() { - m.Combo(""). - Get(repo.ListIssueCommentAttachments). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.CreateIssueCommentAttachment) - m.Combo("/{asset}"). - Get(repo.GetIssueCommentAttachment). - Patch(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). - Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.DeleteIssueCommentAttachment) - }, mustEnableAttachments) - }) - }) - m.Group("/{index}", func() { - m.Combo("").Get(repo.GetIssue). - Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditIssueOption{}), repo.EditIssue). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue) - m.Group("/comments", func() { - m.Combo("").Get(repo.ListIssueComments). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) - m.Combo("/{id}", reqToken(auth_model.AccessTokenScopeRepo)).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(auth_model.AccessTokenScopeRepo), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). - Put(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). - Delete(reqToken(auth_model.AccessTokenScopeRepo), repo.ClearIssueLabels) - m.Delete("/{id}", reqToken(auth_model.AccessTokenScopeRepo), 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(auth_model.AccessTokenScopeRepo)) - m.Combo("/deadline").Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditDeadlineOption{}), repo.UpdateIssueDeadline) - m.Group("/stopwatch", func() { - m.Post("/start", reqToken(auth_model.AccessTokenScopeRepo), repo.StartIssueStopwatch) - m.Post("/stop", reqToken(auth_model.AccessTokenScopeRepo), repo.StopIssueStopwatch) - m.Delete("/delete", reqToken(auth_model.AccessTokenScopeRepo), repo.DeleteIssueStopwatch) - }) - m.Group("/subscriptions", func() { - m.Get("", repo.GetIssueSubscribers) - m.Get("/check", reqToken(auth_model.AccessTokenScopeRepo), repo.CheckIssueSubscription) - m.Put("/{user}", reqToken(auth_model.AccessTokenScopeRepo), repo.AddIssueSubscription) - m.Delete("/{user}", reqToken(auth_model.AccessTokenScopeRepo), repo.DelIssueSubscription) - }) - m.Combo("/reactions"). - Get(repo.GetIssueReactions). - Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditReactionOption{}), repo.PostIssueReaction). - Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditReactionOption{}), repo.DeleteIssueReaction) - m.Group("/assets", func() { - m.Combo(""). - Get(repo.ListIssueAttachments). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.CreateIssueAttachment) - m.Combo("/{asset}"). - Get(repo.GetIssueAttachment). - Patch(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). - Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.DeleteIssueAttachment) - }, mustEnableAttachments) - m.Combo("/dependencies"). - Get(repo.GetIssueDependencies). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.CreateIssueDependency). - Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.IssueMeta{}), repo.RemoveIssueDependency) - m.Combo("/blocks"). - Get(repo.GetIssueBlocks). - Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking). - Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking) - m.Group("/pin", func() { - m.Combo(""). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.PinIssue). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.UnpinIssue) - m.Patch("/{position}", reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.MoveIssuePin) - }) - }) - }, mustEnableIssuesOrPulls) - m.Group("/labels", func() { - m.Combo("").Get(repo.ListLabels). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.CreateLabelOption{}), repo.CreateLabel) - m.Combo("/{id}").Get(repo.GetLabel). - Patch(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditLabelOption{}), repo.EditLabel). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteLabel) - }) - m.Post("/markup", reqToken(auth_model.AccessTokenScopeRepo), bind(api.MarkupOption{}), misc.Markup) - m.Post("/markdown", reqToken(auth_model.AccessTokenScopeRepo), bind(api.MarkdownOption{}), misc.Markdown) - m.Post("/markdown/raw", reqToken(auth_model.AccessTokenScopeRepo), misc.MarkdownRaw) - m.Group("/milestones", func() { - m.Combo("").Get(repo.ListMilestones). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.CreateMilestoneOption{}), repo.CreateMilestone) - m.Combo("/{id}").Get(repo.GetMilestone). - Patch(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) - }) + 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("/stargazers", repo.ListStargazers) m.Get("/subscribers", repo.ListSubscribers) m.Group("/subscription", func() { m.Get("", user.IsWatching) - m.Put("", reqToken(auth_model.AccessTokenScopeRepo), user.Watch) - m.Delete("", reqToken(auth_model.AccessTokenScopeRepo), user.Unwatch) + m.Put("", reqToken(), user.Watch) + m.Delete("", reqToken(), user.Unwatch) }) m.Group("/releases", func() { m.Combo("").Get(repo.ListReleases). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), repo.CreateRelease) + Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), repo.CreateRelease) m.Combo("/latest").Get(repo.GetLatestRelease) m.Group("/{id}", func() { m.Combo("").Get(repo.GetRelease). - Patch(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), repo.EditRelease). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease) + Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), repo.EditRelease). + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease) m.Group("/assets", func() { m.Combo("").Get(repo.ListReleaseAttachments). - Post(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), repo.CreateReleaseAttachment) + Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.CreateReleaseAttachment) m.Combo("/{asset}").Get(repo.GetReleaseAttachment). - Patch(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), bind(api.EditAttachmentOptions{}), repo.EditReleaseAttachment). - Delete(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseAttachment) + 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(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) }) }, reqRepoReader(unit.TypeReleases)) - m.Post("/mirror-sync", reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeCode), repo.MirrorSync) - m.Post("/push_mirrors-sync", reqAdmin(), reqToken(auth_model.AccessTokenScopeRepo), repo.PushMirrorSync) + m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), repo.MirrorSync) + m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), repo.PushMirrorSync) m.Group("/push_mirrors", func() { m.Combo("").Get(repo.ListPushMirrors). Post(bind(api.CreatePushMirrorOption{}), repo.AddPushMirror) m.Combo("/{name}"). Delete(repo.DeletePushMirrorByRemoteName). Get(repo.GetPushMirrorByName) - }, reqAdmin(), reqToken(auth_model.AccessTokenScopeRepo)) + }, 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(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) + Post(reqToken(), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) m.Get("/pinned", repo.ListPinnedPullRequests) m.Group("/{index}", func() { m.Combo("").Get(repo.GetPullRequest). - Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest) + Patch(reqToken(), bind(api.EditPullRequestOption{}), repo.EditPullRequest) m.Get(".{diffType:diff|patch}", repo.DownloadPullDiffOrPatch) - m.Post("/update", reqToken(auth_model.AccessTokenScopeRepo), repo.UpdatePullRequest) + m.Post("/update", reqToken(), repo.UpdatePullRequest) m.Get("/commits", repo.GetPullRequestCommits) m.Get("/files", repo.GetPullRequestFiles) m.Combo("/merge").Get(repo.IsPullRequestMerged). - Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest). - Delete(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, repo.CancelScheduledAutoMerge) + Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest). + Delete(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge) m.Group("/reviews", func() { m.Combo(""). Get(repo.ListPullReviews). - Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.CreatePullReviewOptions{}), repo.CreatePullReview) + Post(reqToken(), bind(api.CreatePullReviewOptions{}), repo.CreatePullReview) m.Group("/{id}", func() { m.Combo(""). Get(repo.GetPullReview). - Delete(reqToken(auth_model.AccessTokenScopeRepo), repo.DeletePullReview). - Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview) + Delete(reqToken(), repo.DeletePullReview). + Post(reqToken(), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview) m.Combo("/comments"). Get(repo.GetPullReviewComments) - m.Post("/dismissals", reqToken(auth_model.AccessTokenScopeRepo), bind(api.DismissPullReviewOptions{}), repo.DismissPullReview) - m.Post("/undismissals", reqToken(auth_model.AccessTokenScopeRepo), repo.UnDismissPullReview) + m.Post("/dismissals", reqToken(), bind(api.DismissPullReviewOptions{}), repo.DismissPullReview) + m.Post("/undismissals", reqToken(), repo.UnDismissPullReview) }) }) - m.Combo("/requested_reviewers", reqToken(auth_model.AccessTokenScopeRepo)). + m.Combo("/requested_reviewers", reqToken()). Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests). Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests) }) }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) m.Group("/statuses", func() { m.Combo("/{sha}").Get(repo.GetCommitStatuses). - Post(reqToken(auth_model.AccessTokenScopeRepoStatus), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus) + Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus) }, reqRepoReader(unit.TypeCode)) m.Group("/commits", func() { m.Get("", context.ReferencesGitRepo(), repo.GetAllCommits) @@ -1170,24 +1114,24 @@ func Routes(ctx gocontext.Context) *web.Route { 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(auth_model.AccessTokenScopeRepo), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch) + m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) - m.Post("", reqToken(auth_model.AccessTokenScopeRepo), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles) + m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles) m.Get("/*", repo.GetContents) m.Group("/*", func() { m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile) m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, repo.UpdateFile) m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, repo.DeleteFile) - }, reqToken(auth_model.AccessTokenScopeRepo)) + }, reqToken()) }, reqRepoReader(unit.TypeCode)) m.Get("/signing-key.gpg", misc.SigningKey) m.Group("/topics", func() { m.Combo("").Get(repo.ListTopics). - Put(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) + Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) m.Group("/{topic}", func() { - m.Combo("").Put(reqToken(auth_model.AccessTokenScopeRepo), repo.AddTopic). - Delete(reqToken(auth_model.AccessTokenScopeRepo), repo.DeleteTopic) + m.Combo("").Put(reqToken(), repo.AddTopic). + Delete(reqToken(), repo.DeleteTopic) }, reqAdmin()) }, reqAnyRepoReader()) m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates) @@ -1197,54 +1141,177 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("/activities/feeds", repo.ListRepoActivityFeeds) m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed) }, repoAssignment()) - }) + }, 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()) + }, 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{}), repo.CreateIssue) + m.Get("/pinned", 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, repo.CreateIssueCommentAttachment) + m.Combo("/{asset}"). + Get(repo.GetIssueCommentAttachment). + Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). + Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueCommentAttachment) + }, mustEnableAttachments) + }) + }) + 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(), repo.ClearIssueLabels) + m.Delete("/{id}", reqToken(), 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, repo.CreateIssueAttachment) + m.Combo("/{asset}"). + 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()) + }, 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(auth_model.AccessTokenScopeReadPackage), packages.GetPackage) - m.Delete("", reqToken(auth_model.AccessTokenScopeDeletePackage), reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) - m.Get("/files", reqToken(auth_model.AccessTokenScopeReadPackage), packages.ListPackageFiles) + m.Get("", reqToken(), packages.GetPackage) + m.Delete("", reqToken(), reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) + m.Get("/files", reqToken(), packages.ListPackageFiles) }) - m.Get("/", reqToken(auth_model.AccessTokenScopeReadPackage), packages.ListPackages) - }, context_service.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead)) + m.Get("/", reqToken(), packages.ListPackages) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context_service.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead)) // Organizations - m.Get("/user/orgs", reqToken(auth_model.AccessTokenScopeReadOrg), org.ListMyOrgs) + m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) m.Group("/users/{username}/orgs", func() { - m.Get("", reqToken(auth_model.AccessTokenScopeReadOrg), org.ListUserOrgs) - m.Get("/{org}/permissions", reqToken(auth_model.AccessTokenScopeReadOrg), org.GetUserOrgsPermissions) - }, context_service.UserAssignmentAPI()) - m.Post("/orgs", reqToken(auth_model.AccessTokenScopeWriteOrg), bind(api.CreateOrgOption{}), org.Create) - m.Get("/orgs", org.GetAll) + m.Get("", reqToken(), org.ListUserOrgs) + m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context_service.UserAssignmentAPI()) + 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(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.Delete) + Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). + Delete(reqToken(), reqOrgOwnership(), org.Delete) m.Combo("/repos").Get(user.ListOrgRepos). - Post(reqToken(auth_model.AccessTokenScopeWriteOrg), bind(api.CreateRepoOption{}), repo.CreateOrgRepo) + Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo) m.Group("/members", func() { - m.Get("", reqToken(auth_model.AccessTokenScopeReadOrg), org.ListMembers) - m.Combo("/{username}").Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.IsMember). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.DeleteMember) + m.Get("", reqToken(), org.ListMembers) + m.Combo("/{username}").Get(reqToken(), org.IsMember). + Delete(reqToken(), reqOrgOwnership(), org.DeleteMember) }) m.Group("/public_members", func() { m.Get("", org.ListPublicMembers) m.Combo("/{username}").Get(org.IsPublicMember). - Put(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgMembership(), org.PublicizeMember). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgMembership(), org.ConcealMember) + Put(reqToken(), reqOrgMembership(), org.PublicizeMember). + Delete(reqToken(), reqOrgMembership(), org.ConcealMember) }) m.Group("/teams", func() { - m.Get("", reqToken(auth_model.AccessTokenScopeReadOrg), org.ListTeams) - m.Post("", reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) - m.Get("/search", reqToken(auth_model.AccessTokenScopeReadOrg), org.SearchTeam) + m.Get("", reqToken(), org.ListTeams) + m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) + m.Get("/search", reqToken(), org.SearchTeam) }, reqOrgMembership()) m.Group("/labels", func() { m.Get("", org.ListLabels) - m.Post("", reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel) - m.Combo("/{id}").Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetLabel). - Patch(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.DeleteLabel) + 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). @@ -1252,29 +1319,29 @@ func Routes(ctx gocontext.Context) *web.Route { m.Combo("/{id}").Get(org.GetHook). Patch(bind(api.EditHookOption{}), org.EditHook). Delete(org.DeleteHook) - }, reqToken(auth_model.AccessTokenScopeAdminOrgHook), reqOrgOwnership(), reqWebhooksEnabled()) + }, reqToken(), reqOrgOwnership(), reqWebhooksEnabled()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) - }, orgAssignment(true)) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) m.Group("/teams/{teamid}", func() { - m.Combo("").Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeam). - Patch(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.DeleteTeam) + 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(auth_model.AccessTokenScopeReadOrg), org.GetTeamMembers) + m.Get("", reqToken(), org.GetTeamMembers) m.Combo("/{username}"). - Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeamMember). - Put(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.AddTeamMember). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), reqOrgOwnership(), org.RemoveTeamMember) + Get(reqToken(), org.GetTeamMember). + Put(reqToken(), reqOrgOwnership(), org.AddTeamMember). + Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember) }) m.Group("/repos", func() { - m.Get("", reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeamRepos) + m.Get("", reqToken(), org.GetTeamRepos) m.Combo("/{org}/{reponame}"). - Put(reqToken(auth_model.AccessTokenScopeWriteOrg), org.AddTeamRepository). - Delete(reqToken(auth_model.AccessTokenScopeWriteOrg), org.RemoveTeamRepository). - Get(reqToken(auth_model.AccessTokenScopeReadOrg), org.GetTeamRepo) + Put(reqToken(), org.AddTeamRepository). + Delete(reqToken(), org.RemoveTeamRepository). + Get(reqToken(), org.GetTeamRepo) }) m.Get("/activities/feeds", org.ListTeamActivityFeeds) - }, orgAssignment(false, true), reqToken(""), reqTeamMembership()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership()) m.Group("/admin", func() { m.Group("/cron", func() { @@ -1314,11 +1381,11 @@ func Routes(ctx gocontext.Context) *web.Route { Patch(bind(api.EditHookOption{}), admin.EditHook). Delete(admin.DeleteHook) }) - }, reqToken(auth_model.AccessTokenScopeSudo), reqSiteAdmin()) + }, 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/install/install.go b/routers/install/install.go index 4635cd7cb60fa..51ad6ec378794 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -35,7 +35,6 @@ import ( "code.gitea.io/gitea/services/forms" "gitea.com/go-chi/session" - "gopkg.in/ini.v1" ) const ( @@ -371,17 +370,11 @@ func SubmitInstall(ctx *context.Context) { } // Save settings. - cfg := ini.Empty() - isFile, err := util.IsFile(setting.CustomConf) + cfg, err := setting.NewConfigProviderFromFile(&setting.Options{CustomConf: setting.CustomConf, AllowEmpty: true}) if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", setting.CustomConf, err) - } - if isFile { - // Keeps custom settings if there is already something. - if err = cfg.Append(setting.CustomConf); err != nil { - log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } + log.Error("Failed to load custom conf '%s': %v", setting.CustomConf, err) } + 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) diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index 1ada4deefc3f8..797ba8798d06c 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -8,11 +8,13 @@ import ( "fmt" "net/http" "runtime" + "sort" "time" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/updatechecker" "code.gitea.io/gitea/modules/web" @@ -26,6 +28,7 @@ const ( tplQueue base.TplName = "admin/queue" tplStacktrace base.TplName = "admin/stacktrace" tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.TplName = "admin/stats" ) var sysStatus struct { @@ -111,7 +114,6 @@ func updateSystemStatus() { func Dashboard(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = activities_model.GetStatistic() ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate() ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion() // FIXME: update periodically @@ -126,7 +128,6 @@ func DashboardPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.AdminDashboardForm) ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = activities_model.GetStatistic() updateSystemStatus() ctx.Data["SysStatus"] = sysStatus @@ -153,3 +154,30 @@ func CronTasks(ctx *context.Context) { 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 + bs, err := json.Marshal(activities_model.GetStatistic().Counter) + if err != nil { + ctx.ServerError("MonitorStats", err) + return + } + statsCounter := map[string]any{} + err = json.Unmarshal(bs, &statsCounter) + if err != nil { + ctx.ServerError("MonitorStats", err) + return + } + statsKeys := make([]string, 0, len(statsCounter)) + for k := range statsCounter { + if statsCounter[k] == nil { + continue + } + statsKeys = append(statsKeys, k) + } + sort.Strings(statsKeys) + ctx.Data["StatsKeys"] = statsKeys + ctx.Data["StatsCounter"] = statsCounter + ctx.HTML(http.StatusOK, tplStats) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 92a06e7c14a03..80f149d8061fc 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -695,7 +695,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server } // "The authorization server MUST ... require client authentication for confidential clients" // https://datatracker.ietf.org/doc/html/rfc6749#section-6 - if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { errorDescription := "invalid client secret" if form.ClientSecret == "" { errorDescription = "invalid empty client secret" @@ -753,7 +753,7 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, s }) return } - if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { errorDescription := "invalid client secret" if form.ClientSecret == "" { errorDescription = "invalid empty client secret" diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go index b6ebd25915626..f4e9ac86a1bc3 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/http.go @@ -152,7 +152,7 @@ func httpBase(ctx *context.Context) (h *serviceHandler) { return } - context.CheckRepoScopedToken(ctx, repo) + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) if ctx.Written() { return } diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index ac935e51bbb38..f9e9ca5e52d31 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -89,6 +89,7 @@ func DeleteApplication(ctx *context.Context) { } func loadApplicationsData(ctx *context.Context) { + ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly tokens, err := auth_model.ListAccessTokens(auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) if err != nil { ctx.ServerError("ListAccessTokens", err) @@ -96,6 +97,7 @@ func loadApplicationsData(ctx *context.Context) { } ctx.Data["Tokens"] = tokens ctx.Data["EnableOAuth2"] = setting.OAuth2.Enable + ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin if setting.OAuth2.Enable { ctx.Data["Applications"], err = auth_model.GetOAuth2ApplicationsByUserID(ctx, ctx.Doer.ID) if err != nil { diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index 5489b60260881..5de0f0e22f427 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" ) @@ -40,7 +41,7 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { // TODO validate redirect URI app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ Name: form.Name, - RedirectURIs: []string{form.RedirectURI}, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), UserID: oa.OwnerID, ConfidentialClient: form.ConfidentialClient, }) @@ -93,7 +94,7 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { if ctx.Data["App"], err = auth.UpdateOAuth2Application(auth.UpdateOAuth2ApplicationOptions{ ID: ctx.ParamsInt64("id"), Name: form.Name, - RedirectURIs: []string{form.RedirectURI}, + RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), UserID: oa.OwnerID, ConfidentialClient: form.ConfidentialClient, }); err != nil { diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 0054318867461..826562f15748d 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -6,6 +6,8 @@ package security import ( "errors" "net/http" + "strconv" + "time" "code.gitea.io/gitea/models/auth" wa "code.gitea.io/gitea/modules/auth/webauthn" @@ -23,8 +25,8 @@ import ( func WebAuthnRegister(ctx *context.Context) { form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) if form.Name == "" { - ctx.Error(http.StatusConflict) - return + // Set name to the hexadecimal of the current time + form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) } cred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, form.Name) diff --git a/routers/web/web.go b/routers/web/web.go index da6064257bd20..f5037a848ea59 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -538,8 +538,8 @@ func registerRoutes(m *web.Route) { // ***** START: Admin ***** m.Group("/admin", func() { - m.Get("", adminReq, admin.Dashboard) - m.Post("", adminReq, web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + m.Get("", admin.Dashboard) + m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) m.Group("/config", func() { m.Get("", admin.Config) @@ -548,6 +548,7 @@ func registerRoutes(m *web.Route) { }) 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) diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 1e04f85319cab..1315fb237b3bb 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -398,7 +398,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) { // EditOAuth2ApplicationForm form for editing oauth2 applications type EditOAuth2ApplicationForm struct { Name string `binding:"Required;MaxSize(255)" form:"application_name"` - RedirectURI string `binding:"Required" form:"redirect_uri"` + RedirectURIs string `binding:"Required" form:"redirect_uris"` ConfidentialClient bool `form:"confidential_client"` } diff --git a/services/forms/user_form_test.go b/services/forms/user_form_test.go index 84efa25d535e7..66050187c9f1c 100644 --- a/services/forms/user_form_test.go +++ b/services/forms/user_form_test.go @@ -112,12 +112,12 @@ func TestNewAccessTokenForm_GetScope(t *testing.T) { expectedErr error }{ { - form: NewAccessTokenForm{Name: "test", Scope: []string{"repo"}}, - scope: "repo", + form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository"}}, + scope: "read:repository", }, { - form: NewAccessTokenForm{Name: "test", Scope: []string{"repo", "user"}}, - scope: "repo,user", + form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository", "write:user"}}, + scope: "read:repository,write:user", }, } diff --git a/services/lfs/locks.go b/services/lfs/locks.go index 1e5db6bd2014a..08d74326567b3 100644 --- a/services/lfs/locks.go +++ b/services/lfs/locks.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + auth_model "code.gitea.io/gitea/models/auth" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/context" @@ -58,7 +59,7 @@ func GetListLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository) + context.CheckRepoScopedToken(ctx, repository, auth_model.Read) if ctx.Written() { return } @@ -150,7 +151,7 @@ func PostLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository) + context.CheckRepoScopedToken(ctx, repository, auth_model.Write) if ctx.Written() { return } @@ -222,7 +223,7 @@ func VerifyLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository) + context.CheckRepoScopedToken(ctx, repository, auth_model.Read) if ctx.Written() { return } @@ -293,7 +294,7 @@ func UnLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository) + context.CheckRepoScopedToken(ctx, repository, auth_model.Write) if ctx.Written() { return } diff --git a/services/lfs/server.go b/services/lfs/server.go index 64e1203394547..1f82aed54b397 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -18,6 +18,7 @@ import ( "strings" actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" @@ -423,7 +424,12 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir return nil } - context.CheckRepoScopedToken(ctx, repository) + if requireWrite { + context.CheckRepoScopedToken(ctx, repository, auth_model.Write) + } else { + context.CheckRepoScopedToken(ctx, repository, auth_model.Read) + } + if ctx.Written() { return nil } diff --git a/services/migrations/gitlab.go b/services/migrations/gitlab.go index 015c38cd3b0cb..76180a5159a2b 100644 --- a/services/migrations/gitlab.go +++ b/services/migrations/gitlab.go @@ -413,7 +413,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er milestone = issue.Milestone.Title } - var reactions []*base.Reaction + var reactions []*gitlab.AwardEmoji awardPage := 1 for { awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx)) @@ -421,9 +421,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er return nil, false, fmt.Errorf("error while listing issue awards: %w", err) } - for i := range awards { - reactions = append(reactions, g.awardToReaction(awards[i])) - } + reactions = append(reactions, awards...) if len(awards) < perPage { break @@ -442,7 +440,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er State: issue.State, Created: *issue.CreatedAt, Labels: labels, - Reactions: reactions, + Reactions: g.awardsToReactions(reactions), Closed: issue.ClosedAt, IsLocked: issue.DiscussionLocked, Updated: *issue.UpdatedAt, @@ -577,7 +575,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque milestone = pr.Milestone.Title } - var reactions []*base.Reaction + var reactions []*gitlab.AwardEmoji awardPage := 1 for { awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx)) @@ -585,9 +583,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque return nil, false, fmt.Errorf("error while listing merge requests awards: %w", err) } - for i := range awards { - reactions = append(reactions, g.awardToReaction(awards[i])) - } + reactions = append(reactions, awards...) if len(awards) < perPage { break @@ -614,7 +610,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque MergeCommitSHA: pr.MergeCommitSHA, MergedTime: mergeTime, IsLocked: locked, - Reactions: reactions, + Reactions: g.awardsToReactions(reactions), Head: base.PullRequestBranch{ Ref: pr.SourceBranch, SHA: pr.SHA, @@ -675,10 +671,19 @@ func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie return reviews, nil } -func (g *GitlabDownloader) awardToReaction(award *gitlab.AwardEmoji) *base.Reaction { - return &base.Reaction{ - UserID: int64(award.User.ID), - UserName: award.User.Username, - Content: award.Name, +func (g *GitlabDownloader) awardsToReactions(awards []*gitlab.AwardEmoji) []*base.Reaction { + result := make([]*base.Reaction, 0, len(awards)) + uniqCheck := make(map[string]struct{}) + for _, award := range awards { + uid := fmt.Sprintf("%s%d", award.Name, award.User.ID) + if _, ok := uniqCheck[uid]; !ok { + result = append(result, &base.Reaction{ + UserID: int64(award.User.ID), + UserName: award.User.Username, + Content: award.Name, + }) + uniqCheck[uid] = struct{}{} + } } + return result } diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go index 1d8c5989bb534..731486eff21e3 100644 --- a/services/migrations/gitlab_test.go +++ b/services/migrations/gitlab_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "code.gitea.io/gitea/modules/json" base "code.gitea.io/gitea/modules/migration" "github.com/stretchr/testify/assert" @@ -469,3 +470,49 @@ func TestGitlabGetReviews(t *testing.T) { assertReviewsEqual(t, []*base.Review{&review}, rvs) } } + +func TestAwardsToReactions(t *testing.T) { + downloader := &GitlabDownloader{} + // yes gitlab can have duplicated reactions (https://gitlab.com/jaywink/socialhome/-/issues/24) + testResponse := ` +[ + { + "name": "thumbsup", + "user": { + "id": 1241334, + "username": "lafriks" + } + }, + { + "name": "thumbsup", + "user": { + "id": 1241334, + "username": "lafriks" + } + }, + { + "name": "thumbsup", + "user": { + "id": 4575606, + "username": "real6543" + } + } +] +` + var awards []*gitlab.AwardEmoji + assert.NoError(t, json.Unmarshal([]byte(testResponse), &awards)) + + reactions := downloader.awardsToReactions(awards) + assert.EqualValues(t, []*base.Reaction{ + { + UserName: "lafriks", + UserID: 1241334, + Content: "thumbsup", + }, + { + UserName: "real6543", + UserID: 4575606, + Content: "thumbsup", + }, + }, reactions) +} diff --git a/services/repository/check.go b/services/repository/check.go index 3a1f0b7f306bd..84fdb7159bfef 100644 --- a/services/repository/check.go +++ b/services/repository/check.go @@ -115,7 +115,7 @@ func GitGcRepo(ctx context.Context, repo *repo_model.Repository, timeout time.Du return nil } -func gatherMissingRepoRecords(ctx context.Context) ([]*repo_model.Repository, error) { +func gatherMissingRepoRecords(ctx context.Context) (repo_model.RepositoryList, error) { repos := make([]*repo_model.Repository, 0, 10) if err := db.Iterate( ctx, diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index b81e7b5ff371c..af9d4c4bc5024 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -133,7 +133,7 @@
- +
@@ -360,7 +360,7 @@
- +
diff --git a/templates/admin/auth/source/ldap.tmpl b/templates/admin/auth/source/ldap.tmpl index 909cf77047fee..26eef890b29fa 100644 --- a/templates/admin/auth/source/ldap.tmpl +++ b/templates/admin/auth/source/ldap.tmpl @@ -106,7 +106,7 @@
- +
diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl index 277ebdb011625..a0c5a87d0ff30 100644 --- a/templates/admin/auth/source/oauth.tmpl +++ b/templates/admin/auth/source/oauth.tmpl @@ -100,7 +100,7 @@
- +
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl index e7d986ee17b88..20cf3ba7f485b 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -5,14 +5,6 @@

{{(.locale.Tr "admin.dashboard.new_version_hint" .RemoteVersion AppVer) | Str2html}}

{{end}} -

- {{.locale.Tr "admin.dashboard.statistic"}} -

-
-

- {{.locale.Tr "admin.dashboard.statistic_info" .Stats.Counter.User .Stats.Counter.Org .Stats.Counter.PublicKey .Stats.Counter.Repo .Stats.Counter.Watch .Stats.Counter.Star .Stats.Counter.Action .Stats.Counter.Access .Stats.Counter.Issue .Stats.Counter.Comment .Stats.Counter.Oauth .Stats.Counter.Follow .Stats.Counter.Mirror .Stats.Counter.Release .Stats.Counter.AuthSource .Stats.Counter.Webhook .Stats.Counter.Milestone .Stats.Counter.Label .Stats.Counter.HookTask .Stats.Counter.Team .Stats.Counter.UpdateTask .Stats.Counter.Attachment | Str2html}} -

-

{{.locale.Tr "admin.dashboard.operations"}}

diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index eff5f1d83aaee..777fe29924395 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -53,6 +53,9 @@
{{.locale.Tr "admin.monitor"}}