@@ -4,17 +4,16 @@ import (
44 "archive/zip"
55 "bytes"
66 "crypto/sha1"
7+ "crypto/tls"
78 "encoding/json"
8- "errors"
99 "fmt"
10- "html"
1110 "io"
1211 "log"
1312 "net/http"
1413 "os"
15- "regexp"
1614 "strings"
1715 "time"
16+ "unicode"
1817)
1918
2019// KeepAliveToken represents a unique token per calendar month.
@@ -25,6 +24,8 @@ type KeepAliveToken struct{}
2524
2625func (g KeepAliveToken ) MarshalJSON () ([]byte , error ) { return json .Marshal (g .String ()) }
2726
27+ func (g * KeepAliveToken ) UnmarshalJSON ([]byte ) error { return nil }
28+
2829func (KeepAliveToken ) String () string {
2930 return fmt .Sprintf ("%x" , sha1 .Sum ([]byte {byte (time .Now ().Month ())}))
3031}
@@ -43,35 +44,83 @@ func (f FormsInfo) String() string {
4344 return fmt .Sprintf ("version: '%s', url: '%s'" , f .Version , f .ArchiveURL )
4445}
4546
46- const FormsInfoURL = "https://www.winlink.org/content/how_manually_update_standard_templates"
4747const DirectFormsZipURL = "https://downloads.winlink.org/User%20Programs/Standard_Forms.zip"
48+ const MirrorFormsAPIPath = "https://api.getpat.io/v1/forms/standard-templates/"
4849const PatFormsAPIPath = "https://forms-api.islandmagic.co/v1/forms/standard-templates/"
4950
50- var client = & http.Client {Timeout : 30 * time .Second }
51+ const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
52+
53+ var (
54+ // Cloudflare challenges non-browser HTTP/2 requests to downloads.winlink.org.
55+ formsTransport = http .DefaultTransport .(* http.Transport ).Clone ()
56+ formsClient = & http.Client {
57+ Timeout : 30 * time .Second ,
58+ Transport : formsTransport ,
59+ }
60+ )
61+
62+ func init () {
63+ formsTransport .ForceAttemptHTTP2 = false
64+ formsTransport .TLSNextProto = make (map [string ]func (string , * tls.Conn ) http.RoundTripper )
65+ formsTransport .TLSClientConfig = & tls.Config {NextProtos : []string {"http/1.1" }, MinVersion : tls .VersionTLS12 }
66+ }
5167
5268func main () {
53- url := DirectFormsZipURL
54- latest , err := downloadZipURL (url )
69+ latest , err := downloadZipURL (DirectFormsZipURL )
5570 if err != nil {
56- log .Printf ("direct download failed (%v); falling back to HTML scrape " , err )
57- url , err = getLatestFormsUrl ()
71+ log .Printf ("direct download failed (%v); falling back to api.getpat.io mirror " , err )
72+ mirrorURL , err := getMirrorArchiveURL ()
5873 if err != nil {
59- log .Fatalf ("could not get latest forms info: %v" , err )
74+ log .Fatalf ("could not get latest forms info from mirror : %v" , err )
6075 }
61- log .Printf ("Found URL %s" , url )
62- latest , err = downloadZipURL (url )
76+ log .Printf ("Found mirror URL %s" , mirrorURL )
77+ latest , err = downloadZipURL (mirrorURL )
6378 if err != nil {
64- log .Fatalf ("could not download archive url: %v" , err )
79+ log .Fatalf ("could not download mirror archive url: %v" , err )
6580 }
81+ log .Printf ("Downloaded from mirror URL %s" , mirrorURL )
6682 } else {
67- log .Printf ("Downloaded from direct URL %s" , url )
83+ log .Printf ("Downloaded from direct URL %s" , DirectFormsZipURL )
6884 }
6985 log .Printf ("Found version %s" , latest .Version )
7086 _ = json .NewEncoder (os .Stdout ).Encode (latest )
7187}
7288
73- func downloadZipURL (url string ) (* FormsInfo , error ) {
74- resp , err := client .Get (url )
89+ func getMirrorArchiveURL () (string , error ) {
90+ req , err := http .NewRequest (http .MethodGet , MirrorFormsAPIPath + "latest" , nil )
91+ if err != nil {
92+ return "" , err
93+ }
94+ req .Header .Set ("User-Agent" , browserUserAgent )
95+
96+ resp , err := formsClient .Do (req )
97+ if err != nil {
98+ return "" , err
99+ }
100+ defer resp .Body .Close ()
101+
102+ if resp .StatusCode != http .StatusOK {
103+ return "" , fmt .Errorf ("unexpected status code: %d" , resp .StatusCode )
104+ }
105+
106+ var info FormsInfo
107+ if err := json .NewDecoder (resp .Body ).Decode (& info ); err != nil {
108+ return "" , fmt .Errorf ("decode mirror latest: %w" , err )
109+ }
110+ if info .ArchiveURL == "" {
111+ return "" , fmt .Errorf ("mirror latest response missing archive_url" )
112+ }
113+ return info .ArchiveURL , nil
114+ }
115+
116+ func downloadZipURL (zipURL string ) (* FormsInfo , error ) {
117+ req , err := http .NewRequest (http .MethodGet , zipURL , nil )
118+ if err != nil {
119+ return nil , err
120+ }
121+ req .Header .Set ("User-Agent" , browserUserAgent )
122+
123+ resp , err := formsClient .Do (req )
75124 if err != nil {
76125 return nil , err
77126 }
@@ -97,7 +146,7 @@ func downloadZipURL(url string) (*FormsInfo, error) {
97146 _ , err = io .Copy (out , bytes .NewReader (b ))
98147 return & FormsInfo {
99148 Version : version ,
100- ArchiveURL : fmt . Sprintf ( "%s%s" , PatFormsAPIPath , filename ) ,
149+ ArchiveURL : PatFormsAPIPath + filename ,
101150 Generated : time .Now (),
102151 }, err
103152}
@@ -123,7 +172,8 @@ func readAndCheckZip(rc io.ReadCloser) ([]byte, string, error) {
123172 if err != nil {
124173 return nil , "" , err
125174 }
126- version = strings .TrimSpace (string (ver ))
175+ // strings.TrimSpace is not sufficient. Version 1.1.6.0 was released as `1.1.6\t.0`
176+ version = stripSpaces (string (ver ))
127177 }
128178
129179 // Otherwise, read the file to check for errors but discard the contents
@@ -135,6 +185,15 @@ func readAndCheckZip(rc io.ReadCloser) ([]byte, string, error) {
135185 return body , version , nil
136186}
137187
188+ func stripSpaces (str string ) string {
189+ return strings .Map (func (r rune ) rune {
190+ if unicode .IsSpace (r ) {
191+ return - 1
192+ }
193+ return r
194+ }, str )
195+ }
196+
138197func readZipFile (zf * zip.File ) error {
139198 f , err := zf .Open ()
140199 if err != nil {
@@ -153,35 +212,3 @@ func readZipFileContents(zf *zip.File) ([]byte, error) {
153212 defer f .Close ()
154213 return io .ReadAll (f )
155214}
156-
157- func getLatestFormsUrl () (string , error ) {
158- req , err := http .NewRequest ("GET" , FormsInfoURL , nil )
159- if err != nil {
160- return "" , err
161- }
162- req .Header .Set ("User-Agent" , "pat-forms-scraper" )
163- req .Header .Set ("Cache-Control" , "no-cache" )
164- resp , err := client .Do (req )
165- if err != nil {
166- return "" , err
167- }
168- defer resp .Body .Close ()
169-
170- if resp .StatusCode != http .StatusOK {
171- return "" , fmt .Errorf ("unexpected status code: %d" , resp .StatusCode )
172- }
173-
174- bodyBytes , err := io .ReadAll (resp .Body )
175- if err != nil {
176- return "" , fmt .Errorf ("can't read winlink forms version page: %w" , err )
177- }
178- bodyString := string (bodyBytes )
179-
180- // Scrape for the version and download link
181- hrefRe := regexp .MustCompile (`<a href="(https://.+)">\s*Standard_Forms - Latest Version\s*</a>` )
182- hrefMatches := hrefRe .FindStringSubmatch (bodyString )
183- if len (hrefMatches ) < 2 {
184- return "" , errors .New ("can't scrape the version info page, HTML structure may have changed" )
185- }
186- return html .UnescapeString (hrefMatches [1 ]), nil
187- }
0 commit comments