commit 29c640af2fa833497428143000f249d8f5ef52aa Author: Dennis C. Oosterhof Date: Thu Apr 24 13:30:09 2025 +0200 First setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..99eda88 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# FediResolve + +FediResolve is a command-line tool for resolving and displaying Fediverse content. It can parse and display ActivityPub content from various Fediverse platforms including Mastodon, Lemmy, PeerTube, and others. + +## Features + +- Resolve Fediverse URLs to their ActivityPub representation +- Resolve Fediverse handles (e.g., @username@domain.tld) +- Display both the full JSON data and a human-readable summary +- Support for various ActivityPub types (Person, Note, Article, Create, Announce, etc.) +- Automatic resolution of shared/forwarded content to the original source + +## Installation + +### Prerequisites + +- Go 1.16 or later + +### Building from source + +```bash +git clone https://github.com/dennis/fediresolve.git +cd fediresolve +go build +``` + +## Usage + +### Basic usage + +```bash +# Provide a URL or handle as an argument +./fediresolve https://mastodon.social/@user/12345 +./fediresolve @username@domain.tld + +# Or run without arguments and enter the URL/handle when prompted +./fediresolve +``` + +## Examples + +### Resolving a Mastodon post + +```bash +./fediresolve https://mastodon.social/@Gargron/12345 +``` + +### Resolving a user profile + +```bash +./fediresolve @Gargron@mastodon.social +``` + +## How it works + +FediResolve uses the following process to resolve Fediverse content: + +1. For handles (@username@domain.tld), it uses the WebFinger protocol to discover the ActivityPub actor URL +2. For URLs, it attempts to fetch the ActivityPub representation directly +3. It checks if the content is shared/forwarded and resolves to the original source if needed +4. It parses the ActivityPub JSON and displays both the raw data and a formatted summary + +## License + +MIT diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..b5fcb42 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/dennis/fediresolve/resolver" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "fediresolve [url]", + Short: "Resolve and display Fediverse content", + Long: `Fediresolve is a CLI tool that resolves Fediverse URLs and handles. +It can parse and display content from Mastodon, Lemmy, and other Fediverse platforms. +The tool supports both direct URLs to posts/comments/threads and Fediverse handles like @username@server.com.`, + Run: func(cmd *cobra.Command, args []string) { + var input string + + if len(args) > 0 { + input = args[0] + } else { + fmt.Print("Enter a Fediverse URL or handle: ") + reader := bufio.NewReader(os.Stdin) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + } + + if input == "" { + fmt.Println("No URL or handle provided. Exiting.") + return + } + + r := resolver.NewResolver() + result, err := r.Resolve(input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", input, err) + os.Exit(1) + } + + fmt.Println(result) + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() error { + return rootCmd.Execute() +} + +func init() { + // Here you can define flags and configuration settings +} diff --git a/fediresolve b/fediresolve new file mode 100755 index 0000000..13b4bc6 Binary files /dev/null and b/fediresolve differ diff --git a/formatter/formatter.go b/formatter/formatter.go new file mode 100644 index 0000000..a1516cf --- /dev/null +++ b/formatter/formatter.go @@ -0,0 +1,326 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/tidwall/gjson" +) + +// Format takes ActivityPub data and returns a formatted string representation +func Format(data map[string]interface{}) (string, error) { + // First, get the beautified JSON + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("error formatting JSON: %v", err) + } + + // Convert to string for gjson parsing + jsonStr := string(jsonData) + + // Create a summary based on the object type + summary := createSummary(jsonStr) + + // Combine the full JSON first, followed by the summary at the bottom + result := fmt.Sprintf("%s\n\n%s", string(jsonData), summary) + return result, nil +} + +// createSummary generates a human-readable summary of the ActivityPub object +func createSummary(jsonStr string) string { + objectType := gjson.Get(jsonStr, "type").String() + + // Build a header with the object type + bold := color.New(color.Bold).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + header := fmt.Sprintf("%s: %s\n", bold("Type"), cyan(objectType)) + + // Add common fields + var summaryParts []string + summaryParts = append(summaryParts, header) + + // Add ID if available + if id := gjson.Get(jsonStr, "id").String(); id != "" { + summaryParts = append(summaryParts, fmt.Sprintf("%s: %s", bold("ID"), id)) + } + + // Process based on type + switch objectType { + case "Person", "Application", "Group", "Organization", "Service": + summaryParts = formatActor(jsonStr, summaryParts, bold, green) + case "Note", "Article", "Page", "Question": + summaryParts = formatContent(jsonStr, summaryParts, bold, green) + case "Create", "Update", "Delete", "Follow", "Add", "Remove", "Like", "Block", "Announce": + summaryParts = formatActivity(jsonStr, summaryParts, bold, green, yellow) + case "Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage": + summaryParts = formatCollection(jsonStr, summaryParts, bold, green) + case "Image", "Audio", "Video", "Document": + summaryParts = formatMedia(jsonStr, summaryParts, bold, green) + case "Event": + summaryParts = formatEvent(jsonStr, summaryParts, bold, green) + case "Tombstone": + summaryParts = formatTombstone(jsonStr, summaryParts, bold, green) + } + + return strings.Join(summaryParts, "\n") +} + +// formatActor formats actor-type objects (Person, Service, etc.) +func formatActor(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if name := gjson.Get(jsonStr, "name").String(); name != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Name"), name)) + } + + if preferredUsername := gjson.Get(jsonStr, "preferredUsername").String(); preferredUsername != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Username"), preferredUsername)) + } + + if url := gjson.Get(jsonStr, "url").String(); url != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), url)) + } + + if summary := gjson.Get(jsonStr, "summary").String(); summary != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Summary"), summary)) + } + + if published := gjson.Get(jsonStr, "published").String(); published != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), formatDate(published))) + } + + if followers := gjson.Get(jsonStr, "followers").String(); followers != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Followers"), followers)) + } + + if following := gjson.Get(jsonStr, "following").String(); following != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Following"), following)) + } + + return parts +} + +// formatContent formats content-type objects (Note, Article, etc.) +func formatContent(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if content := gjson.Get(jsonStr, "content").String(); content != "" { + // Strip HTML tags for display + content = stripHTML(content) + if len(content) > 300 { + content = content[:297] + "..." + } + parts = append(parts, fmt.Sprintf("%s: %s", bold("Content"), content)) + } + + if published := gjson.Get(jsonStr, "published").String(); published != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), formatDate(published))) + } + + if updated := gjson.Get(jsonStr, "updated").String(); updated != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Updated"), formatDate(updated))) + } + + if attributedTo := gjson.Get(jsonStr, "attributedTo").String(); attributedTo != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), attributedTo)) + } + + if to := gjson.Get(jsonStr, "to").String(); to != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("To"), to)) + } + + if cc := gjson.Get(jsonStr, "cc").String(); cc != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("CC"), cc)) + } + + if inReplyTo := gjson.Get(jsonStr, "inReplyTo").String(); inReplyTo != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("In Reply To"), inReplyTo)) + } + + return parts +} + +// formatActivity formats activity-type objects (Create, Like, etc.) +func formatActivity(jsonStr string, parts []string, bold, green, yellow func(a ...interface{}) string) []string { + if actor := gjson.Get(jsonStr, "actor").String(); actor != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Actor"), actor)) + } + + if object := gjson.Get(jsonStr, "object").String(); object != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Object"), object)) + } else if gjson.Get(jsonStr, "object").IsObject() { + objectType := gjson.Get(jsonStr, "object.type").String() + parts = append(parts, fmt.Sprintf("%s: %s", bold("Object Type"), yellow(objectType))) + + if content := gjson.Get(jsonStr, "object.content").String(); content != "" { + content = stripHTML(content) + if len(content) > 300 { + content = content[:297] + "..." + } + parts = append(parts, fmt.Sprintf("%s: %s", bold("Content"), content)) + } + } + + if published := gjson.Get(jsonStr, "published").String(); published != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), formatDate(published))) + } + + if target := gjson.Get(jsonStr, "target").String(); target != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Target"), target)) + } + + return parts +} + +// formatCollection formats collection-type objects +func formatCollection(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if totalItems := gjson.Get(jsonStr, "totalItems").Int(); totalItems > 0 { + parts = append(parts, fmt.Sprintf("%s: %d", bold("Total Items"), totalItems)) + } + + // Show first few items if available + items := gjson.Get(jsonStr, "items").Array() + if len(items) == 0 { + items = gjson.Get(jsonStr, "orderedItems").Array() + } + + if len(items) > 0 { + itemCount := len(items) + if itemCount > 3 { + itemCount = 3 + } + + parts = append(parts, fmt.Sprintf("%s:", bold("First Items"))) + for i := 0; i < itemCount; i++ { + item := items[i].String() + if len(item) > 100 { + item = item[:97] + "..." + } + parts = append(parts, fmt.Sprintf(" - %s", item)) + } + + if len(items) > 3 { + parts = append(parts, fmt.Sprintf(" ... and %d more items", len(items)-3)) + } + } + + return parts +} + +// formatMedia formats media-type objects (Image, Video, etc.) +func formatMedia(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if name := gjson.Get(jsonStr, "name").String(); name != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Name"), name)) + } + + if url := gjson.Get(jsonStr, "url").String(); url != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("URL"), url)) + } + + if duration := gjson.Get(jsonStr, "duration").String(); duration != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Duration"), duration)) + } + + if published := gjson.Get(jsonStr, "published").String(); published != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Published"), formatDate(published))) + } + + if attributedTo := gjson.Get(jsonStr, "attributedTo").String(); attributedTo != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Author"), attributedTo)) + } + + return parts +} + +// formatEvent formats event-type objects +func formatEvent(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if name := gjson.Get(jsonStr, "name").String(); name != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Name"), name)) + } + + if content := gjson.Get(jsonStr, "content").String(); content != "" { + content = stripHTML(content) + if len(content) > 300 { + content = content[:297] + "..." + } + parts = append(parts, fmt.Sprintf("%s: %s", bold("Description"), content)) + } + + if startTime := gjson.Get(jsonStr, "startTime").String(); startTime != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Start Time"), formatDate(startTime))) + } + + if endTime := gjson.Get(jsonStr, "endTime").String(); endTime != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("End Time"), formatDate(endTime))) + } + + if location := gjson.Get(jsonStr, "location").String(); location != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Location"), location)) + } + + return parts +} + +// formatTombstone formats tombstone-type objects +func formatTombstone(jsonStr string, parts []string, bold, green func(a ...interface{}) string) []string { + if formerType := gjson.Get(jsonStr, "formerType").String(); formerType != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Former Type"), formerType)) + } + + if deleted := gjson.Get(jsonStr, "deleted").String(); deleted != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Deleted"), formatDate(deleted))) + } + + return parts +} + +// formatDate formats an ISO 8601 date string to a more readable format +func formatDate(isoDate string) string { + t, err := time.Parse(time.RFC3339, isoDate) + if err != nil { + return isoDate + } + return t.Format("Jan 02, 2006 15:04:05") +} + +// stripHTML removes HTML tags from a string +func stripHTML(html string) string { + // Simple HTML tag stripping - in a real implementation, you might want to use a proper HTML parser + result := html + + // Replace common HTML entities + replacements := map[string]string{ + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'", + " ": " ", + } + + for entity, replacement := range replacements { + result = strings.ReplaceAll(result, entity, replacement) + } + + // Remove HTML tags + for { + startIdx := strings.Index(result, "<") + if startIdx == -1 { + break + } + + endIdx := strings.Index(result[startIdx:], ">") + if endIdx == -1 { + break + } + + result = result[:startIdx] + result[startIdx+endIdx+1:] + } + + // Normalize whitespace + result = strings.Join(strings.Fields(result), " ") + + return result +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf2de65 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/dennis/fediresolve + +go 1.21 + +require ( + github.com/fatih/color v1.16.0 + github.com/spf13/cobra v1.8.0 + github.com/tidwall/gjson v1.17.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e05dacc --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..eab32de --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/dennis/fediresolve/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/resolver/resolver.go b/resolver/resolver.go new file mode 100644 index 0000000..5520f3d --- /dev/null +++ b/resolver/resolver.go @@ -0,0 +1,266 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dennis/fediresolve/formatter" + "github.com/tidwall/gjson" +) + +// Resolver handles the resolution of Fediverse URLs and handles +type Resolver struct { + client *http.Client +} + +// NewResolver creates a new Resolver instance +func NewResolver() *Resolver { + return &Resolver{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ResolveInput is a convenience function that creates a new resolver and resolves the input +func ResolveInput(input string) (string, error) { + r := NewResolver() + return r.Resolve(input) +} + +// Resolve takes a URL or handle and resolves it to a formatted result +func (r *Resolver) Resolve(input string) (string, error) { + // Check if input looks like a Fediverse handle (@username@domain.tld) + if strings.Contains(input, "@") { + // Simple check for handle format + if strings.HasPrefix(input, "@") || strings.Count(input, "@") == 2 { + fmt.Println("Detected Fediverse handle, using WebFinger resolution") + return r.resolveHandle(input) + } + } + + // Otherwise treat as URL + fmt.Println("Detected URL, attempting direct resolution") + return r.resolveURL(input) +} + +// WebFingerResponse represents the structure of a WebFinger response +type WebFingerResponse struct { + Subject string `json:"subject"` + Links []struct { + Rel string `json:"rel"` + Type string `json:"type"` + Href string `json:"href"` + } `json:"links"` +} + +// resolveHandle resolves a Fediverse handle using WebFinger +func (r *Resolver) resolveHandle(handle string) (string, error) { + // Remove @ prefix if present + if handle[0] == '@' { + handle = handle[1:] + } + + // Split handle into username and domain + parts := strings.Split(handle, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid handle format: %s", handle) + } + + username, domain := parts[0], parts[1] + + // Construct WebFinger URL with proper URL encoding + resource := fmt.Sprintf("acct:%s@%s", username, domain) + webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=%s", + domain, url.QueryEscape(resource)) + + fmt.Printf("Fetching WebFinger data from: %s\n", webfingerURL) + + // Create request for WebFinger data + req, err := http.NewRequest("GET", webfingerURL, nil) + if err != nil { + return "", fmt.Errorf("error creating WebFinger request: %v", err) + } + + // Set appropriate headers for WebFinger + req.Header.Set("Accept", "application/jrd+json, application/json") + req.Header.Set("User-Agent", "FediResolve/1.0 (https://github.com/dennis/fediresolve)") + + // Fetch WebFinger data + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("error fetching WebFinger data: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("WebFinger request failed with status: %s", resp.Status) + } + + // Read and parse the WebFinger response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading WebFinger response: %v", err) + } + + fmt.Printf("WebFinger response content type: %s\n", resp.Header.Get("Content-Type")) + fmt.Printf("WebFinger response body: %s\n", string(body)) + + var webfinger WebFingerResponse + if err := json.Unmarshal(body, &webfinger); err != nil { + return "", fmt.Errorf("error decoding WebFinger response: %v", err) + } + + // Find the ActivityPub actor URL + var actorURL string + + // First try to find a link with rel="self" and type containing "activity+json" + for _, link := range webfinger.Links { + if link.Rel == "self" && strings.Contains(link.Type, "activity+json") { + actorURL = link.Href + fmt.Printf("Found ActivityPub actor URL with type %s: %s\n", link.Type, actorURL) + break + } + } + + // If not found, try with rel="self" and any type + if actorURL == "" { + for _, link := range webfinger.Links { + if link.Rel == "self" { + actorURL = link.Href + fmt.Printf("Found ActivityPub actor URL with rel=self: %s\n", actorURL) + break + } + } + } + + // If still not found, try with any link that might be useful + if actorURL == "" { + for _, link := range webfinger.Links { + if link.Rel == "http://webfinger.net/rel/profile-page" { + actorURL = link.Href + fmt.Printf("Using profile page as fallback: %s\n", actorURL) + break + } + } + } + + if actorURL == "" { + return "", fmt.Errorf("could not find any suitable URL in WebFinger response") + } + + // Now fetch the actor data + return r.fetchActivityPubObject(actorURL) +} + +// resolveURL resolves a Fediverse URL to its ActivityPub representation +func (r *Resolver) resolveURL(inputURL string) (string, error) { + // Parse the URL + parsedURL, err := url.Parse(inputURL) + if err != nil { + return "", fmt.Errorf("invalid URL: %v", err) + } + + // Ensure the URL has a scheme + if parsedURL.Scheme == "" { + inputURL = "https://" + inputURL + parsedURL, err = url.Parse(inputURL) + if err != nil { + return "", fmt.Errorf("invalid URL: %v", err) + } + } + + // Try to fetch the ActivityPub object directly + return r.fetchActivityPubObject(inputURL) +} + +// fetchActivityPubObject fetches an ActivityPub object from a URL +func (r *Resolver) fetchActivityPubObject(objectURL string) (string, error) { + fmt.Printf("Fetching ActivityPub object from: %s\n", objectURL) + + // Make sure the URL is valid + parsedURL, err := url.Parse(objectURL) + if err != nil { + return "", fmt.Errorf("invalid URL: %v", err) + } + + // Ensure the URL has a scheme + if parsedURL.Scheme == "" { + objectURL = "https://" + objectURL + parsedURL, err = url.Parse(objectURL) + if err != nil { + return "", fmt.Errorf("invalid URL: %v", err) + } + } + + // Create the request + req, err := http.NewRequest("GET", objectURL, nil) + if err != nil { + return "", fmt.Errorf("error creating request: %v", err) + } + + // Set Accept headers to request ActivityPub data + // Use multiple Accept headers to increase compatibility with different servers + req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json") + req.Header.Set("User-Agent", "FediResolve/1.0 (https://github.com/dennis/fediresolve)") + + // Perform the request + fmt.Printf("Sending request with headers: %v\n", req.Header) + resp, err := r.client.Do(req) + if err != nil { + return "", fmt.Errorf("error fetching ActivityPub data: %v", err) + } + defer resp.Body.Close() + + fmt.Printf("Received response with status: %s\n", resp.Status) + if resp.StatusCode != http.StatusOK { + // Try to read the error response body for debugging + errorBody, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("ActivityPub request failed with status: %s\nResponse body: %s", + resp.Status, string(errorBody)) + } + + // Read and parse the ActivityPub response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading ActivityPub response: %v", err) + } + + // Debug output + fmt.Printf("ActivityPub 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 { + // If we can't parse as JSON, return the raw response for debugging + return "", fmt.Errorf("error decoding ActivityPub response: %v\nResponse body: %s", + err, string(body)) + } + + // Check if this is a shared/forwarded object and we need to fetch the original + jsonData, _ := json.Marshal(data) + jsonStr := string(jsonData) + + // Check for various ActivityPub types that might reference an original object + if gjson.Get(jsonStr, "type").String() == "Announce" { + // This is a boost/share, get the original object + originalURL := gjson.Get(jsonStr, "object").String() + if originalURL != "" && (strings.HasPrefix(originalURL, "http://") || strings.HasPrefix(originalURL, "https://")) { + fmt.Printf("Found Announce, following original at: %s\n", originalURL) + return r.fetchActivityPubObject(originalURL) + } + } + + // Format the result + return formatter.Format(data) +}