mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-01-31 14:08:06 +08:00
feat: 数据库密码加密存储 (#2146)
This commit is contained in:
parent
0c6a065994
commit
7dacac8846
@ -2,10 +2,12 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/model"
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -34,20 +36,36 @@ func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&database).Error
|
||||
return database, err
|
||||
if err := db.First(&database).Error; err != nil {
|
||||
return database, err
|
||||
}
|
||||
pass, err := encrypt.StringDecrypt(database.Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database %s password failed, err: %v", database.Name, err)
|
||||
}
|
||||
database.Password = pass
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (d *DatabaseRepo) Page(page, size int, opts ...DBOption) (int64, []model.Database, error) {
|
||||
var users []model.Database
|
||||
var databases []model.Database
|
||||
db := global.DB.Model(&model.Database{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
count := int64(0)
|
||||
db = db.Count(&count)
|
||||
err := db.Limit(size).Offset(size * (page - 1)).Find(&users).Error
|
||||
return count, users, err
|
||||
if err := db.Limit(size).Offset(size * (page - 1)).Find(&databases).Error; err != nil {
|
||||
return count, databases, err
|
||||
}
|
||||
for i := 0; i < len(databases); i++ {
|
||||
pass, err := encrypt.StringDecrypt(databases[i].Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database db %s password failed, err: %v", databases[i].Name, err)
|
||||
}
|
||||
databases[i].Password = pass
|
||||
}
|
||||
return count, databases, nil
|
||||
}
|
||||
|
||||
func (d *DatabaseRepo) GetList(opts ...DBOption) ([]model.Database, error) {
|
||||
@ -56,8 +74,17 @@ func (d *DatabaseRepo) GetList(opts ...DBOption) ([]model.Database, error) {
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Find(&databases).Error
|
||||
return databases, err
|
||||
if err := db.Find(&databases).Error; err != nil {
|
||||
return databases, err
|
||||
}
|
||||
for i := 0; i < len(databases); i++ {
|
||||
pass, err := encrypt.StringDecrypt(databases[i].Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database db %s password failed, err: %v", databases[i].Name, err)
|
||||
}
|
||||
databases[i].Password = pass
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func (d *DatabaseRepo) WithByFrom(from string) DBOption {
|
||||
@ -94,6 +121,11 @@ func (d *DatabaseRepo) WithAppInstallID(appInstallID uint) DBOption {
|
||||
}
|
||||
|
||||
func (d *DatabaseRepo) Create(ctx context.Context, database *model.Database) error {
|
||||
pass, err := encrypt.StringEncrypt(database.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt database db %s password failed, err: %v", database.Name, err)
|
||||
}
|
||||
database.Password = pass
|
||||
return getTx(ctx).Create(database).Error
|
||||
}
|
||||
|
||||
|
@ -2,9 +2,11 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/model"
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -19,7 +21,6 @@ type IMysqlRepo interface {
|
||||
Create(ctx context.Context, mysql *model.DatabaseMysql) error
|
||||
Delete(ctx context.Context, opts ...DBOption) error
|
||||
Update(id uint, vars map[string]interface{}) error
|
||||
UpdateDatabaseInfo(id uint, vars map[string]interface{}) error
|
||||
DeleteLocal(ctx context.Context) error
|
||||
}
|
||||
|
||||
@ -33,33 +34,64 @@ func (u *MysqlRepo) Get(opts ...DBOption) (model.DatabaseMysql, error) {
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&mysql).Error
|
||||
if err := db.First(&mysql).Error; err != nil {
|
||||
return mysql, err
|
||||
}
|
||||
|
||||
pass, err := encrypt.StringDecrypt(mysql.Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database db %s password failed, err: %v", mysql.Name, err)
|
||||
}
|
||||
mysql.Password = pass
|
||||
return mysql, err
|
||||
}
|
||||
|
||||
func (u *MysqlRepo) List(opts ...DBOption) ([]model.DatabaseMysql, error) {
|
||||
var users []model.DatabaseMysql
|
||||
var mysqls []model.DatabaseMysql
|
||||
db := global.DB.Model(&model.DatabaseMysql{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Find(&users).Error
|
||||
return users, err
|
||||
if err := db.Find(&mysqls).Error; err != nil {
|
||||
return mysqls, err
|
||||
}
|
||||
for i := 0; i < len(mysqls); i++ {
|
||||
pass, err := encrypt.StringDecrypt(mysqls[i].Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database db %s password failed, err: %v", mysqls[i].Name, err)
|
||||
}
|
||||
mysqls[i].Password = pass
|
||||
}
|
||||
return mysqls, nil
|
||||
}
|
||||
|
||||
func (u *MysqlRepo) Page(page, size int, opts ...DBOption) (int64, []model.DatabaseMysql, error) {
|
||||
var users []model.DatabaseMysql
|
||||
var mysqls []model.DatabaseMysql
|
||||
db := global.DB.Model(&model.DatabaseMysql{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
count := int64(0)
|
||||
db = db.Count(&count)
|
||||
err := db.Limit(size).Offset(size * (page - 1)).Find(&users).Error
|
||||
return count, users, err
|
||||
if err := db.Limit(size).Offset(size * (page - 1)).Find(&mysqls).Error; err != nil {
|
||||
return count, mysqls, err
|
||||
}
|
||||
for i := 0; i < len(mysqls); i++ {
|
||||
pass, err := encrypt.StringDecrypt(mysqls[i].Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("decrypt database db %s password failed, err: %v", mysqls[i].Name, err)
|
||||
}
|
||||
mysqls[i].Password = pass
|
||||
}
|
||||
return count, mysqls, nil
|
||||
}
|
||||
|
||||
func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) error {
|
||||
pass, err := encrypt.StringEncrypt(mysql.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt database db %s password failed, err: %v", mysql.Name, err)
|
||||
}
|
||||
mysql.Password = pass
|
||||
return getTx(ctx).Create(mysql).Error
|
||||
}
|
||||
|
||||
@ -75,13 +107,6 @@ func (u *MysqlRepo) Update(id uint, vars map[string]interface{}) error {
|
||||
return global.DB.Model(&model.DatabaseMysql{}).Where("id = ?", id).Updates(vars).Error
|
||||
}
|
||||
|
||||
func (u *MysqlRepo) UpdateDatabaseInfo(id uint, vars map[string]interface{}) error {
|
||||
if err := global.DB.Model(&model.AppInstall{}).Where("id = ?", id).Updates(vars).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *MysqlRepo) WithByMysqlName(mysqlName string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("mysql_name = ?", mysqlName)
|
||||
|
@ -2,9 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/backend/constant"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql/client"
|
||||
"github.com/jinzhu/copier"
|
||||
@ -136,12 +138,17 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pass, err := encrypt.StringEncrypt(req.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt database password failed, err: %v", err)
|
||||
}
|
||||
|
||||
upMap := make(map[string]interface{})
|
||||
upMap["version"] = req.Version
|
||||
upMap["address"] = req.Address
|
||||
upMap["port"] = req.Port
|
||||
upMap["username"] = req.Username
|
||||
upMap["password"] = req.Password
|
||||
upMap["password"] = pass
|
||||
upMap["description"] = req.Description
|
||||
return databaseRepo.Update(req.ID, upMap)
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/common"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/compose"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql/client"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
@ -301,7 +302,11 @@ func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
||||
}
|
||||
}
|
||||
global.LOG.Info("excute password change sql successful")
|
||||
_ = mysqlRepo.Update(mysqlData.ID, map[string]interface{}{"password": req.Value})
|
||||
pass, err := encrypt.StringEncrypt(req.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt database db password failed, err: %v", err)
|
||||
}
|
||||
_ = mysqlRepo.Update(mysqlData.ID, map[string]interface{}{"password": pass})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -18,11 +18,6 @@ func Init() {
|
||||
global.LOG.Errorf("load service port from setting failed, err: %v", err)
|
||||
}
|
||||
global.CONF.System.Port = portSetting.Value
|
||||
encryptSetting, err := settingRepo.Get(settingRepo.WithByKey("EncryptKey"))
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load service encrypt key from setting failed, err: %v", err)
|
||||
}
|
||||
global.CONF.System.EncryptKey = encryptSetting.Value
|
||||
sslSetting, err := settingRepo.Get(settingRepo.WithByKey("SSL"))
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load service ssl from setting failed, err: %v", err)
|
||||
|
@ -39,7 +39,7 @@ func Init() {
|
||||
migrations.UpdateRedisParam,
|
||||
migrations.UpdateCronjobWithDb,
|
||||
migrations.AddTableFirewall,
|
||||
migrations.AddMariaDB,
|
||||
migrations.AddDatabases,
|
||||
migrations.UpdateDatabase,
|
||||
migrations.UpdateAppInstallResource,
|
||||
})
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/model"
|
||||
"github.com/1Panel-dev/1Panel/backend/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/backend/constant"
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/common"
|
||||
@ -582,52 +583,97 @@ var AddTableFirewall = &gormigrate.Migration{
|
||||
},
|
||||
}
|
||||
|
||||
var AddMariaDB = &gormigrate.Migration{
|
||||
ID: "20230828-add-mariadb",
|
||||
var AddDatabases = &gormigrate.Migration{
|
||||
ID: "20230831-add-databases",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var (
|
||||
app model.App
|
||||
appInstall model.AppInstall
|
||||
)
|
||||
if err := tx.AutoMigrate(&model.Database{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := global.DB.Where("key = ?", "mariadb").First(&app).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := global.DB.Where("app_id = ?", app.ID).First(&appInstall).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
installRepo := repo.NewIAppInstallRepo()
|
||||
mariadbInfo, err := installRepo.LoadBaseInfo("mariadb", "")
|
||||
if err == nil {
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: mariadbInfo.ID,
|
||||
Name: mariadbInfo.Name,
|
||||
Type: "mariadb",
|
||||
Version: mariadbInfo.Version,
|
||||
From: "local",
|
||||
Address: mariadbInfo.ServiceName,
|
||||
Port: uint(mariadbInfo.Port),
|
||||
Username: "root",
|
||||
Password: mariadbInfo.Password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
envMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(appInstall.Env), &envMap); err != nil {
|
||||
return err
|
||||
redisInfo, err := installRepo.LoadBaseInfo("redis", "")
|
||||
if err == nil {
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: redisInfo.ID,
|
||||
Name: redisInfo.Name,
|
||||
Type: "mariadb",
|
||||
Version: redisInfo.Version,
|
||||
From: "local",
|
||||
Address: redisInfo.ServiceName,
|
||||
Port: uint(redisInfo.Port),
|
||||
Username: "root",
|
||||
Password: redisInfo.Password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
password, ok := envMap["PANEL_DB_ROOT_PASSWORD"].(string)
|
||||
if !ok {
|
||||
return errors.New("error password in app env")
|
||||
pgInfo, err := installRepo.LoadBaseInfo("postgresql", "")
|
||||
if err == nil {
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: pgInfo.ID,
|
||||
Name: pgInfo.Name,
|
||||
Type: "mariadb",
|
||||
Version: pgInfo.Version,
|
||||
From: "local",
|
||||
Address: pgInfo.ServiceName,
|
||||
Port: uint(pgInfo.Port),
|
||||
Username: "root",
|
||||
Password: pgInfo.Password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: appInstall.ID,
|
||||
Name: appInstall.Name,
|
||||
Type: "mariadb",
|
||||
Version: appInstall.Version,
|
||||
From: "local",
|
||||
Address: appInstall.ServiceName,
|
||||
Port: uint(appInstall.HttpPort),
|
||||
Username: "root",
|
||||
Password: password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
mongodbInfo, err := installRepo.LoadBaseInfo("mongodb", "")
|
||||
if err == nil {
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: mongodbInfo.ID,
|
||||
Name: mongodbInfo.Name,
|
||||
Type: "mariadb",
|
||||
Version: mongodbInfo.Version,
|
||||
From: "local",
|
||||
Address: mongodbInfo.ServiceName,
|
||||
Port: uint(mongodbInfo.Port),
|
||||
Username: "root",
|
||||
Password: mongodbInfo.Password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
memcachedInfo, err := installRepo.LoadBaseInfo("memcached", "")
|
||||
if err == nil {
|
||||
if err := tx.Create(&model.Database{
|
||||
AppInstallID: memcachedInfo.ID,
|
||||
Name: memcachedInfo.Name,
|
||||
Type: "mariadb",
|
||||
Version: memcachedInfo.Version,
|
||||
From: "local",
|
||||
Address: memcachedInfo.ServiceName,
|
||||
Port: uint(memcachedInfo.Port),
|
||||
Username: "root",
|
||||
Password: memcachedInfo.Password,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var UpdateDatabase = &gormigrate.Migration{
|
||||
ID: "20230829-update-database",
|
||||
ID: "20230831-update-database",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := global.DB.Model(&model.DatabaseMysql{}).Where("`from` != ?", "local").Updates(map[string]interface{}{
|
||||
"from": "remote",
|
||||
@ -635,30 +681,59 @@ var UpdateDatabase = &gormigrate.Migration{
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
appMysql model.App
|
||||
appInstallMysql model.AppInstall
|
||||
localDatabase model.Database
|
||||
)
|
||||
_ = global.DB.Where("name = ? AND address = ?", "local", "127.0.0.1").First(&localDatabase).Error
|
||||
if localDatabase.ID == 0 {
|
||||
var datas []model.Database
|
||||
if err := global.DB.Find(&datas).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := global.DB.Where("key = ?", "mysql").First(&appMysql).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := global.DB.Where("app_id = ?", appMysql.ID).First(&appInstallMysql).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
for _, data := range datas {
|
||||
if data.Name == "local" && data.Address == "127.0.0.1" && data.Type == "mysql" {
|
||||
installRepo := repo.NewIAppInstallRepo()
|
||||
mysqlInfo, err := installRepo.LoadBaseInfo("mysql", "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pass, err := encrypt.StringEncrypt(data.Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("encrypt database %s password failed, err: %v", data.Name, err)
|
||||
continue
|
||||
}
|
||||
if err := global.DB.Model(&model.Database{}).Where("id = ?", data.ID).Updates(map[string]interface{}{
|
||||
"app_install_id": mysqlInfo.ID,
|
||||
"name": mysqlInfo.Name,
|
||||
"password": pass,
|
||||
"address": mysqlInfo.ServiceName,
|
||||
}).Error; err != nil {
|
||||
global.LOG.Errorf("updata database %s info failed, err: %v", data.Name, err)
|
||||
}
|
||||
} else {
|
||||
pass, err := encrypt.StringEncrypt(data.Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("encrypt database %s password failed, err: %v", data.Name, err)
|
||||
continue
|
||||
}
|
||||
if err := global.DB.Model(&model.Database{}).Where("id = ?", data.ID).Updates(map[string]interface{}{
|
||||
"password": pass,
|
||||
}).Error; err != nil {
|
||||
global.LOG.Errorf("updata database %s info failed, err: %v", data.Name, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := global.DB.Model(&model.Database{}).Where("id = ?", localDatabase.ID).Updates(map[string]interface{}{
|
||||
"app_install_id": appInstallMysql.ID,
|
||||
"name": appInstallMysql.Name,
|
||||
"address": appInstallMysql.ServiceName,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
|
||||
var mysqls []model.DatabaseMysql
|
||||
if err := global.DB.Find(&mysqls).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, data := range mysqls {
|
||||
pass, err := encrypt.StringEncrypt(data.Password)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("encrypt database db %s password failed, err: %v", data.Name, err)
|
||||
continue
|
||||
}
|
||||
if err := global.DB.Model(&model.DatabaseMysql{}).Where("id = ?", data.ID).Updates(map[string]interface{}{
|
||||
"password": pass,
|
||||
}).Error; err != nil {
|
||||
global.LOG.Errorf("updata database db %s info failed, err: %v", data.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
@ -8,16 +8,23 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/model"
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
)
|
||||
|
||||
func StringEncrypt(text string) (string, error) {
|
||||
if len(text) == 0 {
|
||||
return "", errors.New("it is not possible to encrypt an empty string.")
|
||||
return "", nil
|
||||
}
|
||||
if len(global.CONF.System.EncryptKey) == 0 {
|
||||
var encryptSetting model.Setting
|
||||
if err := global.DB.Where("key = ?", "EncryptKey").First(&encryptSetting).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
global.CONF.System.EncryptKey = encryptSetting.Value
|
||||
}
|
||||
key := global.CONF.System.EncryptKey
|
||||
pass := []byte(text)
|
||||
@ -31,7 +38,14 @@ func StringEncrypt(text string) (string, error) {
|
||||
|
||||
func StringDecrypt(text string) (string, error) {
|
||||
if len(text) == 0 {
|
||||
return "", errors.New("it is not possible to decrypt an empty string.")
|
||||
return "", nil
|
||||
}
|
||||
if len(global.CONF.System.EncryptKey) == 0 {
|
||||
var encryptSetting model.Setting
|
||||
if err := global.DB.Where("key = ?", "EncryptKey").First(&encryptSetting).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
global.CONF.System.EncryptKey = encryptSetting.Value
|
||||
}
|
||||
key := global.CONF.System.EncryptKey
|
||||
bytesPass, err := base64.StdEncoding.DecodeString(text)
|
||||
|
Loading…
x
Reference in New Issue
Block a user