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