mirror of
https://github.com/goharbor/harbor.git
synced 2025-02-17 04:11:24 +01:00
Merge branch 'job-service' into new-ui-with-sync-image
This commit is contained in:
commit
b2c7a7d44b
16
api/base.go
16
api/base.go
@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/astaxie/beego/validation"
|
||||
"github.com/vmware/harbor/auth"
|
||||
@ -120,3 +121,18 @@ func (b *BaseAPI) Redirect(statusCode int, resouceID string) {
|
||||
|
||||
b.Ctx.Redirect(statusCode, resoucreURI)
|
||||
}
|
||||
|
||||
// GetIDFromURL checks the ID in request URL
|
||||
func (b *BaseAPI) GetIDFromURL() int64 {
|
||||
idStr := b.Ctx.Input.Param(":id")
|
||||
if len(idStr) == 0 {
|
||||
b.CustomAbort(http.StatusBadRequest, "invalid ID in URL")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
b.CustomAbort(http.StatusBadRequest, "invalid ID in URL")
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ type RepPolicyAPI struct {
|
||||
}
|
||||
|
||||
// Prepare validates whether the user has system admin role
|
||||
// and parsed the policy ID if it exists
|
||||
func (pa *RepPolicyAPI) Prepare() {
|
||||
uid := pa.ValidateUser()
|
||||
var err error
|
||||
@ -30,38 +29,45 @@ func (pa *RepPolicyAPI) Prepare() {
|
||||
if !isAdmin {
|
||||
pa.CustomAbort(http.StatusForbidden, "")
|
||||
}
|
||||
idStr := pa.Ctx.Input.Param(":id")
|
||||
if len(idStr) > 0 {
|
||||
pa.policyID, err = strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
log.Errorf("Error parsing policy id: %s, error: %v", idStr, err)
|
||||
pa.CustomAbort(http.StatusBadRequest, "invalid policy id")
|
||||
}
|
||||
p, err := dao.GetRepPolicy(pa.policyID)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in GetRepPolicy, error: %v", err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
if p == nil {
|
||||
pa.CustomAbort(http.StatusNotFound, fmt.Sprintf("policy does not exist, id: %v", pa.policyID))
|
||||
}
|
||||
pa.policy = p
|
||||
}
|
||||
}
|
||||
|
||||
// Get gets all the policies according to the project
|
||||
// Get ...
|
||||
func (pa *RepPolicyAPI) Get() {
|
||||
projectID, err := pa.GetInt64("project_id")
|
||||
id := pa.GetIDFromURL()
|
||||
policy, err := dao.GetRepPolicy(id)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get project id, error: %v", err)
|
||||
pa.RenderError(http.StatusBadRequest, "Invalid project id")
|
||||
return
|
||||
log.Errorf("failed to get policy %d: %v", id, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
policies, err := dao.GetRepPolicyByProject(projectID)
|
||||
|
||||
if policy == nil {
|
||||
pa.CustomAbort(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
}
|
||||
|
||||
pa.Data["json"] = policy
|
||||
pa.ServeJSON()
|
||||
}
|
||||
|
||||
// List filters policies by name and project_id, if name and project_id
|
||||
// are nil, List returns all policies
|
||||
func (pa *RepPolicyAPI) List() {
|
||||
name := pa.GetString("name")
|
||||
projectIDStr := pa.GetString("project_id")
|
||||
|
||||
var projectID int64
|
||||
var err error
|
||||
|
||||
if len(projectIDStr) != 0 {
|
||||
projectID, err = strconv.ParseInt(projectIDStr, 10, 64)
|
||||
if err != nil || projectID <= 0 {
|
||||
pa.CustomAbort(http.StatusBadRequest, "invalid project ID")
|
||||
}
|
||||
}
|
||||
|
||||
policies, err := dao.FilterRepPolicies(name, projectID)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to query policies from db, error: %v", err)
|
||||
pa.RenderError(http.StatusInternalServerError, "Failed to query policies")
|
||||
return
|
||||
log.Errorf("failed to filter policies %s project ID %d: %v", name, projectID, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
pa.Data["json"] = policies
|
||||
pa.ServeJSON()
|
||||
@ -122,12 +128,85 @@ func (pa *RepPolicyAPI) Post() {
|
||||
pa.Redirect(http.StatusCreated, strconv.FormatInt(pid, 10))
|
||||
}
|
||||
|
||||
// Put modifies name and description of policy
|
||||
func (pa *RepPolicyAPI) Put() {
|
||||
id := pa.GetIDFromURL()
|
||||
originalPolicy, err := dao.GetRepPolicy(id)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get policy %d: %v", id, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
if originalPolicy == nil {
|
||||
pa.CustomAbort(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
}
|
||||
|
||||
var policy *models.RepPolicy
|
||||
pa.DecodeJSONReq(policy)
|
||||
policy.ProjectID = originalPolicy.ProjectID
|
||||
policy.TargetID = originalPolicy.TargetID
|
||||
pa.Validate(policy)
|
||||
|
||||
if policy.Name != originalPolicy.Name {
|
||||
po, err := dao.GetRepPolicyByName(policy.Name)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get policy %s: %v", policy.Name, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
if po != nil {
|
||||
pa.CustomAbort(http.StatusConflict, "name is already used")
|
||||
}
|
||||
}
|
||||
|
||||
policy.ID = id
|
||||
|
||||
if err = dao.UpdateRepPolicy(policy); err != nil {
|
||||
log.Errorf("failed to update policy %d: %v", id, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
if policy.Enabled == originalPolicy.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
//enablement has been modified
|
||||
if policy.Enabled == 1 {
|
||||
go func() {
|
||||
if err := TriggerReplication(id, "", nil, models.RepOpTransfer); err != nil {
|
||||
log.Errorf("failed to trigger replication of %d: %v", id, err)
|
||||
} else {
|
||||
log.Infof("replication of %d triggered", id)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
go func() {
|
||||
if err := postReplicationAction(id, "stop"); err != nil {
|
||||
log.Errorf("failed to stop replication of %d: %v", id, err)
|
||||
} else {
|
||||
log.Infof("try to stop replication of %d", id)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
type enablementReq struct {
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateEnablement changes the enablement of the policy
|
||||
func (pa *RepPolicyAPI) UpdateEnablement() {
|
||||
id := pa.GetIDFromURL()
|
||||
policy, err := dao.GetRepPolicy(id)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get policy %d: %v", id, err)
|
||||
pa.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
if policy == nil {
|
||||
pa.CustomAbort(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
}
|
||||
|
||||
e := enablementReq{}
|
||||
pa.DecodeJSONReq(&e)
|
||||
if e.Enabled != 0 && e.Enabled != 1 {
|
||||
|
@ -120,23 +120,7 @@ func (t *TargetAPI) Ping() {
|
||||
|
||||
// Get ...
|
||||
func (t *TargetAPI) Get() {
|
||||
id := t.getIDFromURL()
|
||||
// list targets
|
||||
if id == 0 {
|
||||
targets, err := dao.GetAllRepTargets()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get all targets: %v", err)
|
||||
t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
target.Password = ""
|
||||
}
|
||||
|
||||
t.Data["json"] = targets
|
||||
t.ServeJSON()
|
||||
return
|
||||
}
|
||||
id := t.GetIDFromURL()
|
||||
|
||||
target, err := dao.GetRepTarget(id)
|
||||
if err != nil {
|
||||
@ -148,6 +132,9 @@ func (t *TargetAPI) Get() {
|
||||
t.CustomAbort(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
}
|
||||
|
||||
// The reason why the password is returned is that when user just wants to
|
||||
// modify other fields of target he does not need to input the password again.
|
||||
// The security issue can be fixed by enable https.
|
||||
if len(target.Password) != 0 {
|
||||
pwd, err := utils.ReversibleDecrypt(target.Password)
|
||||
if err != nil {
|
||||
@ -161,6 +148,33 @@ func (t *TargetAPI) Get() {
|
||||
t.ServeJSON()
|
||||
}
|
||||
|
||||
// List ...
|
||||
func (t *TargetAPI) List() {
|
||||
name := t.GetString("name")
|
||||
targets, err := dao.FilterRepTargets(name)
|
||||
if err != nil {
|
||||
log.Errorf("failed to filter targets %s: %v", name, err)
|
||||
t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
if len(target.Password) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
str, err := utils.ReversibleDecrypt(target.Password)
|
||||
if err != nil {
|
||||
log.Errorf("failed to decrypt password: %v", err)
|
||||
t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
target.Password = str
|
||||
}
|
||||
|
||||
t.Data["json"] = targets
|
||||
t.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
// Post ...
|
||||
func (t *TargetAPI) Post() {
|
||||
target := &models.RepTarget{}
|
||||
@ -191,21 +205,22 @@ func (t *TargetAPI) Post() {
|
||||
|
||||
// Put ...
|
||||
func (t *TargetAPI) Put() {
|
||||
id := t.getIDFromURL()
|
||||
if id == 0 {
|
||||
t.CustomAbort(http.StatusBadRequest, "id can not be empty or 0")
|
||||
}
|
||||
id := t.GetIDFromURL()
|
||||
|
||||
target := &models.RepTarget{}
|
||||
t.DecodeJSONReqAndValidate(target)
|
||||
|
||||
originTarget, err := dao.GetRepTarget(id)
|
||||
originalTarget, err := dao.GetRepTarget(id)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get target %d: %v", id, err)
|
||||
t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
if target.Name != originTarget.Name {
|
||||
if originalTarget == nil {
|
||||
t.CustomAbort(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
}
|
||||
|
||||
target := &models.RepTarget{}
|
||||
t.DecodeJSONReqAndValidate(target)
|
||||
|
||||
if target.Name != originalTarget.Name {
|
||||
ta, err := dao.GetRepTargetByName(target.Name)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get target %s: %v", target.Name, err)
|
||||
@ -231,10 +246,7 @@ func (t *TargetAPI) Put() {
|
||||
|
||||
// Delete ...
|
||||
func (t *TargetAPI) Delete() {
|
||||
id := t.getIDFromURL()
|
||||
if id == 0 {
|
||||
t.CustomAbort(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
|
||||
}
|
||||
id := t.GetIDFromURL()
|
||||
|
||||
target, err := dao.GetRepTarget(id)
|
||||
if err != nil {
|
||||
@ -251,17 +263,3 @@ func (t *TargetAPI) Delete() {
|
||||
t.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TargetAPI) getIDFromURL() int64 {
|
||||
idStr := t.Ctx.Input.Param(":id")
|
||||
if len(idStr) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
t.CustomAbort(http.StatusBadRequest, "invalid ID in request URL")
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
@ -764,6 +764,7 @@ var targetID, policyID, policyID2, policyID3, jobID, jobID2, jobID3 int64
|
||||
|
||||
func TestAddRepTarget(t *testing.T) {
|
||||
target := models.RepTarget{
|
||||
Name: "test",
|
||||
URL: "127.0.0.1:5000",
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
@ -865,10 +866,15 @@ func TestUpdateRepTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllRepTargets(t *testing.T) {
|
||||
if _, err := GetAllRepTargets(); err != nil {
|
||||
func TestFilterRepTargets(t *testing.T) {
|
||||
targets, err := FilterRepTargets("test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get all targets: %v", err)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("unexpected num of targets: %d, expected: %d", len(targets), 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRepPolicy(t *testing.T) {
|
||||
@ -1143,6 +1149,23 @@ func TestDeleteRepTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRepPolicies(t *testing.T) {
|
||||
_, err := FilterRepPolicies("name", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to filter policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRepPolicy(t *testing.T) {
|
||||
policy := &models.RepPolicy{
|
||||
ID: policyID,
|
||||
Name: "new_policy_name",
|
||||
}
|
||||
if err := UpdateRepPolicy(policy); err != nil {
|
||||
t.Fatalf("failed to update policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRepPolicy(t *testing.T) {
|
||||
err := DeleteRepPolicy(policyID)
|
||||
if err != nil {
|
||||
@ -1151,7 +1174,7 @@ func TestDeleteRepPolicy(t *testing.T) {
|
||||
}
|
||||
t.Logf("delete rep policy, id: %d", policyID)
|
||||
p, err := GetRepPolicy(policyID)
|
||||
if err != nil {
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
t.Errorf("Error occured in GetRepPolicy:%v", err)
|
||||
}
|
||||
if p != nil {
|
||||
|
@ -51,13 +51,26 @@ func UpdateRepTarget(target models.RepTarget) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllRepTargets ...
|
||||
func GetAllRepTargets() ([]*models.RepTarget, error) {
|
||||
// FilterRepTargets filters targets by name
|
||||
func FilterRepTargets(name string) ([]*models.RepTarget, error) {
|
||||
o := GetOrmer()
|
||||
qs := o.QueryTable(&models.RepTarget{})
|
||||
|
||||
var args []interface{}
|
||||
|
||||
sql := `select * from replication_target `
|
||||
if len(name) != 0 {
|
||||
sql += `where name like ? `
|
||||
args = append(args, "%"+name+"%")
|
||||
}
|
||||
sql += `order by creation_time`
|
||||
|
||||
var targets []*models.RepTarget
|
||||
_, err := qs.All(&targets)
|
||||
return targets, err
|
||||
|
||||
if _, err := o.Raw(sql, args).QueryRows(&targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// AddRepPolicy ...
|
||||
@ -85,31 +98,84 @@ func AddRepPolicy(policy models.RepPolicy) (int64, error) {
|
||||
// GetRepPolicy ...
|
||||
func GetRepPolicy(id int64) (*models.RepPolicy, error) {
|
||||
o := GetOrmer()
|
||||
p := models.RepPolicy{ID: id}
|
||||
err := o.Read(&p)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
sql := `select * from replication_policy where id = ?`
|
||||
|
||||
var policy models.RepPolicy
|
||||
|
||||
if err := o.Raw(sql, id).QueryRow(&policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, err
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// FilterRepPolicies filters policies by name and project ID
|
||||
func FilterRepPolicies(name string, projectID int64) ([]*models.RepPolicy, error) {
|
||||
o := GetOrmer()
|
||||
|
||||
var args []interface{}
|
||||
|
||||
sql := `select rp.id, rp.project_id, p.name as project_name, rp.target_id,
|
||||
rt.name as target_name, rp.name, rp.enabled, rp.description,
|
||||
rp.cron_str, rp.start_time, rp.creation_time, rp.update_time
|
||||
from replication_policy rp
|
||||
join project p on rp.project_id=p.project_id
|
||||
join replication_target rt on rp.target_id=rt.id `
|
||||
|
||||
if len(name) != 0 && projectID != 0 {
|
||||
sql += `where rp.name like ? and rp.project_id = ? `
|
||||
args = append(args, "%"+name+"%")
|
||||
args = append(args, projectID)
|
||||
} else if len(name) != 0 {
|
||||
sql += `where rp.name like ? `
|
||||
args = append(args, "%"+name+"%")
|
||||
} else if projectID != 0 {
|
||||
sql += `where rp.project_id = ? `
|
||||
args = append(args, projectID)
|
||||
}
|
||||
|
||||
sql += `order by rp.creation_time`
|
||||
|
||||
var policies []*models.RepPolicy
|
||||
if _, err := o.Raw(sql, args).QueryRows(&policies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// GetRepPolicyByName ...
|
||||
func GetRepPolicyByName(name string) (*models.RepPolicy, error) {
|
||||
o := GetOrmer()
|
||||
p := models.RepPolicy{Name: name}
|
||||
err := o.Read(&p, "Name")
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
sql := `select * from replication_policy where name = ?`
|
||||
|
||||
var policy models.RepPolicy
|
||||
|
||||
if err := o.Raw(sql, name).QueryRow(&policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, err
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// GetRepPolicyByProject ...
|
||||
func GetRepPolicyByProject(projectID int64) ([]*models.RepPolicy, error) {
|
||||
var res []*models.RepPolicy
|
||||
o := GetOrmer()
|
||||
_, err := o.QueryTable("replication_policy").Filter("project_id", projectID).All(&res)
|
||||
return res, err
|
||||
sql := `select * from replication_policy where project_id = ?`
|
||||
|
||||
var policies []*models.RepPolicy
|
||||
|
||||
if _, err := o.Raw(sql, projectID).QueryRows(&policies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdateRepPolicy ...
|
||||
func UpdateRepPolicy(policy *models.RepPolicy) error {
|
||||
o := GetOrmer()
|
||||
_, err := o.Update(policy, "Name", "Enabled", "Description", "CronStr")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRepPolicy ...
|
||||
|
@ -31,10 +31,12 @@ const (
|
||||
|
||||
// RepPolicy is the model for a replication policy, which associate to a project and a target (destination)
|
||||
type RepPolicy struct {
|
||||
ID int64 `orm:"column(id)" json:"id"`
|
||||
ProjectID int64 `orm:"column(project_id)" json:"project_id"`
|
||||
TargetID int64 `orm:"column(target_id)" json:"target_id"`
|
||||
Name string `orm:"column(name)" json:"name"`
|
||||
ID int64 `orm:"column(id)" json:"id"`
|
||||
ProjectID int64 `orm:"column(project_id)" json:"project_id"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
TargetID int64 `orm:"column(target_id)" json:"target_id"`
|
||||
TargetName string `json:"target_name,omitempty"`
|
||||
Name string `orm:"column(name)" json:"name"`
|
||||
// Target RepTarget `orm:"-" json:"target"`
|
||||
Enabled int `orm:"column(enabled)" json:"enabled"`
|
||||
Description string `orm:"column(description)" json:"description"`
|
||||
|
@ -65,9 +65,11 @@ func initRouters() {
|
||||
beego.Router("/api/repositories/manifests", &api.RepositoryAPI{}, "get:GetManifests")
|
||||
beego.Router("/api/jobs/replication/?:id([0-9]+)", &api.RepJobAPI{})
|
||||
beego.Router("/api/jobs/replication/:id([0-9]+)/log", &api.RepJobAPI{}, "get:GetLog")
|
||||
beego.Router("/api/policies/replication", &api.RepPolicyAPI{})
|
||||
beego.Router("/api/policies/replication/:id([0-9]+)", &api.RepPolicyAPI{})
|
||||
beego.Router("/api/policies/replication", &api.RepPolicyAPI{}, "get:List")
|
||||
beego.Router("/api/policies/replication/:id([0-9]+)/enablement", &api.RepPolicyAPI{}, "put:UpdateEnablement")
|
||||
beego.Router("/api/targets/?:id([0-9]+)", &api.TargetAPI{})
|
||||
beego.Router("/api/targets/", &api.TargetAPI{}, "get:List")
|
||||
beego.Router("/api/targets/:id([0-9]+)", &api.TargetAPI{})
|
||||
beego.Router("/api/targets/ping", &api.TargetAPI{}, "post:Ping")
|
||||
beego.Router("/api/users/:id/sysadmin", &api.UserAPI{}, "put:ToggleUserAdminRole")
|
||||
beego.Router("/api/repositories/top", &api.RepositoryAPI{}, "get:GetTopRepos")
|
||||
|
Loading…
Reference in New Issue
Block a user