From 5b56bc2d950483692c75f7326fd558960e6022cc Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Tue, 26 Mar 2024 21:59:05 +0100 Subject: [PATCH] Print notice if password recovery email is not set This was requested by the Winlink Development Team. Resolves #442 --- main.go | 8 +-- version_report.go | 89 ------------------------------ winlink_api.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 93 deletions(-) delete mode 100644 version_report.go create mode 100644 winlink_api.go diff --git a/main.go b/main.go index f0f648ac..ef545f4b 100644 --- a/main.go +++ b/main.go @@ -377,10 +377,10 @@ func main() { if config.VersionReportingDisabled { return } - - for { // Check every 6 hours, but it won't post more frequent than 24h. - postVersionUpdate() // Ignore errors - time.Sleep(6 * time.Hour) + for { + postVersionUpdate() // 24 hour hold on success + checkPasswordRecoveryEmailIsSet(ctx) // 14 day hold on success + time.Sleep(6 * time.Hour) // Retry every 6 hours } }() } diff --git a/version_report.go b/version_report.go deleted file mode 100644 index 857bff45..00000000 --- a/version_report.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. -// Use of this source code is governed by the MIT-license that can be -// found in the LICENSE file. - -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/la5nta/pat/internal/buildinfo" - "github.com/la5nta/pat/internal/cmsapi" - "github.com/la5nta/pat/internal/directories" -) - -func accountExistsCached(callsign string) (bool, error) { - var cache struct { - Expires time.Time - AccountExists bool - } - - fileName := fmt.Sprintf(".cached_account_check_%s.json", callsign) - filePath := filepath.Join(directories.StateDir(), fileName) - f, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) - if err != nil { - return false, err - } - json.NewDecoder(f).Decode(&cache) - if time.Since(cache.Expires) < 0 { - return cache.AccountExists, nil - } - defer func() { - f.Truncate(0) - f.Seek(0, 0) - json.NewEncoder(f).Encode(cache) - }() - - exists, err := cmsapi.AccountExists(callsign) - if !exists || err != nil { - // Let's try again in 48 hours - cache.Expires = time.Now().Add(48 * time.Hour) - return false, err - } - - // Keep this response for a month. It will probably not change. - cache.Expires = time.Now().Add(30 * 24 * time.Hour) - cache.AccountExists = exists - return exists, err -} - -func postVersionUpdate() error { - var lastUpdated time.Time - filePath := filepath.Join(directories.StateDir(), "last_version_report.json") - file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) - if err != nil { - return err - } - defer file.Close() - json.NewDecoder(file).Decode(&lastUpdated) - if time.Since(lastUpdated) < 24*time.Hour { - return nil - } - - // WDT do not want us to post version reports for callsigns without a registered account - if exists, err := accountExistsCached(fOptions.MyCall); err != nil { - return err - } else if !exists { - return nil - } - - v := cmsapi.VersionAdd{ - Callsign: fOptions.MyCall, - Program: buildinfo.AppName, - Version: buildinfo.Version, - Comments: fmt.Sprintf("%s - %s/%s", buildinfo.GitRev, runtime.GOOS, runtime.GOARCH), - } - - if err := v.Post(); err != nil { - return err - } - - file.Truncate(0) - file.Seek(0, 0) - return json.NewEncoder(file).Encode(time.Now()) -} diff --git a/winlink_api.go b/winlink_api.go new file mode 100644 index 00000000..90f247d5 --- /dev/null +++ b/winlink_api.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/la5nta/pat/internal/buildinfo" + "github.com/la5nta/pat/internal/cmsapi" + "github.com/la5nta/pat/internal/debug" + "github.com/la5nta/pat/internal/directories" +) + +// doIfElapsed implements a per-callsign rate limited function. +func doIfElapsed(name string, t time.Duration, fn func() error) error { + filePath := filepath.Join(directories.StateDir(), "."+name+"_"+fOptions.MyCall+".json") + file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return err + } + defer file.Close() + + var lastUpdated time.Time + json.NewDecoder(file).Decode(&lastUpdated) + if since := time.Since(lastUpdated); since < t { + debug.Printf("Skipping %q (last run: %s ago)", name, since.Truncate(time.Minute)) + return nil + } + + if err := fn(); err != nil { + return err + } + + file.Truncate(0) + file.Seek(0, 0) + return json.NewEncoder(file).Encode(time.Now()) +} + +func postVersionUpdate() { + const interval = 24 * time.Hour + err := doIfElapsed("version_report", interval, func() error { + debug.Printf("Posting version update...") + // WDT do not want us to post version reports for callsigns without a registered account + if exists, err := accountExists(fOptions.MyCall); err != nil { + return err + } else if !exists { + return fmt.Errorf("account does not exist") + } + return cmsapi.VersionAdd{ + Callsign: fOptions.MyCall, + Program: buildinfo.AppName, + Version: buildinfo.Version, + Comments: fmt.Sprintf("%s - %s/%s", buildinfo.GitRev, runtime.GOOS, runtime.GOARCH), + }.Post() + }) + if err != nil { + debug.Printf("Failed to post version update: %v", err) + } +} + +func checkPasswordRecoveryEmailIsSet(ctx context.Context) { + const interval = 14 * 24 * time.Hour + err := doIfElapsed("pw_recovery_email_check", interval, func() error { + debug.Printf("Checking if winlink.org password recovery email is set...") + set, err := passwordRecoveryEmailSet(ctx) + if err != nil { + return err + } + debug.Printf("Password recovery email set: %t", set) + if set { + return nil + } + fmt.Println("") + fmt.Println("WINLINK NOTICE: Password recovery email is not set for your Winlink account. It is highly recommended to do so.") + fmt.Println("Run `" + os.Args[0] + " account --help` for help setting your recovery address. You can also manage your account settings at https://winlink.org/.") + fmt.Println("") + return nil + }) + if err != nil { + debug.Printf("Failed to check if password recovery email is set: %v", err) + } +} + +func passwordRecoveryEmailSet(ctx context.Context) (bool, error) { + if config.SecureLoginPassword == "" { + return false, fmt.Errorf("missing password") + } + switch exists, err := accountExists(fOptions.MyCall); { + case err != nil: + return false, fmt.Errorf("error checking if account exist: %w", err) + case !exists: + return false, fmt.Errorf("account does not exist") + } + email, err := cmsapi.PasswordRecoveryEmailGet(ctx, fOptions.MyCall, config.SecureLoginPassword) + return email != "", err +} + +func accountExists(callsign string) (bool, error) { + var cache struct { + Expires time.Time + AccountExists bool + } + + fileName := fmt.Sprintf(".cached_account_check_%s.json", callsign) + filePath := filepath.Join(directories.StateDir(), fileName) + f, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return false, err + } + json.NewDecoder(f).Decode(&cache) + if time.Since(cache.Expires) < 0 { + return cache.AccountExists, nil + } + defer func() { + f.Truncate(0) + f.Seek(0, 0) + json.NewEncoder(f).Encode(cache) + }() + + debug.Printf("Checking if account exists...") + exists, err := cmsapi.AccountExists(callsign) + debug.Printf("Account exists: %t (%v)", exists, err) + if !exists || err != nil { + // Let's try again in 48 hours + cache.Expires = time.Now().Add(48 * time.Hour) + return false, err + } + + // Keep this response for a month. It will probably not change. + cache.Expires = time.Now().Add(30 * 24 * time.Hour) + cache.AccountExists = exists + return exists, err +}