Compare commits
11 commits
ab63a14df7
...
b518e568ee
Author | SHA1 | Date | |
---|---|---|---|
b518e568ee | |||
0080543ccc | |||
2003af03ef | |||
2731b673c8 | |||
935744799d | |||
d15755581e | |||
0e5d8988bf | |||
0d43d3a2f6 | |||
cd590f7a05 | |||
3bdc960285 | |||
c5ce0c1902 |
10 changed files with 196 additions and 134 deletions
15
README.MD
15
README.MD
|
@ -1,7 +1,11 @@
|
|||
# MailAutoConf - a simple, configurable autodiscover/autoconfig service for distributed and self-hosted services.
|
||||
|
||||
## New GoLang version - please make sure you update your ini files to yaml!
|
||||
Github: https://github.com/pswilde/mailautoconf
|
||||
Github : https://github.com/pswilde/mailautoconf
|
||||
|
||||
<a href="https://www.buymeacoffee.com/pswilde" target="_blank">
|
||||
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" height="60" width="217" alt="Buy Me A Coffee" style="height: 30px !important;width: 106px !important;" >
|
||||
</a>
|
||||
|
||||
## What is MailAutoConf?
|
||||
MailAutoConf is autodiscover/autoconfig web server for self-hosted mail services
|
||||
|
@ -60,7 +64,7 @@ SRV _autodiscover._tcp.your.domain 3600 10 10 443 autoconfig.your.domain
|
|||
```
|
||||
|
||||
## Compatibility
|
||||
MailAutoConf has been tested and confirmed working with the following software packages
|
||||
MailAutoConf has been tested and confirmed working (for IMAP and SMTP) with the following software packages
|
||||
- [x] Thunderbird (v78 and probably earlier versions too)
|
||||
- [x] Evolution Mail (v3.40.3 and probably earlier versions too)
|
||||
- [x] Nextcloud Mail app
|
||||
|
@ -86,8 +90,11 @@ Calendar and AddressBook is in the autoconfig XML documentation, but currently n
|
|||
|
||||
|
||||
## When will it be ready for production?
|
||||
Well, not yet. Though it does sort of work already.
|
||||
I'm working on this ultimately for my own use for my own small business. I'm hoping once it's good enough I could deploy the set up to my businesses customers and ultimately get them away from a Microsoft Exchange based environment. There's a long way to go for that right now though.
|
||||
It works for non-Microsoft email clients now (see Compatibility above).
|
||||
Outlook's autodiscover is a troublesome little blighter, MailAutoConf does generate a valid Autodiscover.xml, but modern Outlook clients use an Autodiscover.json file now which isn't documented anywhere. I'm working on this and hope to get Outlook Compatibility as soon as possible.
|
||||
|
||||
Then it's down to Autoconfiguration of Calendars and AddressBooks... but that's down to the email client developers really...
|
||||
|
||||
|
||||
If you feel you may be able to help, or ideas on features and their implementation, notice any bugs, or just want to say hi. Please do so and submit a pull request if required.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
Version : "0.1.3"
|
||||
Version : "0.1.5"
|
||||
# Sample config.yaml file.
|
||||
# Copy this file to "config/config.yaml" and adjust the
|
||||
# settings to your requirements
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
package global
|
||||
import (
|
||||
. "mailautoconf/structs"
|
||||
. "mailautoconf/global/structs"
|
||||
"mailautoconf/global/logger"
|
||||
"fmt"
|
||||
"gopkg.in/yaml.v2"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
// "os"
|
||||
"encoding/json"
|
||||
"text/template"
|
||||
"path"
|
||||
|
@ -39,16 +40,16 @@ func NewConfig() Config {
|
|||
}
|
||||
func loadConfig() Config {
|
||||
cfg := Config{}
|
||||
fmt.Println("Loading Default Config…")
|
||||
logger.Log("Loading Default Config…")
|
||||
cfgfile := defaultConfigDir + "config.default.yaml"
|
||||
unmarshalConfig(cfgfile, &cfg)
|
||||
fmt.Println("Loading Custom Config…")
|
||||
logger.Log("Loading Custom Config…")
|
||||
customcfgfile := configDir + "config.yaml"
|
||||
unmarshalConfig(customcfgfile, &cfg)
|
||||
fmt.Println("Loading Default Services…")
|
||||
logger.Log("Loading Default Services…")
|
||||
svcfile := defaultConfigDir + "services.default.yaml"
|
||||
unmarshalConfig(svcfile, &cfg)
|
||||
fmt.Println("Loading Custom Services…")
|
||||
logger.Log("Loading Custom Services…")
|
||||
customsvcfile := configDir + "services.yaml"
|
||||
unmarshalConfig(customsvcfile, &cfg)
|
||||
removeDisabledItems(&cfg)
|
||||
|
@ -64,21 +65,19 @@ func loadXMLTemplates(){
|
|||
"onoff": chooseOnOff,
|
||||
}
|
||||
t, err := template.New(name).Funcs(fmap).ParseFiles(tmpl)
|
||||
if err != nil {
|
||||
panic (err)
|
||||
}
|
||||
logger.CheckError(err)
|
||||
Templates[name] = t
|
||||
}
|
||||
}
|
||||
func unmarshalConfig(file string, cfg *Config) {
|
||||
if FileExists(file) {
|
||||
if logger.FileExists(file) {
|
||||
content, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
fmt.Println("Error reading config :", file, " : ", err)
|
||||
if !logger.ErrorOK(err){
|
||||
logger.Log("Error reading config :", file, " : ", fmt.Sprintf("%v",err))
|
||||
}
|
||||
err2 := yaml.Unmarshal(content, &cfg)
|
||||
if err2 != nil {
|
||||
fmt.Println("Error unmarshaling config :", file, " : ", err2)
|
||||
if !logger.ErrorOK(err2){
|
||||
logger.Log("Error unmarshaling config :", file, " : ", fmt.Sprintf("%v",err2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -108,22 +107,15 @@ func removeDisabledItems(cfg *Config) {
|
|||
}
|
||||
cfg.OtherServices = new_svcs
|
||||
}
|
||||
func FileExists(file string) bool {
|
||||
exists := false
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
exists = true
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
fmt.Printf("File %s does not exist\n", file);
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
func JSONifyConfig(content Config) string {
|
||||
data, err := json.Marshal(content)
|
||||
logger.CheckError(err)
|
||||
return string(data)
|
||||
}
|
||||
func JSONify(content interface{}) string {
|
||||
data, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
logger.CheckError(err)
|
||||
return string(data)
|
||||
}
|
||||
func parseUsername(svc Service, email string) string {
|
||||
|
@ -160,6 +152,6 @@ func GetSessionIP() string {
|
|||
if forwarded != "" {
|
||||
ip = forwarded
|
||||
}
|
||||
fmt.Printf("Session %s Connect From : %s\r\f",ThisSession.ID, ip)
|
||||
logger.Log("Session ", ThisSession.ID, " Connect From : ", ip)
|
||||
return ip
|
||||
}
|
||||
|
|
73
src/global/logger/logger.go
Normal file
73
src/global/logger/logger.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
const logDir = "config/logs"
|
||||
func Log(str ...string) {
|
||||
makeLogDir()
|
||||
|
||||
line := ""
|
||||
for _, s := range str {
|
||||
line = line + s
|
||||
}
|
||||
line = line + "\r\n"
|
||||
log.Print(line)
|
||||
t := time.Now()
|
||||
logname := fmt.Sprintf("%s_log.log",t.Format("200601"))
|
||||
logfile := fmt.Sprintf("%s/%s",logDir, logname)
|
||||
line = fmt.Sprintf("%s %s",t.Format("2006/01/02 15:04:05"), line)
|
||||
if !FileExists(logfile) {
|
||||
err := ioutil.WriteFile(logfile, []byte(line), 0755)
|
||||
CheckError(err)
|
||||
} else {
|
||||
file, err := os.OpenFile(logfile, os.O_APPEND|os.O_WRONLY, 0755)
|
||||
CheckError(err)
|
||||
defer file.Close()
|
||||
if _, err := file.WriteString(line); err != nil {
|
||||
CheckError(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
func CheckError(err error) (ok bool) {
|
||||
// here for obsolescence
|
||||
return ErrorOK(err)
|
||||
}
|
||||
func ErrorOK(err error) (ok bool) {
|
||||
ok = true // All is OK, err == nil
|
||||
if err != nil {
|
||||
ok = false // There's an error, print it
|
||||
e := fmt.Sprintf("%v",err)
|
||||
Log(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
func Fatal(err error) {
|
||||
e := fmt.Sprintf("%v",err)
|
||||
Log(e)
|
||||
log.Fatal(err)
|
||||
}
|
||||
func makeLogDir(){
|
||||
_, err := os.Stat(logDir)
|
||||
if os.IsNotExist(err) {
|
||||
os.Mkdir(logDir, 0755)
|
||||
}
|
||||
}
|
||||
func FileExists(file string) bool {
|
||||
exists := false
|
||||
_, err := os.Stat(file);
|
||||
if os.IsNotExist(err) {
|
||||
log.Print("File does not exist : ", file);
|
||||
} else if err == nil {
|
||||
exists = true
|
||||
} else {
|
||||
log.Fatal(err)
|
||||
log.Print("File %s does not exist\n", file);
|
||||
}
|
||||
return exists
|
||||
}
|
67
src/global/structs/structs.go
Normal file
67
src/global/structs/structs.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package structs
|
||||
|
||||
import "net/http"
|
||||
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
IP string
|
||||
ResponseWriter http.ResponseWriter
|
||||
Request *http.Request
|
||||
Path string
|
||||
WebContent string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Version string `yaml:"Version"`
|
||||
BaseURL string `yaml:"BaseURL"`
|
||||
Domains []string `yaml:"Domains"`
|
||||
LocalDomain string `yaml:"LocalDomain"`
|
||||
InMail Service `yaml:"InMail" json:",omitempty"`
|
||||
OutMail Service `yaml:"OutMail" json:",omitempty"`
|
||||
Calendar Service `yaml:"Calendar" json:",omitempty"`
|
||||
AddressBook Service `yaml:"AddressBook" json:",omitempty"`
|
||||
WebMail Service `yaml:"WebMail" json:",omitempty"`
|
||||
OtherServices []Service `yaml:"OtherServices" json:",omitempty"`
|
||||
|
||||
}
|
||||
type Service struct {
|
||||
Name string `yaml:"Name" json:",omitempty"`
|
||||
Enabled bool `yaml:"Enabled" json:",omitempty"`
|
||||
Type string `yaml:"Type" json:",omitempty"`
|
||||
Server string `yaml:"Server" json:",omitempty"`
|
||||
Port int `yaml:"Port" json:",omitempty"`
|
||||
SocketType string `yaml:"SocketType" json:",omitempty"`
|
||||
SPA bool `yaml:"SPA" json:",omitempty"`
|
||||
UsernameIsFQDN bool `yaml:"UsernameIsFQDN" json:",omitempty"`
|
||||
RequireLocalDomain bool `yaml:"RequireLocalDomain" json:",omitempty"`
|
||||
NoAuthRequired bool `yaml:"NoAuthRequired" json:",omitempty"`
|
||||
Authentication string `yaml:"Authentication" json:",omitempty"`
|
||||
// For Outgoing Mail
|
||||
POPAuth bool `yaml:"POPAuth" json:",omitempty"`
|
||||
SMTPLast bool `yaml:"SMTPLast" json:",omitempty"`
|
||||
// For WebMail (Unused)
|
||||
UsernameDivID string `yaml:"UsernameDivID" json:",omitempty"`
|
||||
UsernameDivName string `yaml:"UsernameDivName" json:",omitempty"`
|
||||
PasswordDivName string `yaml:"PasswordDivName" json:",omitempty"`
|
||||
SubmitButtonID string `yaml:"SubmitButtonID" json:",omitempty"`
|
||||
SubmitButtonName string `yaml:"SubmitButtonName" json:",omitempty"`
|
||||
}
|
||||
type Response struct {
|
||||
Url string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
Message string `json:"message"`
|
||||
Content map[string]interface{} `json:"content,omitempty"`
|
||||
Config Config `json:"_"`
|
||||
Email string `json:"_"`
|
||||
}
|
||||
type MSAutodiscoverJSONResponse struct {
|
||||
// More work to do - handling of MS Autodiscover.json requests
|
||||
Protocol string
|
||||
Url string
|
||||
}
|
||||
type MSAutodiscoverJSONError struct{
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
// "fmt"
|
||||
"net/http"
|
||||
"log"
|
||||
// "log"
|
||||
"mailautoconf/web/handler"
|
||||
"mailautoconf/global"
|
||||
"mailautoconf/global/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
global.NewConfig()
|
||||
http.HandleFunc("/", handler.WebHandler)
|
||||
fmt.Println("Starting up Web Listener on port 8010")
|
||||
log.Fatal(http.ListenAndServe(":8010", nil))
|
||||
logger.Log("Starting up Web Listener on port 8010")
|
||||
logger.Fatal(http.ListenAndServe(":8010", nil))
|
||||
}
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
package structs
|
||||
|
||||
import "net/http"
|
||||
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
IP string
|
||||
ResponseWriter http.ResponseWriter
|
||||
Request *http.Request
|
||||
Path string
|
||||
WebContent string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Version string `yaml:"Version"`
|
||||
BaseURL string `yaml:"BaseURL"`
|
||||
Domains []string `yaml:"Domains"`
|
||||
LocalDomain string `yaml:"LocalDomain"`
|
||||
InMail Service `yaml:"InMail"`
|
||||
OutMail Service `yaml:"OutMail"`
|
||||
Calendar Service `yaml:"Calendar"`
|
||||
AddressBook Service `yaml:"AddressBook"`
|
||||
WebMail Service `yaml:"WebMail"`
|
||||
OtherServices []Service `yaml:"OtherServices"`
|
||||
|
||||
}
|
||||
type Service struct {
|
||||
Name string `yaml:"Name"`
|
||||
Enabled bool `yaml:"Enabled"`
|
||||
Type string `yaml:"Type"`
|
||||
Server string `yaml:"Server"`
|
||||
Port int `yaml:"Port"`
|
||||
SocketType string `yaml:"SocketType"`
|
||||
SPA bool `yaml:"SPA"`
|
||||
UsernameIsFQDN bool `yaml:"UsernameIsFQDN"`
|
||||
RequireLocalDomain bool `yaml:"RequireLocalDomain"`
|
||||
NoAuthRequired bool `yaml:"NoAuthRequired"`
|
||||
Authentication string `yaml:"Authentication"`
|
||||
// For Outgoing Mail
|
||||
POPAuth bool `yaml:"POPAuth"`
|
||||
SMTPLast bool `yaml:"SMTPLast"`
|
||||
// For WebMail (Unused)
|
||||
UsernameDivID string `yaml:"UsernameDivID"`
|
||||
UsernameDivName string `yaml:"UsernameDivName"`
|
||||
PasswordDivName string `yaml:"PasswordDivName"`
|
||||
SubmitButtonID string `yaml:"SubmitButtonID"`
|
||||
SubmitButtonName string `yaml:"SubmitButtonName"`
|
||||
}
|
||||
type Response struct {
|
||||
Url string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
Message string `json:"message"`
|
||||
Content map[string]interface{} `json:"content"`
|
||||
Config Config `json:"_"`
|
||||
Email string `json:"_"`
|
||||
}
|
||||
type MSAutodiscoverJSONResponse struct {
|
||||
// More work to do - handling of MS Autodiscover.json requests
|
||||
Protocol string
|
||||
Url string
|
||||
}
|
||||
type MSAutodiscoverJSONError struct{
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
}
|
|
@ -30,7 +30,7 @@
|
|||
<addressBook type="{{ .Type | lower }}">
|
||||
<username>{{ $.Email | parseUsername . }}</username>
|
||||
<authentication>{{ .Authentication }}</authentication>
|
||||
<serverURL>{{ .Server }}</serverURL>
|
||||
<serverURL>{{ .Server }}addressbooks/users/{{ $.Email | parseUsername . }}/contacts/</serverURL>
|
||||
</addressBook>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
@ -39,7 +39,7 @@
|
|||
<calendar type="{{ .Type | lower }}">
|
||||
<username>{{ $.Email | parseUsername . }}</username>
|
||||
<authentication>{{ .Authentication }}</authentication>
|
||||
<serverURL>{{ .Server }}</serverURL>
|
||||
<serverURL>{{ .Server }}calendars/{{ $.Email | parseUsername . }}/personal/</serverURL>
|
||||
</calendar>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
package handler
|
||||
import (
|
||||
. "mailautoconf/structs"
|
||||
|
||||
. "mailautoconf/global"
|
||||
. "mailautoconf/global/structs"
|
||||
"mailautoconf/global/logger"
|
||||
"mailautoconf/web/responses"
|
||||
"strings"
|
||||
"net/http"
|
||||
"fmt"
|
||||
)
|
||||
func WebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
ThisSession = Session{}
|
||||
ThisSession.ResponseWriter = w
|
||||
ThisSession.Request = r
|
||||
|
||||
ThisSession.ID = NewSessionID()
|
||||
fmt.Printf("Session %s Request For : %s\r\f",ThisSession.ID, r.URL)
|
||||
url := fmt.Sprintf("%s", r.URL)
|
||||
logger.Log("Session ", ThisSession.ID, " Request For : ", url )
|
||||
ThisSession.IP = GetSessionIP()
|
||||
|
||||
ThisSession.Path = strings.ToLower(r.URL.Path[1:])
|
||||
|
@ -35,11 +36,9 @@ func WebHandler(w http.ResponseWriter, r *http.Request) {
|
|||
default:
|
||||
ThisSession.WebContent = responses.DefaultResponse()
|
||||
}
|
||||
|
||||
writeWebOutput()
|
||||
webOutput()
|
||||
}
|
||||
|
||||
func writeWebOutput () {
|
||||
func webOutput(){
|
||||
ThisSession.ResponseWriter.Header().Set("Content-Type", ThisSession.ContentType)
|
||||
fmt.Fprintf(ThisSession.ResponseWriter, ThisSession.WebContent)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
package responses
|
||||
import (
|
||||
"mailautoconf/global"
|
||||
. "mailautoconf/structs"
|
||||
"mailautoconf/global/logger"
|
||||
. "mailautoconf/global/structs"
|
||||
// "text/template"
|
||||
"fmt"
|
||||
// "path"
|
||||
|
@ -16,9 +17,7 @@ func MozAutoconfig() string {
|
|||
// https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
|
||||
|
||||
// parse the querystring
|
||||
if err := global.ThisSession.Request.ParseForm(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
logger.CheckError(global.ThisSession.Request.ParseForm())
|
||||
|
||||
// build the response
|
||||
response := Response{}
|
||||
|
@ -33,9 +32,7 @@ func MozAutoconfig() string {
|
|||
var result bytes.Buffer
|
||||
template := global.Templates["autoconfig.xml"]
|
||||
err := template.Execute(&result, response)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
logger.CheckError(err)
|
||||
|
||||
// return our string of xml
|
||||
return result.String()
|
||||
|
@ -53,9 +50,7 @@ func MsAutoDiscoverXML() string {
|
|||
// </Autodiscover>
|
||||
|
||||
// Parse the form to get the values
|
||||
if err := global.ThisSession.Request.ParseForm(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
logger.CheckError(global.ThisSession.Request.ParseForm())
|
||||
|
||||
// convert the input to a string so we can extract the email address
|
||||
form := fmt.Sprintf("%s",global.ThisSession.Request.Form)
|
||||
|
@ -68,7 +63,7 @@ func MsAutoDiscoverXML() string {
|
|||
replace := regexp.MustCompile(`\<[\/]?EMailAddress\>`)
|
||||
email = replace.ReplaceAllString(email,``)
|
||||
|
||||
fmt.Printf("Session %s Request for email : %s\r\f",global.ThisSession.ID,email)
|
||||
logger.Log("Session ",global.ThisSession.ID ," Request for email : ",email)
|
||||
// build the reponse
|
||||
response := Response{}
|
||||
response.Email = email
|
||||
|
@ -79,9 +74,7 @@ func MsAutoDiscoverXML() string {
|
|||
global.ThisSession.ContentType = "application/xml"
|
||||
var result bytes.Buffer
|
||||
err := template.Execute(&result, response)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
logger.CheckError(err)
|
||||
|
||||
// return our string of xml
|
||||
return result.String()
|
||||
|
@ -93,7 +86,6 @@ func MsAutoDiscoverJSON() string {
|
|||
// /autodiscover/autodiscover.json?Email=you@your.domain&Protocol=Autodiscoverv1&RedirectCount=1
|
||||
email = global.ThisSession.Request.FormValue("Email")
|
||||
protocol := global.ThisSession.Request.FormValue("Protocol")
|
||||
fmt.Println(protocol)
|
||||
global.ThisSession.ContentType = "application/json"
|
||||
switch strings.ToLower(protocol) {
|
||||
case "autodiscoverv1":
|
||||
|
@ -102,13 +94,11 @@ func MsAutoDiscoverJSON() string {
|
|||
response.Url = fmt.Sprintf("%s/Autodiscover/Autodiscover.xml", global.MainConfig.BaseURL)
|
||||
return global.JSONify(response)
|
||||
default:
|
||||
|
||||
response := MSAutodiscoverJSONError{}
|
||||
response.ErrorCode = "InvalidProtocol";
|
||||
response.ErrorMessage = fmt.Sprintf("The given protocol value '%s' is invalid. Supported values are 'AutodiscoverV1'", protocol)
|
||||
return global.JSONify(response)
|
||||
}
|
||||
|
||||
}
|
||||
func DefaultResponse() string {
|
||||
response := Response{}
|
||||
|
@ -120,6 +110,6 @@ func DefaultResponse() string {
|
|||
}
|
||||
func OurConfig() string {
|
||||
global.ThisSession.ContentType = "application/json"
|
||||
content := global.JSONify(global.MainConfig)
|
||||
content := global.JSONifyConfig(global.MainConfig)
|
||||
return content
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue