Browse Source

chore(gui): update dependency copyrights, add script for periodic maintenance (#10067)

### Purpose

This PR parses the output of `go mod graph` and updates the copyright
list in our [about
modal](https://github.com/syncthing/syncthing/blob/486eebc4ac911b86b3d110dd0f7a955edd0f5106/gui/default/syncthing/core/aboutModalView.html#L38).

If there are no changes, the program is silent. Otherwise, it reports
what additions, and deletions it made. It does not rewrite existing
copyright notices, but it does remove notices that we no longer use, as
well as add new ones.

It uses a GitHub API to try to determine the copyright string in the
license file. If one is not found, it defaults to `Copyright ©
<this_year> the <owner/repo> authors`. If a proper copyright is found,
simply update the notice in `aboutModalView.html`, and it will be used.
Ross Smith II 5 months ago
parent
commit
93ae30d889
3 changed files with 557 additions and 18 deletions
  1. 1 0
      build.sh
  2. 67 18
      gui/default/syncthing/core/aboutModalView.html
  3. 489 0
      script/copyrights.go

+ 1 - 0
build.sh

@@ -23,6 +23,7 @@ case "${1:-default}" in
 
 	prerelease)
 		script authors
+		script copyrights
 		build weblate
 		pushd man ; ./refresh.sh ; popd
 		git add -A gui man AUTHORS

+ 67 - 18
gui/default/syncthing/core/aboutModalView.html

@@ -38,45 +38,94 @@ Jakob Borg, Audrius Butkevicius, Jesse Lucas, Simon Frei, Tomasz Wilczyński, Al
       <div id="about-includes" class="tab-pane">
         <p translate>Syncthing includes the following software or portions thereof:</p>
         <ul class="list-unstyled two-columns" id="copyright-notices">
-          <li><a href="http://getbootstrap.com/">Bootstrap</a>, Copyright &copy; 2011-2016 Twitter, Inc.</li>
+          <li><a href="https://getbootstrap.com/">Bootstrap</a>, Copyright &copy; 2011-2016 Twitter, Inc.</li>
           <li><a href="https://angularjs.org/">AngularJS</a>, Copyright &copy; 2010-2014, 2016 Google, Inc.</li>
-          <li><a href="http://www.daterangepicker.com/">Date Range Picker</a>, Copyright &copy; 2012-2018 Dan Grossman.</li>
+          <li><a href="https://www.daterangepicker.com/">Date Range Picker</a>, Copyright &copy; 2012-2018 Dan Grossman.</li>
           <li><a href="https://github.com/mar10/fancytree">JQuery Fancytree Plugin</a>, Copyright &copy; 2008-2018 Martin Wendt.</li>
+          <li><a href="https://fontawesome.com/">Font Awesome</a>Copyright &copy; 2024 Fonticons, Inc.</li>
           <li><a href="https://forkaweso.me/Fork-Awesome/">Fork Awesome</a>, Copyright &copy; 2018 Dave Gandy &amp; Fork Awesome.</li>
-          <li><a href="http://jquery.com/">jQuery JavaScript Library</a>, Copyright &copy; jQuery Foundation and other contributors.</li>
-          <li><a href="http://momentjs.com/">moment.js</a>, Copyright &copy; JS Foundation and other contributors.</li>
+          <li><a href="https://evanhahn.github.io/HumanizeDuration.js/">HumanDuration.js</a>, Copyright &copy; 2013-2024 Evan Hahn, portions copyright &copy; 2024 Ross Smith II.</li>
+          <li><a href="https://jquery.com/">jQuery JavaScript Library</a>, Copyright &copy; jQuery Foundation and other contributors.</li>
+          <li><a href="https://leafletjs.com/">leaflet.js</a>, Copyright &copy; 2010-2025 Volodymyr Agafonkin, Copyright &copy; 2010-2011 CloudMade.</li>
+          <li><a href="https://momentjs.com/">moment.js</a>, Copyright &copy; JS Foundation and other contributors.</li>
+          <li><a href="https://golang.org/">The Go Programming Language</a>, Copyright &copy; 2009 The Go Authors.</li>
           <li><a href="https://prometheus.io/">Prometheus</a>, Copyright &copy; 2012-2015 The Prometheus Authors.</li>
-          <li><a href="https://github.com/AudriusButkevicius/go-nat-pmp">AudriusButkevicius/go-nat-pmp</a>, Copyright &copy; 2013 John Howard Palevich.</li>
           <li><a href="https://github.com/AudriusButkevicius/recli">AudriusButkevicius/recli</a>, Copyright &copy; 2019 Audrius Butkevicius.</li>
+          <li><a href="https://github.com/Azure/azure-sdk-for-go">Azure/azure-sdk-for-go</a>, Copyright &copy; Microsoft Corporation.</li>
+          <li><a href="https://github.com/Azure/go-ntlmssp">Azure/go-ntlmssp</a>, Copyright &copy; 2016 Microsoft.</li>
+          <li><a href="https://github.com/alecthomas/kong">alecthomas/kong</a>, Copyright &copy; 2018 Alec Thomas.</li>
+          <li><a href="https://github.com/aws/aws-sdk-go">aws/aws-sdk-go</a>, Copyright &copy; 2015 Amazon.com, Inc. or its affiliates, Copyright 2014-2015 Stripe, Inc.</li>
           <li><a href="https://github.com/beorn7/perks">beorn7/perks</a>, Copyright &copy; 2013 Blake Mizerany.</li>
-          <li><a href="https://github.com/pierrec/lz4">pierrec/lz4</a>, Copyright &copy; 2015 Pierre Curto.</li>
-          <li><a href="https://github.com/calmh/du">calmh/du</a>, Public domain.</li>
+          <li><a href="https://github.com/calmh/incontainer">calmh/incontainer</a>, Copyright &copy; 2022 calmh.</li>
           <li><a href="https://github.com/calmh/xdr">calmh/xdr</a>, Copyright &copy; 2014 Jakob Borg.</li>
+          <li><a href="https://github.com/ccding/go-stun">ccding/go-stun</a>, Copyright &copy; 2016 Cong Ding.</li>
+          <li><a href="https://github.com/cenkalti/backoff">cenkalti/backoff</a>, Copyright &copy; 2014 Cenk Altı.</li>
+          <li><a href="https://github.com/certifi/gocertifi">certifi/gocertifi</a>, Copyright &copy; 2025, the certifi/gocertifi authors.</li>
+          <li><a href="https://github.com/cespare/xxhash">cespare/xxhash</a>, Copyright &copy; 2016 Caleb Spare.</li>
           <li><a href="https://github.com/chmduquesne/rollinghash">chmduquesne/rollinghash</a>, Copyright &copy; 2015 Christophe-Marie Duquesne.</li>
+          <li><a href="https://github.com/cpuguy83/go-md2man">cpuguy83/go-md2man</a>, Copyright &copy; 2014 Brian Goff.</li>
           <li><a href="https://github.com/d4l3k/messagediff">d4l3k/messagediff</a>, Copyright &copy; 2015 Tristan Rice.</li>
+          <li><a href="https://github.com/davecgh/go-spew">davecgh/go-spew</a>, Copyright &copy; 2012-2016 Dave Collins <[email protected]>.</li>
+          <li><a href="https://github.com/ebitengine/purego">ebitengine/purego</a>, Copyright &copy; 2022 The Ebitengine Authors.</li>
+          <li><a href="https://github.com/fsnotify/fsnotify">fsnotify/fsnotify</a>, Copyright &copy; 2012 The Go Authors.</li>
+          <li><a href="https://github.com/getsentry/raven-go">getsentry/raven-go</a>, Copyright &copy; 2013 Apollic Software, LLC.</li>
+          <li><a href="https://github.com/go-asn1-ber/asn1-ber">go-asn1-ber/asn1-ber</a>, Copyright &copy; 2011-2015 Michael Mitton ([email protected]).</li>
+          <li><a href="https://github.com/go-ldap/ldap">go-ldap/ldap</a>, Copyright &copy; 2011-2015 Michael Mitton ([email protected]).</li>
+          <li><a href="https://github.com/go-ole/go-ole">go-ole/go-ole</a>, Copyright &copy; 2013-2017 Yasuhiro Matsumoto, <[email protected]>.</li>
+          <li><a href="https://github.com/go-task/slim-sprig">go-task/slim-sprig</a>, Copyright &copy; 2013-2020 Masterminds.</li>
+          <li><a href="https://github.com/uber-go/automaxprocs">go.uber.org/automaxprocs</a>, Copyright &copy; 2017 Uber Technologies, Inc.</li>
+          <li><a href="https://github.com/uber-go/mock">go.uber.org/mock</a>, Copyright &copy; 2010-2022 Google LLC.</li>
           <li><a href="https://github.com/gobwas/glob">gobwas/glob</a>, Copyright &copy; 2016 Sergey Kamardin.</li>
-          <li><a href="https://github.com/golang/groupcache">golang/groupcache</a>, Copyright &copy; 2013 Google Inc.</li>
-          <li><a href="https://github.com/golang/protobuf">golang/protobuf</a>, Copyright &copy; 2010 The Go Authors.</li>
+          <li><a href="https://github.com/gofrs/flock">gofrs/flock</a>, Copyright &copy; 2018-2025, The Gofrs.</li>
           <li><a href="https://github.com/golang/snappy">golang/snappy</a>, Copyright &copy; 2011 The Snappy-Go Authors.</li>
+          <li><a href="https://github.com/protocolbuffers/protobuf-go">google.golang.org/protobuf</a>, Copyright &copy; 2018 The Go Authors.</li>
+          <li><a href="https://github.com/google/pprof">google/pprof</a>, Copyright &copy; 2016 Google Inc.</li>
+          <li><a href="https://github.com/google/uuid">google/uuid</a>, Copyright &copy; 2009,2014 Google Inc.</li>
+          <li><a href="https://github.com/go-yaml/yaml">gopkg.in/yaml.v3</a>, Copyright &copy; 2006-2010 Kirill Simonov.</li>
+          <li><a href="https://github.com/greatroar/blobloom">greatroar/blobloom</a>, Copyright &copy; 2020-2024 the Blobloom authors.</li>
+          <li><a href="https://github.com/hashicorp/errwrap">hashicorp/errwrap</a>, Copyright &copy; 2014 HashiCorp, Inc.</li>
+          <li><a href="https://github.com/hashicorp/go-multierror">hashicorp/go-multierror</a>, Copyright &copy; 2014 HashiCorp, Inc.</li>
+          <li><a href="https://github.com/hashicorp/golang-lru">hashicorp/golang-lru</a>, Copyright &copy; 2014 HashiCorp, Inc.</li>
           <li><a href="https://github.com/jackpal/gateway">jackpal/gateway</a>, Copyright &copy; 2010 Jack Palevich.</li>
+          <li><a href="https://github.com/jackpal/go-nat-pmp">jackpal/go-nat-pmp</a>, Copyright 2013 John Howard Palevich.</li>
+          <li><a href="https://github.com/jmespath/go-jmespath">jmespath/go-jmespath</a>, Copyright &copy; 2015 James Saryerwinnie.</li>
+          <li><a href="https://github.com/julienschmidt/httprouter">julienschmidt/httprouter</a>, Copyright &copy; 2013, Julien Schmidt.</li>
           <li><a href="https://github.com/kballard/go-shellquote">kballard/go-shellquote</a>, Copyright &copy; 2014 Kevin Ballard.</li>
-          <li><a href="https://github.com/mattn/go-isatty">mattn/go-isatty</a>, Copyright &copy; Yasuhiro MATSUMOTO.</li>
-          <li><a href="https://github.com/matttproud/golang_protobuf_extensions">matttproud/golang_protobuf_extensions</a>, Copyright &copy; 2012 Matt T. Proud.</li>
+          <li><a href="https://github.com/klauspost/compress">klauspost/compress</a>, Copyright &copy; 2012 The Go Authors.</li>
+          <li><a href="https://github.com/lufia/plan9stats">lufia/plan9stats</a>, Copyright &copy; 2019, KADOTA, Kyohei.</li>
+          <li><a href="https://github.com/maruel/panicparse">maruel/panicparse</a>, Copyright 2015 Marc-Antoine Ruel.</li>
+          <li><a href="https://github.com/maxbrunsfeld/counterfeiter">maxbrunsfeld/counterfeiter</a>, Copyright &copy; 2014 maxbrunsfeld.</li>
+          <li><a href="https://github.com/maxmind/geoipupdate">maxmind/geoipupdate</a>, Copyright &copy; 2018-2024 by MaxMind, Inc.</li>
+          <li><a href="https://github.com/miscreant/miscreant.go">miscreant/miscreant.go</a>, Copyright &copy; 2017-2019 The Miscreant Developers.</li>
+          <li><a href="https://github.com/munnerz/goautoneg">munnerz/goautoneg</a>, Copyright &copy; 2011, Open Knowledge Foundation Ltd.</li>
+          <li><a href="https://github.com/nxadm/tail">nxadm/tail</a>, Copyright &copy; 2014 ActiveState.</li>
+          <li><a href="https://github.com/onsi/ginkgo">onsi/ginkgo</a>, Copyright &copy; 2013-2014 Onsi Fakhouri.</li>
           <li><a href="https://github.com/oschwald/geoip2-golang">oschwald/geoip2-golang</a>, Copyright &copy; 2015, Gregory J. Oschwald.</li>
           <li><a href="https://github.com/oschwald/maxminddb-golang">oschwald/maxminddb-golang</a>, Copyright &copy; 2015, Gregory J. Oschwald.</li>
-          <li><a href="https://github.com/petermattis/goid">petermattis/goid</a>, Copyright &copy; 2015-2016 Peter Mattis.</li>
+          <li><a href="https://github.com/pierrec/lz4">pierrec/lz4</a>, Copyright &copy; 2015 Pierre Curto.</li>
           <li><a href="https://github.com/pkg/errors">pkg/errors</a>, Copyright &copy; 2015, Dave Cheney.</li>
+          <li><a href="https://github.com/pmezard/go-difflib">pmezard/go-difflib</a>, Copyright &copy; 2013, Patrick Mezard.</li>
+          <li><a href="https://github.com/posener/complete">posener/complete</a>, Copyright &copy; 2017 Eyal Posener.</li>
+          <li><a href="https://github.com/power-devops/perfstat">power-devops/perfstat</a>, Copyright &copy; 2020 Power DevOps.</li>
+          <li><a href="https://github.com/puzpuzpuz/xsync">puzpuzpuz/xsync</a>, Copyright &copy; 2025, the puzpuzpuz/xsync authors.</li>
+          <li><a href="https://github.com/quic-go/quic-go">quic-go/quic-go</a>, Copyright &copy; 2016 the quic-go authors & Google, Inc.</li>
+          <li><a href="https://github.com/rabbitmq/amqp091-go">rabbitmq/amqp091-go</a>, Copyright &copy; 2021 VMware, Inc. or its affiliates.</li>
           <li><a href="https://github.com/rcrowley/go-metrics">rcrowley/go-metrics</a>, Copyright &copy; 2012 Richard Crowley.</li>
-          <li><a href="https://github.com/sasha-s/go-deadlock">sasha-s/go-deadlock</a>, Copyright &copy; 2016 sasha-s.</li>
+          <li><a href="https://github.com/riywo/loginshell">riywo/loginshell</a>, Copyright &copy; 2019 Ryosuke IWANAGA.</li>
+          <li><a href="https://github.com/russross/blackfriday">russross/blackfriday</a>, Copyright &copy; 2011 Russ Ross.</li>
+          <li><a href="https://github.com/shirou/gopsutil">shirou/gopsutil</a>, Copyright &copy; 2014, WAKAYAMA Shirou.</li>
+          <li><a href="https://github.com/kubernetes-sigs/yaml">sigs.k8s.io/yaml</a>, Copyright &copy; 2014 Sam Ghods.</li>
+          <li><a href="https://github.com/stretchr/objx">stretchr/objx</a>, Copyright &copy; 2014 Stretchr, Inc.</li>
+          <li><a href="https://github.com/stretchr/testify">stretchr/testify</a>, Copyright &copy; 2012-2020 Mat Ryer, Tyler Bunnell and contributors.</li>
           <li><a href="https://github.com/syncthing/notify">syncthing/notify</a>, Copyright &copy; 2014-2015 The Notify Authors.</li>
           <li><a href="https://github.com/syndtr/goleveldb">syndtr/goleveldb</a>, Copyright &copy; 2012 Suryandaru Triandana.</li>
           <li><a href="https://github.com/thejerf/suture">thejerf/suture</a>, Copyright &copy; 2014-2015 Barracuda Networks, Inc.</li>
-          <li><a href="https://github.com/urfave/cli">urfave/cli</a>, Copyright &copy; 2016 Jeremy Saenz &amp; Contributors.</li>
+          <li><a href="https://github.com/tklauser/go-sysconf">tklauser/go-sysconf</a>, Copyright &copy; 2018-2022, Tobias Klauser.</li>
+          <li><a href="https://github.com/tklauser/numcpus">tklauser/numcpus</a>, Copyright &copy; 2018-2024 Tobias Klauser.</li>
+          <li><a href="https://github.com/urfave/cli">urfave/cli</a>, Copyright &copy; 2016 Jeremy Saenz & Contributors.</li>
           <li><a href="https://github.com/vitrun/qart">vitrun/qart</a>, Copyright &copy; 2010-2011 The Go Authors.</li>
-          <li><a href="https://gopkg.in/asn1-ber.v1">gopkg.in/asn1-ber.v1</a>, Copyright &copy; 2011-2015 Michael Mitton, portions Copyright &copy; 2015-2016 go-asn1-ber Authors.</li>
-          <li><a href="https://gopkg.in/ldap.v2">gopkg.in/ldap.v2</a>, Copyright &copy; 2011-2015 Michael Mitton, portions Copyright &copy; 2015-2016 go-ldap Authors.</li>
-          <li><a href="https://golang.org">The Go Programming Language</a>, Copyright &copy; 2009 The Go Authors.</li>
-          <li>Font Awesome by Dave Gandy - <a href="http://fontawesome.io/">http://fontawesome.io</a></li>
+          <li><a href="https://github.com/willabides/kongplete">willabides/kongplete</a>, Copyright &copy; 2020 WillAbides.</li>
+          <li><a href="https://github.com/yusufpapurcu/wmi">yusufpapurcu/wmi</a>, Copyright &copy; 2013 Stack Exchange.</li>
         </ul>
       </div>
 

+ 489 - 0
script/copyrights.go

@@ -0,0 +1,489 @@
+// 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 (
+	"bufio"
+	"bytes"
+	"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 &copy; 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 &copy; 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 &copy; 2022 The Ebitengine Authors",
+	// https://github.com/search?q=repo%3Agoogle%2Fpprof%20copyright&type=code
+	"google/pprof": "Copyright &copy; 2016 Google Inc",
+	// https://github.com/greatroar/blobloom/blob/master/README.md?plain=1#L74
+	"greatroar/blobloom": "Copyright &copy; 2020-2024 the Blobloom authors",
+	// https://github.com/jmespath/go-jmespath/blob/master/NOTICE#L2
+	"jmespath/go-jmespath": "Copyright &copy; 2015 James Saryerwinnie",
+	// https://github.com/maxmind/geoipupdate/blob/main/README.md?plain=1#L140
+	"maxmind/geoipupdate": "Copyright &copy; 2018-2024 by MaxMind, Inc",
+	// 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 &copy; 2018-2024 Tobias Klauser",
+	// https://github.com/search?q=repo%3Auber-go%2Fmock%20copyright&type=code
+	"go.uber.org/mock": "Copyright &copy; 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">(.+?)</ul>`)
+
+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("<li><a href=\"%s\">%s</a>, %s.</li>", 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<li><a href=\"%s\">%s</a>, %s.</li>", 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        </ul>"))
+	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 {
+	cmd := exec.Command("go", "mod", "graph")
+	output, err := cmd.Output()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	seen := make(map[string]struct{})
+	scanner := bufio.NewScanner(bytes.NewReader(output))
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if len(fields) == 0 {
+			continue
+		}
+
+		if !strings.HasPrefix(fields[0], "github.com/syncthing/syncthing") {
+			continue
+		}
+
+		// Get left-hand side of dependency pair (before '@')
+		mod := strings.SplitN(fields[1], "@", 2)[0]
+
+		// Keep only first 3 path components
+		parts := strings.Split(mod, "/")
+		if len(parts) == 1 {
+			continue
+		}
+		short := strings.Join(parts[:min(len(parts), 3)], "/")
+
+		if strings.HasPrefix(short, "golang.org/x") ||
+			strings.HasPrefix(short, "github.com/prometheus") ||
+			short == "go" {
+
+			continue
+		}
+
+		seen[short] = 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("<ul>" + input + "</ul>"))
+	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 <a> 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, "©", "&copy;")
+			notice.HTML = strings.ReplaceAll(notice.HTML, "©", "&copy;")
+			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()
+
+	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: %s", 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\)|&copy;|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, "©", "&copy;")
+			copyright = strings.ReplaceAll(copyright, "(C)", "&copy;")
+			copyright = strings.ReplaceAll(copyright, "(c)", "&copy;")
+			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 &copy; %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"
+	}
+}