Compare commits

...

17 commits
1.1 ... main

Author SHA1 Message Date
Melroy van den Berg
e0fb8682c8 Edit README.md 2025-04-27 17:08:37 +02:00
Melroy van den Berg
7fe3ce2234
Beatify the output again. Like for nodeinfo responses 2025-04-25 01:42:53 +02:00
Melroy van den Berg
e56d6c9c41
Improve performance 2025-04-25 01:10:36 +02:00
Melroy van den Berg
8e5a08ab5f
Improve performance 2025-04-25 01:07:45 +02:00
Melroy van den Berg
d85cf4bd11
Improve performance 2025-04-25 00:55:17 +02:00
Melroy van den Berg
1c8dad5500
Improve performance 2025-04-25 00:43:10 +02:00
Melroy van den Berg
b951b920ac
Clean up code 2025-04-25 00:33:43 +02:00
Melroy van den Berg
f0399df34a
Clean up code 2025-04-25 00:15:47 +02:00
Melroy van den Berg
a802483eb6
Add sensitive content warning 2025-04-25 00:03:23 +02:00
Melroy van den Berg
f2ffead88b
Use public key id from the same object if attributeTo doesn't exist 2025-04-24 23:54:12 +02:00
Melroy van den Berg
02853f346d
Refactoring 2025-04-24 23:23:43 +02:00
Melroy van den Berg
5d08981a5f
Remove urlFormats from resolver.go 2025-04-24 22:48:01 +02:00
Melroy van den Berg
3f377e14c0
Dead code 2025-04-24 22:35:54 +02:00
Melroy van den Berg
416e4cded4
Add some color flair 2025-04-24 22:12:18 +02:00
Melroy van den Berg
9203ac81b7
More robust domain 2025-04-24 22:12:18 +02:00
Melroy van den Berg
6393d94db3
Add support for instance info 2025-04-24 22:12:18 +02:00
Melroy van den Berg
c7828633c8 Small readme update regarding go install 2025-04-24 21:34:28 +02:00
4 changed files with 349 additions and 450 deletions

View file

@ -34,6 +34,17 @@ cd fediresolve
go build go build
``` ```
Optionally, install the binary by executing:
```bash
go install
```
Which depending on the OS installs to the installation directory, under Linux that would be: `$HOME/go/bin`.
Be sure to add `$HOME/go/bin` path to your `$PATH` in your shell, [more info](https://go.dev/doc/tutorial/compile-install).
Once installed (assuming the `$HOME/go/bin` path is in your `$PATH`) you can just execute the `fediresolve` binary without `./` of course.
## Usage ## Usage
### Basic usage ### Basic usage

View file

@ -13,27 +13,36 @@ import (
) )
// Format takes ActivityPub data and returns a formatted string representation // Format takes ActivityPub data and returns a formatted string representation
func Format(data map[string]interface{}) (string, error) { func Format(jsonData []byte) (string, error) {
// First, get the beautified JSON // Create a summary based on the object type
jsonData, err := json.MarshalIndent(data, "", " ") summary := createSummary(jsonData)
if err != nil {
return "", fmt.Errorf("error formatting JSON: %v", err) // This might look unnecessary, but it is not in order to beautify the JSON.
// First Unmarkshall to get a map[string]interface{}
var data map[string]interface{}
if err := json.Unmarshal(jsonData, &data); err != nil {
return "", fmt.Errorf("error parsing JSON: %v", err)
} }
// Convert to string for gjson parsing // Marshal with indentation to beautify the output
jsonStr := string(jsonData) pretty, err := json.MarshalIndent(data, "", " ")
if err != nil {
// Create a summary based on the object type return "", fmt.Errorf("error beautifying JSON: %v", err)
summary := createSummary(jsonStr) }
// Combine the full JSON first, followed by the summary at the bottom // Combine the full JSON first, followed by the summary at the bottom
result := fmt.Sprintf("%s\n\n%s", string(jsonData), summary) result := fmt.Sprintf("%s\n\n%s", pretty, summary)
return result, nil return result, nil
} }
// createSummary generates a human-readable summary of the ActivityPub object // createSummary generates a human-readable summary of the ActivityPub object or nodeinfo
func createSummary(jsonStr string) string { func createSummary(jsonStr []byte) string {
objectType := gjson.Get(jsonStr, "type").String() // Try to detect nodeinfo
if gjson.GetBytes(jsonStr, "software.name").Exists() && gjson.GetBytes(jsonStr, "version").Exists() {
return nodeInfoSummary(jsonStr)
}
objectType := gjson.GetBytes(jsonStr, "type").String()
// Build a header with the object type // Build a header with the object type
bold := color.New(color.Bold).SprintFunc() bold := color.New(color.Bold).SprintFunc()
@ -44,12 +53,17 @@ func createSummary(jsonStr string) string {
header := fmt.Sprintf("%s: %s\n", bold("Type"), cyan(objectType)) header := fmt.Sprintf("%s: %s\n", bold("Type"), cyan(objectType))
// Add sensitive content warning if present
if gjson.GetBytes(jsonStr, "sensitive").Bool() {
header += fmt.Sprintf("%s: %s\n", red(bold("WARNING")), red("Sensitive Content!"))
}
// Add common fields // Add common fields
var summaryParts []string var summaryParts []string
summaryParts = append(summaryParts, header) summaryParts = append(summaryParts, header)
// Add ID if available // Add ID if available
if id := gjson.Get(jsonStr, "id").String(); id != "" { if id := gjson.GetBytes(jsonStr, "id").String(); id != "" {
summaryParts = append(summaryParts, fmt.Sprintf("%s: %s", bold("Original URL"), green(id))) summaryParts = append(summaryParts, fmt.Sprintf("%s: %s", bold("Original URL"), green(id)))
} }
@ -74,39 +88,95 @@ func createSummary(jsonStr string) string {
return strings.Join(summaryParts, "\n") return strings.Join(summaryParts, "\n")
} }
// nodeInfoSummary generates a summary for nodeinfo objects
func nodeInfoSummary(jsonStr []byte) string {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
parts := []string{}
parts = append(parts, fmt.Sprintf("%s: %s", bold("NodeInfo Version"), cyan(gjson.GetBytes(jsonStr, "version").String())))
parts = append(parts, fmt.Sprintf("%s: %s %s", bold("Software"), green(gjson.GetBytes(jsonStr, "software.name").String()), yellow(gjson.GetBytes(jsonStr, "software.version").String())))
if repo := gjson.GetBytes(jsonStr, "software.repository").String(); repo != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Repository"), green(repo)))
}
// Color openRegistrations green if true, red if false
openReg := gjson.GetBytes(jsonStr, "openRegistrations")
openRegStr := openReg.String()
var openRegColored string
if openReg.Exists() {
if openReg.Bool() {
openRegColored = green(openRegStr)
} else {
openRegColored = red(openRegStr)
}
} else {
openRegColored = openRegStr
}
parts = append(parts, fmt.Sprintf("%s: %s", bold("Open Registrations"), openRegColored))
if protocols := gjson.GetBytes(jsonStr, "protocols").Array(); len(protocols) > 0 {
var plist []string
for _, p := range protocols {
plist = append(plist, p.String())
}
parts = append(parts, fmt.Sprintf("%s: %s", bold("Protocols"), strings.Join(plist, ", ")))
}
if users := gjson.GetBytes(jsonStr, "usage.users.total").Int(); users > 0 {
activeMonth := gjson.GetBytes(jsonStr, "usage.users.activeMonth").Int()
activeHalfyear := gjson.GetBytes(jsonStr, "usage.users.activeHalfyear").Int()
parts = append(parts, fmt.Sprintf("%s: %d (active month: %d, halfyear: %d)", bold("Users"), users, activeMonth, activeHalfyear))
}
if posts := gjson.GetBytes(jsonStr, "usage.localPosts").Int(); posts > 0 {
parts = append(parts, fmt.Sprintf("%s: %d", bold("Local Posts"), posts))
}
if comments := gjson.GetBytes(jsonStr, "usage.localComments").Int(); comments > 0 {
parts = append(parts, fmt.Sprintf("%s: %d", bold("Local Comments"), comments))
}
if nodeName := gjson.GetBytes(jsonStr, "metadata.nodeName").String(); nodeName != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Node Name"), cyan(nodeName)))
}
if nodeDesc := gjson.GetBytes(jsonStr, "metadata.nodeDescription").String(); nodeDesc != "" {
parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Node Description"), nodeDesc))
}
return strings.Join(parts, "\n")
}
// formatActor formats actor-type objects (Person, Service, etc.) // formatActor formats actor-type objects (Person, Service, etc.)
func formatActor(jsonStr string, parts []string, bold, cyan, green, red, yellow func(a ...interface{}) string) []string { func formatActor(jsonStr []byte, parts []string, bold, cyan, green, red, yellow func(a ...interface{}) string) []string {
if name := gjson.Get(jsonStr, "name").String(); name != "" { if name := gjson.GetBytes(jsonStr, "name").String(); name != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Name"), cyan(name))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Name"), cyan(name)))
} }
if preferredUsername := gjson.Get(jsonStr, "preferredUsername").String(); preferredUsername != "" { if preferredUsername := gjson.GetBytes(jsonStr, "preferredUsername").String(); preferredUsername != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Username"), red(preferredUsername))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Username"), red(preferredUsername)))
} }
if url := gjson.Get(jsonStr, "url").String(); url != "" { if url := gjson.GetBytes(jsonStr, "url").String(); url != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), green(url))) parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), green(url)))
} }
iconUrl := gjson.Get(jsonStr, "icon.url").String() iconUrl := gjson.GetBytes(jsonStr, "icon.url").String()
if iconUrl != "" { if iconUrl != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Avatar"), green(iconUrl))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Avatar"), green(iconUrl)))
} }
if summary := gjson.Get(jsonStr, "summary").String(); summary != "" { if summary := gjson.GetBytes(jsonStr, "summary").String(); summary != "" {
md := htmlToMarkdown(summary) md := htmlToMarkdown(summary)
parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Summary"), renderMarkdown(md))) parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Summary"), renderMarkdown(md)))
} }
if published := gjson.Get(jsonStr, "published").String(); published != "" { if published := gjson.GetBytes(jsonStr, "published").String(); published != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published))))
} }
if followers := gjson.Get(jsonStr, "followers").String(); followers != "" { if followers := gjson.GetBytes(jsonStr, "followers").String(); followers != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Followers"), green(followers))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Followers"), green(followers)))
} }
if following := gjson.Get(jsonStr, "following").String(); following != "" { if following := gjson.GetBytes(jsonStr, "following").String(); following != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Following"), green(following))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Following"), green(following)))
} }
@ -114,13 +184,13 @@ func formatActor(jsonStr string, parts []string, bold, cyan, green, red, yellow
} }
// formatContent formats content-type objects (Note, Article, Page, etc.) // formatContent formats content-type objects (Note, Article, Page, etc.)
func formatContent(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { func formatContent(jsonStr []byte, parts []string, bold, green, yellow func(a ...interface{}) string) []string {
// Show the name/title if present (especially for Page/thread) // Show the name/title if present (especially for Page/thread)
if name := gjson.Get(jsonStr, "name").String(); name != "" { if name := gjson.GetBytes(jsonStr, "name").String(); name != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name))
} }
if content := gjson.Get(jsonStr, "content").String(); content != "" { if content := gjson.GetBytes(jsonStr, "content").String(); content != "" {
md := htmlToMarkdown(content) md := htmlToMarkdown(content)
// Truncate the content if its too big. // Truncate the content if its too big.
if len(md) > 1200 { if len(md) > 1200 {
@ -130,7 +200,7 @@ func formatContent(jsonStr string, parts []string, bold, green, yellow func(a ..
} }
// Check for attachments (images, videos, etc.) // Check for attachments (images, videos, etc.)
attachments := gjson.Get(jsonStr, "attachment").Array() attachments := gjson.GetBytes(jsonStr, "attachment").Array()
if len(attachments) > 0 { if len(attachments) > 0 {
parts = append(parts, fmt.Sprintf("%s:", bold("Attachments"))) parts = append(parts, fmt.Sprintf("%s:", bold("Attachments")))
for i, attachment := range attachments { for i, attachment := range attachments {
@ -155,7 +225,7 @@ func formatContent(jsonStr string, parts []string, bold, green, yellow func(a ..
parts = append(parts, attachmentInfo) parts = append(parts, attachmentInfo)
// For type Page and attachment type Link, show href if present // For type Page and attachment type Link, show href if present
objectType := gjson.Get(jsonStr, "type").String() objectType := gjson.GetBytes(jsonStr, "type").String()
if objectType == "Page" && attachmentType == "Link" && href != "" { if objectType == "Page" && attachmentType == "Link" && href != "" {
parts = append(parts, fmt.Sprintf(" URL: %s", green(href))) parts = append(parts, fmt.Sprintf(" URL: %s", green(href)))
} else if url != "" { } else if url != "" {
@ -164,39 +234,39 @@ func formatContent(jsonStr string, parts []string, bold, green, yellow func(a ..
} }
} }
if published := gjson.Get(jsonStr, "published").String(); published != "" { if published := gjson.GetBytes(jsonStr, "published").String(); published != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published))))
} }
if updated := gjson.Get(jsonStr, "updated").String(); updated != "" { if updated := gjson.GetBytes(jsonStr, "updated").String(); updated != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Updated"), yellow(formatDate(updated)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Updated"), yellow(formatDate(updated))))
} }
if attributedTo := gjson.Get(jsonStr, "attributedTo").String(); attributedTo != "" { if attributedTo := gjson.GetBytes(jsonStr, "attributedTo").String(); attributedTo != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), green(attributedTo))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), green(attributedTo)))
} }
if to := gjson.Get(jsonStr, "to").Array(); len(to) > 0 { if to := gjson.GetBytes(jsonStr, "to").Array(); len(to) > 0 {
parts = append(parts, fmt.Sprintf("%s: %s", bold("To"), green(formatArray(to)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("To"), green(formatArray(to))))
} }
if cc := gjson.Get(jsonStr, "cc").Array(); len(cc) > 0 { if cc := gjson.GetBytes(jsonStr, "cc").Array(); len(cc) > 0 {
parts = append(parts, fmt.Sprintf("%s: %s", bold("CC"), green(formatArray(cc)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("CC"), green(formatArray(cc))))
} }
if inReplyTo := gjson.Get(jsonStr, "inReplyTo").String(); inReplyTo != "" { if inReplyTo := gjson.GetBytes(jsonStr, "inReplyTo").String(); inReplyTo != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("In Reply To"), green(inReplyTo))) parts = append(parts, fmt.Sprintf("%s: %s", bold("In Reply To"), green(inReplyTo)))
} }
// Include endTime for Question type // Include endTime for Question type
if endTime := gjson.Get(jsonStr, "endTime").String(); endTime != "" { if endTime := gjson.GetBytes(jsonStr, "endTime").String(); endTime != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("End Time"), yellow(formatDate(endTime)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("End Time"), yellow(formatDate(endTime))))
} }
// Include options (oneOf/anyOf) for Question type // Include options (oneOf/anyOf) for Question type
options := gjson.Get(jsonStr, "oneOf").Array() options := gjson.GetBytes(jsonStr, "oneOf").Array()
if len(options) == 0 { if len(options) == 0 {
options = gjson.Get(jsonStr, "anyOf").Array() options = gjson.GetBytes(jsonStr, "anyOf").Array()
} }
if len(options) > 0 { if len(options) > 0 {
parts = append(parts, fmt.Sprintf("%s:", bold("Poll Options"))) parts = append(parts, fmt.Sprintf("%s:", bold("Poll Options")))
@ -210,24 +280,24 @@ func formatContent(jsonStr string, parts []string, bold, green, yellow func(a ..
} }
// formatActivity formats activity-type objects (Create, Like, etc.) // formatActivity formats activity-type objects (Create, Like, etc.)
func formatActivity(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { func formatActivity(jsonStr []byte, parts []string, bold, green, yellow func(a ...interface{}) string) []string {
if actor := gjson.Get(jsonStr, "actor").String(); actor != "" { if actor := gjson.GetBytes(jsonStr, "actor").String(); actor != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Actor"), actor)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Actor"), actor))
} }
if object := gjson.Get(jsonStr, "object").String(); object != "" { if object := gjson.GetBytes(jsonStr, "object").String(); object != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Object"), object)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Object"), object))
} else if gjson.Get(jsonStr, "object").IsObject() { } else if gjson.GetBytes(jsonStr, "object").IsObject() {
objectType := gjson.Get(jsonStr, "object.type").String() objectType := gjson.GetBytes(jsonStr, "object.type").String()
parts = append(parts, fmt.Sprintf("%s: %s", bold("Object Type"), yellow(objectType))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Object Type"), yellow(objectType)))
if content := gjson.Get(jsonStr, "object.content").String(); content != "" { if content := gjson.GetBytes(jsonStr, "object.content").String(); content != "" {
md := htmlToMarkdown(content) md := htmlToMarkdown(content)
parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Content"), renderMarkdown(md))) parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Content"), renderMarkdown(md)))
} }
// Check for attachments in the object // Check for attachments in the object
attachments := gjson.Get(jsonStr, "object.attachment").Array() attachments := gjson.GetBytes(jsonStr, "object.attachment").Array()
if len(attachments) > 0 { if len(attachments) > 0 {
parts = append(parts, fmt.Sprintf("%s:", bold("Attachments"))) parts = append(parts, fmt.Sprintf("%s:", bold("Attachments")))
for i, attachment := range attachments { for i, attachment := range attachments {
@ -257,11 +327,11 @@ func formatActivity(jsonStr string, parts []string, bold, green, yellow func(a .
} }
} }
if published := gjson.Get(jsonStr, "published").String(); published != "" { if published := gjson.GetBytes(jsonStr, "published").String(); published != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published))))
} }
if target := gjson.Get(jsonStr, "target").String(); target != "" { if target := gjson.GetBytes(jsonStr, "target").String(); target != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Target"), target)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Target"), target))
} }
@ -269,15 +339,15 @@ func formatActivity(jsonStr string, parts []string, bold, green, yellow func(a .
} }
// formatCollection formats collection-type objects // formatCollection formats collection-type objects
func formatCollection(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { func formatCollection(jsonStr []byte, parts []string, bold, green, yellow func(a ...interface{}) string) []string {
if totalItems := gjson.Get(jsonStr, "totalItems").Int(); totalItems > 0 { if totalItems := gjson.GetBytes(jsonStr, "totalItems").Int(); totalItems > 0 {
parts = append(parts, fmt.Sprintf("%s: %d", bold("Total Items"), totalItems)) parts = append(parts, fmt.Sprintf("%s: %d", bold("Total Items"), totalItems))
} }
// Show first few items if available // Show first few items if available
items := gjson.Get(jsonStr, "items").Array() items := gjson.GetBytes(jsonStr, "items").Array()
if len(items) == 0 { if len(items) == 0 {
items = gjson.Get(jsonStr, "orderedItems").Array() items = gjson.GetBytes(jsonStr, "orderedItems").Array()
} }
if len(items) > 0 { if len(items) > 0 {
@ -300,7 +370,7 @@ func formatCollection(jsonStr string, parts []string, bold, green, yellow func(a
} }
} }
if published := gjson.Get(jsonStr, "published").String(); published != "" { if published := gjson.GetBytes(jsonStr, "published").String(); published != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published))))
} }
@ -308,24 +378,24 @@ func formatCollection(jsonStr string, parts []string, bold, green, yellow func(a
} }
// formatMedia formats media-type objects (Image, Video, etc.) // formatMedia formats media-type objects (Image, Video, etc.)
func formatMedia(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { func formatMedia(jsonStr []byte, parts []string, bold, green, yellow func(a ...interface{}) string) []string {
if name := gjson.Get(jsonStr, "name").String(); name != "" { if name := gjson.GetBytes(jsonStr, "name").String(); name != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name))
} }
if url := gjson.Get(jsonStr, "url").String(); url != "" { if url := gjson.GetBytes(jsonStr, "url").String(); url != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), green(url))) parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), green(url)))
} }
if duration := gjson.Get(jsonStr, "duration").String(); duration != "" { if duration := gjson.GetBytes(jsonStr, "duration").String(); duration != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Duration"), duration)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Duration"), duration))
} }
if published := gjson.Get(jsonStr, "published").String(); published != "" { if published := gjson.GetBytes(jsonStr, "published").String(); published != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), yellow(formatDate(published))))
} }
if attributedTo := gjson.Get(jsonStr, "attributedTo").String(); attributedTo != "" { if attributedTo := gjson.GetBytes(jsonStr, "attributedTo").String(); attributedTo != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), attributedTo)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), attributedTo))
} }
@ -333,25 +403,25 @@ func formatMedia(jsonStr string, parts []string, bold, green, yellow func(a ...i
} }
// formatEvent formats event-type objects // formatEvent formats event-type objects
func formatEvent(jsonStr string, parts []string, bold, yellow func(a ...interface{}) string) []string { func formatEvent(jsonStr []byte, parts []string, bold, yellow func(a ...interface{}) string) []string {
if name := gjson.Get(jsonStr, "name").String(); name != "" { if name := gjson.GetBytes(jsonStr, "name").String(); name != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Title"), name))
} }
if content := gjson.Get(jsonStr, "content").String(); content != "" { if content := gjson.GetBytes(jsonStr, "content").String(); content != "" {
md := htmlToMarkdown(content) md := htmlToMarkdown(content)
parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Description"), renderMarkdown(md))) parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Description"), renderMarkdown(md)))
} }
if startTime := gjson.Get(jsonStr, "startTime").String(); startTime != "" { if startTime := gjson.GetBytes(jsonStr, "startTime").String(); startTime != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Start Time"), yellow(formatDate(startTime)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Start Time"), yellow(formatDate(startTime))))
} }
if endTime := gjson.Get(jsonStr, "endTime").String(); endTime != "" { if endTime := gjson.GetBytes(jsonStr, "endTime").String(); endTime != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("End Time"), yellow(formatDate(endTime)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("End Time"), yellow(formatDate(endTime))))
} }
if location := gjson.Get(jsonStr, "location").String(); location != "" { if location := gjson.GetBytes(jsonStr, "location").String(); location != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Location"), location)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Location"), location))
} }
@ -359,12 +429,12 @@ func formatEvent(jsonStr string, parts []string, bold, yellow func(a ...interfac
} }
// formatTombstone formats tombstone-type objects // formatTombstone formats tombstone-type objects
func formatTombstone(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { func formatTombstone(jsonStr []byte, parts []string, bold, green, yellow func(a ...interface{}) string) []string {
if formerType := gjson.Get(jsonStr, "formerType").String(); formerType != "" { if formerType := gjson.GetBytes(jsonStr, "formerType").String(); formerType != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Former Type"), formerType)) parts = append(parts, fmt.Sprintf("%s: %s", bold("Former Type"), formerType))
} }
if deleted := gjson.Get(jsonStr, "deleted").String(); deleted != "" { if deleted := gjson.GetBytes(jsonStr, "deleted").String(); deleted != "" {
parts = append(parts, fmt.Sprintf("%s: %s", bold("Deleted"), yellow(formatDate(deleted)))) parts = append(parts, fmt.Sprintf("%s: %s", bold("Deleted"), yellow(formatDate(deleted))))
} }

View file

@ -3,7 +3,6 @@ package resolver
import ( import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -18,112 +17,76 @@ import (
// Define common constants // Define common constants
const ( const (
// UserAgent is the user agent string used for all HTTP requests // UserAgent is the user agent string used for all HTTP requests
UserAgent = "FediResolve/1.0 (https://melroy.org)" UserAgent = "FediResolve/1.0 (https://github.com/melroy89/FediResolve)"
AcceptHeader = "application/activity+json, application/ld+json"
) )
// fetchActivityPubObjectWithSignature is a helper function that always signs HTTP requests // fetchActivityPubObjectWithSignature is a helper function that always signs HTTP requests
// This is the preferred way to fetch ActivityPub content as many instances require signatures // This is the preferred way to fetch ActivityPub content as many instances require signatures
func (r *Resolver) fetchActivityPubObjectWithSignature(objectURL string) (string, error) { func (r *Resolver) fetchActivityPubObjectWithSignature(objectURL string) ([]byte, error) {
fmt.Printf("Fetching ActivityPub object with HTTP signatures from: %s\n", objectURL) fmt.Printf("Fetching ActivityPub object with HTTP signatures from: %s\n", objectURL)
// First, we need to extract the actor URL from the object URL // Fetch the object itself
actorURL, err := r.extractActorURLFromObjectURL(objectURL) data, err := r.fetchActivityPubObjectDirect(objectURL)
if err != nil { if err != nil {
// If we can't extract the actor URL, fall back to a direct request return nil, err
fmt.Printf("Could not extract actor URL: %v, falling back to direct request\n", err)
return r.fetchActivityPubObjectDirect(objectURL)
}
// Then, we need to fetch the actor data to get the public key
actorData, err := r.fetchActorData(actorURL)
if err != nil {
// If we can't fetch the actor data, fall back to a direct request
fmt.Printf("Could not fetch actor data: %v, falling back to direct request\n", err)
return r.fetchActivityPubObjectDirect(objectURL)
} }
// Extract the public key ID // Extract the public key ID
keyID, _, err := r.extractPublicKey(actorData) keyID, err := r.extractPublicKey(data)
if err != nil { if err != nil {
// If we can't extract the public key, fall back to a direct request return nil, fmt.Errorf("could not extract public key: %v", err)
fmt.Printf("Could not extract public key: %v, falling back to direct request\n", err)
return r.fetchActivityPubObjectDirect(objectURL)
} }
// Create a new private key for signing (in a real app, we would use a persistent key) // Create a new private key for signing (in a real app, we would use a persistent key)
privateKey, err := generateRSAKey() privateKey, err := generateRSAKey()
if err != nil { if err != nil {
// If we can't generate a key, fall back to a direct request return nil, fmt.Errorf("could not generate RSA key: %v", err)
fmt.Printf("Could not generate RSA key: %v, falling back to direct request\n", err)
return r.fetchActivityPubObjectDirect(objectURL)
} }
// Now, sign and send the request // Now, sign and send the request
req, err := http.NewRequest("GET", objectURL, nil) req, err := http.NewRequest("GET", objectURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("error creating signed request: %v", err) return nil, fmt.Errorf("error creating signed request: %v", err)
} }
// Set headers // Set headers
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json") req.Header.Set("Accept", AcceptHeader)
req.Header.Set("User-Agent", UserAgent) req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
// Sign the request // Sign the request
if err := signRequest(req, keyID, privateKey); err != nil { if err := signRequest(req, keyID, privateKey); err != nil {
// If we can't sign the request, fall back to a direct request return nil, fmt.Errorf("could not sign request: %v", err)
fmt.Printf("Could not sign request: %v, falling back to direct request\n", err)
return r.fetchActivityPubObjectDirect(objectURL)
} }
// Send the request // Send the request
fmt.Printf("Sending signed request with headers: %v\n", req.Header) fmt.Printf("Sending signed request with headers: %v\n", req.Header)
resp, err := r.client.Do(req) resp, err := r.client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("error sending signed request: %v", err) return nil, fmt.Errorf("error sending signed request: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
fmt.Printf("Received response with status: %s\n", resp.Status)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// If the signed request fails, try a direct request as a fallback
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
fmt.Println("Signed request failed with auth error, trying direct request as fallback")
return r.fetchActivityPubObjectDirect(objectURL)
}
// Read body for error info
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body)) return nil, fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body))
} }
// Read and parse the response bodyBytes, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("error reading response: %v", err) return nil, fmt.Errorf("error reading response: %v", err)
}
if len(bodyBytes) == 0 {
return nil, fmt.Errorf("received empty response body")
} }
// Debug output return bodyBytes, nil
fmt.Printf("Response content type: %s\n", resp.Header.Get("Content-Type"))
// Check if the response is empty
if len(body) == 0 {
return "", fmt.Errorf("received empty response body")
}
// Try to decode the JSON response
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("error decoding response: %v", err)
}
// Format the result
return formatter.Format(data)
} }
// fetchActivityPubObjectDirect is a helper function to fetch content without signatures // fetchActivityPubObjectDirect is a helper function to fetch content without signatures
// This is used as a fallback when signing fails // This is used as a fallback when signing fails
func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error) { func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) ([]byte, error) {
fmt.Printf("Fetching ActivityPub object directly from: %s\n", objectURL) fmt.Printf("Fetching ActivityPub object directly from: %s\n", objectURL)
// Create a custom client that doesn't follow redirects automatically // Create a custom client that doesn't follow redirects automatically
@ -137,18 +100,18 @@ func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error
// Create the request // Create the request
req, err := http.NewRequest("GET", objectURL, nil) req, err := http.NewRequest("GET", objectURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("error creating request: %v", err) return nil, fmt.Errorf("error creating request: %v", err)
} }
// Set Accept headers to request ActivityPub data // Set Accept headers to request ActivityPub data
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json") req.Header.Set("Accept", AcceptHeader)
req.Header.Set("User-Agent", UserAgent) req.Header.Set("User-Agent", UserAgent)
// Perform the request // Perform the request
fmt.Printf("Sending direct request with headers: %v\n", req.Header) fmt.Printf("Sending direct request with headers: %v\n", req.Header)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("error fetching content: %v", err) return nil, fmt.Errorf("error fetching content: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -169,186 +132,28 @@ func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Read body for error info // Read body for error info
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("request failed with status: %s, body: %s", resp.Status, string(body)) return nil, fmt.Errorf("request failed with status: %s, body: %s", resp.Status, string(body))
} }
// Read and parse the response // Read and parse the response
body, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("error reading response: %v", err) return nil, fmt.Errorf("error reading response: %v", err)
} }
// Debug output // Debug output
fmt.Printf("Response content type: %s\n", resp.Header.Get("Content-Type")) fmt.Printf("Response content type: %s\n", resp.Header.Get("Content-Type"))
// Check if the response is empty // Check if the response is empty
if len(body) == 0 { if len(bodyBytes) == 0 {
return "", fmt.Errorf("received empty response body") return nil, fmt.Errorf("received empty response body")
} }
// Try to decode the JSON response return bodyBytes, nil
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("error decoding response: %v", err)
}
// Format the result
return formatter.Format(data)
}
// fetchWithSignature fetches ActivityPub content using HTTP Signatures
func (r *Resolver) fetchWithSignature(objectURL string) (string, error) {
fmt.Printf("Fetching with HTTP signatures from: %s\n", objectURL)
// First, we need to extract the actor URL from the object URL
actorURL, err := r.extractActorURLFromObjectURL(objectURL)
if err != nil {
return "", fmt.Errorf("error extracting actor URL: %v", err)
}
// Then, we need to fetch the actor data to get the public key
actorData, err := r.fetchActorData(actorURL)
if err != nil {
return "", fmt.Errorf("error fetching actor data: %v", err)
}
// Extract the public key ID
keyID, _, err := r.extractPublicKey(actorData)
if err != nil {
return "", fmt.Errorf("error extracting public key: %v", err)
}
// Create a new private key for signing (in a real app, we would use a persistent key)
privateKey, err := generateRSAKey()
if err != nil {
return "", fmt.Errorf("error generating RSA key: %v", err)
}
// Now, sign and send the request
req, err := http.NewRequest("GET", objectURL, nil)
if err != nil {
return "", fmt.Errorf("error creating signed request: %v", err)
}
// Set headers
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json")
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
// Sign the request
if err := signRequest(req, keyID, privateKey); err != nil {
return "", fmt.Errorf("error signing request: %v", err)
}
// Send the request
fmt.Printf("Sending signed request with headers: %v\n", req.Header)
resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending signed request: %v", err)
}
defer resp.Body.Close()
fmt.Printf("Received response with status: %s\n", resp.Status)
if resp.StatusCode != http.StatusOK {
// Read body for error info
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body))
}
// Read and parse the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %v", err)
}
// Debug output
fmt.Printf("Response content type: %s\n", resp.Header.Get("Content-Type"))
// Check if the response is empty
if len(body) == 0 {
return "", fmt.Errorf("received empty response body")
}
// Try to decode the JSON response
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("error decoding response: %v", err)
}
// Format the result
return formatter.Format(data)
}
// extractActorURLFromObjectURL extracts the actor URL from an object URL
func (r *Resolver) extractActorURLFromObjectURL(objectURL string) (string, error) {
// This is a simplified approach - in a real app, we would parse the object URL properly
// For now, we'll assume the actor URL is the base domain with the username
// Basic URL pattern: https://domain.tld/@username/postid
parts := strings.Split(objectURL, "/")
if len(parts) < 4 {
return "", fmt.Errorf("invalid object URL format: %s", objectURL)
}
// Extract domain and username
domain := parts[2]
username := parts[3]
// Handle different URL formats
if strings.HasPrefix(username, "@") {
// Format: https://domain.tld/@username/postid
username = strings.TrimPrefix(username, "@")
// Check for cross-instance handles like @user@domain.tld
if strings.Contains(username, "@") {
userParts := strings.Split(username, "@")
if len(userParts) == 2 {
username = userParts[0]
domain = userParts[1]
}
}
// Try common URL patterns
actorURLs := []string{
fmt.Sprintf("https://%s/users/%s", domain, username),
fmt.Sprintf("https://%s/@%s", domain, username),
fmt.Sprintf("https://%s/user/%s", domain, username),
fmt.Sprintf("https://%s/accounts/%s", domain, username),
fmt.Sprintf("https://%s/profile/%s", domain, username),
}
// Try each URL pattern
for _, actorURL := range actorURLs {
fmt.Printf("Trying potential actor URL: %s\n", actorURL)
// Check if this URL returns a valid actor
actorData, err := r.fetchActorData(actorURL)
if err == nil && actorData != nil {
return actorURL, nil
}
// Add a small delay between requests to avoid rate limiting
fmt.Println("Waiting 1 second before trying next actor URL...")
time.Sleep(1 * time.Second)
}
// If we couldn't find a valid actor URL, try WebFinger
fmt.Printf("Trying WebFinger resolution for: %s@%s\n", username, domain)
return r.resolveActorViaWebFinger(username, domain)
} else if username == "users" || username == "user" || username == "accounts" || username == "profile" {
// Format: https://domain.tld/users/username/postid
if len(parts) < 5 {
return "", fmt.Errorf("invalid user URL format: %s", objectURL)
}
actorURL := fmt.Sprintf("https://%s/%s/%s", domain, username, parts[4])
return actorURL, nil
}
// If we get here, we couldn't determine the actor URL
return "", fmt.Errorf("could not determine actor URL from: %s", objectURL)
} }
// fetchActorData fetches actor data from an actor URL // fetchActorData fetches actor data from an actor URL
func (r *Resolver) fetchActorData(actorURL string) (map[string]interface{}, error) { func (r *Resolver) fetchActorData(actorURL string) ([]byte, error) {
fmt.Printf("Fetching actor data from: %s\n", actorURL) fmt.Printf("Fetching actor data from: %s\n", actorURL)
// Create the request // Create the request
@ -358,7 +163,7 @@ func (r *Resolver) fetchActorData(actorURL string) (map[string]interface{}, erro
} }
// Set headers // Set headers
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json") req.Header.Set("Accept", AcceptHeader)
req.Header.Set("User-Agent", UserAgent) req.Header.Set("User-Agent", UserAgent)
// Send the request // Send the request
@ -373,43 +178,48 @@ func (r *Resolver) fetchActorData(actorURL string) (map[string]interface{}, erro
} }
// Read and parse the response // Read and parse the response
body, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading actor response: %v", err) return nil, fmt.Errorf("error reading actor response: %v", err)
} }
// Parse JSON return bodyBytes, nil
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("error parsing actor data: %v", err)
}
return data, nil
} }
// extractPublicKey extracts the public key ID from actor data // extractPublicKey extracts the public key ID from actor data
func (r *Resolver) extractPublicKey(actorData map[string]interface{}) (string, string, error) { // TODO: We are actually now extracting the ID not the public key pem....
// Convert to JSON string for easier parsing with gjson // Lets see if we can improve this, without breaking the signing.
actorJSON, err := json.Marshal(actorData) func (r *Resolver) extractPublicKey(data []byte) (string, error) {
// Try to find the attributedTo URL
actorURL := gjson.GetBytes(data, "attributedTo").String()
if actorURL == "" {
fmt.Printf("Could not find attributedTo in object\n")
// Try to find key in the object itself
keyID := gjson.GetBytes(data, "publicKey.id").String()
if keyID == "" {
return "", fmt.Errorf("could not find public key ID in object")
}
return keyID, nil
} else {
actorData, err := r.fetchActorData(actorURL)
if err != nil { if err != nil {
return "", "", fmt.Errorf("error marshaling actor data: %v", err) return "", fmt.Errorf("error fetching actor data: %v", err)
} }
// Extract key ID // Extract key ID
keyID := gjson.GetBytes(actorJSON, "publicKey.id").String() keyID := gjson.GetBytes(actorData, "publicKey.id").String()
if keyID == "" { if keyID == "" {
// Try alternate formats // Try alternate formats
keyID = gjson.GetBytes(actorJSON, "publicKey.0.id").String() keyID = gjson.GetBytes(actorData, "publicKey.0.id").String()
} }
if keyID == "" { if keyID == "" {
return "", "", fmt.Errorf("could not find public key ID in actor data") fmt.Printf("could not find public key ID in actor data")
return "dummy", nil
}
return keyID, nil
} }
// For future implementation, we might need to parse and use the public key
// But for now, we just return a dummy value since we're focused on signing
dummyPEM := "dummy-key"
return keyID, dummyPEM, nil
} }
// generateRSAKey generates a new RSA key pair for signing requests // generateRSAKey generates a new RSA key pair for signing requests
@ -448,61 +258,68 @@ func signRequest(req *http.Request, keyID string, privateKey *rsa.PrivateKey) er
return signer.SignRequest(privateKey, keyID, req, nil) return signer.SignRequest(privateKey, keyID, req, nil)
} }
// resolveActorViaWebFinger resolves an actor URL via WebFinger protocol // fetchNodeInfo fetches nodeinfo from the given domain, returning the raw JSON
func (r *Resolver) resolveActorViaWebFinger(username, domain string) (string, error) { func (r *Resolver) fetchNodeInfo(domain string) ([]byte, error) {
// WebFinger URL format: https://domain.tld/.well-known/webfinger?resource=acct:username@domain.tld nodeinfoURL := "https://" + domain + "/.well-known/nodeinfo"
webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", fmt.Printf("Fetching nodeinfo discovery from: %s\n", nodeinfoURL)
domain, username, domain)
fmt.Printf("Fetching WebFinger data from: %s\n", webfingerURL) resp, err := r.client.Get(nodeinfoURL)
// Create the request
req, err := http.NewRequest("GET", webfingerURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("error creating WebFinger request: %v", err) return nil, fmt.Errorf("error fetching nodeinfo discovery: %v", err)
}
// Set headers
req.Header.Set("Accept", "application/jrd+json, application/json")
req.Header.Set("User-Agent", UserAgent)
// Send the request
resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("error fetching WebFinger data: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("WebFinger request failed with status: %s", resp.Status) return nil, fmt.Errorf("nodeinfo discovery failed with status: %s", resp.Status)
} }
// Read and parse the response
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("error reading WebFinger response: %v", err) return nil, fmt.Errorf("error reading nodeinfo discovery: %v", err)
} }
// Find the ActivityPub actor URL in the WebFinger response var nodeinfoHref string
actorURL := "" result := gjson.GetBytes(body, `links.#(rel%"*/schema/2.1").href`)
webfingerData := gjson.ParseBytes(body) if !result.Exists() {
links := webfingerData.Get("links").Array() result = gjson.GetBytes(body, `links.#(rel%"*/schema/2.0").href`)
for _, link := range links {
rel := link.Get("rel").String()
typ := link.Get("type").String()
href := link.Get("href").String()
if rel == "self" && (typ == "application/activity+json" ||
typ == "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" ||
strings.Contains(typ, "activity+json")) {
actorURL = href
break
} }
if result.Exists() {
nodeinfoHref = result.String()
}
if nodeinfoHref == "" {
return nil, fmt.Errorf("no nodeinfo schema 2.1 or 2.0 found")
} }
if actorURL == "" { fmt.Printf("Fetching nodeinfo from: %s\n", nodeinfoHref)
return "", fmt.Errorf("could not find ActivityPub actor URL in WebFinger response") resp2, err := r.client.Get(nodeinfoHref)
if err != nil {
return nil, fmt.Errorf("error fetching nodeinfo: %v", err)
} }
defer resp2.Body.Close()
return actorURL, nil if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("nodeinfo fetch failed with status: %s", resp2.Status)
}
raw, err := io.ReadAll(resp2.Body)
if err != nil {
return nil, fmt.Errorf("error reading nodeinfo: %v", err)
}
return raw, nil
}
// Try to extract actor, else try nodeinfo fallback for top-level domains
func (r *Resolver) ResolveObjectOrNodeInfo(objectURL string) ([]byte, error) {
// If actor resolution fails, try nodeinfo
parts := strings.Split(objectURL, "/")
if len(parts) < 3 {
return nil, fmt.Errorf("invalid object URL: %s", objectURL)
}
domain := parts[2]
body, err := r.fetchNodeInfo(domain)
if err != nil {
return nil, fmt.Errorf("could not fetch nodeinfo: %v", err)
}
return body, nil
}
// Format result
func formatResult(raw []byte) (string, error) {
return formatter.Format(raw)
} }

View file

@ -32,25 +32,40 @@ func ResolveInput(input string) (string, error) {
// Resolve takes a URL or handle and resolves it to a formatted result // Resolve takes a URL or handle and resolves it to a formatted result
func (r *Resolver) Resolve(input string) (string, error) { func (r *Resolver) Resolve(input string) (string, error) {
// Check if input looks like a URL // Always prepend https:// if missing and not a handle
inputNorm := input
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") && !strings.Contains(input, "@") {
inputNorm = "https://" + input
}
parsedURL, err := url.Parse(inputNorm)
if err == nil && parsedURL.Host != "" && (parsedURL.Path == "" || parsedURL.Path == "/") && parsedURL.RawQuery == "" && parsedURL.Fragment == "" {
// Looks like a root domain (with or without scheme), fetch nodeinfo
raw, err := r.ResolveObjectOrNodeInfo(parsedURL.String())
if err != nil {
return "", fmt.Errorf("error fetching nodeinfo: %v", err)
}
formatted, err := formatResult(raw)
if err != nil {
return "", fmt.Errorf("error formatting nodeinfo: %v", err)
}
return formatted, nil
}
// If not a root domain, proceed with other checks
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
fmt.Println("Detected URL, attempting direct resolution") fmt.Println("Detected URL, attempting direct resolution")
return r.resolveURL(input) return r.resolveURL(input)
} }
// Check if input looks like a Fediverse handle (@username@domain.tld)
if strings.Contains(input, "@") { if strings.Contains(input, "@") {
// Handle format should be either @username@domain.tld or username@domain.tld
// and should not contain any slashes or other URL-like characters
if !strings.Contains(input, "/") && !strings.Contains(input, ":") { if !strings.Contains(input, "/") && !strings.Contains(input, ":") {
if strings.HasPrefix(input, "@") { if strings.HasPrefix(input, "@") {
// Format: @username@domain.tld
if strings.Count(input, "@") == 2 { if strings.Count(input, "@") == 2 {
fmt.Println("Detected Fediverse handle, using WebFinger resolution") fmt.Println("Detected Fediverse handle, using WebFinger resolution")
return r.resolveHandle(input) return r.resolveHandle(input)
} }
} else { } else {
// Format: username@domain.tld
if strings.Count(input, "@") == 1 { if strings.Count(input, "@") == 1 {
fmt.Println("Detected Fediverse handle, using WebFinger resolution") fmt.Println("Detected Fediverse handle, using WebFinger resolution")
return r.resolveHandle(input) return r.resolveHandle(input)
@ -59,7 +74,6 @@ func (r *Resolver) Resolve(input string) (string, error) {
} }
} }
// If we're not sure, try to treat it as a URL
fmt.Println("Input format unclear, attempting URL resolution") fmt.Println("Input format unclear, attempting URL resolution")
return r.resolveURL(input) return r.resolveURL(input)
} }
@ -175,88 +189,67 @@ func (r *Resolver) resolveHandle(handle string) (string, error) {
// resolveURL resolves a Fediverse URL to its ActivityPub representation // resolveURL resolves a Fediverse URL to its ActivityPub representation
func (r *Resolver) resolveURL(inputURL string) (string, error) { func (r *Resolver) resolveURL(inputURL string) (string, error) {
// Parse the URL // Always fetch the provided URL as-is, using ActivityPub Accept header and HTTP signatures
parsedURL, err := url.Parse(inputURL) // Then, if the response contains an `id` field that differs from the requested URL, fetch that recursively
return r.resolveCanonicalActivityPub(inputURL, 0)
}
// resolveCanonicalActivityPub fetches the ActivityPub object at the given URL, and if the response contains an `id` field
// that differs from the requested URL, recursively fetches that canonical URL. Max depth is used to prevent infinite loops.
func (r *Resolver) resolveCanonicalActivityPub(objectURL string, depth int) (string, error) {
if depth > 3 {
return "", fmt.Errorf("too many canonical redirects (possible loop)")
}
fmt.Printf("Fetching ActivityPub object for canonical resolution: %s\n", objectURL)
raw, err := r.fetchActivityPubObjectRaw(objectURL)
if err != nil { if err != nil {
return "", fmt.Errorf("error parsing URL: %v", err) return "", err
} }
var data map[string]interface{}
// For cross-instance URLs, we'll skip the redirect check if err := json.Unmarshal(raw, &data); err != nil {
// because some instances (like Mastodon) have complex redirect systems return "", fmt.Errorf("error parsing ActivityPub JSON: %v", err)
// that might not work reliably
// Check if this is a cross-instance URL (e.g., https://mastodon.social/@user@another.instance/123)
username := parsedURL.Path
if len(username) > 0 && username[0] == '/' {
username = username[1:]
} }
idVal, ok := data["id"].(string)
// Check if the username contains an @ symbol (indicating a cross-instance URL) if ok && idVal != "" && idVal != objectURL {
if strings.HasPrefix(username, "@") && strings.Contains(username[1:], "@") { fmt.Printf("Found canonical id: %s (different from requested URL), following...\n", idVal)
// This is a cross-instance URL return r.resolveCanonicalActivityPub(idVal, depth+1)
fmt.Println("Detected cross-instance URL. Original instance:", strings.Split(username[1:], "/")[0])
// Extract the original instance, username, and post ID
parts := strings.Split(username, "/")
if len(parts) >= 2 {
userParts := strings.Split(parts[0][1:], "@") // Remove the leading @ and split by @
if len(userParts) == 2 {
username := userParts[0]
originalDomain := userParts[1]
postID := parts[1]
fmt.Printf("Detected cross-instance URL. Original instance: %s, username: %s, post ID: %s\n",
originalDomain, username, postID)
// Try different URL formats that are commonly used by different Fediverse platforms
urlFormats := []string{
// Mastodon format
"https://%s/@%s/%s",
"https://%s/users/%s/statuses/%s",
// Pleroma format
"https://%s/notice/%s",
// Misskey format
"https://%s/notes/%s",
// Friendica format
"https://%s/display/%s",
// Hubzilla format
"https://%s/item/%s",
} }
// If no id or already canonical, format and return using helpers.go
// Try each URL format formatted, err := formatResult(raw)
for _, format := range urlFormats { if err != nil {
var targetURL string return "", fmt.Errorf("error formatting ActivityPub object: %v", err)
if strings.Count(format, "%s") == 3 {
// Format with username
targetURL = fmt.Sprintf(format, originalDomain, username, postID)
} else {
// Format without username (just domain and ID)
targetURL = fmt.Sprintf(format, originalDomain, postID)
} }
return formatted, nil
}
fmt.Printf("Trying URL format: %s\n", targetURL) // fetchActivityPubObjectRaw fetches an ActivityPub object and returns the raw JSON []byte (not formatted)
func (r *Resolver) fetchActivityPubObjectRaw(objectURL string) ([]byte, error) {
fmt.Printf("Fetching ActivityPub object with HTTP from: %s\n", objectURL)
// Try to fetch with our signature-first approach req, err := http.NewRequest("GET", objectURL, nil)
result, err := r.fetchActivityPubObject(targetURL) if err != nil {
if err == nil { return nil, fmt.Errorf("error creating get request: %v", err)
return result, nil
} }
req.Header.Set("Accept", "application/ld+json, application/activity+json")
fmt.Printf("Failed with error: %v\n", err) req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
// Add a delay between requests to avoid rate limiting resp, err := r.client.Do(req)
fmt.Println("Waiting 2 seconds before trying next URL format...") if err != nil {
time.Sleep(2 * time.Second) return nil, fmt.Errorf("error sending get request: %v", err)
} }
defer resp.Body.Close()
// If all formats fail, return the last error if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to fetch content from original instance %s: all URL formats tried", originalDomain) body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get request failed with status: %s, body: %s", resp.Status, string(body))
} }
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %v", err)
} }
if len(body) == 0 {
return nil, fmt.Errorf("received empty response body")
} }
return body, nil
// If not a cross-instance URL, fetch the ActivityPub object directly
return r.fetchActivityPubObject(inputURL)
} }
// fetchActivityPubObject fetches an ActivityPub object from a URL // fetchActivityPubObject fetches an ActivityPub object from a URL
@ -276,5 +269,13 @@ func (r *Resolver) fetchActivityPubObject(objectURL string) (string, error) {
} }
// Use our signature-first approach by default // Use our signature-first approach by default
return r.fetchActivityPubObjectWithSignature(objectURL) raw, err := r.fetchActivityPubObjectWithSignature(objectURL)
if err != nil {
return "", fmt.Errorf("error fetching ActivityPub object: %v", err)
}
formatted, err := formatResult(raw)
if err != nil {
return "", fmt.Errorf("error formatting ActivityPub object: %v", err)
}
return formatted, nil
} }