+51
cmd/plcwatch/comparisons.go
+51
cmd/plcwatch/comparisons.go
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"reflect"
6
+
)
7
+
8
+
type CompareResults struct {
9
+
Title string
10
+
Description string
11
+
}
12
+
13
+
func compareAlsoKnownAs(old, new []string) *CompareResults {
14
+
if !reflect.DeepEqual(old, new) {
15
+
return &CompareResults{
16
+
"Alias updated",
17
+
fmt.Sprintf("%+v -> %+v", old, new),
18
+
}
19
+
}
20
+
return nil
21
+
}
22
+
23
+
func compareServices(old, new map[string]PlcService) *CompareResults {
24
+
if !reflect.DeepEqual(old, new) {
25
+
return &CompareResults{
26
+
"Service updated",
27
+
fmt.Sprintf("%+v -> %+v", old, new),
28
+
}
29
+
}
30
+
return nil
31
+
}
32
+
33
+
func compareRotationKeys(old, new []string) *CompareResults {
34
+
if !reflect.DeepEqual(old, new) {
35
+
return &CompareResults{
36
+
"Rotation key updated",
37
+
fmt.Sprintf("%+v -> %+v", old, new),
38
+
}
39
+
}
40
+
return nil
41
+
}
42
+
43
+
func compareVerificationMethods(old, new map[string]string) *CompareResults {
44
+
if !reflect.DeepEqual(old, new) {
45
+
return &CompareResults{
46
+
"Verification method updated",
47
+
fmt.Sprintf("%+v -> %+v", old, new),
48
+
}
49
+
}
50
+
return nil
51
+
}
+105
cmd/plcwatch/main.go
+105
cmd/plcwatch/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"io"
7
+
"net/http"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/labstack/echo/v4"
12
+
)
13
+
14
+
const plcDirectoryHost = "https://plc.directory"
15
+
16
+
func rssTimeFormat(raw string) string {
17
+
t, err := syntax.ParseDatetimeTime(raw)
18
+
if err != nil {
19
+
return time.Now().UTC().Format(time.RFC1123Z)
20
+
}
21
+
return t.Format(time.RFC1123Z)
22
+
}
23
+
24
+
func main() {
25
+
e := echo.New()
26
+
e.GET("/:did", func(c echo.Context) error {
27
+
did := c.Param("did")
28
+
29
+
url := fmt.Sprintf("%s/%s/log/audit", plcDirectoryHost, did)
30
+
resp, err := http.Get(url)
31
+
if err != nil {
32
+
return fmt.Errorf("failed fetching did audit log: %w", err)
33
+
}
34
+
defer resp.Body.Close()
35
+
36
+
respBytes, err := io.ReadAll(resp.Body)
37
+
if err != nil {
38
+
return fmt.Errorf("failed reading PLC bytes: %w", err)
39
+
}
40
+
41
+
var entries []PlcAudit
42
+
err = json.Unmarshal(respBytes, &entries)
43
+
if err != nil {
44
+
return fmt.Errorf("failed unmarshalling bytes: %w", err)
45
+
}
46
+
47
+
rss := NewRss(did)
48
+
var last *PlcAudit
49
+
for _, entry := range entries {
50
+
if last == nil {
51
+
item := RssItem{
52
+
Title: "Identity created",
53
+
Published: rssTimeFormat(entry.CreatedAt),
54
+
Permalink: RssPermalink{entry.Cid, "false"},
55
+
}
56
+
rss.Add(item)
57
+
} else {
58
+
results := compareAlsoKnownAs(last.Operation.AlsoKnownAs, entry.Operation.AlsoKnownAs)
59
+
if results != nil {
60
+
rss.Add(RssItem{
61
+
Title: results.Title,
62
+
Description: results.Description,
63
+
Published: rssTimeFormat(entry.CreatedAt),
64
+
Permalink: RssPermalink{entry.Cid + "#alsoKnownAs", "false"},
65
+
})
66
+
}
67
+
68
+
results = compareServices(last.Operation.Services, entry.Operation.Services)
69
+
if results != nil {
70
+
rss.Add(RssItem{
71
+
Title: results.Title,
72
+
Description: results.Description,
73
+
Published: rssTimeFormat(entry.CreatedAt),
74
+
Permalink: RssPermalink{entry.Cid + "#services", "false"},
75
+
})
76
+
}
77
+
78
+
results = compareRotationKeys(last.Operation.RotationKeys, entry.Operation.RotationKeys)
79
+
if results != nil {
80
+
rss.Add(RssItem{
81
+
Title: results.Title,
82
+
Description: results.Description,
83
+
Published: rssTimeFormat(entry.CreatedAt),
84
+
Permalink: RssPermalink{entry.Cid + "#rotationKeys", "false"},
85
+
})
86
+
}
87
+
88
+
results = compareVerificationMethods(last.Operation.VerificationMethods, entry.Operation.VerificationMethods)
89
+
if results != nil {
90
+
rss.Add(RssItem{
91
+
Title: results.Title,
92
+
Description: results.Description,
93
+
Published: rssTimeFormat(entry.CreatedAt),
94
+
Permalink: RssPermalink{entry.Cid + "#verificationMethods", "false"},
95
+
})
96
+
}
97
+
}
98
+
99
+
last = &entry
100
+
}
101
+
102
+
return c.XMLPretty(http.StatusOK, rss, " ")
103
+
})
104
+
e.Logger.Fatal(e.Start(":1234"))
105
+
}
+24
cmd/plcwatch/plc.go
+24
cmd/plcwatch/plc.go
···
1
+
package main
2
+
3
+
type PlcAudit struct {
4
+
Did string `json:"did"`
5
+
Operation PlcOperation `json:"operation"`
6
+
Cid string `json:"cid"`
7
+
Nullified bool `json:"nullified"`
8
+
CreatedAt string `json:"createdAt"`
9
+
}
10
+
11
+
type PlcOperation struct {
12
+
Signature string `json:"sig"`
13
+
Previous *string `json:"prev"`
14
+
Type string `json:"type"`
15
+
Services map[string]PlcService `json:"services"`
16
+
AlsoKnownAs []string `json:"alsoKnownAs"`
17
+
RotationKeys []string `json:"rotationKeys"`
18
+
VerificationMethods map[string]string `json:"verificationMethods"`
19
+
}
20
+
21
+
type PlcService struct {
22
+
Type string `json:"type"`
23
+
Endpoint string `json:"endpoint"`
24
+
}
+40
cmd/plcwatch/rss.go
+40
cmd/plcwatch/rss.go
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
)
6
+
7
+
type Rss struct {
8
+
XMLName string `xml:"rss"`
9
+
Version string `xml:"version,attr"`
10
+
Title string `xml:"channel>title"`
11
+
Items []RssItem `xml:"channel>item"`
12
+
}
13
+
14
+
type RssItem struct {
15
+
Title string `xml:"title"`
16
+
Description string `xml:"description,omitempty"`
17
+
Published string `xml:"pubDate"`
18
+
Permalink RssPermalink `xml:"guid"`
19
+
}
20
+
21
+
type CDATA struct {
22
+
Value string `xml:",cdata"`
23
+
}
24
+
25
+
type RssPermalink struct {
26
+
Value string `xml:",chardata"`
27
+
Permanent string `xml:"isPermaLink,attr"`
28
+
}
29
+
30
+
func NewRss(title string) *Rss {
31
+
return &Rss{
32
+
Version: "2.0",
33
+
Title: fmt.Sprintf("plcwatch: %s", title),
34
+
}
35
+
}
36
+
37
+
func (r *Rss) Add(item RssItem) {
38
+
// r.Items = append(r.Items, item)
39
+
r.Items = append([]RssItem{item}, r.Items...)
40
+
}
+20
go.mod
+20
go.mod
···
1
+
module github.com/edavis/plcwatch
2
+
3
+
go 1.23.6
4
+
5
+
require (
6
+
github.com/bluesky-social/indigo v0.0.0-20250324192039-40f397713b63
7
+
github.com/labstack/echo/v4 v4.13.3
8
+
)
9
+
10
+
require (
11
+
github.com/labstack/gommon v0.4.2 // indirect
12
+
github.com/mattn/go-colorable v0.1.13 // indirect
13
+
github.com/mattn/go-isatty v0.0.20 // indirect
14
+
github.com/valyala/bytebufferpool v1.0.0 // indirect
15
+
github.com/valyala/fasttemplate v1.2.2 // indirect
16
+
golang.org/x/crypto v0.31.0 // indirect
17
+
golang.org/x/net v0.33.0 // indirect
18
+
golang.org/x/sys v0.28.0 // indirect
19
+
golang.org/x/text v0.21.0 // indirect
20
+
)
+33
go.sum
+33
go.sum
···
1
+
github.com/bluesky-social/indigo v0.0.0-20250324192039-40f397713b63 h1:0ohcaD0jwrEgMrPb87f3kL5n1OaIGuBqXdlE7iO3s6M=
2
+
github.com/bluesky-social/indigo v0.0.0-20250324192039-40f397713b63/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
3
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5
+
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
6
+
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
7
+
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
8
+
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
9
+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
10
+
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
11
+
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
12
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
13
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
14
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
15
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
16
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
17
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
18
+
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
19
+
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
20
+
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
21
+
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
22
+
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
23
+
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
24
+
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
25
+
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
26
+
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
27
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
28
+
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
29
+
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
30
+
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
31
+
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
32
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
33
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=