Improve database authentication
Improve the database authentication implementation for namespaces, databases, and scopes.
This commit is contained in:
parent
d5604f589c
commit
94c9631d91
8 changed files with 462 additions and 60 deletions
13
cnf/cnf.go
13
cnf/cnf.go
|
@ -16,6 +16,19 @@ package cnf
|
|||
|
||||
var Settings *Options
|
||||
|
||||
type Auth struct {
|
||||
Kind int
|
||||
Data interface{}
|
||||
Possible struct {
|
||||
NS string
|
||||
DB string
|
||||
}
|
||||
Selected struct {
|
||||
NS string
|
||||
DB string
|
||||
}
|
||||
}
|
||||
|
||||
// Options defines global configuration options
|
||||
type Options struct {
|
||||
DB struct {
|
||||
|
|
6
db/db.go
6
db/db.go
|
@ -103,6 +103,12 @@ func Execute(ctx *fibre.Context, txt interface{}, vars map[string]interface{}) (
|
|||
return
|
||||
}
|
||||
|
||||
// Ensure that the current authentication data
|
||||
// is made available as a runtime variable to
|
||||
// the query layer.
|
||||
|
||||
vars["auth"] = ctx.Get("auth").(*cnf.Auth).Data
|
||||
|
||||
return Process(ctx, ast, vars)
|
||||
|
||||
}
|
||||
|
|
|
@ -34,26 +34,22 @@ const (
|
|||
|
||||
// options represents context runtime config.
|
||||
type options struct {
|
||||
kind int
|
||||
auth map[string]string
|
||||
conf map[string]string
|
||||
auth *cnf.Auth
|
||||
}
|
||||
|
||||
func newOptions(c *fibre.Context) *options {
|
||||
return &options{
|
||||
kind: c.Get("kind").(int),
|
||||
auth: c.Get("auth").(map[string]string),
|
||||
conf: c.Get("conf").(map[string]string),
|
||||
auth: c.Get("auth").(*cnf.Auth),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *options) get(kind int) (kv, ns, db string, err error) {
|
||||
|
||||
kv = cnf.Settings.DB.Base
|
||||
ns = o.conf["NS"]
|
||||
db = o.conf["DB"]
|
||||
ns = o.auth.Selected.NS
|
||||
db = o.auth.Selected.DB
|
||||
|
||||
if o.kind > kind {
|
||||
if kind < o.auth.Kind {
|
||||
err = &QueryError{}
|
||||
return
|
||||
}
|
||||
|
@ -81,7 +77,7 @@ func (o *options) ns(ns string) (err error) {
|
|||
// that it is remembered across requests on
|
||||
// any persistent connections.
|
||||
|
||||
o.conf["NS"] = ns
|
||||
o.auth.Selected.NS = ns
|
||||
|
||||
return
|
||||
|
||||
|
@ -101,7 +97,7 @@ func (o *options) db(db string) (err error) {
|
|||
// that it is remembered across requests on
|
||||
// any persistent connections.
|
||||
|
||||
o.conf["DB"] = db
|
||||
o.auth.Selected.DB = db
|
||||
|
||||
return
|
||||
|
||||
|
|
175
web/auth.go
175
web/auth.go
|
@ -18,10 +18,13 @@ import (
|
|||
"fmt"
|
||||
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/abcum/fibre"
|
||||
"github.com/abcum/surreal/cnf"
|
||||
"github.com/abcum/surreal/mem"
|
||||
"github.com/abcum/surreal/sql"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
|
@ -29,26 +32,41 @@ import (
|
|||
|
||||
func auth() fibre.MiddlewareFunc {
|
||||
return func(h fibre.HandlerFunc) fibre.HandlerFunc {
|
||||
return func(c *fibre.Context) error {
|
||||
return func(c *fibre.Context) (err error) {
|
||||
|
||||
auth := map[string]string{"NS": "", "DB": ""}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fibre.NewHTTPError(403)
|
||||
}
|
||||
}()
|
||||
|
||||
auth := &cnf.Auth{}
|
||||
c.Set("auth", auth)
|
||||
|
||||
conf := map[string]string{"NS": "", "DB": ""}
|
||||
c.Set("conf", conf)
|
||||
|
||||
// Start off with an authentication level
|
||||
// which prevents running any sql queries,
|
||||
// and denies access to all data.
|
||||
|
||||
c.Set("kind", sql.AuthNO)
|
||||
auth.Kind = sql.AuthNO
|
||||
|
||||
// Check whether there is an Authorization
|
||||
// header present, and if there is check
|
||||
// whether it is a Basic Auth header.
|
||||
// Retrieve the current domain host and
|
||||
// if we are using a subdomain then set
|
||||
// the NS and DB to the subdomain bits.
|
||||
|
||||
bits := strings.Split(c.Request().URL().Host, ".")
|
||||
subs := strings.Split(bits[0], "-")
|
||||
|
||||
if len(subs) == 2 {
|
||||
auth.Kind = sql.AuthSC
|
||||
auth.Possible.NS = subs[0]
|
||||
auth.Selected.NS = subs[0]
|
||||
auth.Possible.DB = subs[1]
|
||||
auth.Selected.DB = subs[1]
|
||||
}
|
||||
|
||||
// Retrieve the HTTP Authorization header
|
||||
// from the request, and continue.
|
||||
// from the request, so that we can detect
|
||||
// whether it is Basic auth or Bearer auth.
|
||||
|
||||
head := c.Request().Header().Get("Authorization")
|
||||
|
||||
|
@ -72,11 +90,11 @@ func auth() fibre.MiddlewareFunc {
|
|||
// Root authentication
|
||||
// ------------------------------
|
||||
|
||||
c.Set("kind", sql.AuthKV)
|
||||
auth["NS"] = "*" // Anything allowed
|
||||
conf["NS"] = "" // Must specify
|
||||
auth["DB"] = "*" // Anything allowed
|
||||
conf["DB"] = "" // Must specify
|
||||
auth.Kind = sql.AuthKV
|
||||
auth.Possible.NS = "*"
|
||||
auth.Selected.NS = ""
|
||||
auth.Possible.DB = "*"
|
||||
auth.Selected.DB = ""
|
||||
|
||||
return h(c)
|
||||
|
||||
|
@ -92,44 +110,107 @@ func auth() fibre.MiddlewareFunc {
|
|||
|
||||
if head != "" && head[:6] == "Bearer" {
|
||||
|
||||
var vars jwt.MapClaims
|
||||
var nok, dok, sok, tok, uok bool
|
||||
var nsv, dbv, scv, tkv, usv string
|
||||
|
||||
token, err := jwt.Parse(head[7:], func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
|
||||
vars = token.Claims.(jwt.MapClaims)
|
||||
|
||||
if err := vars.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(cnf.Settings.Auth.Token), nil
|
||||
|
||||
nsv, nok = vars["NS"].(string) // Namespace
|
||||
dbv, dok = vars["DB"].(string) // Database
|
||||
scv, sok = vars["SC"].(string) // Scope
|
||||
tkv, tok = vars["TK"].(string) // Token
|
||||
usv, uok = vars["US"].(string) // Login
|
||||
|
||||
if tkv == "default" {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method")
|
||||
}
|
||||
}
|
||||
|
||||
if nok && dok && sok && tok {
|
||||
|
||||
if tkv != "default" {
|
||||
key := mem.GetNS(nsv).GetDB(dbv).GetSC(scv).GetTK(tkv)
|
||||
if token.Header["alg"] != key.Type {
|
||||
return nil, fmt.Errorf("Unexpected signing method")
|
||||
}
|
||||
auth.Kind = sql.AuthSC
|
||||
return []byte(key.Text), nil
|
||||
} else {
|
||||
scp := mem.GetNS(nsv).GetDB(dbv).GetSC(scv)
|
||||
auth.Kind = sql.AuthSC
|
||||
return []byte(scp.Uniq), nil
|
||||
}
|
||||
|
||||
} else if nok && dok && tok {
|
||||
|
||||
if tkv != "default" {
|
||||
key := mem.GetNS(nsv).GetDB(dbv).GetTK(tkv)
|
||||
if token.Header["alg"] != key.Type {
|
||||
return nil, fmt.Errorf("Unexpected signing method")
|
||||
}
|
||||
auth.Kind = sql.AuthDB
|
||||
return []byte(key.Text), nil
|
||||
} else if uok {
|
||||
usr := mem.GetNS(nsv).GetDB(dbv).GetAC(usv)
|
||||
auth.Kind = sql.AuthDB
|
||||
return []byte(usr.Uniq), nil
|
||||
}
|
||||
|
||||
} else if nok && tok {
|
||||
|
||||
if tkv != "default" {
|
||||
key := mem.GetNS(nsv).GetTK(tkv)
|
||||
if token.Header["alg"] != key.Type {
|
||||
return nil, fmt.Errorf("Unexpected signing method")
|
||||
}
|
||||
auth.Kind = sql.AuthNS
|
||||
return []byte(key.Text), nil
|
||||
} else if uok {
|
||||
usr := mem.GetNS(nsv).GetAC(usv)
|
||||
auth.Kind = sql.AuthNS
|
||||
return []byte(usr.Uniq), nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("No available token")
|
||||
|
||||
})
|
||||
|
||||
if err == nil && token.Valid {
|
||||
|
||||
// ------------------------------
|
||||
// Namespace authentication
|
||||
// ------------------------------
|
||||
if auth.Kind == sql.AuthNS {
|
||||
auth.Possible.NS = nsv
|
||||
auth.Selected.NS = nsv
|
||||
auth.Possible.DB = "*"
|
||||
auth.Selected.DB = ""
|
||||
}
|
||||
|
||||
// c.Set("kind", sql.AuthNS)
|
||||
// auth["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// conf["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// auth["DB"] = "*" // Anything allowed
|
||||
// conf["DB"] = "" // Must specify
|
||||
if auth.Kind == sql.AuthDB {
|
||||
auth.Possible.NS = nsv
|
||||
auth.Selected.NS = nsv
|
||||
auth.Possible.DB = dbv
|
||||
auth.Selected.DB = dbv
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Database authentication
|
||||
// ------------------------------
|
||||
if auth.Kind == sql.AuthSC {
|
||||
auth.Possible.NS = nsv
|
||||
auth.Selected.NS = nsv
|
||||
auth.Possible.DB = dbv
|
||||
auth.Selected.DB = dbv
|
||||
}
|
||||
|
||||
// c.Set("kind", sql.AuthDB)
|
||||
// auth["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// conf["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// auth["DB"] = "SPECIFIED" // Not allowed to change
|
||||
// conf["DB"] = "SPECIFIED" // Not allowed to change
|
||||
|
||||
// ------------------------------
|
||||
// Scoped authentication
|
||||
// ------------------------------
|
||||
|
||||
// c.Set("kind", sql.AuthTB)
|
||||
// auth["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// conf["NS"] = "SPECIFIED" // Not allowed to change
|
||||
// auth["DB"] = "SPECIFIED" // Not allowed to change
|
||||
// conf["DB"] = "SPECIFIED" // Not allowed to change
|
||||
if val, ok := vars["auth"]; ok {
|
||||
auth.Data = val
|
||||
}
|
||||
|
||||
return h(c)
|
||||
|
||||
|
@ -137,11 +218,7 @@ func auth() fibre.MiddlewareFunc {
|
|||
|
||||
}
|
||||
|
||||
if c.Request().Header().Get("Upgrade") == "websocket" {
|
||||
return h(c)
|
||||
}
|
||||
|
||||
return fibre.NewHTTPError(401)
|
||||
return h(c)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,12 @@ func errors(val error, c *fibre.Context) {
|
|||
code, info = 409, e.Error()
|
||||
case *kvs.CKError:
|
||||
code, info = 403, e.Error()
|
||||
case *sql.NSError:
|
||||
code, info = 403, e.Error()
|
||||
case *sql.DBError:
|
||||
code, info = 403, e.Error()
|
||||
case *sql.QueryError:
|
||||
code, info = 401, e.Error()
|
||||
case *sql.ParseError:
|
||||
code, info = 400, e.Error()
|
||||
case *fibre.HTTPError:
|
||||
|
|
|
@ -65,6 +65,30 @@ func routes(s *fibre.Fibre) {
|
|||
|
||||
s.Rpc("/rpc", &rpc{})
|
||||
|
||||
// --------------------------------------------------
|
||||
// Endpoints for authentication signup
|
||||
// --------------------------------------------------
|
||||
|
||||
s.Options("/signup", func(c *fibre.Context) error {
|
||||
return c.Code(200)
|
||||
})
|
||||
|
||||
s.Post("/signup", func(c *fibre.Context) error {
|
||||
return signup(c)
|
||||
})
|
||||
|
||||
// --------------------------------------------------
|
||||
// Endpoints for authentication signin
|
||||
// --------------------------------------------------
|
||||
|
||||
s.Options("/signin", func(c *fibre.Context) error {
|
||||
return c.Code(200)
|
||||
})
|
||||
|
||||
s.Post("/signin", func(c *fibre.Context) error {
|
||||
return signin(c)
|
||||
})
|
||||
|
||||
// --------------------------------------------------
|
||||
// Endpoints for submitting sql queries
|
||||
// --------------------------------------------------
|
||||
|
|
208
web/signin.go
Normal file
208
web/signin.go
Normal file
|
@ -0,0 +1,208 @@
|
|||
// Copyright © 2016 Abcum Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/abcum/fibre"
|
||||
"github.com/abcum/surreal/db"
|
||||
"github.com/abcum/surreal/mem"
|
||||
"github.com/abcum/surreal/sql"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func signin(c *fibre.Context) (err error) {
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fibre.NewHTTPError(403)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars map[string]interface{}
|
||||
|
||||
c.Bind(&vars)
|
||||
|
||||
n, nok := vars["NS"].(string)
|
||||
d, dok := vars["DB"].(string)
|
||||
s, sok := vars["SC"].(string)
|
||||
|
||||
// If we have a namespace, database, and
|
||||
// scope defined, then we are logging in
|
||||
// to the scope level.
|
||||
|
||||
if nok && len(n) > 0 && dok && len(d) > 0 && sok && len(s) > 0 {
|
||||
|
||||
var str string
|
||||
var scp *mem.SC
|
||||
var res []*db.Response
|
||||
|
||||
// Get the specified signin scope.
|
||||
|
||||
if scp = mem.GetNS(n).GetDB(d).GetSC(s); scp == nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Process the scope signin statement.
|
||||
|
||||
res, err = db.Process(c, &sql.Query{[]sql.Statement{scp.Signin}}, vars)
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
if len(res) != 1 && len(res[0].Result) != 1 {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Create a new token signer with the default claims.
|
||||
|
||||
signr := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{
|
||||
"NS": n,
|
||||
"DB": d,
|
||||
"SC": s,
|
||||
"TK": "default",
|
||||
"iss": "Surreal",
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": time.Now().Unix(),
|
||||
"exp": time.Now().Add(scp.Time).Unix(),
|
||||
"auth": res[0].Result[0],
|
||||
})
|
||||
|
||||
// Try to create the final signed token as a string.
|
||||
|
||||
str, err = signr.SignedString([]byte(scp.Uniq))
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
return c.Text(200, str)
|
||||
|
||||
}
|
||||
|
||||
// If we have a namespace, database, but
|
||||
// no scope defined, then we are logging
|
||||
// in to the database level.
|
||||
|
||||
if nok && len(n) > 0 && dok && len(d) > 0 {
|
||||
|
||||
var str string
|
||||
var usr *mem.AC
|
||||
|
||||
// Get the specified user and password.
|
||||
|
||||
u, uok := vars["user"].(string)
|
||||
p, pok := vars["pass"].(string)
|
||||
|
||||
if !uok || len(u) == 0 || !pok || len(p) == 0 {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Get the specified database login.
|
||||
|
||||
if usr = mem.GetNS(n).GetDB(d).GetAC(u); usr == nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Compare the hashed and stored passwords.
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(usr.Pass), []byte(p))
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Create a new token signer with the default claims.
|
||||
|
||||
signr := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{
|
||||
"US": u,
|
||||
"NS": n,
|
||||
"DB": d,
|
||||
"TK": "default",
|
||||
"iss": "Surreal",
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": time.Now().Unix(),
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
})
|
||||
|
||||
// Try to create the final signed token as a string.
|
||||
|
||||
str, err = signr.SignedString([]byte(usr.Uniq))
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
return c.Text(200, str)
|
||||
|
||||
}
|
||||
|
||||
// If we have a namespace, but no database,
|
||||
// or scope defined, then we are logging
|
||||
// in to the namespace level.
|
||||
|
||||
if nok && len(n) > 0 {
|
||||
|
||||
var str string
|
||||
var usr *mem.AC
|
||||
|
||||
// Get the specified user and password.
|
||||
|
||||
u, uok := vars["user"].(string)
|
||||
p, pok := vars["pass"].(string)
|
||||
|
||||
if !uok || len(u) == 0 || !pok || len(p) == 0 {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Get the specified namespace login.
|
||||
|
||||
if usr = mem.GetNS(n).GetAC(u); usr == nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Compare the hashed and stored passwords.
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(usr.Pass), []byte(p))
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Create a new token signer with the default claims.
|
||||
|
||||
signr := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{
|
||||
"US": u,
|
||||
"NS": n,
|
||||
"TK": "default",
|
||||
"iss": "Surreal",
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": time.Now().Unix(),
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
})
|
||||
|
||||
// Try to create the final signed token as a string.
|
||||
|
||||
str, err = signr.SignedString([]byte(usr.Uniq))
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
return c.Text(200, str)
|
||||
|
||||
}
|
||||
|
||||
return fibre.NewHTTPError(403)
|
||||
|
||||
}
|
72
web/signup.go
Normal file
72
web/signup.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright © 2016 Abcum Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/abcum/fibre"
|
||||
"github.com/abcum/surreal/db"
|
||||
"github.com/abcum/surreal/mem"
|
||||
"github.com/abcum/surreal/sql"
|
||||
)
|
||||
|
||||
func signup(c *fibre.Context) (err error) {
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fibre.NewHTTPError(403)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars map[string]interface{}
|
||||
|
||||
c.Bind(&vars)
|
||||
|
||||
n, nok := vars["NS"].(string)
|
||||
d, dok := vars["DB"].(string)
|
||||
s, sok := vars["SC"].(string)
|
||||
|
||||
// If we have a namespace, database, and
|
||||
// scope defined, then we are logging in
|
||||
// to the scope level.
|
||||
|
||||
if nok && len(n) > 0 && dok && len(d) > 0 && sok && len(s) > 0 {
|
||||
|
||||
var scp *mem.SC
|
||||
var res []*db.Response
|
||||
|
||||
// Get the specified signin scope.
|
||||
|
||||
if scp = mem.GetNS(n).GetDB(d).GetSC(s); scp == nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
// Process the scope signup statement.
|
||||
|
||||
res, err = db.Process(c, &sql.Query{[]sql.Statement{scp.Signup}}, vars)
|
||||
if err != nil {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
if len(res) != 1 && len(res[0].Result) != 1 {
|
||||
return fibre.NewHTTPError(403)
|
||||
}
|
||||
|
||||
return c.Code(200)
|
||||
|
||||
}
|
||||
|
||||
return fibre.NewHTTPError(401)
|
||||
|
||||
}
|
Loading…
Reference in a new issue