2022-08-15 02:16:22 -04:00
|
|
|
package redirector
|
2022-03-31 00:56:59 -04:00
|
|
|
|
|
|
|
|
import (
|
2022-08-15 02:37:51 -04:00
|
|
|
"crypto/tls"
|
2022-08-15 02:16:22 -04:00
|
|
|
"crypto/x509"
|
2025-02-19 18:01:29 +03:00
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
2024-01-25 21:33:37 -05:00
|
|
|
"github.com/armbian/redirector/db"
|
2022-03-31 00:56:59 -04:00
|
|
|
lru "github.com/hashicorp/golang-lru"
|
|
|
|
|
"github.com/oschwald/maxminddb-golang"
|
2022-08-14 03:42:49 -04:00
|
|
|
"github.com/pkg/errors"
|
2022-03-31 00:56:59 -04:00
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
2023-04-01 20:31:16 -04:00
|
|
|
"github.com/samber/lo"
|
2022-03-31 00:56:59 -04:00
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
|
)
|
|
|
|
|
|
2022-10-03 01:39:02 -04:00
|
|
|
// Config represents our application's configuration.
|
2022-08-15 02:16:22 -04:00
|
|
|
type Config struct {
|
2022-10-03 01:39:02 -04:00
|
|
|
// BindAddress is the address to bind our webserver to.
|
|
|
|
|
BindAddress string `mapstructure:"bind"`
|
|
|
|
|
|
|
|
|
|
// GeoDBPath is the path to the MaxMind GeoLite2 City DB.
|
|
|
|
|
GeoDBPath string `mapstructure:"geodb"`
|
|
|
|
|
|
2025-10-12 20:13:58 +02:00
|
|
|
// CertDataPath is the path to fetch CA certs from system.
|
|
|
|
|
// If empty, CAs will be fetched from Mozilla directly.
|
|
|
|
|
CertDataPath string `mapstructure:"certDataPath"`
|
|
|
|
|
|
2022-10-03 01:39:02 -04:00
|
|
|
// ASNDBPath is the path to the GeoLite2 ASN DB.
|
|
|
|
|
ASNDBPath string `mapstructure:"asndb"`
|
|
|
|
|
|
|
|
|
|
// MapFile is a file used to map download urls via redirect.
|
|
|
|
|
MapFile string `mapstructure:"dl_map"`
|
|
|
|
|
|
|
|
|
|
// CacheSize is the number of items to keep in the LRU cache.
|
|
|
|
|
CacheSize int `mapstructure:"cacheSize"`
|
|
|
|
|
|
|
|
|
|
// TopChoices is the number of servers to use in a rotation.
|
|
|
|
|
// With the default being 3, the top 3 servers will be rotated based on weight.
|
|
|
|
|
TopChoices int `mapstructure:"topChoices"`
|
|
|
|
|
|
|
|
|
|
// ReloadToken is a secret token used for web-based reload.
|
|
|
|
|
ReloadToken string `mapstructure:"reloadToken"`
|
|
|
|
|
|
2023-01-07 10:15:08 -05:00
|
|
|
// CheckURL is the url used to verify mirror versions
|
|
|
|
|
CheckURL string `mapstructure:"checkUrl"`
|
|
|
|
|
|
2025-02-21 18:36:13 +00:00
|
|
|
// SameCityThreshold is the parameter used to specify a threshold between mirrors and the client
|
|
|
|
|
SameCityThreshold float64 `mapstructure:"sameCityThreshold"`
|
|
|
|
|
|
2022-10-03 01:39:02 -04:00
|
|
|
// ServerList is a list of ServerConfig structs, which gets parsed into servers.
|
|
|
|
|
ServerList []ServerConfig `mapstructure:"servers"`
|
|
|
|
|
|
2025-04-02 18:30:15 +03:00
|
|
|
// Special extensions for the download map
|
|
|
|
|
SpecialExtensions map[string]string `mapstructure:"specialExtensions"`
|
|
|
|
|
|
2025-08-20 10:40:02 +03:00
|
|
|
// LogLevel is the log level to use for the application.
|
|
|
|
|
// It can be one of: "debug", "info", "warn", "error", "fatal", "panic".
|
|
|
|
|
// If not set, it defaults to "warn".
|
|
|
|
|
LogLevel string `mapstructure:"logLevel"`
|
|
|
|
|
|
2025-10-21 00:28:46 +02:00
|
|
|
// EnableProfiler enables the pprof profiler on the server.
|
|
|
|
|
// This is not recommended for production use.
|
|
|
|
|
EnableProfiler bool `mapstructure:"enableProfiler"`
|
|
|
|
|
|
2022-10-03 01:39:02 -04:00
|
|
|
// ReloadFunc is called when a reload is done via http api.
|
|
|
|
|
ReloadFunc func()
|
|
|
|
|
|
|
|
|
|
// RootCAs is a list of CA certificates, which we parse from Mozilla directly.
|
|
|
|
|
RootCAs *x509.CertPool
|
|
|
|
|
|
2022-08-15 02:37:51 -04:00
|
|
|
checkClient *http.Client
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetRootCAs sets the root ca files, and creates the http client for checks
|
|
|
|
|
// This **MUST** be called before r.checkClient is used.
|
|
|
|
|
func (c *Config) SetRootCAs(cas *x509.CertPool) {
|
|
|
|
|
c.RootCAs = cas
|
|
|
|
|
|
|
|
|
|
t := &http.Transport{
|
|
|
|
|
TLSClientConfig: &tls.Config{
|
|
|
|
|
RootCAs: cas,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.checkClient = &http.Client{
|
|
|
|
|
Transport: t,
|
|
|
|
|
Timeout: 20 * time.Second,
|
|
|
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
|
},
|
|
|
|
|
}
|
2022-08-15 02:16:22 -04:00
|
|
|
}
|
|
|
|
|
|
2023-04-01 20:31:16 -04:00
|
|
|
func Remove[V comparable](collection []V, value V) []V {
|
|
|
|
|
return lo.Filter(collection, func(item V, _ int) bool {
|
|
|
|
|
return item != value
|
|
|
|
|
})
|
2022-08-15 02:16:22 -04:00
|
|
|
}
|
|
|
|
|
|
2022-10-03 01:39:02 -04:00
|
|
|
// ReloadConfig is called to reload the server's configuration.
|
2022-08-15 02:16:22 -04:00
|
|
|
func (r *Redirector) ReloadConfig() error {
|
|
|
|
|
log.Info("Loading configuration...")
|
2026-01-06 01:39:21 +01:00
|
|
|
if reloadFunc := r.config.ReloadFunc; reloadFunc != nil {
|
|
|
|
|
reloadFunc()
|
|
|
|
|
}
|
2022-08-15 02:16:22 -04:00
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
// Load maxmind database
|
|
|
|
|
if r.db != nil {
|
|
|
|
|
err = r.db.Close()
|
2022-03-31 00:56:59 -04:00
|
|
|
if err != nil {
|
2022-08-15 02:16:22 -04:00
|
|
|
return errors.Wrap(err, "Unable to close database")
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-10-02 22:04:08 -04:00
|
|
|
if r.asnDB != nil {
|
|
|
|
|
err = r.asnDB.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return errors.Wrap(err, "Unable to close asn database")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 10:40:02 +03:00
|
|
|
// set log level
|
|
|
|
|
level, err := log.ParseLevel(r.config.LogLevel)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.WithField("level", r.config.LogLevel).Warning("Invalid log level, using default")
|
|
|
|
|
level = log.WarnLevel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.SetLevel(level)
|
|
|
|
|
|
2022-08-15 02:16:22 -04:00
|
|
|
// db can be hot-reloaded if the file changed
|
|
|
|
|
r.db, err = maxminddb.Open(r.config.GeoDBPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return errors.Wrap(err, "Unable to open database")
|
|
|
|
|
}
|
|
|
|
|
|
2022-10-02 22:04:08 -04:00
|
|
|
if r.config.ASNDBPath != "" {
|
|
|
|
|
r.asnDB, err = maxminddb.Open(r.config.ASNDBPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return errors.Wrap(err, "Unable to open asn database")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-03-31 00:56:59 -04:00
|
|
|
// Refresh server cache if size changed
|
2022-08-15 02:16:22 -04:00
|
|
|
if r.serverCache == nil {
|
|
|
|
|
r.serverCache, err = lru.New(r.config.CacheSize)
|
2022-03-31 00:56:59 -04:00
|
|
|
} else {
|
2022-08-15 02:16:22 -04:00
|
|
|
r.serverCache.Resize(r.config.CacheSize)
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
2022-08-06 16:19:12 -04:00
|
|
|
// Purge the cache to ensure we don't have any invalid servers in it
|
2022-08-15 02:16:22 -04:00
|
|
|
r.serverCache.Purge()
|
2022-03-31 00:56:59 -04:00
|
|
|
|
|
|
|
|
// Reload map file
|
2022-08-15 02:16:22 -04:00
|
|
|
if err := r.reloadMap(); err != nil {
|
2022-08-14 03:42:49 -04:00
|
|
|
return errors.Wrap(err, "Unable to load map file")
|
|
|
|
|
}
|
2022-03-31 00:56:59 -04:00
|
|
|
|
|
|
|
|
// Reload server list
|
2022-08-15 02:16:22 -04:00
|
|
|
if err := r.reloadServers(); err != nil {
|
2022-08-14 03:42:49 -04:00
|
|
|
return errors.Wrap(err, "Unable to load servers")
|
|
|
|
|
}
|
2022-03-31 00:56:59 -04:00
|
|
|
|
2022-04-01 00:04:27 -04:00
|
|
|
// Create mirror map
|
|
|
|
|
mirrors := make(map[string][]*Server)
|
2022-08-15 02:16:22 -04:00
|
|
|
for _, server := range r.servers {
|
2022-04-01 00:04:27 -04:00
|
|
|
mirrors[server.Continent] = append(mirrors[server.Continent], server)
|
|
|
|
|
}
|
|
|
|
|
mirrors["default"] = append(mirrors["NA"], mirrors["EU"]...)
|
2022-08-15 02:16:22 -04:00
|
|
|
r.regionMap = mirrors
|
2022-04-02 04:47:14 -04:00
|
|
|
|
|
|
|
|
hosts := make(map[string]*Server)
|
2022-08-15 02:16:22 -04:00
|
|
|
for _, server := range r.servers {
|
2022-04-02 04:47:14 -04:00
|
|
|
hosts[server.Host] = server
|
|
|
|
|
}
|
2022-08-15 02:16:22 -04:00
|
|
|
r.hostMap = hosts
|
2022-04-01 00:04:27 -04:00
|
|
|
|
2022-03-31 00:56:59 -04:00
|
|
|
// Check top choices size
|
2022-10-17 20:04:22 -04:00
|
|
|
if r.config.TopChoices == 0 {
|
|
|
|
|
r.config.TopChoices = 3
|
|
|
|
|
} else if r.config.TopChoices > len(r.servers) {
|
2022-08-15 02:16:22 -04:00
|
|
|
r.config.TopChoices = len(r.servers)
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
2025-04-02 18:30:15 +03:00
|
|
|
// Check if on the config is declared or use default logic
|
2025-02-21 18:36:13 +00:00
|
|
|
if r.config.SameCityThreshold == 0 {
|
|
|
|
|
r.config.SameCityThreshold = 200000.0
|
|
|
|
|
}
|
|
|
|
|
|
2022-03-31 00:56:59 -04:00
|
|
|
// Force check
|
2024-01-25 21:33:37 -05:00
|
|
|
go r.servers.Check(r, r.checks)
|
2022-08-14 03:42:49 -04:00
|
|
|
|
|
|
|
|
return nil
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
2022-08-15 02:16:22 -04:00
|
|
|
func (r *Redirector) reloadServers() error {
|
|
|
|
|
log.WithField("count", len(r.config.ServerList)).Info("Loading servers")
|
2022-03-31 00:56:59 -04:00
|
|
|
var wg sync.WaitGroup
|
2026-01-06 01:28:43 +01:00
|
|
|
var serversLock sync.Mutex
|
2022-03-31 00:56:59 -04:00
|
|
|
|
|
|
|
|
existing := make(map[string]int)
|
2022-08-15 02:16:22 -04:00
|
|
|
for i, server := range r.servers {
|
2022-03-31 00:56:59 -04:00
|
|
|
existing[server.Host] = i
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hosts := make(map[string]bool)
|
2022-08-18 23:43:58 -04:00
|
|
|
var hostsLock sync.Mutex
|
|
|
|
|
|
2026-01-06 01:28:43 +01:00
|
|
|
// Collect new servers to add after all goroutines complete
|
|
|
|
|
type serverUpdate struct {
|
|
|
|
|
index int // -1 means new server
|
|
|
|
|
server *Server
|
|
|
|
|
}
|
|
|
|
|
var updates []serverUpdate
|
|
|
|
|
var updatesLock sync.Mutex
|
|
|
|
|
|
2022-08-15 02:16:22 -04:00
|
|
|
for _, server := range r.config.ServerList {
|
2022-03-31 00:56:59 -04:00
|
|
|
wg.Add(1)
|
|
|
|
|
var prefix string
|
|
|
|
|
if !strings.HasPrefix(server.Server, "http") {
|
|
|
|
|
prefix = "https://"
|
|
|
|
|
}
|
|
|
|
|
u, err := url.Parse(prefix + server.Server)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
|
"error": err,
|
|
|
|
|
"server": server,
|
|
|
|
|
}).Warning("Server is invalid")
|
2022-08-14 03:42:49 -04:00
|
|
|
return err
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
i := -1
|
|
|
|
|
if v, exists := existing[u.Host]; exists {
|
|
|
|
|
i = v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
go func(i int, server ServerConfig, u *url.URL) {
|
|
|
|
|
defer wg.Done()
|
2022-08-18 23:42:54 -04:00
|
|
|
s, err := r.addServer(server, u)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.WithError(err).Warning("Unable to add server")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-06 01:28:43 +01:00
|
|
|
|
2022-08-18 23:43:58 -04:00
|
|
|
hostsLock.Lock()
|
|
|
|
|
hosts[u.Host] = true
|
|
|
|
|
hostsLock.Unlock()
|
2026-01-06 01:28:43 +01:00
|
|
|
|
|
|
|
|
updatesLock.Lock()
|
|
|
|
|
updates = append(updates, serverUpdate{index: i, server: s})
|
|
|
|
|
updatesLock.Unlock()
|
2022-03-31 00:56:59 -04:00
|
|
|
}(i, server, u)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
2026-01-06 01:28:43 +01:00
|
|
|
// Apply all updates after goroutines completed
|
|
|
|
|
serversLock.Lock()
|
|
|
|
|
for _, update := range updates {
|
|
|
|
|
if update.index >= 0 && update.index < len(r.servers) {
|
|
|
|
|
// Update existing server
|
|
|
|
|
update.server.Redirects = r.servers[update.index].Redirects
|
|
|
|
|
r.servers[update.index] = update.server
|
|
|
|
|
} else if update.index == -1 {
|
|
|
|
|
// Add new server
|
|
|
|
|
update.server.Redirects = promauto.NewCounter(prometheus.CounterOpts{
|
|
|
|
|
Name: "armbian_router_redirects_" + metricReplacer.Replace(update.server.Host),
|
|
|
|
|
Help: "The number of redirects for server " + update.server.Host,
|
|
|
|
|
})
|
|
|
|
|
r.servers = append(r.servers, update.server)
|
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
|
"server": update.server.Host,
|
|
|
|
|
"path": update.server.Path,
|
|
|
|
|
"latitude": update.server.Latitude,
|
|
|
|
|
"longitude": update.server.Longitude,
|
|
|
|
|
"country": update.server.Country,
|
|
|
|
|
}).Info("Added server")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-03-31 00:56:59 -04:00
|
|
|
// Remove servers that no longer exist in the config
|
2022-08-15 02:16:22 -04:00
|
|
|
for i := len(r.servers) - 1; i >= 0; i-- {
|
|
|
|
|
if _, exists := hosts[r.servers[i].Host]; exists {
|
2022-03-31 00:56:59 -04:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
log.WithFields(log.Fields{
|
2022-08-15 02:16:22 -04:00
|
|
|
"server": r.servers[i].Host,
|
2022-03-31 00:56:59 -04:00
|
|
|
}).Info("Removed server")
|
2022-08-15 02:16:22 -04:00
|
|
|
r.servers = append(r.servers[:i], r.servers[i+1:]...)
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
2026-01-06 01:28:43 +01:00
|
|
|
serversLock.Unlock()
|
2022-08-14 03:42:49 -04:00
|
|
|
|
|
|
|
|
return nil
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var metricReplacer = strings.NewReplacer(".", "_", "-", "_")
|
|
|
|
|
|
|
|
|
|
// addServer takes ServerConfig and constructs a server.
|
|
|
|
|
// This will create duplicate servers, but it will overwrite existing ones when changed.
|
2022-08-18 23:42:54 -04:00
|
|
|
func (r *Redirector) addServer(server ServerConfig, u *url.URL) (*Server, error) {
|
2022-03-31 00:56:59 -04:00
|
|
|
s := &Server{
|
|
|
|
|
Available: true,
|
|
|
|
|
Host: u.Host,
|
|
|
|
|
Path: u.Path,
|
|
|
|
|
Latitude: server.Latitude,
|
|
|
|
|
Longitude: server.Longitude,
|
2022-03-31 22:04:19 -04:00
|
|
|
Continent: server.Continent,
|
2022-03-31 00:56:59 -04:00
|
|
|
Weight: server.Weight,
|
2023-04-01 20:31:16 -04:00
|
|
|
Protocols: []string{"http", "https"},
|
2025-02-19 18:01:29 +03:00
|
|
|
Rules: server.Rules,
|
2022-08-15 02:16:22 -04:00
|
|
|
}
|
|
|
|
|
if len(server.Protocols) > 0 {
|
|
|
|
|
for _, proto := range server.Protocols {
|
2023-04-01 20:31:16 -04:00
|
|
|
if !lo.Contains(s.Protocols, proto) {
|
|
|
|
|
s.Protocols = append(s.Protocols, proto)
|
2022-08-15 02:16:22 -04:00
|
|
|
}
|
|
|
|
|
}
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
2022-03-31 22:04:19 -04:00
|
|
|
// Defaults to 10 to allow servers to be set lower for lower priority
|
2022-03-31 00:56:59 -04:00
|
|
|
if s.Weight == 0 {
|
2022-03-31 22:04:19 -04:00
|
|
|
s.Weight = 10
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
2022-03-31 22:04:19 -04:00
|
|
|
ips, err := net.LookupIP(u.Host)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
|
"error": err,
|
|
|
|
|
"server": s.Host,
|
|
|
|
|
}).Warning("Could not resolve address")
|
2022-08-18 23:42:54 -04:00
|
|
|
return nil, err
|
2022-03-31 22:04:19 -04:00
|
|
|
}
|
2026-01-06 02:10:19 +01:00
|
|
|
|
|
|
|
|
// Check for IPv6 support using resolved IPs
|
|
|
|
|
hasIPv6 := false
|
|
|
|
|
for _, ip := range ips {
|
|
|
|
|
if ip.To4() == nil && ip.To16() != nil {
|
|
|
|
|
hasIPv6 = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
s.IPv6 = hasIPv6
|
|
|
|
|
|
2024-01-25 21:33:37 -05:00
|
|
|
var city db.City
|
2022-08-15 02:16:22 -04:00
|
|
|
err = r.db.Lookup(ips[0], &city)
|
2022-03-31 22:04:19 -04:00
|
|
|
if err != nil {
|
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
|
"error": err,
|
|
|
|
|
"server": s.Host,
|
|
|
|
|
"ip": ips[0],
|
|
|
|
|
}).Warning("Could not geolocate address")
|
2022-08-18 23:42:54 -04:00
|
|
|
return nil, err
|
2022-03-31 22:04:19 -04:00
|
|
|
}
|
2025-02-19 22:29:09 +00:00
|
|
|
s.Country = city.Country.IsoCode
|
2022-03-31 22:04:19 -04:00
|
|
|
if s.Continent == "" {
|
|
|
|
|
s.Continent = city.Continent.Code
|
|
|
|
|
}
|
|
|
|
|
if s.Latitude == 0 && s.Longitude == 0 {
|
2022-03-31 00:56:59 -04:00
|
|
|
s.Latitude = city.Location.Latitude
|
|
|
|
|
s.Longitude = city.Location.Longitude
|
|
|
|
|
}
|
2022-08-18 23:42:54 -04:00
|
|
|
return s, nil
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
|
2022-08-15 02:16:22 -04:00
|
|
|
func (r *Redirector) reloadMap() error {
|
|
|
|
|
mapFile := r.config.MapFile
|
2022-03-31 00:56:59 -04:00
|
|
|
if mapFile == "" {
|
2022-08-14 03:42:49 -04:00
|
|
|
return nil
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
|
|
|
|
log.WithField("file", mapFile).Info("Loading download map")
|
2025-04-02 18:30:15 +03:00
|
|
|
newMap, err := loadMapFile(mapFile, r.config.SpecialExtensions)
|
2022-03-31 00:56:59 -04:00
|
|
|
if err != nil {
|
2022-08-14 03:42:49 -04:00
|
|
|
return err
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|
2022-08-15 02:16:22 -04:00
|
|
|
r.dlMap = newMap
|
2022-08-14 03:42:49 -04:00
|
|
|
return nil
|
2022-03-31 00:56:59 -04:00
|
|
|
}
|