First setup

This commit is contained in:
Dennis C. Oosterhof 2025-04-24 13:30:09 +02:00
commit 29c640af2f
8 changed files with 773 additions and 0 deletions

65
README.md Normal file
View file

@ -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

54
cmd/root.go Normal file
View file

@ -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
}

BIN
fediresolve Executable file

Binary file not shown.

326
formatter/formatter.go Normal file
View file

@ -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{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&nbsp;": " ",
}
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
}

19
go.mod Normal file
View file

@ -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
)

28
go.sum Normal file
View file

@ -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=

15
main.go Normal file
View file

@ -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)
}
}

266
resolver/resolver.go Normal file
View file

@ -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)
}