forked from p85947160/gitea
* Add is_writable checkbox to deploy keys interface * Add writable key option to deploy key form * Add support for writable ssh keys in the interface * Rename IsWritable to ReadOnly * Test: create read-only and read-write deploy keys via api * Add DeployKey access mode migration * Update gitea sdk via govendor * Fix deploykey migration * Add unittests for writable deploy keys * Move template text to locale * Remove implicit column update * Remove duplicate locales * Replace ReadOnly field with IsReadOnly method * Fix deploy_keys related integration test * Rename v54 migration with v55 * Fix migration hell
This commit is contained in:
parent
70b6c07590
commit
e78786ef39
|
@ -5,9 +5,11 @@
|
||||||
package integrations
|
package integrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
api "code.gitea.io/sdk/gitea"
|
api "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,3 +39,54 @@ func TestDeleteDeployKeyNoLogin(t *testing.T) {
|
||||||
req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/keys/1")
|
req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/keys/1")
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateReadOnlyDeployKey(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
repo := models.AssertExistsAndLoadBean(t, &models.Repository{Name: "repo1"}).(*models.Repository)
|
||||||
|
repoOwner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
|
||||||
|
|
||||||
|
session := loginUser(t, repoOwner.Name)
|
||||||
|
|
||||||
|
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
|
||||||
|
rawKeyBody := api.CreateKeyOption{
|
||||||
|
Title: "read-only",
|
||||||
|
Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
|
||||||
|
ReadOnly: true,
|
||||||
|
}
|
||||||
|
req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody)
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newDeployKey api.DeployKey
|
||||||
|
DecodeJSON(t, resp, &newDeployKey)
|
||||||
|
models.AssertExistsAndLoadBean(t, &models.DeployKey{
|
||||||
|
ID: newDeployKey.ID,
|
||||||
|
Name: rawKeyBody.Title,
|
||||||
|
Content: rawKeyBody.Key,
|
||||||
|
Mode: models.AccessModeRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReadWriteDeployKey(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
repo := models.AssertExistsAndLoadBean(t, &models.Repository{Name: "repo1"}).(*models.Repository)
|
||||||
|
repoOwner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
|
||||||
|
|
||||||
|
session := loginUser(t, repoOwner.Name)
|
||||||
|
|
||||||
|
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
|
||||||
|
rawKeyBody := api.CreateKeyOption{
|
||||||
|
Title: "read-write",
|
||||||
|
Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDsufOCrDDlT8DLkodnnJtbq7uGflcPae7euTfM+Laq4So+v4WeSV362Rg0O/+Sje1UthrhN6lQkfRkdWIlCRQEXg+LMqr6RhvDfZquE2Xwqv/itlz7LjbdAUdYoO1iH7rMSmYvQh4WEnC/DAacKGbhdGIM/ZBz0z6tHm7bPgbI9ykEKekTmPwQFP1Qebvf5NYOFMWqQ2sCEAI9dBMVLoojsIpV+KADf+BotiIi8yNfTG2rzmzpxBpW9fYjd1Sy1yd4NSUpoPbEJJYJ1TrjiSWlYOVq9Ar8xW1O87i6gBjL/3zN7ANeoYhaAXupdOS6YL22YOK/yC0tJtXwwdh/eSrh",
|
||||||
|
}
|
||||||
|
req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody)
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newDeployKey api.DeployKey
|
||||||
|
DecodeJSON(t, resp, &newDeployKey)
|
||||||
|
models.AssertExistsAndLoadBean(t, &models.DeployKey{
|
||||||
|
ID: newDeployKey.ID,
|
||||||
|
Name: rawKeyBody.Title,
|
||||||
|
Content: rawKeyBody.Key,
|
||||||
|
Mode: models.AccessModeWrite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
[] # empty
|
|
@ -162,6 +162,8 @@ var migrations = []Migration{
|
||||||
NewMigration("add reactions", addReactions),
|
NewMigration("add reactions", addReactions),
|
||||||
// v54 -> v55
|
// v54 -> v55
|
||||||
NewMigration("add pull request options", addPullRequestOptions),
|
NewMigration("add pull request options", addPullRequestOptions),
|
||||||
|
// v55 -> v56
|
||||||
|
NewMigration("add writable deploy keys", addModeToDeploKeys),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate database to current version
|
// Migrate database to current version
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"github.com/go-xorm/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addModeToDeploKeys(x *xorm.Engine) error {
|
||||||
|
type DeployKey struct {
|
||||||
|
Mode models.AccessMode `xorm:"NOT NULL DEFAULT 1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := x.Sync2(new(DeployKey)); err != nil {
|
||||||
|
return fmt.Errorf("Sync2: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -600,6 +600,8 @@ type DeployKey struct {
|
||||||
Fingerprint string
|
Fingerprint string
|
||||||
Content string `xorm:"-"`
|
Content string `xorm:"-"`
|
||||||
|
|
||||||
|
Mode AccessMode `xorm:"NOT NULL DEFAULT 1"`
|
||||||
|
|
||||||
CreatedUnix util.TimeStamp `xorm:"created"`
|
CreatedUnix util.TimeStamp `xorm:"created"`
|
||||||
UpdatedUnix util.TimeStamp `xorm:"updated"`
|
UpdatedUnix util.TimeStamp `xorm:"updated"`
|
||||||
HasRecentActivity bool `xorm:"-"`
|
HasRecentActivity bool `xorm:"-"`
|
||||||
|
@ -622,6 +624,11 @@ func (key *DeployKey) GetContent() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsReadOnly checks if the key can only be used for read operations
|
||||||
|
func (key *DeployKey) IsReadOnly() bool {
|
||||||
|
return key.Mode == AccessModeRead
|
||||||
|
}
|
||||||
|
|
||||||
func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
|
func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
|
||||||
// Note: We want error detail, not just true or false here.
|
// Note: We want error detail, not just true or false here.
|
||||||
has, err := e.
|
has, err := e.
|
||||||
|
@ -646,7 +653,7 @@ func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDeployKey adds new key-repo relation.
|
// addDeployKey adds new key-repo relation.
|
||||||
func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string) (*DeployKey, error) {
|
func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string, mode AccessMode) (*DeployKey, error) {
|
||||||
if err := checkDeployKey(e, keyID, repoID, name); err != nil {
|
if err := checkDeployKey(e, keyID, repoID, name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -656,6 +663,7 @@ func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string
|
||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Fingerprint: fingerprint,
|
Fingerprint: fingerprint,
|
||||||
|
Mode: mode,
|
||||||
}
|
}
|
||||||
_, err := e.Insert(key)
|
_, err := e.Insert(key)
|
||||||
return key, err
|
return key, err
|
||||||
|
@ -670,15 +678,20 @@ func HasDeployKey(keyID, repoID int64) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDeployKey add new deploy key to database and authorized_keys file.
|
// AddDeployKey add new deploy key to database and authorized_keys file.
|
||||||
func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) {
|
func AddDeployKey(repoID int64, name, content string, readOnly bool) (*DeployKey, error) {
|
||||||
fingerprint, err := calcFingerprint(content)
|
fingerprint, err := calcFingerprint(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessMode := AccessModeRead
|
||||||
|
if !readOnly {
|
||||||
|
accessMode = AccessModeWrite
|
||||||
|
}
|
||||||
|
|
||||||
pkey := &PublicKey{
|
pkey := &PublicKey{
|
||||||
Fingerprint: fingerprint,
|
Fingerprint: fingerprint,
|
||||||
Mode: AccessModeRead,
|
Mode: accessMode,
|
||||||
Type: KeyTypeDeploy,
|
Type: KeyTypeDeploy,
|
||||||
}
|
}
|
||||||
has, err := x.Get(pkey)
|
has, err := x.Get(pkey)
|
||||||
|
@ -701,7 +714,7 @@ func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint)
|
key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint, accessMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("addDeployKey: %v", err)
|
return nil, fmt.Errorf("addDeployKey: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -172,6 +172,7 @@ type AddKeyForm struct {
|
||||||
Type string `binding:"OmitEmpty"`
|
Type string `binding:"OmitEmpty"`
|
||||||
Title string `binding:"Required;MaxSize(50)"`
|
Title string `binding:"Required;MaxSize(50)"`
|
||||||
Content string `binding:"Required"`
|
Content string `binding:"Required"`
|
||||||
|
IsWritable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the fields
|
// Validate validates the fields
|
||||||
|
|
|
@ -401,6 +401,8 @@ valid_until = Valid until
|
||||||
valid_forever = Valid forever
|
valid_forever = Valid forever
|
||||||
last_used = Last used on
|
last_used = Last used on
|
||||||
no_activity = No recent activity
|
no_activity = No recent activity
|
||||||
|
can_read_info = Read
|
||||||
|
can_write_info = Write
|
||||||
key_state_desc = This key has been used in the last 7 days
|
key_state_desc = This key has been used in the last 7 days
|
||||||
token_state_desc = This token has been used in the last 7 days
|
token_state_desc = This token has been used in the last 7 days
|
||||||
show_openid = Show on profile
|
show_openid = Show on profile
|
||||||
|
@ -995,6 +997,8 @@ settings.add_dingtalk_hook_desc = Add <a href="%s">Dingtalk</a> integration to y
|
||||||
settings.deploy_keys = Deploy Keys
|
settings.deploy_keys = Deploy Keys
|
||||||
settings.add_deploy_key = Add Deploy Key
|
settings.add_deploy_key = Add Deploy Key
|
||||||
settings.deploy_key_desc = Deploy keys have read-only access. They are not the same as personal account SSH keys.
|
settings.deploy_key_desc = Deploy keys have read-only access. They are not the same as personal account SSH keys.
|
||||||
|
settings.is_writable = Allow write access
|
||||||
|
settings.is_writable_info = Can this key be used to <strong>push</strong> to this repository? Deploy keys always have pull access.
|
||||||
settings.no_deploy_keys = You haven't added any deploy keys.
|
settings.no_deploy_keys = You haven't added any deploy keys.
|
||||||
settings.title = Title
|
settings.title = Title
|
||||||
settings.deploy_key_content = Content
|
settings.deploy_key_content = Content
|
||||||
|
|
|
@ -160,7 +160,7 @@ func CreateDeployKey(ctx *context.APIContext, form api.CreateKeyOption) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content)
|
key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
HandleAddKeyError(ctx, err)
|
HandleAddKeyError(ctx, err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -544,7 +544,7 @@ func DeployKeysPost(ctx *context.Context, form auth.AddKeyForm) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content)
|
key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Data["HasError"] = true
|
ctx.Data["HasError"] = true
|
||||||
switch {
|
switch {
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/auth"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddReadOnlyDeployKey(t *testing.T) {
|
||||||
|
models.PrepareTestEnv(t)
|
||||||
|
|
||||||
|
ctx := test.MockContext(t, "user2/repo1/settings/keys")
|
||||||
|
|
||||||
|
test.LoadUser(t, ctx, 2)
|
||||||
|
test.LoadRepo(t, ctx, 2)
|
||||||
|
|
||||||
|
addKeyForm := auth.AddKeyForm{
|
||||||
|
Title: "read-only",
|
||||||
|
Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
|
||||||
|
}
|
||||||
|
DeployKeysPost(ctx, addKeyForm)
|
||||||
|
assert.EqualValues(t, http.StatusFound, ctx.Resp.Status())
|
||||||
|
|
||||||
|
models.AssertExistsAndLoadBean(t, &models.DeployKey{
|
||||||
|
Name: addKeyForm.Title,
|
||||||
|
Content: addKeyForm.Content,
|
||||||
|
Mode: models.AccessModeRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddReadWriteOnlyDeployKey(t *testing.T) {
|
||||||
|
models.PrepareTestEnv(t)
|
||||||
|
|
||||||
|
ctx := test.MockContext(t, "user2/repo1/settings/keys")
|
||||||
|
|
||||||
|
test.LoadUser(t, ctx, 2)
|
||||||
|
test.LoadRepo(t, ctx, 2)
|
||||||
|
|
||||||
|
addKeyForm := auth.AddKeyForm{
|
||||||
|
Title: "read-write",
|
||||||
|
Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
|
||||||
|
IsWritable: true,
|
||||||
|
}
|
||||||
|
DeployKeysPost(ctx, addKeyForm)
|
||||||
|
assert.EqualValues(t, http.StatusFound, ctx.Resp.Status())
|
||||||
|
|
||||||
|
models.AssertExistsAndLoadBean(t, &models.DeployKey{
|
||||||
|
Name: addKeyForm.Title,
|
||||||
|
Content: addKeyForm.Content,
|
||||||
|
Mode: models.AccessModeWrite,
|
||||||
|
})
|
||||||
|
}
|
|
@ -31,7 +31,7 @@
|
||||||
{{.Fingerprint}}
|
{{.Fingerprint}}
|
||||||
</div>
|
</div>
|
||||||
<div class="activity meta">
|
<div class="activity meta">
|
||||||
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> — <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
|
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> — <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}} - <span>{{$.i18n.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{$.i18n.Tr "settings.can_write_info"}} {{end}}</i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -60,6 +60,15 @@
|
||||||
<label for="content">{{.i18n.Tr "repo.settings.deploy_key_content"}}</label>
|
<label for="content">{{.i18n.Tr "repo.settings.deploy_key_content"}}</label>
|
||||||
<textarea id="ssh-key-content" name="content" required>{{.content}}</textarea>
|
<textarea id="ssh-key-content" name="content" required>{{.content}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui checkbox {{if .Err_IsWritable}}error{{end}}">
|
||||||
|
<input id="ssh-key-is-writable" name="is_writable" class="hidden" type="checkbox" value="1">
|
||||||
|
<label for="is_writable">
|
||||||
|
{{.i18n.Tr "repo.settings.is_writable"}}
|
||||||
|
</label>
|
||||||
|
<small style="padding-left: 26px;">{{$.i18n.Tr "repo.settings.is_writable_info" | Str2html}}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="ui green button">
|
<button class="ui green button">
|
||||||
{{.i18n.Tr "repo.settings.add_deploy_key"}}
|
{{.i18n.Tr "repo.settings.add_deploy_key"}}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -46,6 +46,10 @@ type CreateKeyOption struct {
|
||||||
// required: true
|
// required: true
|
||||||
// unique: true
|
// unique: true
|
||||||
Key string `json:"key" binding:"Required"`
|
Key string `json:"key" binding:"Required"`
|
||||||
|
// Describe if the key has only read access or read/write
|
||||||
|
//
|
||||||
|
// required: false
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDeployKey options when create one deploy key
|
// CreateDeployKey options when create one deploy key
|
||||||
|
|
|
@ -9,10 +9,10 @@
|
||||||
"revisionTime": "2017-12-22T02:43:26Z"
|
"revisionTime": "2017-12-22T02:43:26Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"checksumSHA1": "QQ7g7B9+EIzGjO14KCGEs9TNEzM=",
|
"checksumSHA1": "Qtq0kW+BnpYMOriaoCjMa86WGG8=",
|
||||||
"path": "code.gitea.io/sdk/gitea",
|
"path": "code.gitea.io/sdk/gitea",
|
||||||
"revision": "ec7d3af43b598c1a3f2cb12f633b9625649d8e54",
|
"revision": "79eee8f12c7fc1cc5b802c5cdc5b494ef3733866",
|
||||||
"revisionTime": "2017-11-28T12:30:39Z"
|
"revisionTime": "2017-12-20T06:57:50Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"checksumSHA1": "bOODD4Gbw3GfcuQPU2dI40crxxk=",
|
"checksumSHA1": "bOODD4Gbw3GfcuQPU2dI40crxxk=",
|
||||||
|
|
Loading…
Reference in New Issue