fix(uploads): Club Log is configured — it never needed an API key
The guard I added for services with no credentials demanded one, and nobody has ever set it: OpsLog carries its own Club Log APPLICATION key (clublogAppAPIKey), so the account is an email, a password and the logbook callsign. An operator whose live upload had been working for months was told the service was not configured the moment he sent QSOs by hand after an import. Written in app.go, the rules drifted from the uploaders on the first try. They now live in internal/extsvc beside the Upload* functions that enforce them, each case mirroring that function's own guard — which also caught Cloudlog, where the station profile is required and the check did not ask for it. The message names the fields actually missing rather than listing everything the service takes.
This commit is contained in:
@@ -11834,40 +11834,16 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadConfigured reports whether a service has what it needs to be uploaded
|
// uploadConfigured reports whether a service can be uploaded to at all.
|
||||||
// to at all — the credentials it cannot work without, not a guarantee they are
|
//
|
||||||
// correct. The service says whether they are; this says whether to ask.
|
// The rules live in internal/extsvc, beside the uploaders that enforce them.
|
||||||
|
// Written here instead they drifted at once: Club Log was refused for a missing
|
||||||
|
// API key that nobody has ever set — OpsLog carries its own application key —
|
||||||
|
// and an operator whose live upload had worked for months was told his service
|
||||||
|
// was not configured.
|
||||||
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
|
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
|
||||||
has := func(v string) bool { return strings.TrimSpace(v) != "" }
|
if err := extsvc.Configured(svc, cfg); err != nil {
|
||||||
switch svc {
|
return fmt.Errorf("%w (Settings → External services)", err)
|
||||||
case extsvc.ServiceCloudlog:
|
|
||||||
if !has(cfg.Cloudlog.URL) || !has(cfg.Cloudlog.APIKey) {
|
|
||||||
return fmt.Errorf("Cloudlog / Wavelog is not configured — set its URL and API key in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceQRZ:
|
|
||||||
if !has(cfg.QRZ.APIKey) {
|
|
||||||
return fmt.Errorf("QRZ.com is not configured — set the logbook API key in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceClublog:
|
|
||||||
if !has(cfg.Clublog.Email) || !has(cfg.Clublog.Password) || !has(cfg.Clublog.APIKey) {
|
|
||||||
return fmt.Errorf("Club Log is not configured — set the account email, password and API key in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceHRDLog:
|
|
||||||
if !has(cfg.HRDLog.Callsign) || !has(cfg.HRDLog.Code) {
|
|
||||||
return fmt.Errorf("HRDLog.net is not configured — set the callsign and upload code in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceEQSL:
|
|
||||||
if !has(cfg.EQSL.Username) || !has(cfg.EQSL.Password) {
|
|
||||||
return fmt.Errorf("eQSL.cc is not configured — set the username and password in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceHamQTH:
|
|
||||||
if !has(cfg.HamQTH.Username) || !has(cfg.HamQTH.Password) {
|
|
||||||
return fmt.Errorf("HamQTH is not configured — set the username and password in Settings → External services")
|
|
||||||
}
|
|
||||||
case extsvc.ServiceLoTW:
|
|
||||||
if !has(cfg.LoTW.StationLocation) {
|
|
||||||
return fmt.Errorf("LoTW is not configured — set the TQSL station location in Settings → External services")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -7,14 +7,16 @@
|
|||||||
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
|
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
|
||||||
"Icom over the network: a rig left in standby no longer sits in a dial-and-drop loop. The clock that bounds “the control link answers but no CI-V comes back” belongs to a session and was never cleared when a new one opened, so every fresh session started already past its grace — torn down at once, redialled twenty seconds later, and torn down again for as long as the radio was asleep. Silent since connect is now read as what it is: a rig in standby, with the session kept so it can be woken.",
|
"Icom over the network: a rig left in standby no longer sits in a dial-and-drop loop. The clock that bounds “the control link answers but no CI-V comes back” belongs to a session and was never cleared when a new one opened, so every fresh session started already past its grace — torn down at once, redialled twenty seconds later, and torn down again for as long as the radio was asleep. Silent since connect is now read as what it is: a rig in standby, with the session kept so it can be woken.",
|
||||||
"The Icom console appears whenever the configured radio is an Icom, not only once the rig is talking — the console is where the power-ON button lives, so it used to be missing at the one moment it was needed. The console’s configured backend also follows a radio switched from the status bar, instead of waiting for a trip through Settings and a Save that changed nothing.",
|
"The Icom console appears whenever the configured radio is an Icom, not only once the rig is talking — the console is where the power-ON button lives, so it used to be missing at the one moment it was needed. The console’s configured backend also follows a radio switched from the status bar, instead of waiting for a trip through Settings and a Save that changed nothing.",
|
||||||
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rig’s session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one."
|
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rig’s session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one.",
|
||||||
|
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each service’s requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
|
"Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
|
||||||
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — qu’OpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (l’ADIF compte les octets) ; ils étaient illisibles.",
|
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — qu’OpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (l’ADIF compte les octets) ; ils étaient illisibles.",
|
||||||
"Icom en réseau : un poste laissé en veille ne tourne plus en boucle connexion/déconnexion. L’horloge qui borne « la liaison de contrôle répond mais aucun CI-V ne revient » appartient à une session et n’était jamais remise à zéro à l’ouverture de la suivante : chaque nouvelle session démarrait déjà au-delà de son délai de grâce — coupée aussitôt, rappelée vingt secondes plus tard, recoupée, aussi longtemps que la radio dormait. « Silencieux depuis la connexion » se lit désormais pour ce que c’est : un poste en veille, dont on garde la session pour pouvoir le réveiller.",
|
"Icom en réseau : un poste laissé en veille ne tourne plus en boucle connexion/déconnexion. L’horloge qui borne « la liaison de contrôle répond mais aucun CI-V ne revient » appartient à une session et n’était jamais remise à zéro à l’ouverture de la suivante : chaque nouvelle session démarrait déjà au-delà de son délai de grâce — coupée aussitôt, rappelée vingt secondes plus tard, recoupée, aussi longtemps que la radio dormait. « Silencieux depuis la connexion » se lit désormais pour ce que c’est : un poste en veille, dont on garde la session pour pouvoir le réveiller.",
|
||||||
"La console Icom s’affiche dès que la radio configurée est un Icom, et pas seulement quand le poste parle — c’est là que se trouve le bouton d’allumage, il manquait donc au seul moment où il servait. Le backend configuré suit aussi un changement de radio fait depuis la barre d’état, au lieu d’attendre un passage dans les réglages et un « Enregistrer » qui ne changeait rien.",
|
"La console Icom s’affiche dès que la radio configurée est un Icom, et pas seulement quand le poste parle — c’est là que se trouve le bouton d’allumage, il manquait donc au seul moment où il servait. Le backend configuré suit aussi un changement de radio fait depuis la barre d’état, au lieu d’attendre un passage dans les réglages et un « Enregistrer » qui ne changeait rien.",
|
||||||
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue d’arriver, le journal le dit et nomme l’option à essayer. Les deux partagent la session du poste, et c’est exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais s’est en général vu envoyer quelque chose qu’il n’aime pas, et un compteur de commandes sans réponse n’a jamais dit laquelle."
|
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue d’arriver, le journal le dit et nomme l’option à essayer. Les deux partagent la session du poste, et c’est exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais s’est en général vu envoyer quelque chose qu’il n’aime pas, et un compteur de commandes sans réponse n’a jamais dit laquelle.",
|
||||||
|
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne n’a jamais saisie — OpsLog embarque la sienne — et un opérateur dont l’envoi automatique fonctionnait depuis des mois se voyait éconduit au moment d’envoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configured reports what a service still needs before it can be uploaded to.
|
||||||
|
//
|
||||||
|
// It exists so the answer lives NEXT TO THE UPLOADERS that enforce it. Written
|
||||||
|
// once in the app instead, it drifted immediately: Club Log was refused for a
|
||||||
|
// missing API key, which nobody has ever set — OpsLog carries its own
|
||||||
|
// application key (see clublogAppAPIKey) and the account is an email, a password
|
||||||
|
// and the logbook callsign. An operator whose live upload had been working for
|
||||||
|
// months was told his service was not configured.
|
||||||
|
//
|
||||||
|
// Each case mirrors the guard at the top of the matching Upload* function. It
|
||||||
|
// answers "can this be attempted", not "are these credentials right": only the
|
||||||
|
// service can say that, and it says it by refusing the upload.
|
||||||
|
func Configured(svc Service, cfg ExternalServices) error {
|
||||||
|
missing := func(service string, fields ...string) error {
|
||||||
|
return fmt.Errorf("%s is not configured — %s", service, strings.Join(fields, ", "))
|
||||||
|
}
|
||||||
|
set := func(v string) bool { return strings.TrimSpace(v) != "" }
|
||||||
|
var need []string
|
||||||
|
add := func(ok bool, what string) {
|
||||||
|
if !ok {
|
||||||
|
need = append(need, what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch svc {
|
||||||
|
case ServiceQRZ:
|
||||||
|
add(set(cfg.QRZ.APIKey), "the logbook API key")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("QRZ.com", need...)
|
||||||
|
}
|
||||||
|
case ServiceClublog:
|
||||||
|
// No API key: OpsLog's own application key is embedded.
|
||||||
|
add(set(cfg.Clublog.Email), "the account email")
|
||||||
|
add(set(cfg.Clublog.Password), "the password")
|
||||||
|
add(set(cfg.Clublog.Callsign), "the logbook callsign")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("Club Log", need...)
|
||||||
|
}
|
||||||
|
case ServiceHRDLog:
|
||||||
|
add(set(cfg.HRDLog.Callsign), "the station callsign")
|
||||||
|
add(set(cfg.HRDLog.Code), "the upload code")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("HRDLog.net", need...)
|
||||||
|
}
|
||||||
|
case ServiceEQSL:
|
||||||
|
add(set(cfg.EQSL.Username), "the username (callsign)")
|
||||||
|
add(set(cfg.EQSL.Password), "the password")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("eQSL.cc", need...)
|
||||||
|
}
|
||||||
|
case ServiceHamQTH:
|
||||||
|
add(set(cfg.HamQTH.Username), "the username")
|
||||||
|
add(set(cfg.HamQTH.Password), "the password")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("HamQTH", need...)
|
||||||
|
}
|
||||||
|
case ServiceCloudlog:
|
||||||
|
add(set(cfg.Cloudlog.URL), "the instance URL")
|
||||||
|
add(set(cfg.Cloudlog.APIKey), "the API key")
|
||||||
|
add(set(cfg.Cloudlog.StationID), "the station profile")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("Cloudlog / Wavelog", need...)
|
||||||
|
}
|
||||||
|
case ServiceLoTW:
|
||||||
|
add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe")
|
||||||
|
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
|
||||||
|
if len(need) > 0 {
|
||||||
|
return missing("LoTW", need...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -25,11 +25,50 @@ func TestUploadRefusesAnUnconfiguredService(t *testing.T) {
|
|||||||
t.Errorf("%s: %q does not say where to fix it", svc, err)
|
t.Errorf("%s: %q does not say where to fix it", svc, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configured: nothing in the way.
|
// And a CONFIGURED service is not turned away. Club Log is the one that was:
|
||||||
cfg := extsvc.ExternalServices{}
|
// its API key is OpsLog's own application key, embedded and never entered, so
|
||||||
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey = "https://log.f4bpo.fr", "cl123"
|
// demanding it refused every operator who had the service working.
|
||||||
if err := uploadConfigured(extsvc.ServiceCloudlog, cfg); err != nil {
|
func TestAConfiguredServiceIsAccepted(t *testing.T) {
|
||||||
t.Errorf("a configured Cloudlog was refused: %v", err)
|
var cfg extsvc.ExternalServices
|
||||||
|
cfg.Clublog.Email, cfg.Clublog.Password, cfg.Clublog.Callsign = "[email protected]", "secret", "F4BPO"
|
||||||
|
cfg.QRZ.APIKey = "1234-5678"
|
||||||
|
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey, cfg.Cloudlog.StationID = "https://log.example.com", "cl-key", "3"
|
||||||
|
cfg.EQSL.Username, cfg.EQSL.Password = "F4BPO", "secret"
|
||||||
|
cfg.HamQTH.Username, cfg.HamQTH.Password = "f4bpo", "secret"
|
||||||
|
cfg.HRDLog.Callsign, cfg.HRDLog.Code = "F4BPO", "12345"
|
||||||
|
cfg.LoTW.TQSLPath, cfg.LoTW.StationLocation = `C:\Program Files (x86)\TrustedQSL\tqsl.exe`, "Home"
|
||||||
|
|
||||||
|
for _, svc := range []extsvc.Service{
|
||||||
|
extsvc.ServiceCloudlog, extsvc.ServiceQRZ, extsvc.ServiceClublog,
|
||||||
|
extsvc.ServiceHRDLog, extsvc.ServiceEQSL, extsvc.ServiceHamQTH, extsvc.ServiceLoTW,
|
||||||
|
} {
|
||||||
|
if err := uploadConfigured(svc, cfg); err != nil {
|
||||||
|
t.Errorf("%s: a configured service was refused: %v", svc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message names what is actually missing, so the operator opens the right
|
||||||
|
// field rather than checking three that were already filled in.
|
||||||
|
func TestTheRefusalNamesTheMissingFields(t *testing.T) {
|
||||||
|
var cfg extsvc.ExternalServices
|
||||||
|
cfg.Clublog.Email = "[email protected]"
|
||||||
|
err := uploadConfigured(extsvc.ServiceClublog, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a half-configured Club Log was accepted")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "email") {
|
||||||
|
t.Errorf("%q asks for the one field that IS set", msg)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"password", "logbook callsign"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("%q does not mention the missing %s", msg, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(msg), "api key") {
|
||||||
|
t.Errorf("%q asks for the API key — it is OpsLog's own, embedded", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user