mirror of
https://github.com/ergochat/ergo.git
synced 2024-11-10 22:19:31 +01:00
647 lines
18 KiB
Go
647 lines
18 KiB
Go
// Copyright (c) 2012-2014 Jeremy Latt
|
|
// Copyright (c) 2014-2015 Edmund Huber
|
|
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
|
|
// released under the MIT license
|
|
|
|
package irc
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.cloudfoundry.org/bytefmt"
|
|
"github.com/oragono/oragono/irc/connection_limits"
|
|
"github.com/oragono/oragono/irc/custime"
|
|
"github.com/oragono/oragono/irc/languages"
|
|
"github.com/oragono/oragono/irc/logger"
|
|
"github.com/oragono/oragono/irc/passwd"
|
|
"github.com/oragono/oragono/irc/utils"
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
// PassConfig holds the connection password.
|
|
type PassConfig struct {
|
|
Password string
|
|
}
|
|
|
|
// TLSListenConfig defines configuration options for listening on TLS.
|
|
type TLSListenConfig struct {
|
|
Cert string
|
|
Key string
|
|
}
|
|
|
|
// Config returns the TLS contiguration assicated with this TLSListenConfig.
|
|
func (conf *TLSListenConfig) Config() (*tls.Config, error) {
|
|
cert, err := tls.LoadX509KeyPair(conf.Cert, conf.Key)
|
|
if err != nil {
|
|
return nil, ErrInvalidCertKeyPair
|
|
}
|
|
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
}, err
|
|
}
|
|
|
|
// PasswordBytes returns the bytes represented by the password hash.
|
|
func (conf *PassConfig) PasswordBytes() []byte {
|
|
bytes, err := passwd.DecodePasswordHash(conf.Password)
|
|
if err != nil {
|
|
log.Fatal("decode password error: ", err)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
type AccountConfig struct {
|
|
Registration AccountRegistrationConfig
|
|
AuthenticationEnabled bool `yaml:"authentication-enabled"`
|
|
SkipServerPassword bool `yaml:"skip-server-password"`
|
|
NickReservation NickReservationConfig `yaml:"nick-reservation"`
|
|
}
|
|
|
|
// AccountRegistrationConfig controls account registration.
|
|
type AccountRegistrationConfig struct {
|
|
Enabled bool
|
|
EnabledCallbacks []string `yaml:"enabled-callbacks"`
|
|
EnabledCredentialTypes []string `yaml:"-"`
|
|
VerifyTimeout time.Duration `yaml:"verify-timeout"`
|
|
Callbacks struct {
|
|
Mailto struct {
|
|
Server string
|
|
Port int
|
|
TLS struct {
|
|
Enabled bool
|
|
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
|
ServerName string `yaml:"servername"`
|
|
}
|
|
Username string
|
|
Password string
|
|
Sender string
|
|
VerifyMessageSubject string `yaml:"verify-message-subject"`
|
|
VerifyMessage string `yaml:"verify-message"`
|
|
}
|
|
}
|
|
AllowMultiplePerConnection bool `yaml:"allow-multiple-per-connection"`
|
|
}
|
|
|
|
type NickReservationMethod int
|
|
|
|
const (
|
|
NickReservationWithTimeout NickReservationMethod = iota
|
|
NickReservationStrict
|
|
)
|
|
|
|
func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
var orig, raw string
|
|
var err error
|
|
if err = unmarshal(&orig); err != nil {
|
|
return err
|
|
}
|
|
if raw, err = Casefold(orig); err != nil {
|
|
return err
|
|
}
|
|
if raw == "timeout" {
|
|
*nr = NickReservationWithTimeout
|
|
} else if raw == "strict" {
|
|
*nr = NickReservationStrict
|
|
} else {
|
|
return errors.New(fmt.Sprintf("invalid nick-reservation.method value: %s", orig))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type NickReservationConfig struct {
|
|
Enabled bool
|
|
Method NickReservationMethod
|
|
RenameTimeout time.Duration `yaml:"rename-timeout"`
|
|
RenamePrefix string `yaml:"rename-prefix"`
|
|
}
|
|
|
|
// ChannelRegistrationConfig controls channel registration.
|
|
type ChannelRegistrationConfig struct {
|
|
Enabled bool
|
|
}
|
|
|
|
// OperClassConfig defines a specific operator class.
|
|
type OperClassConfig struct {
|
|
Title string
|
|
WhoisLine string
|
|
Extends string
|
|
Capabilities []string
|
|
}
|
|
|
|
// OperConfig defines a specific operator's configuration.
|
|
type OperConfig struct {
|
|
Class string
|
|
Vhost string
|
|
WhoisLine string `yaml:"whois-line"`
|
|
Password string
|
|
Modes string
|
|
}
|
|
|
|
// PasswordBytes returns the bytes represented by the password hash.
|
|
func (conf *OperConfig) PasswordBytes() []byte {
|
|
bytes, err := passwd.DecodePasswordHash(conf.Password)
|
|
if err != nil {
|
|
log.Fatal("decode password error: ", err)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
// LineLenConfig controls line lengths.
|
|
type LineLenConfig struct {
|
|
Tags int
|
|
Rest int
|
|
}
|
|
|
|
// STSConfig controls the STS configuration/
|
|
type STSConfig struct {
|
|
Enabled bool
|
|
Duration time.Duration `yaml:"duration-real"`
|
|
DurationString string `yaml:"duration"`
|
|
Port int
|
|
Preload bool
|
|
}
|
|
|
|
// Value returns the STS value to advertise in CAP
|
|
func (sts *STSConfig) Value() string {
|
|
val := fmt.Sprintf("duration=%d", int(sts.Duration.Seconds()))
|
|
if sts.Enabled && sts.Port > 0 {
|
|
val += fmt.Sprintf(",port=%d", sts.Port)
|
|
}
|
|
if sts.Enabled && sts.Preload {
|
|
val += ",preload"
|
|
}
|
|
return val
|
|
}
|
|
|
|
// StackImpactConfig is the config used for StackImpact's profiling.
|
|
type StackImpactConfig struct {
|
|
Enabled bool
|
|
AgentKey string `yaml:"agent-key"`
|
|
AppName string `yaml:"app-name"`
|
|
}
|
|
|
|
// Config defines the overall configuration.
|
|
type Config struct {
|
|
Network struct {
|
|
Name string
|
|
}
|
|
|
|
Server struct {
|
|
PassConfig
|
|
Password string
|
|
Name string
|
|
Listen []string
|
|
TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"`
|
|
STS STSConfig
|
|
CheckIdent bool `yaml:"check-ident"`
|
|
MOTD string
|
|
MOTDFormatting bool `yaml:"motd-formatting"`
|
|
ProxyAllowedFrom []string `yaml:"proxy-allowed-from"`
|
|
WebIRC []webircConfig `yaml:"webirc"`
|
|
MaxSendQString string `yaml:"max-sendq"`
|
|
MaxSendQBytes uint64
|
|
ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"`
|
|
ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
|
|
}
|
|
|
|
Languages struct {
|
|
Enabled bool
|
|
Path string
|
|
Default string
|
|
Data map[string]languages.LangData
|
|
}
|
|
|
|
Datastore struct {
|
|
Path string
|
|
}
|
|
|
|
Accounts AccountConfig
|
|
|
|
Channels struct {
|
|
DefaultModes *string `yaml:"default-modes"`
|
|
Registration ChannelRegistrationConfig
|
|
}
|
|
|
|
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
|
|
|
|
Opers map[string]*OperConfig
|
|
|
|
Logging []logger.LoggingConfig
|
|
|
|
Debug struct {
|
|
RecoverFromErrors *bool `yaml:"recover-from-errors"`
|
|
StackImpact StackImpactConfig
|
|
}
|
|
|
|
Limits struct {
|
|
AwayLen uint `yaml:"awaylen"`
|
|
ChanListModes uint `yaml:"chan-list-modes"`
|
|
ChannelLen uint `yaml:"channellen"`
|
|
KickLen uint `yaml:"kicklen"`
|
|
MonitorEntries uint `yaml:"monitor-entries"`
|
|
NickLen uint `yaml:"nicklen"`
|
|
TopicLen uint `yaml:"topiclen"`
|
|
WhowasEntries uint `yaml:"whowas-entries"`
|
|
LineLen LineLenConfig `yaml:"linelen"`
|
|
}
|
|
|
|
Filename string
|
|
}
|
|
|
|
// OperClass defines an assembled operator class.
|
|
type OperClass struct {
|
|
Title string
|
|
WhoisLine string `yaml:"whois-line"`
|
|
Capabilities map[string]bool // map to make lookups much easier
|
|
}
|
|
|
|
// OperatorClasses returns a map of assembled operator classes from the given config.
|
|
func (conf *Config) OperatorClasses() (*map[string]OperClass, error) {
|
|
ocs := make(map[string]OperClass)
|
|
|
|
// loop from no extends to most extended, breaking if we can't add any more
|
|
lenOfLastOcs := -1
|
|
for {
|
|
if lenOfLastOcs == len(ocs) {
|
|
return nil, ErrOperClassDependencies
|
|
}
|
|
lenOfLastOcs = len(ocs)
|
|
|
|
var anyMissing bool
|
|
for name, info := range conf.OperClasses {
|
|
_, exists := ocs[name]
|
|
_, extendsExists := ocs[info.Extends]
|
|
if exists {
|
|
// class already exists
|
|
continue
|
|
} else if len(info.Extends) > 0 && !extendsExists {
|
|
// class we extend on doesn't exist
|
|
_, exists := conf.OperClasses[info.Extends]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Operclass [%s] extends [%s], which doesn't exist", name, info.Extends)
|
|
}
|
|
anyMissing = true
|
|
continue
|
|
}
|
|
|
|
// create new operclass
|
|
var oc OperClass
|
|
oc.Capabilities = make(map[string]bool)
|
|
|
|
// get inhereted info from other operclasses
|
|
if len(info.Extends) > 0 {
|
|
einfo, _ := ocs[info.Extends]
|
|
|
|
for capab := range einfo.Capabilities {
|
|
oc.Capabilities[capab] = true
|
|
}
|
|
}
|
|
|
|
// add our own info
|
|
oc.Title = info.Title
|
|
for _, capab := range info.Capabilities {
|
|
oc.Capabilities[capab] = true
|
|
}
|
|
if len(info.WhoisLine) > 0 {
|
|
oc.WhoisLine = info.WhoisLine
|
|
} else {
|
|
oc.WhoisLine = "is a"
|
|
if strings.Contains(strings.ToLower(string(oc.Title[0])), "aeiou") {
|
|
oc.WhoisLine += "n"
|
|
}
|
|
oc.WhoisLine += " "
|
|
oc.WhoisLine += oc.Title
|
|
}
|
|
|
|
ocs[name] = oc
|
|
}
|
|
|
|
if !anyMissing {
|
|
// we've got every operclass!
|
|
break
|
|
}
|
|
}
|
|
|
|
return &ocs, nil
|
|
}
|
|
|
|
// Oper represents a single assembled operator's config.
|
|
type Oper struct {
|
|
Class *OperClass
|
|
WhoisLine string
|
|
Vhost string
|
|
Pass []byte
|
|
Modes string
|
|
}
|
|
|
|
// Operators returns a map of operator configs from the given OperClass and config.
|
|
func (conf *Config) Operators(oc *map[string]OperClass) (map[string]Oper, error) {
|
|
operators := make(map[string]Oper)
|
|
for name, opConf := range conf.Opers {
|
|
var oper Oper
|
|
|
|
// oper name
|
|
name, err := CasefoldName(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not casefold oper name: %s", err.Error())
|
|
}
|
|
|
|
oper.Pass = opConf.PasswordBytes()
|
|
oper.Vhost = opConf.Vhost
|
|
class, exists := (*oc)[opConf.Class]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Could not load operator [%s] - they use operclass [%s] which does not exist", name, opConf.Class)
|
|
}
|
|
oper.Class = &class
|
|
if len(opConf.WhoisLine) > 0 {
|
|
oper.WhoisLine = opConf.WhoisLine
|
|
} else {
|
|
oper.WhoisLine = class.WhoisLine
|
|
}
|
|
oper.Modes = strings.TrimSpace(opConf.Modes)
|
|
|
|
// successful, attach to list of opers
|
|
operators[name] = oper
|
|
}
|
|
return operators, nil
|
|
}
|
|
|
|
// TLSListeners returns a list of TLS listeners and their configs.
|
|
func (conf *Config) TLSListeners() map[string]*tls.Config {
|
|
tlsListeners := make(map[string]*tls.Config)
|
|
for s, tlsListenersConf := range conf.Server.TLSListeners {
|
|
config, err := tlsListenersConf.Config()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
config.ClientAuth = tls.RequestClientCert
|
|
tlsListeners[s] = config
|
|
}
|
|
return tlsListeners
|
|
}
|
|
|
|
// LoadConfig loads the given YAML configuration file.
|
|
func LoadConfig(filename string) (config *Config, err error) {
|
|
data, err := ioutil.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = yaml.Unmarshal(data, &config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
config.Filename = filename
|
|
|
|
// we need this so PasswordBytes returns the correct info
|
|
if config.Server.Password != "" {
|
|
config.Server.PassConfig.Password = config.Server.Password
|
|
}
|
|
|
|
if config.Network.Name == "" {
|
|
return nil, ErrNetworkNameMissing
|
|
}
|
|
if config.Server.Name == "" {
|
|
return nil, ErrServerNameMissing
|
|
}
|
|
if !utils.IsHostname(config.Server.Name) {
|
|
return nil, ErrServerNameNotHostname
|
|
}
|
|
if config.Datastore.Path == "" {
|
|
return nil, ErrDatastorePathMissing
|
|
}
|
|
if len(config.Server.Listen) == 0 {
|
|
return nil, ErrNoListenersDefined
|
|
}
|
|
if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 {
|
|
return nil, ErrLimitsAreInsane
|
|
}
|
|
if config.Server.STS.Enabled {
|
|
config.Server.STS.Duration, err = custime.ParseDuration(config.Server.STS.DurationString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse STS duration: %s", err.Error())
|
|
}
|
|
if config.Server.STS.Port < 0 || config.Server.STS.Port > 65535 {
|
|
return nil, fmt.Errorf("STS port is incorrect, should be 0 if disabled: %d", config.Server.STS.Port)
|
|
}
|
|
}
|
|
if config.Server.ConnectionThrottler.Enabled {
|
|
config.Server.ConnectionThrottler.Duration, err = time.ParseDuration(config.Server.ConnectionThrottler.DurationString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse connection-throttle duration: %s", err.Error())
|
|
}
|
|
config.Server.ConnectionThrottler.BanDuration, err = time.ParseDuration(config.Server.ConnectionThrottler.BanDurationString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse connection-throttle ban-duration: %s", err.Error())
|
|
}
|
|
}
|
|
// process webirc blocks
|
|
var newWebIRC []webircConfig
|
|
for _, webirc := range config.Server.WebIRC {
|
|
// skip webirc blocks with no hosts (such as the example one)
|
|
if len(webirc.Hosts) == 0 {
|
|
continue
|
|
}
|
|
|
|
err = webirc.Populate()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse WebIRC config: %s", err.Error())
|
|
}
|
|
newWebIRC = append(newWebIRC, webirc)
|
|
}
|
|
config.Server.WebIRC = newWebIRC
|
|
// process limits
|
|
if config.Limits.LineLen.Tags < 512 || config.Limits.LineLen.Rest < 512 {
|
|
return nil, ErrLineLengthsTooSmall
|
|
}
|
|
var newLogConfigs []logger.LoggingConfig
|
|
for _, logConfig := range config.Logging {
|
|
// methods
|
|
methods := make(map[string]bool)
|
|
for _, method := range strings.Split(logConfig.Method, " ") {
|
|
if len(method) > 0 {
|
|
methods[strings.ToLower(method)] = true
|
|
}
|
|
}
|
|
if methods["file"] && logConfig.Filename == "" {
|
|
return nil, ErrLoggerFilenameMissing
|
|
}
|
|
logConfig.MethodFile = methods["file"]
|
|
logConfig.MethodStdout = methods["stdout"]
|
|
logConfig.MethodStderr = methods["stderr"]
|
|
|
|
// levels
|
|
level, exists := logger.LogLevelNames[strings.ToLower(logConfig.LevelString)]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Could not translate log leve [%s]", logConfig.LevelString)
|
|
}
|
|
logConfig.Level = level
|
|
|
|
// types
|
|
for _, typeStr := range strings.Split(logConfig.TypeString, " ") {
|
|
if len(typeStr) == 0 {
|
|
continue
|
|
}
|
|
if typeStr == "-" {
|
|
return nil, ErrLoggerExcludeEmpty
|
|
}
|
|
if typeStr[0] == '-' {
|
|
typeStr = typeStr[1:]
|
|
logConfig.ExcludedTypes = append(logConfig.ExcludedTypes, typeStr)
|
|
} else {
|
|
logConfig.Types = append(logConfig.Types, typeStr)
|
|
}
|
|
}
|
|
if len(logConfig.Types) < 1 {
|
|
return nil, ErrLoggerHasNoTypes
|
|
}
|
|
|
|
newLogConfigs = append(newLogConfigs, logConfig)
|
|
}
|
|
config.Logging = newLogConfigs
|
|
|
|
// hardcode this for now
|
|
config.Accounts.Registration.EnabledCredentialTypes = []string{"passphrase", "certfp"}
|
|
for i, name := range config.Accounts.Registration.EnabledCallbacks {
|
|
if name == "none" {
|
|
// we store "none" as "*" internally
|
|
config.Accounts.Registration.EnabledCallbacks[i] = "*"
|
|
}
|
|
}
|
|
|
|
config.Server.MaxSendQBytes, err = bytefmt.ToBytes(config.Server.MaxSendQString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse maximum SendQ size (make sure it only contains whole numbers): %s", err.Error())
|
|
}
|
|
|
|
// get language files
|
|
config.Languages.Data = make(map[string]languages.LangData)
|
|
if config.Languages.Enabled {
|
|
files, err := ioutil.ReadDir(config.Languages.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not load language files: %s", err.Error())
|
|
}
|
|
|
|
for _, f := range files {
|
|
// skip dirs
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// only load core .lang.yaml files, and ignore help/irc files
|
|
name := f.Name()
|
|
lowerName := strings.ToLower(name)
|
|
if !strings.HasSuffix(lowerName, ".lang.yaml") {
|
|
continue
|
|
}
|
|
// don't load our example files in practice
|
|
if strings.HasPrefix(lowerName, "example") {
|
|
continue
|
|
}
|
|
|
|
// load core info file
|
|
data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, name))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not load language file [%s]: %s", name, err.Error())
|
|
}
|
|
|
|
var langInfo languages.LangData
|
|
err = yaml.Unmarshal(data, &langInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse language file [%s]: %s", name, err.Error())
|
|
}
|
|
langInfo.Translations = make(map[string]string)
|
|
|
|
// load actual translation files
|
|
var tlList map[string]string
|
|
|
|
// load irc strings file
|
|
ircName := strings.TrimSuffix(name, ".lang.yaml") + "-irc.lang.json"
|
|
|
|
data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, ircName))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not load language's irc file [%s]: %s", ircName, err.Error())
|
|
}
|
|
|
|
err = json.Unmarshal(data, &tlList)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse language's irc file [%s]: %s", ircName, err.Error())
|
|
}
|
|
|
|
for key, value := range tlList {
|
|
// because of how crowdin works, this is how we skip untranslated lines
|
|
if key == value || value == "" {
|
|
continue
|
|
}
|
|
langInfo.Translations[key] = value
|
|
}
|
|
|
|
// load help strings file
|
|
helpName := strings.TrimSuffix(name, ".lang.yaml") + "-help.lang.json"
|
|
|
|
data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, helpName))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not load language's help file [%s]: %s", helpName, err.Error())
|
|
}
|
|
|
|
err = json.Unmarshal(data, &tlList)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse language's help file [%s]: %s", helpName, err.Error())
|
|
}
|
|
|
|
for key, value := range tlList {
|
|
// because of how crowdin works, this is how we skip untranslated lines
|
|
if key == value || value == "" {
|
|
continue
|
|
}
|
|
langInfo.Translations[key] = value
|
|
}
|
|
|
|
// confirm that values are correct
|
|
if langInfo.Code == "en" {
|
|
return nil, fmt.Errorf("Cannot have language file with code 'en' (this is the default language using strings inside the server code). If you're making an English variant, name it with a more specific code")
|
|
}
|
|
|
|
if langInfo.Code == "" || langInfo.Name == "" || langInfo.Contributors == "" {
|
|
return nil, fmt.Errorf("Code, name or contributors is empty in language file [%s]", name)
|
|
}
|
|
|
|
if len(langInfo.Translations) == 0 {
|
|
return nil, fmt.Errorf("Language [%s / %s] contains no translations", langInfo.Code, langInfo.Name)
|
|
}
|
|
|
|
// check for duplicate languages
|
|
_, exists := config.Languages.Data[strings.ToLower(langInfo.Code)]
|
|
if exists {
|
|
return nil, fmt.Errorf("Language code [%s] defined twice", langInfo.Code)
|
|
}
|
|
|
|
// and insert into lang info
|
|
config.Languages.Data[strings.ToLower(langInfo.Code)] = langInfo
|
|
}
|
|
|
|
// confirm that default language exists
|
|
if config.Languages.Default == "" {
|
|
config.Languages.Default = "en"
|
|
} else {
|
|
config.Languages.Default = strings.ToLower(config.Languages.Default)
|
|
}
|
|
|
|
_, exists := config.Languages.Data[config.Languages.Default]
|
|
if config.Languages.Default != "en" && !exists {
|
|
return nil, fmt.Errorf("Cannot find default language [%s]", config.Languages.Default)
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|