diff --git a/README.md b/README.md index cfab4a4..1d93dba 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ You can specify configuration options either via a config file (default: `config | `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) | | `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | | `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | +| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | +| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) | +| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details | +| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details | | `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) | | `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing | | `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry | diff --git a/config.default.yml b/config.default.yml index ac954e9..b7b359c 100644 --- a/config.default.yml +++ b/config.default.yml @@ -1,12 +1,13 @@ env: development server: - listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 - listen_ipv6: ::1 # leave blank to disable ipv6 - tls_cert_path: # leave blank to not use https - tls_key_path: # leave blank to not use https + listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 + listen_ipv6: ::1 # leave blank to disable ipv6 + tls_cert_path: # leave blank to not use https + tls_key_path: # leave blank to not use https port: 3000 base_path: / + public_url: http://localhost:3000 # required for links (e.g. password reset) in e-mail app: aggregation_time: '02:15' # time at which to run daily aggregation batch jobs @@ -38,4 +39,19 @@ sentry: dsn: # leave blank to disable sentry integration enable_tracing: true # whether to use performance monitoring sample_rate: 0.75 # probability of tracing a request - sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request \ No newline at end of file + sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request + +mail: + enabled: true # whether to enable mails (used for password resets, reports, etc.) + provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] + smtp: # smtp settings when sending mails via smtp + host: + port: + username: + password: + tls: + sender: Wakapi + mailwhale: # mailwhale.dev settings when using mailwhale as sending service + url: + client_id: + client_secret: diff --git a/config/config.go b/config/config.go index 40dfd41..b881918 100644 --- a/config/config.go +++ b/config/config.go @@ -49,6 +49,16 @@ const ( WakatimeApiMachineNamesUrl = "/users/current/machine_names" ) +const ( + MailProviderSmtp = "smtp" + MailProviderMailWhale = "mailwhale" +) + +var emailProviders = []string{ + MailProviderSmtp, + MailProviderMailWhale, +} + var cfg *Config var cFlag = flag.String("config", defaultConfigPath, "config file location") @@ -89,6 +99,7 @@ type serverConfig struct { ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"` ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"` BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"` + PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"` TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"` TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"` } @@ -100,6 +111,28 @@ type sentryConfig struct { SampleRateHeartbeats float32 `yaml:"sample_rate_heartbeats" default:"0.1" env:"WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS"` } +type mailConfig struct { + Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"` + Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"` + MailWhale *MailwhaleMailConfig `yaml:"mailwhale"` + Smtp *SMTPMailConfig `yaml:"smtp"` +} + +type MailwhaleMailConfig struct { + Url string `env:"WAKAPI_MAIL_MAILWHALE_URL"` + ClientId string `yaml:"client_id" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_ID"` + ClientSecret string `yaml:"client_secret" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET"` +} + +type SMTPMailConfig struct { + Host string `env:"WAKAPI_MAIL_SMTP_HOST"` + Port uint `env:"WAKAPI_MAIL_SMTP_PORT"` + Username string `env:"WAKAPI_MAIL_SMTP_USER"` + Password string `env:"WAKAPI_MAIL_SMTP_PASS"` + TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"` + Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"` +} + type Config struct { Env string `default:"dev" env:"ENVIRONMENT"` Version string `yaml:"-"` @@ -108,6 +141,7 @@ type Config struct { Db dbConfig Server serverConfig Sentry sentryConfig + Mail mailConfig } func (c *Config) CreateCookie(name, value, path string) *http.Cookie { @@ -238,6 +272,14 @@ func (c *appConfig) GetOSColors() map[string]string { return cloneStringMap(c.Colors["operating_systems"], true) } +func (c *serverConfig) GetPublicUrl() string { + return strings.TrimSuffix(c.PublicUrl, "/") +} + +func (c *SMTPMailConfig) ConnStr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + func IsDev(env string) bool { return env == "dev" || env == "development" } @@ -337,6 +379,15 @@ func initSentry(config sentryConfig, debug bool) { } } +func findString(needle string, haystack []string, defaultVal string) string { + for _, s := range haystack { + if s == needle { + return s + } + } + return defaultVal +} + func Set(config *Config) { cfg = config } @@ -385,6 +436,10 @@ func Load() *Config { initSentry(config.Sentry, config.IsDev()) } + if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" { + logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider) + } + Set(config) return Get() } diff --git a/config/templates.go b/config/templates.go index 0463154..244d1d7 100644 --- a/config/templates.go +++ b/config/templates.go @@ -1,10 +1,12 @@ package config const ( - IndexTemplate = "index.tpl.html" - LoginTemplate = "login.tpl.html" - ImprintTemplate = "imprint.tpl.html" - SignupTemplate = "signup.tpl.html" - SettingsTemplate = "settings.tpl.html" - SummaryTemplate = "summary.tpl.html" + IndexTemplate = "index.tpl.html" + LoginTemplate = "login.tpl.html" + ImprintTemplate = "imprint.tpl.html" + SignupTemplate = "signup.tpl.html" + SetPasswordTemplate = "set-password.tpl.html" + ResetPasswordTemplate = "reset-password.tpl.html" + SettingsTemplate = "settings.tpl.html" + SummaryTemplate = "summary.tpl.html" ) diff --git a/coverage/coverage.out b/coverage/coverage.out index d8c69ca..df80de5 100644 --- a/coverage/coverage.out +++ b/coverage/coverage.out @@ -1,4 +1,12 @@ mode: set +github.com/muety/wakapi/models/user.go:13.13,15.2 1 1 +github.com/muety/wakapi/models/user.go:78.43,81.2 1 0 +github.com/muety/wakapi/models/user.go:83.45,86.2 1 0 +github.com/muety/wakapi/models/user.go:88.33,93.2 1 0 +github.com/muety/wakapi/models/user.go:95.41,97.2 1 0 +github.com/muety/wakapi/models/user.go:99.45,101.2 1 0 +github.com/muety/wakapi/models/user.go:103.45,105.2 1 0 +github.com/muety/wakapi/models/user.go:107.39,109.2 1 0 github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1 github.com/muety/wakapi/models/heartbeat.go:36.65,37.46 1 1 github.com/muety/wakapi/models/heartbeat.go:37.46,38.46 1 1 @@ -16,14 +24,16 @@ github.com/muety/wakapi/models/heartbeat.go:66.37,82.2 1 0 github.com/muety/wakapi/models/heartbeat.go:90.41,92.16 2 0 github.com/muety/wakapi/models/heartbeat.go:95.2,96.10 2 0 github.com/muety/wakapi/models/heartbeat.go:92.16,94.3 1 0 -github.com/muety/wakapi/models/interval.go:39.47,40.23 1 0 -github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0 -github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0 -github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0 -github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0 -github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0 -github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0 -github.com/muety/wakapi/models/models.go:3.14,5.2 0 1 +github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0 +github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0 +github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0 +github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0 +github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0 +github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0 +github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0 +github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0 +github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0 +github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0 github.com/muety/wakapi/models/shared.go:35.52,37.2 1 0 github.com/muety/wakapi/models/shared.go:39.52,42.16 3 0 github.com/muety/wakapi/models/shared.go:45.2,47.12 3 0 @@ -90,28 +100,12 @@ github.com/muety/wakapi/models/summary.go:212.11,220.6 1 1 github.com/muety/wakapi/models/summary.go:237.33,239.2 1 1 github.com/muety/wakapi/models/summary.go:241.43,243.2 1 1 github.com/muety/wakapi/models/summary.go:245.38,247.2 1 1 +github.com/muety/wakapi/models/models.go:3.14,5.2 0 1 github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0 github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0 github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0 github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0 github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0 -github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0 -github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0 -github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0 -github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0 -github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0 -github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0 -github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0 -github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0 -github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0 -github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0 -github.com/muety/wakapi/models/user.go:13.13,15.2 1 1 -github.com/muety/wakapi/models/user.go:67.43,70.2 1 0 -github.com/muety/wakapi/models/user.go:72.33,77.2 1 0 -github.com/muety/wakapi/models/user.go:79.41,81.2 1 0 -github.com/muety/wakapi/models/user.go:83.45,85.2 1 0 -github.com/muety/wakapi/models/user.go:87.45,89.2 1 0 -github.com/muety/wakapi/models/user.go:91.39,93.2 1 0 github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0 github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0 github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0 @@ -130,6 +124,114 @@ github.com/muety/wakapi/models/filters.go:39.8,39.27 1 1 github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0 github.com/muety/wakapi/models/filters.go:41.8,41.28 1 1 github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0 +github.com/muety/wakapi/models/interval.go:39.47,40.23 1 0 +github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0 +github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0 +github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0 +github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0 +github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0 +github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0 +github.com/muety/wakapi/config/config.go:134.70,136.2 1 0 +github.com/muety/wakapi/config/config.go:138.65,140.2 1 0 +github.com/muety/wakapi/config/config.go:142.82,152.2 1 0 +github.com/muety/wakapi/config/config.go:154.31,156.2 1 0 +github.com/muety/wakapi/config/config.go:158.32,160.2 1 0 +github.com/muety/wakapi/config/config.go:162.74,163.19 1 0 +github.com/muety/wakapi/config/config.go:164.10,165.34 1 0 +github.com/muety/wakapi/config/config.go:165.34,174.4 8 0 +github.com/muety/wakapi/config/config.go:178.73,179.33 1 0 +github.com/muety/wakapi/config/config.go:179.33,187.17 5 0 +github.com/muety/wakapi/config/config.go:191.3,192.13 2 0 +github.com/muety/wakapi/config/config.go:187.17,189.4 1 0 +github.com/muety/wakapi/config/config.go:196.50,197.19 1 0 +github.com/muety/wakapi/config/config.go:210.2,210.12 1 0 +github.com/muety/wakapi/config/config.go:198.23,202.5 1 0 +github.com/muety/wakapi/config/config.go:203.26,206.5 1 0 +github.com/muety/wakapi/config/config.go:207.24,208.48 1 0 +github.com/muety/wakapi/config/config.go:213.53,224.2 1 1 +github.com/muety/wakapi/config/config.go:226.56,228.16 2 1 +github.com/muety/wakapi/config/config.go:232.2,239.3 1 1 +github.com/muety/wakapi/config/config.go:228.16,230.3 1 0 +github.com/muety/wakapi/config/config.go:242.54,244.2 1 1 +github.com/muety/wakapi/config/config.go:246.60,248.2 1 0 +github.com/muety/wakapi/config/config.go:250.59,252.2 1 0 +github.com/muety/wakapi/config/config.go:254.57,256.2 1 0 +github.com/muety/wakapi/config/config.go:258.53,260.2 1 0 +github.com/muety/wakapi/config/config.go:262.46,264.2 1 0 +github.com/muety/wakapi/config/config.go:266.29,268.2 1 1 +github.com/muety/wakapi/config/config.go:270.27,272.16 2 0 +github.com/muety/wakapi/config/config.go:275.2,278.16 3 0 +github.com/muety/wakapi/config/config.go:282.2,282.41 1 0 +github.com/muety/wakapi/config/config.go:272.16,274.3 1 0 +github.com/muety/wakapi/config/config.go:278.16,280.3 1 0 +github.com/muety/wakapi/config/config.go:285.48,297.16 3 0 +github.com/muety/wakapi/config/config.go:300.2,302.16 3 0 +github.com/muety/wakapi/config/config.go:306.2,306.55 1 0 +github.com/muety/wakapi/config/config.go:310.2,310.15 1 0 +github.com/muety/wakapi/config/config.go:297.16,299.3 1 0 +github.com/muety/wakapi/config/config.go:302.16,304.3 1 0 +github.com/muety/wakapi/config/config.go:306.55,308.3 1 0 +github.com/muety/wakapi/config/config.go:313.38,314.43 1 0 +github.com/muety/wakapi/config/config.go:317.2,317.15 1 0 +github.com/muety/wakapi/config/config.go:314.43,316.3 1 0 +github.com/muety/wakapi/config/config.go:320.45,321.27 1 0 +github.com/muety/wakapi/config/config.go:324.2,324.15 1 0 +github.com/muety/wakapi/config/config.go:321.27,323.3 1 0 +github.com/muety/wakapi/config/config.go:327.50,331.91 1 0 +github.com/muety/wakapi/config/config.go:331.91,332.29 1 0 +github.com/muety/wakapi/config/config.go:336.4,339.48 3 0 +github.com/muety/wakapi/config/config.go:342.4,342.39 1 0 +github.com/muety/wakapi/config/config.go:345.4,345.69 1 0 +github.com/muety/wakapi/config/config.go:332.29,334.5 1 0 +github.com/muety/wakapi/config/config.go:339.48,341.5 1 0 +github.com/muety/wakapi/config/config.go:342.39,344.5 1 0 +github.com/muety/wakapi/config/config.go:347.79,351.27 2 0 +github.com/muety/wakapi/config/config.go:358.4,358.16 1 0 +github.com/muety/wakapi/config/config.go:351.27,352.84 1 0 +github.com/muety/wakapi/config/config.go:352.84,353.57 1 0 +github.com/muety/wakapi/config/config.go:353.57,355.7 1 0 +github.com/muety/wakapi/config/config.go:360.17,362.3 1 0 +github.com/muety/wakapi/config/config.go:365.77,366.29 1 0 +github.com/muety/wakapi/config/config.go:371.2,371.19 1 0 +github.com/muety/wakapi/config/config.go:366.29,367.18 1 0 +github.com/muety/wakapi/config/config.go:367.18,369.4 1 0 +github.com/muety/wakapi/config/config.go:374.26,376.2 1 0 +github.com/muety/wakapi/config/config.go:378.20,380.2 1 0 +github.com/muety/wakapi/config/config.go:382.21,387.96 3 0 +github.com/muety/wakapi/config/config.go:391.2,399.52 5 0 +github.com/muety/wakapi/config/config.go:403.2,403.47 1 0 +github.com/muety/wakapi/config/config.go:409.2,409.70 1 0 +github.com/muety/wakapi/config/config.go:413.2,413.28 1 0 +github.com/muety/wakapi/config/config.go:417.2,417.29 1 0 +github.com/muety/wakapi/config/config.go:422.2,422.94 1 0 +github.com/muety/wakapi/config/config.go:426.2,427.14 2 0 +github.com/muety/wakapi/config/config.go:387.96,389.3 1 0 +github.com/muety/wakapi/config/config.go:399.52,401.3 1 0 +github.com/muety/wakapi/config/config.go:403.47,404.14 1 0 +github.com/muety/wakapi/config/config.go:404.14,406.4 1 0 +github.com/muety/wakapi/config/config.go:409.70,411.3 1 0 +github.com/muety/wakapi/config/config.go:413.28,415.3 1 0 +github.com/muety/wakapi/config/config.go:417.29,420.3 2 0 +github.com/muety/wakapi/config/config.go:422.94,424.3 1 0 +github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0 +github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0 +github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0 +github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0 +github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0 +github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0 +github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0 +github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0 +github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0 +github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0 +github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0 +github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0 +github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0 +github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0 +github.com/muety/wakapi/utils/filesystem.go:16.16,18.3 1 0 +github.com/muety/wakapi/utils/filesystem.go:21.15,23.47 2 0 +github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0 +github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0 +github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0 github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0 github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0 github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0 @@ -143,8 +245,31 @@ github.com/muety/wakapi/utils/common.go:30.24,32.2 1 0 github.com/muety/wakapi/utils/common.go:34.56,37.45 3 1 github.com/muety/wakapi/utils/common.go:40.2,40.40 1 1 github.com/muety/wakapi/utils/common.go:37.45,39.3 1 1 +github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0 +github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0 +github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0 +github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0 +github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0 +github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0 +github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0 +github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0 +github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0 +github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0 +github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0 +github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0 +github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0 +github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0 +github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0 +github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0 +github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0 +github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0 github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0 github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0 +github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0 +github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0 +github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0 +github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0 +github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0 github.com/muety/wakapi/utils/summary.go:10.66,11.40 1 0 github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0 github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0 @@ -183,12 +308,6 @@ github.com/muety/wakapi/utils/summary.go:96.18,98.5 1 0 github.com/muety/wakapi/utils/summary.go:112.48,116.51 2 0 github.com/muety/wakapi/utils/summary.go:119.2,119.12 1 0 github.com/muety/wakapi/utils/summary.go:116.51,118.3 1 0 -github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0 -github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0 -github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0 -github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0 -github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0 -github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0 github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0 github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0 github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0 @@ -208,146 +327,6 @@ github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0 github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0 github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0 github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0 -github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0 -github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0 -github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0 -github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0 -github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0 -github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0 -github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0 -github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0 -github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0 -github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0 -github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0 -github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0 -github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0 -github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0 -github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0 -github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0 -github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0 -github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0 -github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0 -github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0 -github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0 -github.com/muety/wakapi/utils/filesystem.go:16.16,18.3 1 0 -github.com/muety/wakapi/utils/filesystem.go:21.15,23.47 2 0 -github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0 -github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0 -github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0 -github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0 -github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0 -github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0 -github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0 -github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0 -github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0 -github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0 -github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0 -github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0 -github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0 -github.com/muety/wakapi/config/config.go:113.70,115.2 1 0 -github.com/muety/wakapi/config/config.go:117.65,119.2 1 0 -github.com/muety/wakapi/config/config.go:121.82,131.2 1 0 -github.com/muety/wakapi/config/config.go:133.31,135.2 1 0 -github.com/muety/wakapi/config/config.go:137.32,139.2 1 0 -github.com/muety/wakapi/config/config.go:141.74,142.19 1 0 -github.com/muety/wakapi/config/config.go:143.10,144.34 1 0 -github.com/muety/wakapi/config/config.go:144.34,153.4 8 0 -github.com/muety/wakapi/config/config.go:157.73,158.33 1 0 -github.com/muety/wakapi/config/config.go:158.33,166.17 5 0 -github.com/muety/wakapi/config/config.go:170.3,171.13 2 0 -github.com/muety/wakapi/config/config.go:166.17,168.4 1 0 -github.com/muety/wakapi/config/config.go:175.50,176.19 1 0 -github.com/muety/wakapi/config/config.go:189.2,189.12 1 0 -github.com/muety/wakapi/config/config.go:177.23,181.5 1 0 -github.com/muety/wakapi/config/config.go:182.26,185.5 1 0 -github.com/muety/wakapi/config/config.go:186.24,187.48 1 0 -github.com/muety/wakapi/config/config.go:192.53,203.2 1 1 -github.com/muety/wakapi/config/config.go:205.56,207.16 2 1 -github.com/muety/wakapi/config/config.go:211.2,218.3 1 1 -github.com/muety/wakapi/config/config.go:207.16,209.3 1 0 -github.com/muety/wakapi/config/config.go:221.54,223.2 1 1 -github.com/muety/wakapi/config/config.go:225.60,227.2 1 0 -github.com/muety/wakapi/config/config.go:229.59,231.2 1 0 -github.com/muety/wakapi/config/config.go:233.57,235.2 1 0 -github.com/muety/wakapi/config/config.go:237.53,239.2 1 0 -github.com/muety/wakapi/config/config.go:241.29,243.2 1 1 -github.com/muety/wakapi/config/config.go:245.27,247.16 2 0 -github.com/muety/wakapi/config/config.go:250.2,253.16 3 0 -github.com/muety/wakapi/config/config.go:257.2,257.41 1 0 -github.com/muety/wakapi/config/config.go:247.16,249.3 1 0 -github.com/muety/wakapi/config/config.go:253.16,255.3 1 0 -github.com/muety/wakapi/config/config.go:260.48,272.16 3 0 -github.com/muety/wakapi/config/config.go:275.2,277.16 3 0 -github.com/muety/wakapi/config/config.go:281.2,281.55 1 0 -github.com/muety/wakapi/config/config.go:285.2,285.15 1 0 -github.com/muety/wakapi/config/config.go:272.16,274.3 1 0 -github.com/muety/wakapi/config/config.go:277.16,279.3 1 0 -github.com/muety/wakapi/config/config.go:281.55,283.3 1 0 -github.com/muety/wakapi/config/config.go:288.38,289.43 1 0 -github.com/muety/wakapi/config/config.go:292.2,292.15 1 0 -github.com/muety/wakapi/config/config.go:289.43,291.3 1 0 -github.com/muety/wakapi/config/config.go:295.45,296.27 1 0 -github.com/muety/wakapi/config/config.go:299.2,299.15 1 0 -github.com/muety/wakapi/config/config.go:296.27,298.3 1 0 -github.com/muety/wakapi/config/config.go:302.50,306.91 1 0 -github.com/muety/wakapi/config/config.go:306.91,307.29 1 0 -github.com/muety/wakapi/config/config.go:311.4,314.48 3 0 -github.com/muety/wakapi/config/config.go:317.4,317.39 1 0 -github.com/muety/wakapi/config/config.go:320.4,320.69 1 0 -github.com/muety/wakapi/config/config.go:307.29,309.5 1 0 -github.com/muety/wakapi/config/config.go:314.48,316.5 1 0 -github.com/muety/wakapi/config/config.go:317.39,319.5 1 0 -github.com/muety/wakapi/config/config.go:322.79,326.27 2 0 -github.com/muety/wakapi/config/config.go:333.4,333.16 1 0 -github.com/muety/wakapi/config/config.go:326.27,327.84 1 0 -github.com/muety/wakapi/config/config.go:327.84,328.57 1 0 -github.com/muety/wakapi/config/config.go:328.57,330.7 1 0 -github.com/muety/wakapi/config/config.go:335.17,337.3 1 0 -github.com/muety/wakapi/config/config.go:340.26,342.2 1 0 -github.com/muety/wakapi/config/config.go:344.20,346.2 1 0 -github.com/muety/wakapi/config/config.go:348.21,353.96 3 0 -github.com/muety/wakapi/config/config.go:357.2,365.52 5 0 -github.com/muety/wakapi/config/config.go:369.2,369.47 1 0 -github.com/muety/wakapi/config/config.go:375.2,375.70 1 0 -github.com/muety/wakapi/config/config.go:379.2,379.28 1 0 -github.com/muety/wakapi/config/config.go:383.2,383.29 1 0 -github.com/muety/wakapi/config/config.go:388.2,389.14 2 0 -github.com/muety/wakapi/config/config.go:353.96,355.3 1 0 -github.com/muety/wakapi/config/config.go:365.52,367.3 1 0 -github.com/muety/wakapi/config/config.go:369.47,370.14 1 0 -github.com/muety/wakapi/config/config.go:370.14,372.4 1 0 -github.com/muety/wakapi/config/config.go:375.70,377.3 1 0 -github.com/muety/wakapi/config/config.go:379.28,381.3 1 0 -github.com/muety/wakapi/config/config.go:383.29,386.3 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:19.91,25.2 1 1 -github.com/muety/wakapi/middlewares/authenticate.go:27.90,30.2 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:32.90,35.2 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:37.71,38.71 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:38.71,40.3 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:43.107,47.16 3 0 -github.com/muety/wakapi/middlewares/authenticate.go:51.2,51.31 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:67.2,68.12 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:47.16,49.3 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:51.31,52.31 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:57.3,57.29 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:52.31,55.4 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:57.29,60.4 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:71.70,72.39 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:77.2,77.14 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:72.39,73.60 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:73.60,75.4 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:80.92,82.16 2 1 -github.com/muety/wakapi/middlewares/authenticate.go:86.2,89.16 4 1 -github.com/muety/wakapi/middlewares/authenticate.go:92.2,92.18 1 1 -github.com/muety/wakapi/middlewares/authenticate.go:82.16,84.3 1 1 -github.com/muety/wakapi/middlewares/authenticate.go:89.16,91.3 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:95.92,97.16 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:101.2,102.16 2 0 -github.com/muety/wakapi/middlewares/authenticate.go:109.2,109.18 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:97.16,99.3 1 0 -github.com/muety/wakapi/middlewares/authenticate.go:102.16,104.3 1 0 github.com/muety/wakapi/middlewares/filetype.go:13.83,14.43 1 0 github.com/muety/wakapi/middlewares/filetype.go:14.43,19.3 1 0 github.com/muety/wakapi/middlewares/filetype.go:22.84,24.34 2 0 @@ -396,6 +375,35 @@ github.com/muety/wakapi/middlewares/sentry.go:15.43,19.3 1 0 github.com/muety/wakapi/middlewares/sentry.go:22.78,25.54 3 0 github.com/muety/wakapi/middlewares/sentry.go:25.54,26.43 1 0 github.com/muety/wakapi/middlewares/sentry.go:26.43,28.4 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:19.91,25.2 1 1 +github.com/muety/wakapi/middlewares/authenticate.go:27.90,30.2 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:32.90,35.2 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:37.71,38.71 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:38.71,40.3 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:43.107,47.16 3 0 +github.com/muety/wakapi/middlewares/authenticate.go:51.2,51.31 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:67.2,68.12 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:47.16,49.3 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:51.31,52.31 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:57.3,57.29 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:52.31,55.4 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:57.29,60.4 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:71.70,72.39 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:77.2,77.14 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:72.39,73.60 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:73.60,75.4 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:80.92,82.16 2 1 +github.com/muety/wakapi/middlewares/authenticate.go:86.2,89.16 4 1 +github.com/muety/wakapi/middlewares/authenticate.go:92.2,92.18 1 1 +github.com/muety/wakapi/middlewares/authenticate.go:82.16,84.3 1 1 +github.com/muety/wakapi/middlewares/authenticate.go:89.16,91.3 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:95.92,97.16 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:101.2,102.16 2 0 +github.com/muety/wakapi/middlewares/authenticate.go:109.2,109.18 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:97.16,99.3 1 0 +github.com/muety/wakapi/middlewares/authenticate.go:102.16,104.3 1 0 github.com/muety/wakapi/services/alias.go:17.77,22.2 1 1 github.com/muety/wakapi/services/alias.go:26.60,27.43 1 1 github.com/muety/wakapi/services/alias.go:30.2,30.14 1 1 @@ -431,72 +439,6 @@ github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0 github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0 github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0 github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0 -github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:29.80,34.32 3 0 -github.com/muety/wakapi/services/heartbeat.go:41.2,41.55 1 0 -github.com/muety/wakapi/services/heartbeat.go:34.32,35.36 1 0 -github.com/muety/wakapi/services/heartbeat.go:35.36,38.4 2 0 -github.com/muety/wakapi/services/heartbeat.go:44.53,46.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:48.76,50.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:52.96,54.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:56.111,58.16 2 0 -github.com/muety/wakapi/services/heartbeat.go:61.2,61.43 1 0 -github.com/muety/wakapi/services/heartbeat.go:58.16,60.3 1 0 -github.com/muety/wakapi/services/heartbeat.go:64.116,66.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:68.78,70.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:72.62,74.2 1 0 -github.com/muety/wakapi/services/heartbeat.go:76.116,78.16 2 0 -github.com/muety/wakapi/services/heartbeat.go:82.2,82.28 1 0 -github.com/muety/wakapi/services/heartbeat.go:86.2,86.24 1 0 -github.com/muety/wakapi/services/heartbeat.go:78.16,80.3 1 0 -github.com/muety/wakapi/services/heartbeat.go:82.28,84.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0 -github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0 -github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0 -github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0 -github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0 -github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0 -github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0 -github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0 -github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0 -github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0 -github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0 -github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0 -github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0 -github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0 -github.com/muety/wakapi/services/user.go:19.73,25.2 1 0 -github.com/muety/wakapi/services/user.go:27.74,28.40 1 0 -github.com/muety/wakapi/services/user.go:32.2,33.16 2 0 -github.com/muety/wakapi/services/user.go:37.2,38.15 2 0 -github.com/muety/wakapi/services/user.go:28.40,30.3 1 0 -github.com/muety/wakapi/services/user.go:33.16,35.3 1 0 -github.com/muety/wakapi/services/user.go:41.72,42.37 1 0 -github.com/muety/wakapi/services/user.go:46.2,47.16 2 0 -github.com/muety/wakapi/services/user.go:51.2,52.15 2 0 -github.com/muety/wakapi/services/user.go:42.37,44.3 1 0 -github.com/muety/wakapi/services/user.go:47.16,49.3 1 0 -github.com/muety/wakapi/services/user.go:55.58,57.2 1 0 -github.com/muety/wakapi/services/user.go:59.61,62.2 2 0 -github.com/muety/wakapi/services/user.go:64.48,66.2 1 0 -github.com/muety/wakapi/services/user.go:68.102,77.93 2 0 -github.com/muety/wakapi/services/user.go:83.2,83.38 1 0 -github.com/muety/wakapi/services/user.go:77.93,79.3 1 0 -github.com/muety/wakapi/services/user.go:79.8,81.3 1 0 -github.com/muety/wakapi/services/user.go:86.73,89.2 2 0 -github.com/muety/wakapi/services/user.go:91.78,95.2 3 0 -github.com/muety/wakapi/services/user.go:97.99,100.2 2 0 -github.com/muety/wakapi/services/user.go:102.106,105.96 3 0 -github.com/muety/wakapi/services/user.go:110.2,110.68 1 0 -github.com/muety/wakapi/services/user.go:105.96,107.3 1 0 -github.com/muety/wakapi/services/user.go:107.8,109.3 1 0 -github.com/muety/wakapi/services/user.go:113.57,116.2 2 0 -github.com/muety/wakapi/services/user.go:118.38,120.2 1 0 github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0 github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0 github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0 @@ -537,6 +479,52 @@ github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0 github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0 github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0 github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0 +github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:29.80,34.32 3 0 +github.com/muety/wakapi/services/heartbeat.go:41.2,41.55 1 0 +github.com/muety/wakapi/services/heartbeat.go:34.32,35.36 1 0 +github.com/muety/wakapi/services/heartbeat.go:35.36,38.4 2 0 +github.com/muety/wakapi/services/heartbeat.go:44.53,46.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:48.76,50.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:52.96,54.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:56.111,58.16 2 0 +github.com/muety/wakapi/services/heartbeat.go:61.2,61.43 1 0 +github.com/muety/wakapi/services/heartbeat.go:58.16,60.3 1 0 +github.com/muety/wakapi/services/heartbeat.go:64.116,66.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:68.78,70.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:72.62,74.2 1 0 +github.com/muety/wakapi/services/heartbeat.go:76.116,78.16 2 0 +github.com/muety/wakapi/services/heartbeat.go:82.2,82.28 1 0 +github.com/muety/wakapi/services/heartbeat.go:86.2,86.24 1 0 +github.com/muety/wakapi/services/heartbeat.go:78.16,80.3 1 0 +github.com/muety/wakapi/services/heartbeat.go:82.28,84.3 1 0 +github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0 +github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0 +github.com/muety/wakapi/services/key_value.go:25.78,27.16 2 0 +github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0 +github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0 +github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0 +github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0 +github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0 +github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0 +github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0 +github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0 +github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0 +github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0 +github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0 +github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0 +github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0 +github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0 +github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0 +github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0 +github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0 +github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0 github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0 github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0 github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0 @@ -649,10 +637,33 @@ github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1 github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1 github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1 github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1 -github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0 -github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0 -github.com/muety/wakapi/services/key_value.go:25.78,27.16 2 0 -github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0 -github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0 -github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0 -github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0 +github.com/muety/wakapi/services/user.go:19.73,25.2 1 0 +github.com/muety/wakapi/services/user.go:27.74,28.40 1 0 +github.com/muety/wakapi/services/user.go:32.2,33.16 2 0 +github.com/muety/wakapi/services/user.go:37.2,38.15 2 0 +github.com/muety/wakapi/services/user.go:28.40,30.3 1 0 +github.com/muety/wakapi/services/user.go:33.16,35.3 1 0 +github.com/muety/wakapi/services/user.go:41.72,42.37 1 0 +github.com/muety/wakapi/services/user.go:46.2,47.16 2 0 +github.com/muety/wakapi/services/user.go:51.2,52.15 2 0 +github.com/muety/wakapi/services/user.go:42.37,44.3 1 0 +github.com/muety/wakapi/services/user.go:47.16,49.3 1 0 +github.com/muety/wakapi/services/user.go:55.76,57.2 1 0 +github.com/muety/wakapi/services/user.go:59.86,61.2 1 0 +github.com/muety/wakapi/services/user.go:63.58,65.2 1 0 +github.com/muety/wakapi/services/user.go:67.61,70.2 2 0 +github.com/muety/wakapi/services/user.go:72.48,74.2 1 0 +github.com/muety/wakapi/services/user.go:76.102,85.93 2 0 +github.com/muety/wakapi/services/user.go:91.2,91.38 1 0 +github.com/muety/wakapi/services/user.go:85.93,87.3 1 0 +github.com/muety/wakapi/services/user.go:87.8,89.3 1 0 +github.com/muety/wakapi/services/user.go:94.73,97.2 2 0 +github.com/muety/wakapi/services/user.go:99.78,103.2 3 0 +github.com/muety/wakapi/services/user.go:105.99,108.2 2 0 +github.com/muety/wakapi/services/user.go:110.106,113.96 3 0 +github.com/muety/wakapi/services/user.go:118.2,118.68 1 0 +github.com/muety/wakapi/services/user.go:113.96,115.3 1 0 +github.com/muety/wakapi/services/user.go:115.8,117.3 1 0 +github.com/muety/wakapi/services/user.go:121.85,123.2 1 0 +github.com/muety/wakapi/services/user.go:125.57,128.2 2 0 +github.com/muety/wakapi/services/user.go:130.38,132.2 1 0 diff --git a/go.mod b/go.mod index dd1708e..be47220 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.13 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.15.0 github.com/emvi/logbuch v1.1.1 github.com/getsentry/sentry-go v0.10.0 github.com/go-co-op/gocron v0.3.3 diff --git a/go.sum b/go.sum index 21de677..f26efb4 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,10 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= +github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU= github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= @@ -99,6 +103,7 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs= github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -166,6 +171,7 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -408,6 +414,7 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= diff --git a/main.go b/main.go index 5cde124..dfb478e 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/muety/wakapi/migrations" "github.com/muety/wakapi/repositories" "github.com/muety/wakapi/routes/api" + "github.com/muety/wakapi/services/mail" "github.com/muety/wakapi/utils" "gorm.io/gorm/logger" "log" @@ -51,6 +52,7 @@ var ( languageMappingService services.ILanguageMappingService summaryService services.ISummaryService aggregationService services.IAggregationService + mailService services.IMailService keyValueService services.IKeyValueService miscService services.IMiscService ) @@ -134,6 +136,7 @@ func main() { heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) + mailService = mail.NewMailService() keyValueService = services.NewKeyValueService(keyValueRepository) miscService = services.NewMiscService(userService, summaryService, keyValueService) @@ -159,7 +162,7 @@ func main() { summaryHandler := routes.NewSummaryHandler(summaryService, userService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService) homeHandler := routes.NewHomeHandler(keyValueService) - loginHandler := routes.NewLoginHandler(userService) + loginHandler := routes.NewLoginHandler(userService, mailService) imprintHandler := routes.NewImprintHandler(keyValueService) // Setup Routers diff --git a/mocks/user_service.go b/mocks/user_service.go index 2feed45..351f452 100644 --- a/mocks/user_service.go +++ b/mocks/user_service.go @@ -19,6 +19,16 @@ func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) { return args.Get(0).(*models.User), args.Error(1) } +func (m *UserServiceMock) GetUserByEmail(s string) (*models.User, error) { + args := m.Called(s) + return args.Get(0).(*models.User), args.Error(1) +} + +func (m *UserServiceMock) GetUserByResetToken(s string) (*models.User, error) { + args := m.Called(s) + return args.Get(0).(*models.User), args.Error(1) +} + func (m *UserServiceMock) GetAll() ([]*models.User, error) { args := m.Called() return args.Get(0).([]*models.User), args.Error(1) @@ -69,6 +79,11 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo return args.Get(0).(*models.User), args.Error(1) } +func (m *UserServiceMock) GenerateResetToken(user *models.User) (*models.User, error) { + args := m.Called(user) + return args.Get(0).(*models.User), args.Error(1) +} + func (m *UserServiceMock) FlushCache() { m.Called() } diff --git a/models/mail.go b/models/mail.go new file mode 100644 index 0000000..f0e229c --- /dev/null +++ b/models/mail.go @@ -0,0 +1,45 @@ +package models + +import ( + "fmt" + "strings" +) + +type Mail struct { + From MailAddress + To MailAddresses + Subject string + Body string + Type string +} + +func (m *Mail) WithText(text string) *Mail { + m.Body = text + m.Type = "text/plain; charset=UTF-8" + return m +} + +func (m *Mail) WithHTML(html string) *Mail { + m.Body = html + m.Type = "text/html; charset=UTF-8" + return m +} + +func (m *Mail) String() string { + return fmt.Sprintf("To: %s\r\n"+ + "From: %s\r\n"+ + "Subject: %s\r\n"+ + "Content-Type: %s\r\n"+ + "\r\n"+ + "%s\r\n", + strings.Join(m.To.RawStrings(), ", "), + m.From.String(), + m.Subject, + m.Type, + m.Body, + ) +} + +func (m *Mail) Reader() *strings.Reader { + return strings.NewReader(m.String()) +} diff --git a/models/mail_address.go b/models/mail_address.go new file mode 100644 index 0000000..726a680 --- /dev/null +++ b/models/mail_address.go @@ -0,0 +1,57 @@ +package models + +import "regexp" + +const ( + MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" + EmailAddrPattern = ".*\\s<(" + MailPattern + ")>|(" + MailPattern + ")" +) + +var ( + mailRegex *regexp.Regexp + emailAddrRegex *regexp.Regexp +) + +func init() { + mailRegex = regexp.MustCompile(MailPattern) + emailAddrRegex = regexp.MustCompile(EmailAddrPattern) +} + +type MailAddress string + +type MailAddresses []MailAddress + +func (m MailAddress) String() string { + return string(m) +} + +func (m MailAddress) Raw() string { + match := emailAddrRegex.FindStringSubmatch(string(m)) + if len(match) == 3 { + if match[2] != "" { + return match[2] + } + return match[1] + } + return "" +} + +func (m MailAddress) Valid() bool { + return emailAddrRegex.Match([]byte(m)) +} + +func (m MailAddresses) Strings() []string { + out := make([]string, len(m)) + for i, s := range m { + out[i] = s.String() + } + return out +} + +func (m MailAddresses) RawStrings() []string { + out := make([]string, len(m)) + for i, s := range m { + out[i] = s.Raw() + } + return out +} diff --git a/models/user.go b/models/user.go index a8cc6e4..031b0f1 100644 --- a/models/user.go +++ b/models/user.go @@ -2,14 +2,6 @@ package models import "regexp" -const ( - MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" -) - -var ( - mailRegex *regexp.Regexp -) - func init() { mailRegex = regexp.MustCompile(MailPattern) } @@ -17,7 +9,7 @@ func init() { type User struct { ID string `json:"id" gorm:"primary_key"` ApiKey string `json:"api_key" gorm:"unique"` - Email string `json:"email"` + Email string `json:"email" gorm:"uniqueIndex:idx_user_email"` Password string `json:"-"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` @@ -30,6 +22,7 @@ type User struct { IsAdmin bool `json:"-" gorm:"default:false; type:bool"` HasData bool `json:"-" gorm:"default:false; type:bool"` WakatimeApiKey string `json:"-"` + ResetToken string `json:"-"` } type Login struct { @@ -44,6 +37,16 @@ type Signup struct { PasswordRepeat string `schema:"password_repeat"` } +type SetPasswordRequest struct { + Password string `schema:"password"` + PasswordRepeat string `schema:"password_repeat"` + Token string `schema:"token"` +} + +type ResetPasswordRequest struct { + Email string `schema:"email"` +} + type CredentialsReset struct { PasswordOld string `schema:"password_old"` PasswordNew string `schema:"password_new"` @@ -69,6 +72,11 @@ func (c *CredentialsReset) IsValid() bool { c.PasswordNew == c.PasswordRepeat } +func (c *SetPasswordRequest) IsValid() bool { + return ValidatePassword(c.Password) && + c.Password == c.PasswordRepeat +} + func (s *Signup) IsValid() bool { return ValidateUsername(s.Username) && ValidateEmail(s.Email) && diff --git a/models/view/login.go b/models/view/login.go index 57b62b0..450c5d0 100644 --- a/models/view/login.go +++ b/models/view/login.go @@ -6,6 +6,11 @@ type LoginViewModel struct { TotalUsers int } +type SetPasswordViewModel struct { + LoginViewModel + Token string +} + func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel { s.Success = m return s diff --git a/repositories/repositories.go b/repositories/repositories.go index 7bceac2..111197c 100644 --- a/repositories/repositories.go +++ b/repositories/repositories.go @@ -51,6 +51,8 @@ type IUserRepository interface { GetById(string) (*models.User, error) GetByIds([]string) ([]*models.User, error) GetByApiKey(string) (*models.User, error) + GetByEmail(string) (*models.User, error) + GetByResetToken(string) (*models.User, error) GetAll() ([]*models.User, error) GetByLoggedInAfter(time.Time) ([]*models.User, error) GetByLastActiveAfter(time.Time) ([]*models.User, error) diff --git a/repositories/user.go b/repositories/user.go index fb8f305..f8ab48d 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -42,6 +42,28 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) { return u, nil } +func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) { + if resetToken == "" { + return nil, errors.New("invalid input") + } + u := &models.User{} + if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil { + return u, err + } + return u, nil +} + +func (r *UserRepository) GetByEmail(email string) (*models.User, error) { + if email == "" { + return nil, errors.New("invalid input") + } + u := &models.User{} + if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil { + return u, err + } + return u, nil +} + func (r *UserRepository) GetAll() ([]*models.User, error) { var users []*models.User if err := r.db. @@ -119,6 +141,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) { "share_machines": user.ShareMachines, "wakatime_api_key": user.WakatimeApiKey, "has_data": user.HasData, + "reset_token": user.ResetToken, } result := r.db.Model(user).Updates(updateMap) diff --git a/routes/home.go b/routes/home.go index ab82bf1..f427327 100644 --- a/routes/home.go +++ b/routes/home.go @@ -20,6 +20,7 @@ type HomeHandler struct { var loginDecoder = schema.NewDecoder() var signupDecoder = schema.NewDecoder() +var resetPasswordDecoder = schema.NewDecoder() func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler { return &HomeHandler{ diff --git a/routes/login.go b/routes/login.go index 9c140cb..166f606 100644 --- a/routes/login.go +++ b/routes/login.go @@ -2,6 +2,7 @@ package routes import ( "fmt" + "github.com/emvi/logbuch" "github.com/gorilla/mux" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" @@ -9,18 +10,21 @@ import ( "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" + "net/url" "time" ) type LoginHandler struct { config *conf.Config userSrvc services.IUserService + mailSrvc services.IMailService } -func NewLoginHandler(userService services.IUserService) *LoginHandler { +func NewLoginHandler(userService services.IUserService, mailService services.IMailService) *LoginHandler { return &LoginHandler{ config: conf.Get(), userSrvc: userService, + mailSrvc: mailService, } } @@ -30,6 +34,10 @@ func (h *LoginHandler) RegisterRoutes(router *mux.Router) { router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout) router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup) router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup) + router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword) + router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword) + router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword) + router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword) } func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) { @@ -167,6 +175,128 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound) } +func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r)) +} + +func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + values, _ := url.ParseQuery(r.URL.RawQuery) + token := values.Get("token") + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token")) + return + } + + vm := &view.SetPasswordViewModel{ + LoginViewModel: *h.buildViewModel(r), + Token: token, + } + + templates[conf.SetPasswordTemplate].Execute(w, vm) +} + +func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + var setRequest models.SetPasswordRequest + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + + user, err := h.userSrvc.GetUserByResetToken(setRequest.Token) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token")) + return + } + + if !setRequest.IsValid() { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters")) + return + } + + user.Password = setRequest.Password + user.ResetToken = "" + if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password")) + return + } else { + user.Password = hash + } + + if _, err := h.userSrvc.Update(user); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password")) + return + } + + http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound) +} + +func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + if !h.config.Mail.Enabled { + w.WriteHeader(http.StatusNotImplemented) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server")) + return + } + + var resetRequest models.ResetPasswordRequest + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + + if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil { + if u, err := h.userSrvc.GenerateResetToken(user); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token")) + return + } else { + go func(user *models.User) { + link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) + if err := h.mailSrvc.SendPasswordResetMail(user, link); err != nil { + logbuch.Error("failed to send password reset mail to %s – %v", user.ID, err) + } else { + logbuch.Info("sent password reset mail to %s", user.ID) + } + }(u) + } + } else { + logbuch.Warn("password reset requested for unregistered address '%s'", resetRequest.Email) + } + + http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound) +} + func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel { numUsers, _ := h.userSrvc.Count() diff --git a/services/mail/mail.go b/services/mail/mail.go new file mode 100644 index 0000000..5aeb07c --- /dev/null +++ b/services/mail/mail.go @@ -0,0 +1,61 @@ +package mail + +import ( + "bytes" + "fmt" + "github.com/markbates/pkger" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/services" + "io/ioutil" + "text/template" +) + +const ( + tplPath = "/views/mail" + tplNamePasswordReset = "reset_password" + subjectPasswordReset = "Wakapi – Password Reset" +) + +type passwordResetLinkTplData struct { + ResetLink string +} + +// Factory +func NewMailService() services.IMailService { + config := conf.Get() + if config.Mail.Enabled { + if config.Mail.Provider == conf.MailProviderMailWhale { + return NewMailWhaleService(config.Mail.MailWhale) + } else if config.Mail.Provider == conf.MailProviderSmtp { + return NewSMTPMailService(config.Mail.Smtp) + } + } + return &NoopMailService{} +} + +func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, error) { + tpl, err := loadTemplate(tplNamePasswordReset) + if err != nil { + return nil, err + } + var rendered bytes.Buffer + if err := tpl.Execute(&rendered, data); err != nil { + return nil, err + } + return &rendered, nil +} + +func loadTemplate(tplName string) (*template.Template, error) { + tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName)) + if err != nil { + return nil, err + } + defer tplFile.Close() + + tplData, err := ioutil.ReadAll(tplFile) + if err != nil { + return nil, err + } + + return template.New(tplName).Parse(string(tplData)) +} diff --git a/services/mail/mailwhale.go b/services/mail/mailwhale.go new file mode 100644 index 0000000..bae7986 --- /dev/null +++ b/services/mail/mailwhale.go @@ -0,0 +1,78 @@ +package mail + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/models" + "net/http" + "time" +) + +type MailWhaleMailService struct { + config *conf.MailwhaleMailConfig + httpClient *http.Client +} + +type MailWhaleSendRequest struct { + To []string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` + Html string `json:"html"` + TemplateId string `json:"template_id"` + TemplateVars map[string]string `json:"template_vars"` +} + +func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleMailService { + return &MailWhaleMailService{ + config: config, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (s *MailWhaleMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) + if err != nil { + return err + } + return s.send(recipient.Email, subjectPasswordReset, template.String(), true) +} + +func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error { + if to == "" { + return errors.New("no recipient mail address set, cannot send password reset link") + } + + sendRequest := &MailWhaleSendRequest{ + To: []string{to}, + Subject: subject, + } + if isHtml { + sendRequest.Html = body + } else { + sendRequest.Text = body + } + payload, _ := json.Marshal(sendRequest) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/mail", s.config.Url), bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret) + req.Header.Set("Content-Type", "application/json") + + res, err := s.httpClient.Do(req) + if err != nil { + return err + } + if res.StatusCode >= 400 { + return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode)) + } + + return nil +} diff --git a/services/mail/noop.go b/services/mail/noop.go new file mode 100644 index 0000000..a1ef569 --- /dev/null +++ b/services/mail/noop.go @@ -0,0 +1,13 @@ +package mail + +import ( + "github.com/emvi/logbuch" + "github.com/muety/wakapi/models" +) + +type NoopMailService struct{} + +func (n *NoopMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) + return nil +} diff --git a/services/mail/smtp.go b/services/mail/smtp.go new file mode 100644 index 0000000..cf15b3e --- /dev/null +++ b/services/mail/smtp.go @@ -0,0 +1,100 @@ +package mail + +import ( + "errors" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/models" + "io" +) + +type SMTPMailService struct { + config *conf.SMTPMailConfig + auth sasl.Client +} + +func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService { + return &SMTPMailService{ + config: config, + auth: sasl.NewPlainClient( + "", + config.Username, + config.Password, + ), + } +} + +func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) + if err != nil { + return err + } + + mail := &models.Mail{ + From: models.MailAddress(s.config.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectPasswordReset, + } + mail.WithHTML(template.String()) + + return s.send( + s.config.ConnStr(), + s.config.TLS, + s.auth, + mail.From.Raw(), + mail.To.RawStrings(), + mail.Reader(), + ) +} + +func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string, to []string, r io.Reader) error { + dial := smtp.Dial + if tls { + dial = func(addr string) (*smtp.Client, error) { + return smtp.DialTLS(addr, nil) + } + } + + c, err := dial(addr) + if err != nil { + return err + } + + defer c.Close() + + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(nil); err != nil { + return err + } + } + if a != nil { + if ok, _ := c.Extension("AUTH"); !ok { + return errors.New("smtp: server doesn't support AUTH") + } + if err = c.Auth(a); err != nil { + return err + } + } + if err = c.Mail(from, nil); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = io.Copy(w, r) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} diff --git a/services/mail/utils.go b/services/mail/utils.go new file mode 100644 index 0000000..9ca19e8 --- /dev/null +++ b/services/mail/utils.go @@ -0,0 +1 @@ +package mail diff --git a/services/services.go b/services/services.go index 2414a20..0242cd7 100644 --- a/services/services.go +++ b/services/services.go @@ -64,6 +64,8 @@ type ISummaryService interface { type IUserService interface { GetUserById(string) (*models.User, error) GetUserByKey(string) (*models.User, error) + GetUserByEmail(string) (*models.User, error) + GetUserByResetToken(string) (*models.User, error) GetAll() ([]*models.User, error) GetActive() ([]*models.User, error) Count() (int64, error) @@ -73,5 +75,10 @@ type IUserService interface { ResetApiKey(*models.User) (*models.User, error) SetWakatimeApiKey(*models.User, string) (*models.User, error) MigrateMd5Password(*models.User, *models.Login) (*models.User, error) + GenerateResetToken(*models.User) (*models.User, error) FlushCache() } + +type IMailService interface { + SendPasswordResetMail(recipient *models.User, resetLink string) error +} diff --git a/services/user.go b/services/user.go index 294c670..f8209e2 100644 --- a/services/user.go +++ b/services/user.go @@ -52,6 +52,14 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) { return u, nil } +func (srv *UserService) GetUserByEmail(email string) (*models.User, error) { + return srv.repository.GetByEmail(email) +} + +func (srv *UserService) GetUserByResetToken(resetToken string) (*models.User, error) { + return srv.repository.GetByResetToken(resetToken) +} + func (srv *UserService) GetAll() ([]*models.User, error) { return srv.repository.GetAll() } @@ -110,6 +118,10 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi return srv.repository.UpdateField(user, "password", user.Password) } +func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, error) { + return srv.repository.UpdateField(user, "reset_token", uuid.NewV4()) +} + func (srv *UserService) Delete(user *models.User) error { srv.cache.Flush() return srv.repository.Delete(user) diff --git a/views/login.tpl.html b/views/login.tpl.html index c8c439c..37fa54a 100644 --- a/views/login.tpl.html +++ b/views/login.tpl.html @@ -7,8 +7,12 @@ {{ template "header.tpl.html" . }} -
-

Login

+
+
+
← Go back
+

Login

+
+
{{ template "alerts.tpl.html" . }} @@ -28,11 +32,16 @@ type="password" id="password" name="password" placeholder="******" minlength="6" required>
-
- - +
diff --git a/views/mail/reset_password.tpl.html b/views/mail/reset_password.tpl.html new file mode 100644 index 0000000..982baa7 --- /dev/null +++ b/views/mail/reset_password.tpl.html @@ -0,0 +1,154 @@ + + + + + + Simple Transactional Email + + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Password Reset

+

You have requested to reset your Wakapi password. Please click the following link to proceed.

+ + + + + + +
+ + + + + + +
Reset Password
+
+

If you did not request a password change, please just ignore this mail.

+
+
+ + + + + + +
+
 
+ + diff --git a/views/reset-password.tpl.html b/views/reset-password.tpl.html new file mode 100644 index 0000000..aea3e77 --- /dev/null +++ b/views/reset-password.tpl.html @@ -0,0 +1,42 @@ + + + +{{ template "head.tpl.html" . }} + + + +{{ template "header.tpl.html" . }} + +
+
+
← Go back
+

Reset Password

+
+
+
+ +{{ template "alerts.tpl.html" . }} + +
+
+
+

If you forgot your password, enter the e-mail address you associated with Wakapi to reset it and choose a new one. You will receive an e-mail afterwards. Make sure to check your spam folder if it does not arrive after a few minutes.

+
+ + +
+
+ +
+
+
+
+ +{{ template "footer.tpl.html" . }} + +{{ template "foot.tpl.html" . }} + + + \ No newline at end of file diff --git a/views/set-password.tpl.html b/views/set-password.tpl.html new file mode 100644 index 0000000..df37b1e --- /dev/null +++ b/views/set-password.tpl.html @@ -0,0 +1,44 @@ + + + +{{ template "head.tpl.html" . }} + + + +{{ template "header.tpl.html" . }} + +
+

Choose a new password

+
+ +{{ template "alerts.tpl.html" . }} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +{{ template "footer.tpl.html" . }} + +{{ template "foot.tpl.html" . }} + + + \ No newline at end of file diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 070d393..4f7769c 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -23,7 +23,7 @@
-
← Go back
+
← Go back

Settings

         
diff --git a/views/signup.tpl.html b/views/signup.tpl.html index a2c874a..f474caa 100644 --- a/views/signup.tpl.html +++ b/views/signup.tpl.html @@ -3,10 +3,13 @@ {{ template "head.tpl.html" . }} - + + +{{ template "header.tpl.html" . }} +
-
-
← Go back
+
+

Sign Up