wip: debugging issue
This commit is contained in:
parent
7b29d8f6cc
commit
6c4b48873b
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/simple-privacy-tool
|
/simple-privacy-tool
|
||||||
|
/spt
|
||||||
36
README.md
36
README.md
@ -1,2 +1,38 @@
|
|||||||
# simple-privacy-tool
|
# simple-privacy-tool
|
||||||
|
|
||||||
|
Simple Privacy Tool is a simple tool to encrypt and decrypt files. It uses the symmetric algorithm XChaCha-Poly1305 and
|
||||||
|
Argon2id for the key derivation function.
|
||||||
|
|
||||||
|
Since this tool uses a symmetric algorithm, the level of privacy hinges solely on the password's strength. So, make sure
|
||||||
|
to choose your password carefully.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
#### Encrypt
|
||||||
|
Encrypting `plainfile` to `cryptedfile`
|
||||||
|
```shell
|
||||||
|
simple-privacy-tool encrypt plainfile cryptedfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Decrypt
|
||||||
|
Decrypting `cryptedfile` back to `plainfile`
|
||||||
|
```shell
|
||||||
|
simple-privacy-tool decrypt cryptedfile plainfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using STDIN/STDOUT
|
||||||
|
`simple-privacy-tool` can operate on `STDIN` or `STDOUT`. Just replace the file path with `-`
|
||||||
|
```shell
|
||||||
|
tar -zcf - dir | simple-privacy-tool encrypt - - | another-command
|
||||||
|
```
|
||||||
|
|
||||||
|
Special usage, just omit both file paths to use `STDIN` and `STDOUT`.
|
||||||
|
```shell
|
||||||
|
tar -zcf - dir | simple-privacy-tool encrypt | another-command
|
||||||
|
```
|
||||||
|
|||||||
113
cmd/spt/decrypt.go
Normal file
113
cmd/spt/decrypt.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package spt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gitea.suyono.dev/suyono/simple-privacy-tool/privacy"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type decryptApp struct {
|
||||||
|
cApp
|
||||||
|
r *privacy.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdoutWrapper struct {
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDecryptApp(a *cApp) *decryptApp {
|
||||||
|
return &decryptApp{
|
||||||
|
cApp: *a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decryptApp) GetPassphrase() (err error) {
|
||||||
|
var (
|
||||||
|
passphrase string
|
||||||
|
)
|
||||||
|
|
||||||
|
if passphrase, err = d.term.ReadPassword("input passphrase: "); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d.passphrase = passphrase
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decryptApp) ProcessFiles() (err error) {
|
||||||
|
d.r = privacy.NewPrivacyReader(d.srcFile)
|
||||||
|
if err = d.r.ReadMagic(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = d.r.GenerateKey(d.passphrase); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.dstPath == "-" || d.srcPath == "-" {
|
||||||
|
w := stdoutWrapper{
|
||||||
|
file: d.dstFile,
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(w, d.r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err = io.Copy(d.dstFile, d.r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = d.dstFile.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = d.srcFile.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw stdoutWrapper) ReadFrom(reader io.Reader) (n int64, err error) {
|
||||||
|
var (
|
||||||
|
nr int
|
||||||
|
rErr error
|
||||||
|
)
|
||||||
|
buf := make([]byte, 32768)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if nr, err = reader.Read(buf); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
n += int64(nr)
|
||||||
|
if _, err = sw.file.Write(buf[:nr]); err != nil {
|
||||||
|
return n, fmt.Errorf("readfrom internal write: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rErr = err
|
||||||
|
if nr > 0 {
|
||||||
|
n += int64(nr)
|
||||||
|
if nr > 32768 {
|
||||||
|
return n, fmt.Errorf("last piece length %d: %w", nr, err)
|
||||||
|
}
|
||||||
|
if _, err = sw.file.Write(buf[:nr]); err != nil {
|
||||||
|
return n, fmt.Errorf("readfrom internal write: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rErr == io.EOF || rErr == nil {
|
||||||
|
err = nil
|
||||||
|
} else {
|
||||||
|
err = rErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw stdoutWrapper) Write(b []byte) (n int, err error) {
|
||||||
|
return sw.file.Write(b)
|
||||||
|
}
|
||||||
65
cmd/spt/encrypt.go
Normal file
65
cmd/spt/encrypt.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package spt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.suyono.dev/suyono/simple-privacy-tool/privacy"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type encryptApp struct {
|
||||||
|
cApp
|
||||||
|
wc *privacy.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEncryptApp(a *cApp) *encryptApp {
|
||||||
|
return &encryptApp{
|
||||||
|
cApp: *a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *encryptApp) GetPassphrase() (err error) {
|
||||||
|
var (
|
||||||
|
passphrase string
|
||||||
|
verify string
|
||||||
|
)
|
||||||
|
|
||||||
|
if passphrase, err = e.term.ReadPassword("input passphrase: "); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if verify, err = e.term.ReadPassword("verify - input passphrase: "); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase != verify {
|
||||||
|
return ErrPassphraseMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
e.passphrase = passphrase
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *encryptApp) ProcessFiles() (err error) {
|
||||||
|
e.wc = privacy.NewPrivacyWriteCloser(e.dstFile, privacy.DefaultCipherMethod) //TODO: need to handle when custom keygen accepted
|
||||||
|
if err = e.wc.NewSalt(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = e.wc.GenerateKey(e.passphrase); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(e.wc, e.srcFile); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = e.wc.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = e.srcFile.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
140
cmd/spt/spt.go
140
cmd/spt/spt.go
@ -1,10 +1,25 @@
|
|||||||
package spt
|
package spt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
tw "gitea.suyono.dev/suyono/terminal_wrapper"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type cApp struct {
|
||||||
|
term *tw.Terminal
|
||||||
|
srcPath string
|
||||||
|
dstPath string
|
||||||
|
srcFile *os.File
|
||||||
|
dstFile *os.File
|
||||||
|
passphrase string
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
//ErrFatalError = errors.New("fatal error occurred")
|
||||||
|
ErrPassphraseMismatch = errors.New("mismatch passphrase")
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "spt",
|
Use: "spt",
|
||||||
Short: "a simple tool to encrypt and decrypt file",
|
Short: "a simple tool to encrypt and decrypt file",
|
||||||
@ -12,12 +27,14 @@ var (
|
|||||||
|
|
||||||
encryptCmd = &cobra.Command{
|
encryptCmd = &cobra.Command{
|
||||||
Use: "encrypt",
|
Use: "encrypt",
|
||||||
|
Args: validatePositionalArgs,
|
||||||
RunE: encrypt,
|
RunE: encrypt,
|
||||||
}
|
}
|
||||||
|
|
||||||
decryptCmd = &cobra.Command{
|
decryptCmd = &cobra.Command{
|
||||||
Use: "decrypt",
|
Use: "decrypt",
|
||||||
RunE: decrypt,
|
RunE: decrypt,
|
||||||
|
Args: validatePositionalArgs,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,24 +46,117 @@ func init() {
|
|||||||
rootCmd.AddCommand(encryptCmd, decryptCmd)
|
rootCmd.AddCommand(encryptCmd, decryptCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encrypt(cmd *cobra.Command, args []string) error {
|
func validatePositionalArgs(cmd *cobra.Command, args []string) error {
|
||||||
//TODO: implementation
|
if len(args) != 0 && len(args) != 2 {
|
||||||
|
//TODO: improve the error message
|
||||||
//// this is the sample of reading password
|
return errors.New("invalid arguments")
|
||||||
//fmt.Print("input passphrase: ")
|
}
|
||||||
//passwd, err := privacy.ReadPassword()
|
|
||||||
//if err != nil {
|
|
||||||
// return err
|
|
||||||
//}
|
|
||||||
//fmt.Println()
|
|
||||||
//
|
|
||||||
//fmt.Printf("password: %s\n", passwd)
|
|
||||||
//// end of sample
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(cmd *cobra.Command, args []string) error {
|
func encrypt(cmd *cobra.Command, args []string) (err error) {
|
||||||
//TODO: implementation
|
var (
|
||||||
|
terminal *tw.Terminal
|
||||||
|
app *cApp
|
||||||
|
eApp *encryptApp
|
||||||
|
)
|
||||||
|
|
||||||
|
if terminal, err = tw.MakeTerminal(os.Stderr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
existingErr := err
|
||||||
|
if err = terminal.Restore(); err == nil && existingErr != nil {
|
||||||
|
err = existingErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
app = &cApp{
|
||||||
|
term: terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.ProcessArgs(args); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eApp = newEncryptApp(app)
|
||||||
|
|
||||||
|
if err = eApp.GetPassphrase(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: process additional flags
|
||||||
|
|
||||||
|
if err = eApp.ProcessFiles(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decrypt(cmd *cobra.Command, args []string) (err error) {
|
||||||
|
var (
|
||||||
|
terminal *tw.Terminal
|
||||||
|
app *cApp
|
||||||
|
dApp *decryptApp
|
||||||
|
)
|
||||||
|
|
||||||
|
if terminal, err = tw.MakeTerminal(os.Stderr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
existingErr := err
|
||||||
|
if err = terminal.Restore(); err == nil && existingErr != nil {
|
||||||
|
err = existingErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
app = &cApp{
|
||||||
|
term: terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.ProcessArgs(args); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dApp = newDecryptApp(app)
|
||||||
|
|
||||||
|
if err = dApp.GetPassphrase(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: process additional flags
|
||||||
|
|
||||||
|
if err = dApp.ProcessFiles(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *cApp) ProcessArgs(args []string) (err error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
a.srcFile = os.Stdin
|
||||||
|
a.dstFile = os.Stdout
|
||||||
|
} else {
|
||||||
|
a.srcPath = args[0]
|
||||||
|
a.dstPath = args[1]
|
||||||
|
if a.srcPath == "-" {
|
||||||
|
a.srcFile = os.Stdin
|
||||||
|
} else {
|
||||||
|
if a.srcFile, err = os.Open(a.srcPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.dstPath == "-" {
|
||||||
|
a.dstFile = os.Stdout
|
||||||
|
} else {
|
||||||
|
if a.dstFile, err = os.OpenFile(a.dstPath, os.O_CREATE|os.O_WRONLY, 0640); err != nil { //TODO: allow user to define the destination file permission
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -3,13 +3,14 @@ module gitea.suyono.dev/suyono/simple-privacy-tool
|
|||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.suyono.dev/suyono/terminal_wrapper v0.0.0-20230722101024-a3e50949f40f
|
||||||
github.com/spf13/cobra v1.7.0
|
github.com/spf13/cobra v1.7.0
|
||||||
golang.org/x/crypto v0.11.0
|
golang.org/x/crypto v0.11.0
|
||||||
golang.org/x/term v0.10.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/sys v0.10.0 // indirect
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
|
golang.org/x/term v0.10.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -1,3 +1,5 @@
|
|||||||
|
gitea.suyono.dev/suyono/terminal_wrapper v0.0.0-20230722101024-a3e50949f40f h1:yPac52dWHSutQH99FUhqz9rt2FHKaHr9srdJuDI9pkg=
|
||||||
|
gitea.suyono.dev/suyono/terminal_wrapper v0.0.0-20230722101024-a3e50949f40f/go.mod h1:I8qBJ8oaj+RwbLggXWNRrvRCDD+/bnRoQne0V4xsBNU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
|||||||
1
main.go
1
main.go
@ -9,5 +9,6 @@ func main() {
|
|||||||
err := spt.Execute()
|
err := spt.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error: %v\n", err)
|
fmt.Printf("error: %v\n", err)
|
||||||
|
//panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,8 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/chacha20poly1305"
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
"golang.org/x/term"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CipherMethodType byte
|
type CipherMethodType byte
|
||||||
@ -369,17 +366,3 @@ func (r *Reader) Read(b []byte) (n int, err error) {
|
|||||||
|
|
||||||
return copied, nil
|
return copied, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadPassphraseFromTerminal() (string, error) {
|
|
||||||
var inputFd = int(os.Stdin.Fd())
|
|
||||||
if !term.IsTerminal(inputFd) {
|
|
||||||
return "", errors.New("not a terminal")
|
|
||||||
}
|
|
||||||
|
|
||||||
passwd, err := term.ReadPassword(inputFd)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(passwd), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -91,14 +91,25 @@ func TestReadWriteClose(t *testing.T) {
|
|||||||
ur := mr.New(mr.NewSource(1))
|
ur := mr.New(mr.NewSource(1))
|
||||||
bb := make([]byte, 1048)
|
bb := make([]byte, 1048)
|
||||||
|
|
||||||
var bar int
|
var (
|
||||||
|
//bar int
|
||||||
|
n int
|
||||||
|
wl int
|
||||||
|
rl int
|
||||||
|
)
|
||||||
for i := 0; i < 63; i++ {
|
for i := 0; i < 63; i++ {
|
||||||
bar = 1000 + ur.Intn(49)
|
//bar = 1000 + ur.Intn(49)
|
||||||
ur.Read(bb[:bar])
|
//ur.Read(bb[:bar])
|
||||||
sha.Write(bb[:bar])
|
//sha.Write(bb[:bar])
|
||||||
if _, err = writer.Write(bb[:bar]); err != nil {
|
//if n, err = writer.Write(bb[:bar]); err != nil {
|
||||||
|
// t.Fatal("unexpected: Write failed", err)
|
||||||
|
//}
|
||||||
|
ur.Read(bb)
|
||||||
|
sha.Write(bb)
|
||||||
|
if n, err = writer.Write(bb); err != nil {
|
||||||
t.Fatal("unexpected: Write failed", err)
|
t.Fatal("unexpected: Write failed", err)
|
||||||
}
|
}
|
||||||
|
wl += n
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = writer.Close(); err != nil {
|
if err = writer.Close(); err != nil {
|
||||||
@ -120,25 +131,29 @@ func TestReadWriteClose(t *testing.T) {
|
|||||||
|
|
||||||
sha.Reset()
|
sha.Reset()
|
||||||
err = nil
|
err = nil
|
||||||
|
n = 0
|
||||||
for err == nil {
|
for err == nil {
|
||||||
if _, err = reader.Read(bb); err != nil {
|
if n, err = reader.Read(bb); err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
continue
|
continue
|
||||||
} else {
|
} else {
|
||||||
t.Fatal("unexpected: Read failed", err)
|
t.Fatal("unexpected: Read failed", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rl += n
|
||||||
sha.Write(bb)
|
sha.Write(bb)
|
||||||
}
|
}
|
||||||
|
|
||||||
readHash := sha.Sum(nil)
|
readHash := sha.Sum(nil)
|
||||||
t.Log("read hash:", hex.EncodeToString(readHash))
|
t.Log("read hash:", hex.EncodeToString(readHash))
|
||||||
|
|
||||||
//for i := range writeHash {
|
t.Log("wl", wl)
|
||||||
// if readHash[i] != writeHash[i] {
|
t.Log("rl", rl)
|
||||||
// t.Fatal("unexpected: mismatch hash")
|
for i := range writeHash {
|
||||||
// }
|
if readHash[i] != writeHash[i] {
|
||||||
//}
|
t.Fatal("unexpected: mismatch hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrial(t *testing.T) {
|
func TestTrial(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user