diff --git a/formatter/formatter.go b/formatter/formatter.go index bfad4b2..c5c94f9 100644 --- a/formatter/formatter.go +++ b/formatter/formatter.go @@ -31,8 +31,13 @@ func Format(data map[string]interface{}) (string, error) { return result, nil } -// createSummary generates a human-readable summary of the ActivityPub object +// createSummary generates a human-readable summary of the ActivityPub object or nodeinfo func createSummary(jsonStr string) string { + // Try to detect nodeinfo + if gjson.Get(jsonStr, "software.name").Exists() && gjson.Get(jsonStr, "version").Exists() { + return nodeInfoSummary(jsonStr) + } + objectType := gjson.Get(jsonStr, "type").String() // Build a header with the object type @@ -74,6 +79,49 @@ func createSummary(jsonStr string) string { return strings.Join(summaryParts, "\n") } +// nodeInfoSummary generates a summary for nodeinfo objects +func nodeInfoSummary(jsonStr string) string { + bold := color.New(color.Bold).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + parts := []string{} + parts = append(parts, fmt.Sprintf("%s: %s", bold("NodeInfo Version"), cyan(gjson.Get(jsonStr, "version").String()))) + parts = append(parts, fmt.Sprintf("%s: %s %s", bold("Software"), green(gjson.Get(jsonStr, "software.name").String()), yellow(gjson.Get(jsonStr, "software.version").String()))) + if repo := gjson.Get(jsonStr, "software.repository").String(); repo != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Repository"), repo)) + } + if protocols := gjson.Get(jsonStr, "protocols").Array(); len(protocols) > 0 { + var plist []string + for _, p := range protocols { + plist = append(plist, p.String()) + } + parts = append(parts, fmt.Sprintf("%s: %s", bold("Protocols"), strings.Join(plist, ", "))) + } + if users := gjson.Get(jsonStr, "usage.users.total").Int(); users > 0 { + activeMonth := gjson.Get(jsonStr, "usage.users.activeMonth").Int() + activeHalfyear := gjson.Get(jsonStr, "usage.users.activeHalfyear").Int() + parts = append(parts, fmt.Sprintf("%s: %d (active month: %d, halfyear: %d)", bold("Users"), users, activeMonth, activeHalfyear)) + } + if posts := gjson.Get(jsonStr, "usage.localPosts").Int(); posts > 0 { + parts = append(parts, fmt.Sprintf("%s: %d", bold("Local Posts"), posts)) + } + if comments := gjson.Get(jsonStr, "usage.localComments").Int(); comments > 0 { + parts = append(parts, fmt.Sprintf("%s: %d", bold("Local Comments"), comments)) + } + if open := gjson.Get(jsonStr, "openRegistrations").Exists(); open { + parts = append(parts, fmt.Sprintf("%s: %v", bold("Open Registrations"), gjson.Get(jsonStr, "openRegistrations").Bool())) + } + if nodeName := gjson.Get(jsonStr, "metadata.nodeName").String(); nodeName != "" { + parts = append(parts, fmt.Sprintf("%s: %s", bold("Node Name"), cyan(nodeName))) + } + if nodeDesc := gjson.Get(jsonStr, "metadata.nodeDescription").String(); nodeDesc != "" { + parts = append(parts, fmt.Sprintf("%s:\n%s", bold("Node Description"), nodeDesc)) + } + return strings.Join(parts, "\n") +} + // formatActor formats actor-type objects (Person, Service, etc.) func formatActor(jsonStr string, parts []string, bold, cyan, green, red, yellow func(a ...interface{}) string) []string { if name := gjson.Get(jsonStr, "name").String(); name != "" { diff --git a/resolver/helpers.go b/resolver/helpers.go index 613f90c..adb9b17 100644 --- a/resolver/helpers.go +++ b/resolver/helpers.go @@ -506,3 +506,95 @@ func (r *Resolver) resolveActorViaWebFinger(username, domain string) (string, er return actorURL, nil } + +// fetchNodeInfo fetches nodeinfo from the given domain, returning the raw JSON and parsed data +func (r *Resolver) fetchNodeInfo(domain string) ([]byte, map[string]interface{}, error) { + nodeinfoURL := "https://" + domain + "/.well-known/nodeinfo" + fmt.Printf("Fetching nodeinfo discovery from: %s\n", nodeinfoURL) + + resp, err := r.client.Get(nodeinfoURL) + if err != nil { + return nil, nil, fmt.Errorf("error fetching nodeinfo discovery: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("nodeinfo discovery failed with status: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("error reading nodeinfo discovery: %v", err) + } + var discovery struct { + Links []struct { + Rel string `json:"rel"` + Href string `json:"href"` + } `json:"links"` + } + if err := json.Unmarshal(body, &discovery); err != nil { + return nil, nil, fmt.Errorf("error parsing nodeinfo discovery: %v", err) + } + var nodeinfoHref string + for _, link := range discovery.Links { + if strings.HasSuffix(link.Rel, "/schema/2.1") { + nodeinfoHref = link.Href + break + } + } + if nodeinfoHref == "" { + for _, link := range discovery.Links { + if strings.HasSuffix(link.Rel, "/schema/2.0") { + nodeinfoHref = link.Href + break + } + } + } + if nodeinfoHref == "" { + return nil, nil, fmt.Errorf("no nodeinfo schema 2.1 or 2.0 found") + } + fmt.Printf("Fetching nodeinfo from: %s\n", nodeinfoHref) + resp2, err := r.client.Get(nodeinfoHref) + if err != nil { + return nil, nil, fmt.Errorf("error fetching nodeinfo: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("nodeinfo fetch failed with status: %s", resp2.Status) + } + raw, err := io.ReadAll(resp2.Body) + if err != nil { + return nil, nil, fmt.Errorf("error reading nodeinfo: %v", err) + } + var nodeinfo map[string]interface{} + if err := json.Unmarshal(raw, &nodeinfo); err != nil { + return nil, nil, fmt.Errorf("error parsing nodeinfo: %v", err) + } + return raw, nodeinfo, nil +} + +// Try to extract actor, else try nodeinfo fallback for top-level domains +func (r *Resolver) ResolveObjectOrNodeInfo(objectURL string) ([]byte, map[string]interface{}, string, error) { + actorURL, err := r.extractActorURLFromObjectURL(objectURL) + if err == nil && actorURL != "" { + actorData, err := r.fetchActorData(actorURL) + if err == nil && actorData != nil { + jsonData, _ := json.MarshalIndent(actorData, "", " ") + return jsonData, actorData, "actor", nil + } + } + // If actor resolution fails, try nodeinfo + parts := strings.Split(objectURL, "/") + if len(parts) < 3 { + return nil, nil, "", fmt.Errorf("invalid object URL: %s", objectURL) + } + domain := parts[2] + raw, nodeinfo, err := r.fetchNodeInfo(domain) + if err != nil { + return nil, nil, "", fmt.Errorf("could not fetch nodeinfo: %v", err) + } + return raw, nodeinfo, "nodeinfo", nil +} + +// FormatHelperResult wraps formatter.Format for use by resolver.go, keeping formatter import out of resolver.go +func FormatHelperResult(raw []byte, nodeinfo map[string]interface{}) (string, error) { + return formatter.Format(nodeinfo) +} diff --git a/resolver/resolver.go b/resolver/resolver.go index ec815f3..5af30f5 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -35,6 +35,20 @@ func (r *Resolver) Resolve(input string) (string, error) { // Check if input looks like a URL if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { fmt.Println("Detected URL, attempting direct resolution") + // Special case: if input is just a root domain (no path or only "/"), use nodeinfo fallback + parsedURL, err := url.Parse(input) + if err == nil && (parsedURL.Path == "" || parsedURL.Path == "/") { + raw, nodeinfo, _, err := r.ResolveObjectOrNodeInfo(input) + if err != nil { + return "", err + } + // Format using the formatter (in helpers.go) + formatted, ferr := FormatHelperResult(raw, nodeinfo) + if ferr != nil { + return string(raw), nil + } + return formatted, nil + } return r.resolveURL(input) }