Adding config package
This commit is contained in:
parent
cee6d56d27
commit
483f84a443
8 changed files with 548 additions and 0 deletions
177
config/config.go
Normal file
177
config/config.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"git.lattuga.net/blallo/papero/worker"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
var ErrPortOutOfRange = errors.New("port out of range")
|
||||
var ErrMissingPassword = errors.New("password has not been set")
|
||||
|
||||
// CastingErrors is a simple wrapper around map[string]error. It's endowed with a utility
|
||||
// function to check if there are any errors.
|
||||
type CastingErrors map[string]error
|
||||
|
||||
// Check if there are any errors, i.e. if any value of the map is not nil.
|
||||
func (c CastingErrors) Check() bool {
|
||||
for _, err := range c {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MemConfig is the data structure that will be used program-wise. It holds all the
|
||||
// necessary information to authenticate and manage each provided account.
|
||||
type MemConfig struct {
|
||||
Accounts map[string]*AccountData
|
||||
Workers map[string]*worker.Worker
|
||||
}
|
||||
|
||||
func initMemConfig() MemConfig {
|
||||
m := MemConfig{}
|
||||
m.Accounts = make(map[string]*AccountData)
|
||||
m.Workers = make(map[string]*worker.Worker)
|
||||
return m
|
||||
}
|
||||
|
||||
// AccountData holds the data for the single account.
|
||||
type AccountData struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
ExcludedFolders []string
|
||||
MailboxPath string
|
||||
Messages int
|
||||
}
|
||||
|
||||
func (a *AccountData) String() string {
|
||||
var res string
|
||||
res = "{"
|
||||
res += fmt.Sprintf("Host: %s, ", a.Host)
|
||||
res += fmt.Sprintf("Port: %d, ", a.Port)
|
||||
res += fmt.Sprintf("Username: %s, ", a.Username)
|
||||
res += fmt.Sprintf("Password: ********, ")
|
||||
res += fmt.Sprintf("ExcludedFolders: %s, ", a.ExcludedFolders)
|
||||
res += fmt.Sprintf("MailboxPath: %s}", a.MailboxPath)
|
||||
res += fmt.Sprintf("Messages: %d}", a.Messages)
|
||||
return res
|
||||
}
|
||||
|
||||
// parseConfig translates a *fileConfig, as obtained from a file read, into a usable
|
||||
// *MemConfig that could be used in computation, together with a custom CastingErrors
|
||||
// type, that holds the translation errors for each account.
|
||||
func parseConfig(fileConfig *fileConfig) (*MemConfig, CastingErrors) {
|
||||
outConfig := initMemConfig()
|
||||
basePath, err := getOrDefaultMailbox(fileConfig.MailboxPath)
|
||||
defaultMessages := getOrDefaultMessages(fileConfig.DefaultMessages)
|
||||
if err != nil {
|
||||
log.Fatal("Could not determine base path")
|
||||
}
|
||||
errors := make(map[string]error)
|
||||
for _, account := range fileConfig.Accounts {
|
||||
outConfig.Accounts[account.Name], errors[account.Name] = parseData(&account, basePath, defaultMessages)
|
||||
outConfig.Workers[account.Name] = &worker.Worker{}
|
||||
}
|
||||
return &outConfig, errors
|
||||
}
|
||||
|
||||
func getOrDefaultMailbox(mailboxPath string) (string, error) {
|
||||
if mailboxPath == "" {
|
||||
if homePath, err := homedir.Dir(); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
return path.Join(homePath, ".papero"), nil
|
||||
}
|
||||
}
|
||||
return mailboxPath, nil
|
||||
}
|
||||
|
||||
func getOrDefaultMessages(messages maybeInt) int {
|
||||
if messages.empty() {
|
||||
return 50
|
||||
}
|
||||
return messages.value()
|
||||
}
|
||||
|
||||
func valueOrEmptySlice(value []string) []string {
|
||||
if value == nil {
|
||||
return []string{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func valueOrDefaultInt(value maybeInt, defaultVal int) int {
|
||||
if value.empty() {
|
||||
return defaultVal
|
||||
}
|
||||
return value.value()
|
||||
}
|
||||
|
||||
func parseData(a *account, basePath string, defaultMessages int) (*AccountData, error) {
|
||||
port, err := validatePort(a.ConnectionInfo.Port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err := getPassword(a.ConnectionInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountData := AccountData{
|
||||
Host: a.ConnectionInfo.Host,
|
||||
Port: port,
|
||||
Username: a.ConnectionInfo.Username,
|
||||
Password: password,
|
||||
ExcludedFolders: valueOrEmptySlice(a.ExcludedFolders),
|
||||
MailboxPath: getMailboxPath(a.Name, a.MailboxPath, basePath),
|
||||
Messages: valueOrDefaultInt(a.Messages, defaultMessages),
|
||||
}
|
||||
return &accountData, nil
|
||||
}
|
||||
|
||||
func validatePort(port int) (int, error) {
|
||||
if port > 0 && port < 2<<15 {
|
||||
return port, nil
|
||||
}
|
||||
return 0, ErrPortOutOfRange
|
||||
}
|
||||
|
||||
func getPassword(connection *connectionInfo) (string, error) {
|
||||
if connection.PasswordExec.Present() {
|
||||
return connection.PasswordExec.Run()
|
||||
}
|
||||
if connection.PasswordFile != "" {
|
||||
file, err := os.Open(connection.PasswordFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reader := bufio.NewReader(file)
|
||||
line, _, err := reader.ReadLine()
|
||||
return string(line), err
|
||||
}
|
||||
if connection.Password == "" {
|
||||
return "", ErrMissingPassword
|
||||
}
|
||||
return connection.Password, nil
|
||||
}
|
||||
|
||||
func getMailboxPath(name, configPath, basePath string) string {
|
||||
log.Printf("name: %s,\tconfig_path: %s,\tbase_path: %s\n", name, configPath, basePath)
|
||||
if configPath != "" {
|
||||
if info, err := os.Stat(configPath); err == nil || err != os.ErrNotExist && info != nil {
|
||||
if info.IsDir() {
|
||||
return configPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return path.Join(basePath, name)
|
||||
}
|
52
config/config_test.go
Normal file
52
config/config_test.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.lattuga.net/blallo/papero/worker"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
expected := &MemConfig{
|
||||
Accounts: map[string]*AccountData{
|
||||
"First Account": &AccountData{
|
||||
Host: "mx.example.com",
|
||||
Port: 993,
|
||||
Username: "first@example.com",
|
||||
Password: "123qweasdzxc",
|
||||
ExcludedFolders: []string{"Draft", "Junk"},
|
||||
MailboxPath: "/opt",
|
||||
Messages: 30,
|
||||
},
|
||||
"Other Account": &AccountData{
|
||||
Host: "mail.personal.me",
|
||||
Port: 666,
|
||||
Username: "h4x0R@personal.me",
|
||||
Password: "mySup3r5ekre7p4ssw0rd123",
|
||||
ExcludedFolders: []string{},
|
||||
MailboxPath: "../base/mailbox/Other Account",
|
||||
Messages: 50,
|
||||
},
|
||||
},
|
||||
Workers: map[string]*worker.Worker{
|
||||
"First Account": &worker.Worker{},
|
||||
"Other Account": &worker.Worker{},
|
||||
},
|
||||
}
|
||||
file := writeToTempFile(t, "papero.*.toml", testConfig)
|
||||
|
||||
fileConfig, err := parseFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, errs := parseConfig(fileConfig)
|
||||
if !errs.Check() {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
|
||||
if !cmp.Equal(result.Accounts, expected.Accounts) {
|
||||
t.Errorf("Result and expected result differ: %+v\n", cmp.Diff(result.Accounts, expected.Accounts))
|
||||
}
|
||||
}
|
129
config/data.go
Normal file
129
config/data.go
Normal file
|
@ -0,0 +1,129 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// maybeInt is an integer that may be nil
|
||||
type maybeInt struct {
|
||||
int
|
||||
bool
|
||||
}
|
||||
|
||||
func (m maybeInt) empty() bool {
|
||||
return !m.bool
|
||||
}
|
||||
|
||||
func (m maybeInt) value() int {
|
||||
return m.int
|
||||
}
|
||||
|
||||
func initMaybeInt(value int) maybeInt {
|
||||
return maybeInt{value, true}
|
||||
}
|
||||
|
||||
func (m *maybeInt) UnmarshalText(text []byte) error {
|
||||
var err error
|
||||
var i int64
|
||||
if text != nil {
|
||||
str := string(text)
|
||||
i, err = strconv.ParseInt(str, 10, 64)
|
||||
m.int = int(i)
|
||||
m.bool = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type fileConfig struct {
|
||||
MailboxPath string `toml:"mailbox_path"`
|
||||
Accounts []account `toml:"account"`
|
||||
DefaultMessages maybeInt `toml:"default_messages"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
Name string `toml:"name"`
|
||||
MailboxPath string `toml:"mailbox_path"`
|
||||
ConnectionInfo *connectionInfo `toml:"connection"`
|
||||
ExcludedFolders []string `toml:"excluded_folders"`
|
||||
Messages maybeInt `toml:"messages"`
|
||||
}
|
||||
|
||||
type connectionInfo struct {
|
||||
Host string `toml:"hostname"`
|
||||
Port int `toml:"port"`
|
||||
Username string `toml:"username"`
|
||||
PasswordExec *ShellScript `toml:"password_exec"`
|
||||
PasswordFile string `toml:"password_file"`
|
||||
Password string `toml:"password"`
|
||||
}
|
||||
|
||||
func (c *connectionInfo) String() string {
|
||||
var res string
|
||||
res += "{"
|
||||
res += fmt.Sprintf("Host: %s, ", c.Host)
|
||||
res += fmt.Sprintf("Port: %d, ", c.Port)
|
||||
res += fmt.Sprintf("Username: %s, ", c.Username)
|
||||
res += fmt.Sprintf("Password: **********, ")
|
||||
res += fmt.Sprintf("PasswordFile: %s, ", c.PasswordFile)
|
||||
res += fmt.Sprintf("PasswordExec: %s}", c.PasswordExec)
|
||||
return res
|
||||
}
|
||||
|
||||
// ShellScript might be either:
|
||||
// - the path to an executable
|
||||
// - an executable in path
|
||||
// - a shell command with its argument
|
||||
// all these must return on stdout the password after successful execution.
|
||||
type ShellScript struct {
|
||||
Executable string
|
||||
}
|
||||
|
||||
func (s *ShellScript) UnmarshalText(text []byte) error {
|
||||
s.Executable = string(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ShellScript) String() string {
|
||||
return s.Executable
|
||||
}
|
||||
|
||||
// Present indicates if the value has been filled in into the config
|
||||
func (s *ShellScript) Present() bool {
|
||||
if s != nil {
|
||||
return s.Executable != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Run tries to execute the command, first looking for a corresponding executable and
|
||||
// invoking it, then invoking the string as a command followed by its parameters.
|
||||
func (s *ShellScript) Run() (string, error) {
|
||||
if path, err := exec.LookPath(s.Executable); err == nil {
|
||||
return doExec(path)
|
||||
}
|
||||
parts := strings.Split(s.Executable, " ")
|
||||
return doExec(parts[0], parts[1:]...)
|
||||
}
|
||||
|
||||
func doExec(path string, args ...string) (string, error) {
|
||||
cmd := exec.Command(path, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Println("Error running:", cmd)
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// parseFile reads the given config file and, if succeeding, returns a *FileConfig.
|
||||
func parseFile(path string) (*fileConfig, error) {
|
||||
newConfig := fileConfig{}
|
||||
_, err := toml.DecodeFile(path, &newConfig)
|
||||
return &newConfig, err
|
||||
}
|
83
config/data_test.go
Normal file
83
config/data_test.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestShellScriptRunInlineScript(t *testing.T) {
|
||||
s := ShellScript{Executable: "echo -n hello world!"}
|
||||
doTestShellScriptRun(t, &s)
|
||||
}
|
||||
|
||||
func TestShellScriptRunExecutable(t *testing.T) {
|
||||
file := writeToTempFile(t, "papero.*.sh", "#!/usr/bin/env sh\necho -n hello world!\n")
|
||||
s := ShellScript{Executable: file.Name()}
|
||||
doTestShellScriptRun(t, &s)
|
||||
}
|
||||
|
||||
func TestShellScriptRunExecutableWithArgs(t *testing.T) {
|
||||
file := writeToTempFile(t, "papero.*.sh", "#!/usr/bin/env sh\necho -n ${@}\n")
|
||||
s := ShellScript{Executable: fmt.Sprintf("%s %s %s", file.Name(), "hello", "world!")}
|
||||
doTestShellScriptRun(t, &s)
|
||||
}
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
expected := &fileConfig{
|
||||
MailboxPath: "../base/mailbox",
|
||||
DefaultMessages: initMaybeInt(50),
|
||||
Accounts: []account{
|
||||
{
|
||||
Name: "First Account",
|
||||
MailboxPath: "/other/path",
|
||||
ExcludedFolders: []string{"Draft", "Junk"},
|
||||
Messages: initMaybeInt(30),
|
||||
ConnectionInfo: &connectionInfo{
|
||||
Host: "mx.example.com",
|
||||
Port: 993,
|
||||
Username: "first@example.com",
|
||||
Password: "",
|
||||
PasswordFile: "",
|
||||
PasswordExec: &ShellScript{Executable: "echo -n 123qweasdzxc"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Other Account",
|
||||
MailboxPath: "",
|
||||
ExcludedFolders: []string{},
|
||||
Messages: maybeInt{},
|
||||
ConnectionInfo: &connectionInfo{
|
||||
Host: "mail.personal.me",
|
||||
Port: 666,
|
||||
Username: "h4x0R@personal.me",
|
||||
Password: "mySup3r5ekre7p4ssw0rd123",
|
||||
PasswordFile: "",
|
||||
PasswordExec: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
file := writeToTempFile(t, "papero.*.toml", testConfig)
|
||||
|
||||
result, err := parseFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opt := cmp.Comparer(func(a, b maybeInt) bool {
|
||||
if a.empty() {
|
||||
if b.empty() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return a.value() == b.value()
|
||||
})
|
||||
|
||||
if cmp.Equal(result, expected, opt) {
|
||||
t.Errorf("Result and expected result differ: %+v\n", cmp.Diff(*result, *expected))
|
||||
}
|
||||
}
|
54
config/helper_test.go
Normal file
54
config/helper_test.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testConfig = `
|
||||
mailbox_path = "../base/mailbox"
|
||||
default_messages = 50
|
||||
|
||||
[[account]]
|
||||
name = "First Account"
|
||||
mailbox_path = "/opt"
|
||||
messages = 30
|
||||
excluded_folders = ["Draft", "Junk"]
|
||||
[account.connection]
|
||||
hostname = "mx.example.com"
|
||||
port = 993
|
||||
username = "first@example.com"
|
||||
password_exec = "echo -n 123qweasdzxc"
|
||||
|
||||
[[account]]
|
||||
name = "Other Account"
|
||||
[account.connection]
|
||||
hostname = "mail.personal.me"
|
||||
port = 666
|
||||
username = "h4x0R@personal.me"
|
||||
password = "mySup3r5ekre7p4ssw0rd123"
|
||||
`
|
||||
|
||||
func doTestShellScriptRun(t *testing.T, s *ShellScript) {
|
||||
if res, err := s.Run(); res != "hello world!" || err != nil {
|
||||
t.Errorf("Unexpected result:\n\tres -> %s (type: %T)\n\terr -> %s", res, res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeToTempFile(t *testing.T, fileName, content string) *os.File {
|
||||
dir := t.TempDir()
|
||||
file, err := ioutil.TempFile(dir, fileName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ioutil.WriteFile(file.Name(), []byte(content), 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.Chmod(file.Name(), 0777)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file.Close()
|
||||
return file
|
||||
}
|
21
config/parse.go
Normal file
21
config/parse.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
)
|
||||
|
||||
var ErrFailedToParseConfig = errors.New("unable to cast into usable configuration")
|
||||
|
||||
func Parse(filePath string) (*MemConfig, error) {
|
||||
fileConfig, err := parseFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memConfig, castingErrs := parseConfig(fileConfig)
|
||||
if !castingErrs.Check() {
|
||||
log.Print(castingErrs)
|
||||
return nil, ErrFailedToParseConfig
|
||||
}
|
||||
return memConfig, nil
|
||||
}
|
10
go.mod
Normal file
10
go.mod
Normal file
|
@ -0,0 +1,10 @@
|
|||
module git.lattuga.net/blallo/papero
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1
|
||||
github.com/emersion/go-imap v1.0.6 // indirect
|
||||
github.com/google/go-cmp v0.5.4
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
)
|
22
go.sum
Normal file
22
go.sum
Normal file
|
@ -0,0 +1,22 @@
|
|||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-imap v1.0.6 h1:N9+o5laOGuntStBo+BOgfEB5evPsPD+K5+M0T2dctIc=
|
||||
github.com/emersion/go-imap v1.0.6/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU=
|
||||
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
|
||||
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs=
|
||||
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
Loading…
Reference in a new issue