2018-01-16 08:54:13 +00:00
|
|
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
2022-11-27 18:20:29 +00:00
|
|
|
// SPDX-License-Identifier: MIT
|
2018-01-16 08:54:13 +00:00
|
|
|
|
2022-09-02 19:18:23 +00:00
|
|
|
package integration
|
2018-01-16 08:54:13 +00:00
|
|
|
|
|
|
|
import (
|
2023-08-24 10:36:10 +00:00
|
|
|
"bytes"
|
2018-01-16 08:54:13 +00:00
|
|
|
"fmt"
|
2023-08-24 10:36:10 +00:00
|
|
|
"io"
|
|
|
|
"mime/multipart"
|
2018-01-16 08:54:13 +00:00
|
|
|
"net/http"
|
2021-06-17 08:58:10 +00:00
|
|
|
"net/url"
|
2023-08-24 10:36:10 +00:00
|
|
|
"strings"
|
2018-01-16 08:54:13 +00:00
|
|
|
"testing"
|
|
|
|
|
2023-01-17 21:46:03 +00:00
|
|
|
auth_model "code.gitea.io/gitea/models/auth"
|
2021-12-10 01:27:50 +00:00
|
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
2021-11-16 08:53:21 +00:00
|
|
|
"code.gitea.io/gitea/models/unittest"
|
2021-11-24 09:49:20 +00:00
|
|
|
user_model "code.gitea.io/gitea/models/user"
|
2019-03-27 09:33:00 +00:00
|
|
|
"code.gitea.io/gitea/modules/git"
|
Simplify how git repositories are opened (#28937)
## Purpose
This is a refactor toward building an abstraction over managing git
repositories.
Afterwards, it does not matter anymore if they are stored on the local
disk or somewhere remote.
## What this PR changes
We used `git.OpenRepository` everywhere previously.
Now, we should split them into two distinct functions:
Firstly, there are temporary repositories which do not change:
```go
git.OpenRepository(ctx, diskPath)
```
Gitea managed repositories having a record in the database in the
`repository` table are moved into the new package `gitrepo`:
```go
gitrepo.OpenRepository(ctx, repo_model.Repo)
```
Why is `repo_model.Repository` the second parameter instead of file
path?
Because then we can easily adapt our repository storage strategy.
The repositories can be stored locally, however, they could just as well
be stored on a remote server.
## Further changes in other PRs
- A Git Command wrapper on package `gitrepo` could be created. i.e.
`NewCommand(ctx, repo_model.Repository, commands...)`. `git.RunOpts{Dir:
repo.RepoPath()}`, the directory should be empty before invoking this
method and it can be filled in the function only. #28940
- Remove the `RepoPath()`/`WikiPath()` functions to reduce the
possibility of mistakes.
---------
Co-authored-by: delvh <dev.lh@web.de>
2024-01-27 20:09:51 +00:00
|
|
|
"code.gitea.io/gitea/modules/gitrepo"
|
2019-05-11 10:21:34 +00:00
|
|
|
api "code.gitea.io/gitea/modules/structs"
|
2022-09-02 19:18:23 +00:00
|
|
|
"code.gitea.io/gitea/tests"
|
2018-01-16 08:54:13 +00:00
|
|
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
)
|
|
|
|
|
2021-06-17 08:58:10 +00:00
|
|
|
func TestAPIListReleases(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2021-06-17 08:58:10 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-04 18:57:16 +00:00
|
|
|
token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository)
|
2021-06-17 08:58:10 +00:00
|
|
|
|
|
|
|
link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name))
|
2023-12-23 03:29:51 +00:00
|
|
|
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
|
2021-06-17 08:58:10 +00:00
|
|
|
var apiReleases []*api.Release
|
|
|
|
DecodeJSON(t, resp, &apiReleases)
|
|
|
|
if assert.Len(t, apiReleases, 3) {
|
|
|
|
for _, release := range apiReleases {
|
|
|
|
switch release.ID {
|
|
|
|
case 1:
|
|
|
|
assert.False(t, release.IsDraft)
|
|
|
|
assert.False(t, release.IsPrerelease)
|
2023-08-24 10:36:10 +00:00
|
|
|
assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL)
|
2021-06-17 08:58:10 +00:00
|
|
|
case 4:
|
|
|
|
assert.True(t, release.IsDraft)
|
|
|
|
assert.False(t, release.IsPrerelease)
|
2023-08-24 10:36:10 +00:00
|
|
|
assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/4/assets"), release.UploadURL)
|
2021-06-17 08:58:10 +00:00
|
|
|
case 5:
|
|
|
|
assert.False(t, release.IsDraft)
|
|
|
|
assert.True(t, release.IsPrerelease)
|
2023-08-24 10:36:10 +00:00
|
|
|
assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL)
|
2021-06-17 08:58:10 +00:00
|
|
|
default:
|
|
|
|
assert.NoError(t, fmt.Errorf("unexpected release: %v", release))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// test filter
|
|
|
|
testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
|
2023-12-21 23:59:59 +00:00
|
|
|
link.RawQuery = query.Encode()
|
|
|
|
req := NewRequest(t, "GET", link.String())
|
2021-06-17 08:58:10 +00:00
|
|
|
if auth {
|
2023-12-21 23:59:59 +00:00
|
|
|
req.AddTokenAuth(token)
|
2021-06-17 08:58:10 +00:00
|
|
|
}
|
2023-12-21 23:59:59 +00:00
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
2021-06-17 08:58:10 +00:00
|
|
|
DecodeJSON(t, resp, &apiReleases)
|
|
|
|
assert.Len(t, apiReleases, expectedLength, msgAndArgs)
|
|
|
|
}
|
|
|
|
|
|
|
|
testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
|
|
|
|
testFilterByLen(true, url.Values{"draft": {"true"}}, 1, "repo owner should see drafts")
|
|
|
|
testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
|
|
|
|
testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
|
|
|
|
testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
|
|
|
|
testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
|
|
|
|
}
|
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
func createNewReleaseUsingAPI(t *testing.T, token string, owner *user_model.User, repo *repo_model.Repository, name, target, title, desc string) *api.Release {
|
2023-12-21 23:59:59 +00:00
|
|
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name)
|
2018-01-16 08:54:13 +00:00
|
|
|
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
|
2019-01-30 16:33:00 +00:00
|
|
|
TagName: name,
|
|
|
|
Title: title,
|
|
|
|
Note: desc,
|
2018-01-16 08:54:13 +00:00
|
|
|
IsDraft: false,
|
|
|
|
IsPrerelease: false,
|
2019-01-30 16:33:00 +00:00
|
|
|
Target: target,
|
2023-12-21 23:59:59 +00:00
|
|
|
}).AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
2018-01-16 08:54:13 +00:00
|
|
|
|
|
|
|
var newRelease api.Release
|
|
|
|
DecodeJSON(t, resp, &newRelease)
|
2022-08-25 02:31:57 +00:00
|
|
|
rel := &repo_model.Release{
|
2018-01-16 08:54:13 +00:00
|
|
|
ID: newRelease.ID,
|
|
|
|
TagName: newRelease.TagName,
|
|
|
|
Title: newRelease.Title,
|
2022-06-04 19:18:50 +00:00
|
|
|
}
|
|
|
|
unittest.AssertExistsAndLoadBean(t, rel)
|
|
|
|
assert.EqualValues(t, newRelease.Note, rel.Note)
|
2018-01-16 08:54:13 +00:00
|
|
|
|
2019-01-30 16:33:00 +00:00
|
|
|
return &newRelease
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAPICreateAndUpdateRelease(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
2019-01-30 16:33:00 +00:00
|
|
|
session := loginUser(t, owner.LowerName)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-04 18:57:16 +00:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
2019-01-30 16:33:00 +00:00
|
|
|
|
Simplify how git repositories are opened (#28937)
## Purpose
This is a refactor toward building an abstraction over managing git
repositories.
Afterwards, it does not matter anymore if they are stored on the local
disk or somewhere remote.
## What this PR changes
We used `git.OpenRepository` everywhere previously.
Now, we should split them into two distinct functions:
Firstly, there are temporary repositories which do not change:
```go
git.OpenRepository(ctx, diskPath)
```
Gitea managed repositories having a record in the database in the
`repository` table are moved into the new package `gitrepo`:
```go
gitrepo.OpenRepository(ctx, repo_model.Repo)
```
Why is `repo_model.Repository` the second parameter instead of file
path?
Because then we can easily adapt our repository storage strategy.
The repositories can be stored locally, however, they could just as well
be stored on a remote server.
## Further changes in other PRs
- A Git Command wrapper on package `gitrepo` could be created. i.e.
`NewCommand(ctx, repo_model.Repository, commands...)`. `git.RunOpts{Dir:
repo.RepoPath()}`, the directory should be empty before invoking this
method and it can be filled in the function only. #28940
- Remove the `RepoPath()`/`WikiPath()` functions to reduce the
possibility of mistakes.
---------
Co-authored-by: delvh <dev.lh@web.de>
2024-01-27 20:09:51 +00:00
|
|
|
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
|
2019-01-30 16:33:00 +00:00
|
|
|
assert.NoError(t, err)
|
2019-11-13 07:01:19 +00:00
|
|
|
defer gitRepo.Close()
|
2019-01-30 16:33:00 +00:00
|
|
|
|
|
|
|
err = gitRepo.CreateTag("v0.0.1", "master")
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
|
|
target, err := gitRepo.GetTagCommitID("v0.0.1")
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
newRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", target, "v0.0.1", "test")
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2023-12-21 23:59:59 +00:00
|
|
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, newRelease.ID)
|
|
|
|
req := NewRequest(t, "GET", urlStr).
|
|
|
|
AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2018-01-16 08:54:13 +00:00
|
|
|
|
|
|
|
var release api.Release
|
|
|
|
DecodeJSON(t, resp, &release)
|
|
|
|
|
|
|
|
assert.Equal(t, newRelease.TagName, release.TagName)
|
|
|
|
assert.Equal(t, newRelease.Title, release.Title)
|
|
|
|
assert.Equal(t, newRelease.Note, release.Note)
|
|
|
|
|
|
|
|
req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditReleaseOption{
|
|
|
|
TagName: release.TagName,
|
|
|
|
Title: release.Title,
|
|
|
|
Note: "updated",
|
|
|
|
IsDraft: &release.IsDraft,
|
|
|
|
IsPrerelease: &release.IsPrerelease,
|
|
|
|
Target: release.Target,
|
2023-12-21 23:59:59 +00:00
|
|
|
}).AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
2018-01-16 08:54:13 +00:00
|
|
|
|
|
|
|
DecodeJSON(t, resp, &newRelease)
|
2022-08-25 02:31:57 +00:00
|
|
|
rel := &repo_model.Release{
|
2018-01-16 08:54:13 +00:00
|
|
|
ID: newRelease.ID,
|
|
|
|
TagName: newRelease.TagName,
|
|
|
|
Title: newRelease.Title,
|
2022-06-04 19:18:50 +00:00
|
|
|
}
|
|
|
|
unittest.AssertExistsAndLoadBean(t, rel)
|
|
|
|
assert.EqualValues(t, rel.Note, newRelease.Note)
|
2018-01-16 08:54:13 +00:00
|
|
|
}
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2024-05-14 06:48:21 +00:00
|
|
|
func TestAPICreateProtectedTagRelease(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
|
|
writer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
|
|
|
session := loginUser(t, writer.LowerName)
|
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
|
|
|
|
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
|
|
|
|
assert.NoError(t, err)
|
|
|
|
defer gitRepo.Close()
|
|
|
|
|
|
|
|
commit, err := gitRepo.GetBranchCommit("master")
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/releases", repo.OwnerName, repo.Name), &api.CreateReleaseOption{
|
|
|
|
TagName: "v0.0.1",
|
|
|
|
Title: "v0.0.1",
|
|
|
|
IsDraft: false,
|
|
|
|
IsPrerelease: false,
|
|
|
|
Target: commit.ID.String(),
|
|
|
|
}).AddTokenAuth(token)
|
|
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
|
|
}
|
|
|
|
|
2019-01-30 16:33:00 +00:00
|
|
|
func TestAPICreateReleaseToDefaultBranch(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
2019-01-30 16:33:00 +00:00
|
|
|
session := loginUser(t, owner.LowerName)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-04 18:57:16 +00:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
|
2019-01-30 16:33:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestAPICreateReleaseToDefaultBranchOnExistingTag(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-01-30 16:33:00 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
2019-01-30 16:33:00 +00:00
|
|
|
session := loginUser(t, owner.LowerName)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-04 18:57:16 +00:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
2019-01-30 16:33:00 +00:00
|
|
|
|
Simplify how git repositories are opened (#28937)
## Purpose
This is a refactor toward building an abstraction over managing git
repositories.
Afterwards, it does not matter anymore if they are stored on the local
disk or somewhere remote.
## What this PR changes
We used `git.OpenRepository` everywhere previously.
Now, we should split them into two distinct functions:
Firstly, there are temporary repositories which do not change:
```go
git.OpenRepository(ctx, diskPath)
```
Gitea managed repositories having a record in the database in the
`repository` table are moved into the new package `gitrepo`:
```go
gitrepo.OpenRepository(ctx, repo_model.Repo)
```
Why is `repo_model.Repository` the second parameter instead of file
path?
Because then we can easily adapt our repository storage strategy.
The repositories can be stored locally, however, they could just as well
be stored on a remote server.
## Further changes in other PRs
- A Git Command wrapper on package `gitrepo` could be created. i.e.
`NewCommand(ctx, repo_model.Repository, commands...)`. `git.RunOpts{Dir:
repo.RepoPath()}`, the directory should be empty before invoking this
method and it can be filled in the function only. #28940
- Remove the `RepoPath()`/`WikiPath()` functions to reduce the
possibility of mistakes.
---------
Co-authored-by: delvh <dev.lh@web.de>
2024-01-27 20:09:51 +00:00
|
|
|
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
|
2019-01-30 16:33:00 +00:00
|
|
|
assert.NoError(t, err)
|
2019-11-13 07:01:19 +00:00
|
|
|
defer gitRepo.Close()
|
2019-01-30 16:33:00 +00:00
|
|
|
|
|
|
|
err = gitRepo.CreateTag("v0.0.1", "master")
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
|
2019-01-30 16:33:00 +00:00
|
|
|
}
|
2020-09-25 19:11:43 +00:00
|
|
|
|
2024-09-12 07:47:31 +00:00
|
|
|
func TestAPICreateReleaseGivenInvalidTarget(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
session := loginUser(t, owner.LowerName)
|
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name)
|
|
|
|
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
|
|
|
|
TagName: "i-point-to-an-invalid-target",
|
|
|
|
Title: "Invalid Target",
|
|
|
|
Target: "invalid-target",
|
|
|
|
}).AddTokenAuth(token)
|
|
|
|
|
|
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
|
|
}
|
|
|
|
|
2023-01-26 16:33:47 +00:00
|
|
|
func TestAPIGetLatestRelease(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
|
2023-12-21 23:59:59 +00:00
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/latest", owner.Name, repo.Name))
|
2023-01-26 16:33:47 +00:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
|
|
|
|
var release *api.Release
|
|
|
|
DecodeJSON(t, resp, &release)
|
|
|
|
|
|
|
|
assert.Equal(t, "testing-release", release.Title)
|
|
|
|
}
|
|
|
|
|
2020-09-25 19:11:43 +00:00
|
|
|
func TestAPIGetReleaseByTag(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2020-09-25 19:11:43 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
2020-09-25 19:11:43 +00:00
|
|
|
|
|
|
|
tag := "v1.1"
|
|
|
|
|
2023-12-21 23:59:59 +00:00
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag))
|
2022-12-02 03:39:42 +00:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2020-09-25 19:11:43 +00:00
|
|
|
|
|
|
|
var release *api.Release
|
|
|
|
DecodeJSON(t, resp, &release)
|
|
|
|
|
|
|
|
assert.Equal(t, "testing-release", release.Title)
|
|
|
|
|
|
|
|
nonexistingtag := "nonexistingtag"
|
|
|
|
|
2023-12-21 23:59:59 +00:00
|
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, nonexistingtag))
|
2022-12-02 03:39:42 +00:00
|
|
|
resp = MakeRequest(t, req, http.StatusNotFound)
|
2020-09-25 19:11:43 +00:00
|
|
|
|
|
|
|
var err *api.APIError
|
|
|
|
DecodeJSON(t, resp, &err)
|
2021-12-28 13:28:27 +00:00
|
|
|
assert.NotEmpty(t, err.Message)
|
2020-09-25 19:11:43 +00:00
|
|
|
}
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2021-02-07 18:32:18 +00:00
|
|
|
func TestAPIDeleteReleaseByTagName(t *testing.T) {
|
2022-09-02 19:18:23 +00:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2022-08-16 02:22:25 +00:00
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
2020-10-31 01:56:34 +00:00
|
|
|
session := loginUser(t, owner.LowerName)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-04 18:57:16 +00:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2021-02-07 18:32:18 +00:00
|
|
|
// delete release
|
2024-09-10 02:23:07 +00:00
|
|
|
req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
|
2023-12-21 23:59:59 +00:00
|
|
|
AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
_ = MakeRequest(t, req, http.StatusNoContent)
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2021-02-07 18:32:18 +00:00
|
|
|
// make sure release is deleted
|
2024-09-10 02:23:07 +00:00
|
|
|
req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
|
2023-12-21 23:59:59 +00:00
|
|
|
AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
_ = MakeRequest(t, req, http.StatusNotFound)
|
2020-10-31 01:56:34 +00:00
|
|
|
|
2021-02-07 18:32:18 +00:00
|
|
|
// delete release tag too
|
2024-09-10 02:23:07 +00:00
|
|
|
req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name).
|
2023-12-21 23:59:59 +00:00
|
|
|
AddTokenAuth(token)
|
2022-12-02 03:39:42 +00:00
|
|
|
_ = MakeRequest(t, req, http.StatusNoContent)
|
2020-10-31 01:56:34 +00:00
|
|
|
}
|
2023-08-24 10:36:10 +00:00
|
|
|
|
|
|
|
func TestAPIUploadAssetRelease(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
session := loginUser(t, owner.LowerName)
|
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
|
2024-04-29 08:47:56 +00:00
|
|
|
r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
|
2023-08-24 10:36:10 +00:00
|
|
|
|
|
|
|
filename := "image.png"
|
|
|
|
buff := generateImg()
|
|
|
|
|
2024-03-02 17:02:01 +00:00
|
|
|
assetURL := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner.Name, repo.Name, r.ID)
|
2023-08-24 10:36:10 +00:00
|
|
|
|
2024-03-02 17:02:01 +00:00
|
|
|
t.Run("multipart/form-data", func(t *testing.T) {
|
|
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
|
|
|
|
body := &bytes.Buffer{}
|
|
|
|
|
|
|
|
writer := multipart.NewWriter(body)
|
|
|
|
part, err := writer.CreateFormFile("attachment", filename)
|
|
|
|
assert.NoError(t, err)
|
|
|
|
_, err = io.Copy(part, bytes.NewReader(buff.Bytes()))
|
|
|
|
assert.NoError(t, err)
|
|
|
|
err = writer.Close()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
|
|
req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(body.Bytes())).
|
|
|
|
AddTokenAuth(token).
|
|
|
|
SetHeader("Content-Type", writer.FormDataContentType())
|
|
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
|
|
|
|
var attachment *api.Attachment
|
|
|
|
DecodeJSON(t, resp, &attachment)
|
|
|
|
|
|
|
|
assert.EqualValues(t, filename, attachment.Name)
|
|
|
|
assert.EqualValues(t, 104, attachment.Size)
|
|
|
|
|
|
|
|
req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=test-asset", bytes.NewReader(body.Bytes())).
|
|
|
|
AddTokenAuth(token).
|
|
|
|
SetHeader("Content-Type", writer.FormDataContentType())
|
|
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
|
|
|
|
|
|
var attachment2 *api.Attachment
|
|
|
|
DecodeJSON(t, resp, &attachment2)
|
|
|
|
|
|
|
|
assert.EqualValues(t, "test-asset", attachment2.Name)
|
|
|
|
assert.EqualValues(t, 104, attachment2.Size)
|
|
|
|
})
|
|
|
|
|
|
|
|
t.Run("application/octet-stream", func(t *testing.T) {
|
|
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
|
|
|
|
req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(buff.Bytes())).
|
|
|
|
AddTokenAuth(token)
|
|
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
|
|
|
|
|
|
req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=stream.bin", bytes.NewReader(buff.Bytes())).
|
|
|
|
AddTokenAuth(token)
|
|
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
2023-08-24 10:36:10 +00:00
|
|
|
|
2024-03-02 17:02:01 +00:00
|
|
|
var attachment *api.Attachment
|
|
|
|
DecodeJSON(t, resp, &attachment)
|
2023-08-24 10:36:10 +00:00
|
|
|
|
2024-03-02 17:02:01 +00:00
|
|
|
assert.EqualValues(t, "stream.bin", attachment.Name)
|
|
|
|
assert.EqualValues(t, 104, attachment.Size)
|
|
|
|
})
|
2023-08-24 10:36:10 +00:00
|
|
|
}
|