Initial commit
This commit is contained in:
commit
e8a4ad031b
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
cmd/gpass/gpass
|
||||
cmd/gpass-gui/gpass-gui
|
28
cmd/gpass-gui/log.go
Normal file
28
cmd/gpass-gui/log.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
golog "log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type logLevelT int
|
||||
const (
|
||||
Fatal logLevelT = 1 << iota
|
||||
Error
|
||||
Warn
|
||||
Info
|
||||
Debug
|
||||
DebugGfx
|
||||
)
|
||||
|
||||
var loglevel = Fatal | Error | Warn | Info
|
||||
|
||||
func log(level logLevelT, msg ...interface{}) {
|
||||
if level & loglevel != 0 {
|
||||
golog.Print(msg...)
|
||||
}
|
||||
if level & Fatal != 0 {
|
||||
os.Exit(-1)
|
||||
}
|
||||
}
|
||||
|
243
cmd/gpass-gui/main.go
Normal file
243
cmd/gpass-gui/main.go
Normal file
|
@ -0,0 +1,243 @@
|
|||
// +build darwin linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
//"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"image"
|
||||
"image/color"
|
||||
"sync"
|
||||
|
||||
"gioui.org/ui"
|
||||
"gioui.org/ui/app"
|
||||
"gioui.org/ui/input"
|
||||
"gioui.org/ui/key"
|
||||
"gioui.org/ui/layout"
|
||||
"gioui.org/ui/text"
|
||||
"gioui.org/ui/measure"
|
||||
"gioui.org/ui/f32"
|
||||
"gioui.org/ui/paint"
|
||||
"gioui.org/ui/gesture"
|
||||
"gioui.org/ui/pointer"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/font/sfnt"
|
||||
|
||||
|
||||
"git.wow.st/gmp/pass"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
store,err = pass.GetStore()
|
||||
if err != nil {
|
||||
log(Fatal, err)
|
||||
}
|
||||
updated = make(chan struct{})
|
||||
go Updater()
|
||||
log(Info,"Staring event loop")
|
||||
go eventLoop()
|
||||
app.Main()
|
||||
log(Info,"Event loop returned")
|
||||
}
|
||||
|
||||
var (
|
||||
l []pass.Pass
|
||||
mux sync.Mutex
|
||||
store *pass.Store
|
||||
updated chan struct{}
|
||||
)
|
||||
|
||||
func Updater() {
|
||||
update := func() {
|
||||
ltmp,err := store.List()
|
||||
if err != nil {
|
||||
log(Fatal, err)
|
||||
}
|
||||
mux.Lock()
|
||||
l = ltmp
|
||||
mux.Unlock()
|
||||
updated <- struct{}{}
|
||||
}
|
||||
update()
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log(Fatal, err)
|
||||
}
|
||||
watcher.Add(store.Dir)
|
||||
for {
|
||||
select {
|
||||
case <-watcher.Events:
|
||||
update()
|
||||
case e := <-watcher.Errors:
|
||||
log(Info, "Watcher error: ",e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Button struct {
|
||||
Face text.Face
|
||||
Label string
|
||||
Click gesture.Click
|
||||
Color color.RGBA
|
||||
clicked bool
|
||||
}
|
||||
|
||||
func layoutRRect(col color.RGBA, c ui.Config, ops *ui.Ops, cs layout.Constraints) layout.Dimensions {
|
||||
r := float32(c.Px(ui.Dp(4)))
|
||||
sz := image.Point{X: cs.Width.Min, Y: cs.Height.Min}
|
||||
w, h := float32(sz.X), float32(sz.Y)
|
||||
rrect(ops, w, h, r, r, r, r)
|
||||
paint.ColorOp{Color: col}.Add(ops)
|
||||
paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: w, Y: h}}}.Add(ops)
|
||||
return layout.Dimensions{Size: sz}
|
||||
}
|
||||
|
||||
// https://pomax.github.io/bezierinfo/#circles_cubic.
|
||||
func rrect(ops *ui.Ops, width, height, se, sw, nw, ne float32) {
|
||||
w, h := float32(width), float32(height)
|
||||
const c = 0.55228475 // 4*(sqrt(2)-1)/3
|
||||
var b paint.PathBuilder
|
||||
b.Init(ops)
|
||||
b.Move(f32.Point{X: w, Y: h - se})
|
||||
b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE
|
||||
b.Line(f32.Point{X: sw - w + se, Y: 0})
|
||||
b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW
|
||||
b.Line(f32.Point{X: 0, Y: nw - h + sw})
|
||||
b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW
|
||||
b.Line(f32.Point{X: w - ne - nw, Y: 0})
|
||||
b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE
|
||||
b.End()
|
||||
}
|
||||
|
||||
|
||||
func (b *Button) Layout(c ui.Config, ops *ui.Ops, q input.Queue, cs layout.Constraints) layout.Dimensions {
|
||||
b.clicked = false
|
||||
for ev, ok := b.Click.Next(q); ok; ev, ok = b.Click.Next(q) {
|
||||
if ev.Type == gesture.TypeClick {
|
||||
b.clicked = true
|
||||
}
|
||||
}
|
||||
ins := layout.UniformInset(ui.Dp(1))
|
||||
cs = ins.Begin(c, ops, cs)
|
||||
var dims layout.Dimensions
|
||||
st := layout.Stack{}
|
||||
st.Init(ops, cs)
|
||||
{
|
||||
cs = st.Rigid()
|
||||
l := text.Label{
|
||||
Face: b.Face,
|
||||
Text: b.Label,
|
||||
}
|
||||
ins := layout.UniformInset(ui.Dp(4))
|
||||
dims = ins.End(l.Layout(ops, ins.Begin(c, ops, cs)))
|
||||
pointer.RectAreaOp{image.Rect(0, 0, dims.Size.X, dims.Size.Y)}.Add(ops)
|
||||
b.Click.Add(ops)
|
||||
}
|
||||
c2 := st.End(dims)
|
||||
c1 := st.End(layoutRRect(b.Color, c, ops, st.Expand()))
|
||||
dims = st.Layout(c1, c2)
|
||||
return ins.End(dims)
|
||||
}
|
||||
|
||||
func (b *Button) Clicked() bool {
|
||||
return b.clicked
|
||||
}
|
||||
|
||||
func eventLoop() {
|
||||
w := app.NewWindow(app.WithWidth(ui.Dp(250)))
|
||||
q := w.Queue()
|
||||
_ = q
|
||||
ops := new(ui.Ops)
|
||||
var faces measure.Faces
|
||||
regular, err := sfnt.Parse(goregular.TTF)
|
||||
if err != nil {
|
||||
log(Fatal, "Cannot parse font.")
|
||||
}
|
||||
face := faces.For(regular, ui.Sp(16))
|
||||
|
||||
margin := layout.UniformInset(ui.Dp(10))
|
||||
passInput := &text.Editor{Face: face, SingleLine: true}
|
||||
passInput.SetText("passphrase")
|
||||
passSubmit := &Button{Face: face, Label: "submit"}
|
||||
lst := &layout.List{Axis: layout.Vertical}
|
||||
passBtns := make([]*Button,0)
|
||||
_ = passInput
|
||||
_ = passSubmit
|
||||
pathnames := make([]string,0)
|
||||
|
||||
updateBtns := func() {
|
||||
passBtns = passBtns[:0]
|
||||
pathnames = pathnames[:0]
|
||||
mux.Lock()
|
||||
for _,x := range l {
|
||||
_, n := path.Split(x.Pathname)
|
||||
s := strings.Repeat(" /",x.Level)
|
||||
z := ""
|
||||
if x.Dir { z = "/" }
|
||||
passBtns = append(passBtns,&Button{
|
||||
Face:face,
|
||||
Label:strings.Join([]string{s,n,z},""),
|
||||
Color: color.RGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0},
|
||||
})
|
||||
pathnames = append(pathnames, x.Pathname)
|
||||
}
|
||||
mux.Unlock()
|
||||
}
|
||||
updateBtns()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-updated:
|
||||
log(Info,"UPDATE")
|
||||
updateBtns()
|
||||
w.Invalidate()
|
||||
case e := <-w.Events():
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return
|
||||
case app.UpdateEvent:
|
||||
c := &e.Config
|
||||
ops.Reset()
|
||||
faces.Reset(c)
|
||||
var dims layout.Dimensions
|
||||
cs := layout.RigidConstraints(e.Size)
|
||||
cs = margin.Begin(c, ops, cs)
|
||||
|
||||
mux.Lock()
|
||||
if lst.Dragging() {
|
||||
key.HideInputOp{}.Add(ops)
|
||||
}
|
||||
for lst.Init(c, q, ops, cs, len(passBtns)); lst.More(); lst.Next() {
|
||||
btn := passBtns[lst.Index()]
|
||||
dims = btn.Layout(c, ops, q, lst.Constraints())
|
||||
lst.End(dims)
|
||||
if btn.Clicked() {
|
||||
// don't block UI thread on decryption attempt
|
||||
log(Info,"Clicked ", btn.Label)
|
||||
go func(name string) {
|
||||
//p,err := store.Decrypt(name, prompt)
|
||||
p,err := store.Decrypt(name)
|
||||
if err == nil {
|
||||
pass.Clip(p)
|
||||
} else {
|
||||
log(Info,"Can't decrypt ", name)
|
||||
log(Info,err)
|
||||
}
|
||||
}(pathnames[lst.Index()])
|
||||
}
|
||||
}
|
||||
mux.Unlock()
|
||||
dims = lst.Layout()
|
||||
dims = margin.End(dims)
|
||||
w.Update(ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
76
cmd/gpass/main.go
Normal file
76
cmd/gpass/main.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitlab.wow.st/gmp/pass"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Println("gpass [-c|--clip] [name]")
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
type options struct {
|
||||
clip bool
|
||||
}
|
||||
|
||||
func parse(args []string) ([]string,options) {
|
||||
ret := make([]string,0)
|
||||
opts := options{}
|
||||
for _,a := range args {
|
||||
switch {
|
||||
case a == "-c",a == "--clip":
|
||||
opts.clip = true
|
||||
case a[0] == '-':
|
||||
usage()
|
||||
default:
|
||||
ret = append(ret,a)
|
||||
}
|
||||
}
|
||||
return ret,opts
|
||||
}
|
||||
|
||||
func main() {
|
||||
store,err := pass.GetStore()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
args := os.Args[1:]
|
||||
args,opts := parse(args)
|
||||
switch len(args) {
|
||||
case 0:
|
||||
l,err := store.List()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
for _, x := range l {
|
||||
_, n := path.Split(x.Pathname)
|
||||
s := strings.Repeat(" /",x.Level)
|
||||
z := ""
|
||||
if x.Dir { z = "/" }
|
||||
fmt.Println(strings.Join([]string{s,n,z},""))
|
||||
}
|
||||
case 1:
|
||||
p,err := store.Decrypt(args[0])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
if opts.clip {
|
||||
pass.Clip(p)
|
||||
} else {
|
||||
fmt.Print(p)
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
pass.AskPass2()
|
||||
}
|
||||
|
85
impl_darwin.go
Normal file
85
impl_darwin.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
//+build !android !linux
|
||||
|
||||
package pass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func setAgentInfo() {
|
||||
// get UID
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: cannot get user ID: %s\n", err)
|
||||
return
|
||||
}
|
||||
uid := usr.Uid
|
||||
|
||||
// get GPG Agent PID
|
||||
output, err := exec.Command("pgrep", "-U", uid, "gpg-agent").Output()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %s\n", err)
|
||||
return
|
||||
} else {
|
||||
fmt.Printf("gpg-agent process number is %s\n", output)
|
||||
}
|
||||
pid, err := strconv.Atoi(string(output[:len(output)-1]))
|
||||
if err != nil {
|
||||
fmt.Printf("Integer conversion failed: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// find agent socket file
|
||||
cmd := exec.Command("lsof", "-w", "-Fn", "-u", uid, "-baUcgpg-agent")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: connect stdout to lsof: %s\n", err)
|
||||
return
|
||||
}
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: cannot run lsof: %s\n", err)
|
||||
}
|
||||
|
||||
filename := ""
|
||||
for scanner.Scan() {
|
||||
x := scanner.Text()
|
||||
if x[0] != 'n' {
|
||||
continue
|
||||
}
|
||||
x = x[1:]
|
||||
if path.Base(x) == "S.gpg-agent" {
|
||||
fmt.Println(x)
|
||||
filename = x
|
||||
}
|
||||
}
|
||||
cmd.Wait()
|
||||
|
||||
if filename == "" {
|
||||
fmt.Printf("Error: gpg-agent socket file not found\n")
|
||||
return
|
||||
}
|
||||
s := fmt.Sprintf("%s:%d:1", filename, pid)
|
||||
fmt.Printf("GPG_AGENT_INFO = %s\n", s)
|
||||
os.Setenv("GPG_AGENT_INFO",s)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setAgentInfo()
|
||||
}
|
||||
|
||||
func Clip(x string) {
|
||||
b := bytes.NewBuffer([]byte(x))
|
||||
cmd := exec.Command("pbcopy")
|
||||
cmd.Stdin = b
|
||||
cmd.Run()
|
||||
}
|
||||
|
217
main.go
Normal file
217
main.go
Normal file
|
@ -0,0 +1,217 @@
|
|||
package pass
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jcmdev0/gpgagent"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
"golang.org/x/crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
var (
|
||||
basename *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
basename = regexp.MustCompile(".gpg$")
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
Dir string
|
||||
keyring openpgp.KeyRing
|
||||
}
|
||||
|
||||
func GetStore() (*Store, error) {
|
||||
ret := &Store{}
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return ret, fmt.Errorf("Can't get current user.")
|
||||
}
|
||||
ret.Dir = path.Join(u.HomeDir,".password-store")
|
||||
|
||||
fd, err := os.Open(path.Join(u.HomeDir,".gnupg/secring.gpg"))
|
||||
defer fd.Close()
|
||||
if err != nil {
|
||||
return ret, fmt.Errorf("Can't open keyring file")
|
||||
}
|
||||
kr, err := openpgp.ReadKeyRing(fd)
|
||||
if err != nil {
|
||||
return ret, fmt.Errorf("Can't open gnupg keyring.")
|
||||
}
|
||||
ret.keyring = kr
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
type caseInsensitive []os.FileInfo
|
||||
|
||||
func (fs caseInsensitive) Len() int { return len(fs) }
|
||||
func (fs caseInsensitive) Swap(i, j int) { fs[i], fs[j] = fs[j], fs[i] }
|
||||
func (fs caseInsensitive) Less(i, j int) bool {
|
||||
return strings.ToLower(fs[i].Name()) < strings.ToLower(fs[j].Name())
|
||||
}
|
||||
|
||||
type Pass struct {
|
||||
Pathname string
|
||||
Level int
|
||||
Dir bool
|
||||
}
|
||||
|
||||
func (s *Store) List() ([]Pass,error) {
|
||||
return s.list(0,"")
|
||||
}
|
||||
|
||||
func (s *Store) list(level int, p string) ([]Pass,error) {
|
||||
ret := make([]Pass,0)
|
||||
dir := path.Join(s.Dir,p)
|
||||
fd, err := os.Open(dir)
|
||||
defer fd.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open password store")
|
||||
}
|
||||
files, err := fd.Readdir(0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot read password store")
|
||||
}
|
||||
sort.Sort(caseInsensitive(files))
|
||||
for _, x := range files {
|
||||
n := basename.ReplaceAllLiteralString(x.Name(),"")
|
||||
entry := Pass{Pathname: path.Join(p,n),Level: level}
|
||||
|
||||
if n[0] == '.' {
|
||||
continue
|
||||
}
|
||||
if x.IsDir() {
|
||||
entry.Dir = true
|
||||
ret = append(ret,entry)
|
||||
l,err := s.list(level+1,path.Join(p,x.Name()))
|
||||
if err != nil {
|
||||
return nil,err
|
||||
}
|
||||
ret = append(ret,l...)
|
||||
} else {
|
||||
ret = append(ret,entry)
|
||||
}
|
||||
}
|
||||
return ret,nil
|
||||
}
|
||||
|
||||
func AskPass(prompts ...func() []byte) openpgp.PromptFunction {
|
||||
var prompt func() []byte
|
||||
if len(prompts) > 0 {
|
||||
prompt = prompts[0]
|
||||
} else {
|
||||
prompt = func() []byte {
|
||||
fmt.Print("AskPass(): enter passphrase: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
text, _ := reader.ReadString('\n')
|
||||
return []byte(text[:len(text)-1])
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
var passphrase []byte
|
||||
dec := func(p *packet.PrivateKey) {
|
||||
for i := 0; i<3; i++ {
|
||||
if err = p.Decrypt(passphrase); err == nil {
|
||||
break
|
||||
}
|
||||
passphrase = prompt()
|
||||
}
|
||||
}
|
||||
|
||||
var ret openpgp.PromptFunction
|
||||
ret = func(keys []openpgp.Key, symmetric bool) ([]byte, error) {
|
||||
if !symmetric {
|
||||
for _,k := range keys {
|
||||
if p := k.PrivateKey; p != nil && p.Encrypted {
|
||||
dec(p)
|
||||
}
|
||||
for _, s := range k.Entity.Subkeys {
|
||||
if p := s.PrivateKey; p != nil && p.Encrypted {
|
||||
dec(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return passphrase, err
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
//https://github.com/jcmdev0/gpgagent/blob/master/example/example.go
|
||||
func GPGPrompt(keys []openpgp.Key, symmetric bool) ([]byte, error) {
|
||||
conn, err := gpgagent.NewGpgAgentConn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
for _, key := range keys {
|
||||
cacheId := strings.ToUpper(hex.EncodeToString(key.PublicKey.Fingerprint[:]))
|
||||
request := gpgagent.PassphraseRequest{CacheKey: cacheId}
|
||||
passphrase, err := conn.GetPassphrase(&request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = key.PrivateKey.Decrypt([]byte(passphrase))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(passphrase), nil
|
||||
}
|
||||
return nil, fmt.Errorf("Unable to find key")
|
||||
}
|
||||
|
||||
func (s *Store) Decrypt(name string, prompts ...func() []byte) (string,error) {
|
||||
var ask openpgp.PromptFunction
|
||||
if len(prompts) > 0 {
|
||||
ask = AskPass(prompts...)
|
||||
} else {
|
||||
ask = GPGPrompt
|
||||
}
|
||||
file := path.Join(s.Dir, strings.Join([]string{name,".gpg"},""))
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
return "",fmt.Errorf("Not in password store.")
|
||||
}
|
||||
fd, err := os.Open(file)
|
||||
defer fd.Close()
|
||||
if err != nil {
|
||||
return "",err
|
||||
}
|
||||
var reader io.Reader
|
||||
unarmor, err := armor.Decode(fd)
|
||||
if err == nil {
|
||||
reader = unarmor.Body
|
||||
} else {
|
||||
fd.Close()
|
||||
fd, err = os.Open(file)
|
||||
defer fd.Close()
|
||||
if err != nil {
|
||||
return "",err
|
||||
}
|
||||
reader = fd
|
||||
}
|
||||
md, err := openpgp.ReadMessage(reader,s.keyring,ask,nil)
|
||||
if err != nil {
|
||||
fmt.Println("Error reading message")
|
||||
return "",err
|
||||
}
|
||||
ra, err := ioutil.ReadAll(md.UnverifiedBody)
|
||||
if err != nil {
|
||||
return "",err
|
||||
}
|
||||
return string(ra), nil
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user