mirror of
https://gitlab.melroy.org/melroy/fediresolve.git
synced 2025-06-07 20:08:57 +00:00
First setup
This commit is contained in:
commit
29c640af2f
8 changed files with 773 additions and 0 deletions
65
README.md
Normal file
65
README.md
Normal 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
54
cmd/root.go
Normal 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
BIN
fediresolve
Executable file
Binary file not shown.
326
formatter/formatter.go
Normal file
326
formatter/formatter.go
Normal 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{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
}
|
||||
|
||||
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
19
go.mod
Normal 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
28
go.sum
Normal 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
15
main.go
Normal 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
266
resolver/resolver.go
Normal 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)
|
||||
}
|
Loading…
Add table
Reference in a new issue