// Copyright (C) 2025 The Syncthing Authors. // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. //go:build ignore // +build ignore // Updates the list of software copyrights in aboutModalView.html based on the // output of `go mod graph`. package main import ( "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "os/exec" "regexp" "slices" "strconv" "strings" "time" "golang.org/x/net/html" ) var copyrightMap = map[string]string{ // https://github.com/aws/aws-sdk-go/blob/main/NOTICE.txt#L2 "aws/aws-sdk-go": "Copyright © 2015 Amazon.com, Inc. or its affiliates, Copyright 2014-2015 Stripe, Inc", // https://github.com/ccding/go-stun/blob/master/main.go#L1 "ccding/go-stun": "Copyright © 2016 Cong Ding", // https://github.com/search?q=repo%3Acertifi%2Fgocertifi%20copyright&type=code // "certifi/gocertifi": "No copyrights found", // https://github.com/search?q=repo%3Aebitengine%2Fpurego%20copyright&type=code "ebitengine/purego": "Copyright © 2022 The Ebitengine Authors", // https://github.com/search?q=repo%3Agoogle%2Fpprof%20copyright&type=code "google/pprof": "Copyright © 2016 Google Inc", // https://github.com/greatroar/blobloom/blob/master/README.md?plain=1#L74 "greatroar/blobloom": "Copyright © 2020-2024 the Blobloom authors", // https://github.com/jmespath/go-jmespath/blob/master/NOTICE#L2 "jmespath/go-jmespath": "Copyright © 2015 James Saryerwinnie", // https://github.com/maxmind/geoipupdate/blob/main/README.md?plain=1#L140 "maxmind/geoipupdate": "Copyright © 2018-2024 by MaxMind, Inc", // https://github.com/search?q=repo%3Aprometheus%2Fclient_golang%20copyright&type=code "prometheus/client_golang": "Copyright 2012-2015 The Prometheus Authors", // https://github.com/search?q=repo%3Apuzpuzpuz%2Fxsync%20copyright&type=code // "puzpuzpuz/xsync": "No copyrights found", // https://github.com/search?q=repo%3Atklauser%2Fnumcpus%20copyright&type=code "tklauser/numcpus": "Copyright © 2018-2024 Tobias Klauser", // https://github.com/search?q=repo%3Auber-go%2Fmock%20copyright&type=code "go.uber.org/mock": "Copyright © 2010-2022 Google LLC", } var urlMap = map[string]string{ "fontawesome.io": "https://github.com/FortAwesome/Font-Awesome", "go.uber.org/automaxprocs": "https://github.com/uber-go/automaxprocs", // "go.uber.org/mock": "https://github.com/uber-go/mock", "google.golang.org/protobuf": "https://github.com/protocolbuffers/protobuf-go", // "gopkg.in/yaml.v2": "", // ignore, as gopkg.in/yaml.v3 supersedes // "gopkg.in/yaml.v3": "https://github.com/go-yaml/yaml", "sigs.k8s.io/yaml": "https://github.com/kubernetes-sigs/yaml", } const htmlFile = "gui/default/syncthing/core/aboutModalView.html" type Type int const ( // TypeJS defines non-Go copyright notices TypeJS Type = iota // TypeKeep defines Go copyright notices for packages that are still used. TypeKeep // TypeToss defines Go copyright notices for packages that are no longer used. TypeToss // TypeNew defines Go copyright notices for new packages found via `go mod graph`. TypeNew ) type CopyrightNotice struct { Type Type Name string HTML string Module string URL string Copyright string RepoURL string RepoCopyrights []string } var copyrightRe = regexp.MustCompile(`(?s)id="copyright-notices">(.+?)`) func main() { bs := readAll(htmlFile) matches := copyrightRe.FindStringSubmatch(string(bs)) if len(matches) <= 1 { log.Fatal("Cannot find id copyright-notices in ", htmlFile) } modules := getModules() notices := parseCopyrightNotices(matches[1]) old := len(notices) // match up modules to notices matched := map[string]bool{} removes := 0 for i, notice := range notices { if notice.Type == TypeJS { continue } found := "" for _, module := range modules { if strings.Contains(module, notice.Name) { found = module break } } if found != "" { matched[found] = true notices[i].Module = found continue } removes++ fmt.Printf("Removing: %-40s %-55s %s\n", notice.Name, notice.URL, notice.Copyright) notices[i].Type = TypeToss } // add new modules to notices adds := 0 for _, module := range modules { _, ok := matched[module] if ok { continue } adds++ notice := CopyrightNotice{} notice.Name = module if strings.HasPrefix(notice.Name, "github.com/") { notice.Name = strings.ReplaceAll(notice.Name, "github.com/", "") } notice.Type = TypeNew url, ok := urlMap[module] if ok { notice.URL = url notice.RepoURL = url } else { notice.URL = "https://" + module notice.RepoURL = "https://" + module } notices = append(notices, notice) } if removes == 0 && adds == 0 { // authors.go is quiet, so let's be quiet too. // fmt.Printf("No changes detected in %d modules and %d notices\n", len(modules), len(notices)) os.Exit(0) } // get copyrights via Github API for new modules notfound := 0 for i, n := range notices { if n.Type != TypeNew { continue } copyright, ok := copyrightMap[n.Name] if ok { notices[i].Copyright = copyright continue } notices[i].Copyright = defaultCopyright(n) if strings.Contains(n.URL, "github.com/") { notices[i].RepoURL = notices[i].URL owner, repo := parseGitHubURL(n.URL) licenseText := getLicenseText(owner, repo) notices[i].RepoCopyrights = extractCopyrights(licenseText, n) if len(notices[i].RepoCopyrights) > 0 { notices[i].Copyright = notices[i].RepoCopyrights[0] } notices[i].HTML = fmt.Sprintf("
  • %s, %s.
  • ", n.URL, n.Name, notices[i].Copyright) if len(notices[i].RepoCopyrights) > 0 { continue } } fmt.Printf("Copyright not found: %-30s : using %q\n", n.Name, notices[i].Copyright) notfound++ } replacements := write(notices, bs) fmt.Printf("Removed: %3d\n", removes) fmt.Printf("Added: %3d\n", adds) fmt.Printf("Copyrights not found: %3d\n", notfound) fmt.Printf("Old package count: %3d\n", old) fmt.Printf("New package count: %3d\n", replacements) } func write(notices []CopyrightNotice, bs []byte) int { keys := make([]string, 0, len(notices)) noticeMap := make(map[string]CopyrightNotice, 0) for _, n := range notices { if n.Type != TypeKeep && n.Type != TypeNew { continue } if n.Type == TypeNew { fmt.Printf("Adding: %-40s %-55s %s\n", n.Name, n.URL, n.Copyright) } keys = append(keys, n.Name) noticeMap[n.Name] = n } slices.Sort(keys) indent := " " replacements := []string{} for _, n := range notices { if n.Type != TypeJS { continue } replacements = append(replacements, indent+n.HTML) } for _, k := range keys { n := noticeMap[k] line := fmt.Sprintf("%s
  • %s, %s.
  • ", indent, n.URL, n.Name, n.Copyright) replacements = append(replacements, line) } replacement := strings.Join(replacements, "\n") bs = copyrightRe.ReplaceAll(bs, []byte("id=\"copyright-notices\">\n"+replacement+"\n ")) writeFile(htmlFile, string(bs)) return len(replacements) } func readAll(path string) []byte { fd, err := os.Open(path) if err != nil { log.Fatal(err) } defer fd.Close() bs, err := io.ReadAll(fd) if err != nil { log.Fatal(err) } return bs } func writeFile(path string, data string) { err := os.WriteFile(path, []byte(data), 0o644) if err != nil { log.Fatal(err) } } func getModules() []string { ignoreRe := regexp.MustCompile(`golang\.org/x/|github\.com/syncthing|^[^.]+(/|$)`) // List all modules (used for mapping packages to modules) data, err := exec.Command("go", "list", "-m", "all").Output() if err != nil { log.Fatalf("go list -m all: %v", err) } modules := strings.Split(string(data), "\n") for i := range modules { modules[i], _, _ = strings.Cut(modules[i], " ") } modules = slices.DeleteFunc(modules, func(s string) bool { return s == "" }) // List all packages in use by the syncthing binary, map them to modules data, err = exec.Command("go", "list", "-deps", "./cmd/syncthing").Output() if err != nil { log.Fatalf("go list -deps ./cmd/syncthing: %v", err) } packages := strings.Split(string(data), "\n") packages = slices.DeleteFunc(packages, func(s string) bool { return s == "" }) seen := make(map[string]struct{}) for _, pkg := range packages { if ignoreRe.MatchString(pkg) { continue } // Find module for package modIdx := slices.IndexFunc(modules, func(mod string) bool { return strings.HasPrefix(pkg, mod) }) if modIdx < 0 { log.Println("no module for", pkg) continue } module := modules[modIdx] seen[module] = struct{}{} } adds := make([]string, 0) for k := range seen { adds = append(adds, k) } slices.Sort(adds) return adds } func parseCopyrightNotices(input string) []CopyrightNotice { doc, err := html.Parse(strings.NewReader("")) if err != nil { log.Fatal(err) } var notices []CopyrightNotice typ := TypeJS var f func(*html.Node) f = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "li" { var notice CopyrightNotice var aFound bool for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.ElementNode && c.Data == "a" { aFound = true for _, attr := range c.Attr { if attr.Key == "href" { notice.URL = attr.Val } } if c.FirstChild != nil && c.FirstChild.Type == html.TextNode { notice.Name = strings.TrimSpace(c.FirstChild.Data) } } else if c.Type == html.TextNode && aFound { // Anything after is considered the copyright notice.Copyright = strings.TrimSpace(html.UnescapeString(c.Data)) notice.Copyright = strings.Trim(notice.Copyright, "., ") } if typ == TypeJS && strings.Contains(notice.URL, "AudriusButkevicius") { typ = TypeKeep } notice.Type = typ var buf strings.Builder _ = html.Render(&buf, n) notice.HTML = buf.String() } notice.Copyright = strings.ReplaceAll(notice.Copyright, "©", "©") notice.HTML = strings.ReplaceAll(notice.HTML, "©", "©") notices = append(notices, notice) } for c := n.FirstChild; c != nil; c = c.NextSibling { f(c) } } f(doc) return notices } func parseGitHubURL(u string) (string, string) { parsed, err := url.Parse(u) if err != nil { log.Fatal(err) } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(parts) < 2 { log.Fatal(fmt.Errorf("invalid GitHub URL: %q", parsed.Path)) } return parts[0], parts[1] } func getLicenseText(owner, repo string) string { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/license", owner, repo) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Accept", "application/vnd.github.v3+json") if token := os.Getenv("GITHUB_TOKEN"); token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close() if resp.StatusCode == 404 { return "" } var result struct { Content string `json:"content"` Encoding string `json:"encoding"` } body, _ := io.ReadAll(resp.Body) err = json.Unmarshal(body, &result) if err != nil { log.Fatal(err) } if result.Encoding != "base64" { log.Fatal(fmt.Sprintf("unexpected encoding: %q", result.Encoding)) } decoded, err := base64.StdEncoding.DecodeString(result.Content) if err != nil { log.Fatal(err) } return string(decoded) } func extractCopyrights(license string, notice CopyrightNotice) []string { lines := strings.Split(license, "\n") re := regexp.MustCompile(`(?i)^\s*(copyright\s*(?:©|\(c\)|©|19|20).*)$`) copyrights := []string{} for _, line := range lines { if matches := re.FindStringSubmatch(strings.TrimSpace(line)); len(matches) == 2 { copyright := strings.TrimSpace(matches[1]) re := regexp.MustCompile(`(?i)all rights reserved`) copyright = re.ReplaceAllString(copyright, "") copyright = strings.ReplaceAll(copyright, "©", "©") copyright = strings.ReplaceAll(copyright, "(C)", "©") copyright = strings.ReplaceAll(copyright, "(c)", "©") copyright = strings.Trim(copyright, "., ") copyrights = append(copyrights, copyright) } } if len(copyrights) > 0 { return copyrights } return []string{} } func defaultCopyright(n CopyrightNotice) string { year := time.Now().Format("2006") return fmt.Sprintf("Copyright © %v, the %s authors", year, n.Name) } func writeNotices(path string, notices []CopyrightNotice) { s := "" for i, n := range notices { s += "# : " + strconv.Itoa(i) + "\n" + n.String() } writeFile(path, s) } func (n CopyrightNotice) String() string { return fmt.Sprintf("Type : %v\nHTML : %v\nName : %v\nModule : %v\nURL : %v\nCopyright: %v\nRepoURL : %v\nRepoCopys: %v\n\n", n.Type, n.HTML, n.Name, n.Module, n.URL, n.Copyright, n.RepoURL, strings.Join(n.RepoCopyrights, ",")) } func (t Type) String() string { switch t { case TypeJS: return "TypeJS" case TypeKeep: return "TypeKeep" case TypeToss: return "TypeToss" case TypeNew: return "TypeNew" default: return "unknown" } }