diff --git a/README.md b/README.md
index 4509b26..d94d560 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,11 @@ You can specify configuration options either via a config file (default: `config
| YAML Key / Env. Variable | Default | Description |
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /
`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
+| `app.aggregation_time`
`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
+| `app.report_time_weekly`
`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
+| `app.import_batch_size`
`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
+| `app.inactive_days`
`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
+| `app.heartbeat_max_age`
`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.custom_languages` | - | Map from file endings to language names |
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `server.port` /
`WAKAPI_PORT` | `3000` | Port to listen on |
diff --git a/config.default.yml b/config.default.yml
index a6fcaa7..1509c45 100644
--- a/config.default.yml
+++ b/config.default.yml
@@ -16,6 +16,7 @@ app:
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: ',')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
+ heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
custom_languages:
vue: Vue
jsx: JSX
diff --git a/config/config.go b/config/config.go
index 1182a53..b80e71a 100644
--- a/config/config.go
+++ b/config/config.go
@@ -68,6 +68,7 @@ type appConfig struct {
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
+ HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg"`
CustomLanguages map[string]string `yaml:"custom_languages"`
@@ -242,6 +243,11 @@ func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
}
+func (c *appConfig) HeartbeatsMaxAge() time.Duration {
+ d, _ := time.ParseDuration(c.HeartbeatMaxAge)
+ return d
+}
+
func (c *dbConfig) IsSQLite() bool {
return c.Dialect == "sqlite3"
}
@@ -400,6 +406,9 @@ func Load(version string) *Config {
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
+ if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
+ logbuch.Fatal("invalid duration set for heartbeat_max_age")
+ }
Set(config)
return Get()
diff --git a/models/heartbeat.go b/models/heartbeat.go
index f1bdd6a..98cb45d 100644
--- a/models/heartbeat.go
+++ b/models/heartbeat.go
@@ -34,6 +34,11 @@ func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
}
+func (h *Heartbeat) Timely(maxAge time.Duration) bool {
+ now := time.Now()
+ return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Before(now)
+}
+
func (h *Heartbeat) Augment(languageMappings map[string]string) {
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
for ending, value := range languageMappings {
diff --git a/routes/api/heartbeat.go b/routes/api/heartbeat.go
index fca3f86..68e1bff 100644
--- a/routes/api/heartbeat.go
+++ b/routes/api/heartbeat.go
@@ -86,7 +86,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
hb.UserID = user.ID
hb.UserAgent = userAgent
- if !hb.Valid() {
+ if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object"))
return