fix(clublog): pace and cap the real-time deletes

Club Log wrote to this station about 167 requests in four minutes and a
coming IP block. That was one deletion of a couple of hundred rows going
through delete.php, which is a REAL-TIME endpoint: it exists for an
operator removing a contact they just logged wrongly, at human pace.

Today's earlier fix stops the requests that were pointless -- QSOs Club
Log never had. This one handles the rest, which are legitimate but still
a batch: one delete every 1.2 s, and a stop at 25 with a line saying to
finish on clublog.org. There is no bulk-delete API to move to, so going
slower and refusing to push a large deletion through a real-time endpoint
is the whole of the answer.

Costs the operator nothing: the withdrawals already run in the
background.
This commit is contained in:
2026-08-23 19:46:42 +02:00
parent 8b7756beb4
commit 506a1e6e4c
2 changed files with 31 additions and 2 deletions
+27
View File
@@ -6510,6 +6510,21 @@ func (a *App) deleteRemoteCopies(ids []int64) {
// withdrawals stop. Three is enough to tell a one-off from a blocked account.
const maxClublogRefusals = 3
// Club Log's delete endpoint is a REAL-TIME one: it exists for an operator
// removing a contact they just logged wrongly, at human pace. Club Log watches
// the rate and blocks the IP of anything that batches through it — they wrote
// to this station about 167 requests in four minutes, which was one deletion of
// a couple of hundred rows, not a pile-up.
//
// There is no bulk-delete API to move to, so the only honest answer is to go at
// the pace the endpoint is meant for and to stop rather than push a large
// deletion through it. Whoever needs to remove hundreds of QSOs from Club Log
// does it on their site, where the tool for it exists.
const (
clublogDeletePace = 1200 * time.Millisecond
maxClublogDeletes = 25
)
// clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
// (modified since upload) counts: the copy is there, it is merely out of date.
func clublogWasUploaded(status string) bool {
@@ -6523,6 +6538,7 @@ func clublogWasUploaded(status string) bool {
func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
started := time.Now()
clublogRefused := 0
clublogSent := 0
for i := range rows {
q := rows[i]
id := q.ID
@@ -6553,6 +6569,17 @@ func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices,
if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
} else if doClublog {
if clublogSent >= maxClublogDeletes {
applog.Printf("extsvc: %d QSOs already withdrawn from Club Log — stopping there. Their delete endpoint is for one contact at a time; remove the rest on clublog.org, which has a tool for it.", clublogSent)
doClublog = false
continue
}
if clublogSent > 0 {
// Paced deliberately: see clublogDeletePace. This runs in the
// background, so the wait costs the operator nothing.
time.Sleep(clublogDeletePace)
}
clublogSent++
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
clublogRefused++
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)