mirror of
https://gitlab.melroy.org/melroy/fediresolve.git
synced 2025-06-07 20:08:57 +00:00
Refactoring
This commit is contained in:
parent
5d08981a5f
commit
02853f346d
2 changed files with 85 additions and 188 deletions
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -23,45 +24,42 @@ const (
|
|||
|
||||
// fetchActivityPubObjectWithSignature is a helper function that always signs HTTP requests
|
||||
// This is the preferred way to fetch ActivityPub content as many instances require signatures
|
||||
func (r *Resolver) fetchActivityPubObjectWithSignature(objectURL string) (string, error) {
|
||||
func (r *Resolver) fetchActivityPubObjectWithSignature(objectURL string) ([]byte, map[string]interface{}, error) {
|
||||
fmt.Printf("Fetching ActivityPub object with HTTP signatures from: %s\n", objectURL)
|
||||
|
||||
// First, we need to extract the actor URL from the object URL
|
||||
actorURL, err := r.extractActorURLFromObjectURL(objectURL)
|
||||
// Fetch the object itself
|
||||
_, data, err := r.fetchActivityPubObjectDirect(objectURL)
|
||||
if err != nil {
|
||||
// If we can't extract the actor URL, fall back to a direct request
|
||||
fmt.Printf("Could not extract actor URL: %v, falling back to direct request\n", err)
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Then, we need to fetch the actor data to get the public key
|
||||
actorData, err := r.fetchActorData(actorURL)
|
||||
actorURL, ok := data["attributedTo"].(string)
|
||||
if !ok || actorURL == "" {
|
||||
return nil, nil, fmt.Errorf("could not find attributedTo in object")
|
||||
}
|
||||
|
||||
// Fetch actor data
|
||||
_, actorData, err := r.fetchActorData(actorURL)
|
||||
if err != nil {
|
||||
// If we can't fetch the actor data, fall back to a direct request
|
||||
fmt.Printf("Could not fetch actor data: %v, falling back to direct request\n", err)
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
return nil, nil, fmt.Errorf("could not fetch actor data: %v", err)
|
||||
}
|
||||
|
||||
// Extract the public key ID
|
||||
keyID, _, err := r.extractPublicKey(actorData)
|
||||
if err != nil {
|
||||
// If we can't extract the public key, fall back to a direct request
|
||||
fmt.Printf("Could not extract public key: %v, falling back to direct request\n", err)
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
return nil, nil, fmt.Errorf("could not extract public key: %v", err)
|
||||
}
|
||||
|
||||
// Create a new private key for signing (in a real app, we would use a persistent key)
|
||||
privateKey, err := generateRSAKey()
|
||||
if err != nil {
|
||||
// If we can't generate a key, fall back to a direct request
|
||||
fmt.Printf("Could not generate RSA key: %v, falling back to direct request\n", err)
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
return nil, nil, fmt.Errorf("could not generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
// Now, sign and send the request
|
||||
req, err := http.NewRequest("GET", objectURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating signed request: %v", err)
|
||||
return nil, nil, fmt.Errorf("error creating signed request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
|
@ -71,59 +69,45 @@ func (r *Resolver) fetchActivityPubObjectWithSignature(objectURL string) (string
|
|||
|
||||
// Sign the request
|
||||
if err := signRequest(req, keyID, privateKey); err != nil {
|
||||
// If we can't sign the request, fall back to a direct request
|
||||
fmt.Printf("Could not sign request: %v, falling back to direct request\n", err)
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
return nil, nil, fmt.Errorf("could not sign request: %v", err)
|
||||
}
|
||||
|
||||
// Send the request
|
||||
fmt.Printf("Sending signed request with headers: %v\n", req.Header)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending signed request: %v", err)
|
||||
return nil, nil, fmt.Errorf("error sending signed request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Printf("Received response with status: %s\n", resp.Status)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// If the signed request fails, try a direct request as a fallback
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
fmt.Println("Signed request failed with auth error, trying direct request as fallback")
|
||||
return r.fetchActivityPubObjectDirect(objectURL)
|
||||
}
|
||||
|
||||
// Read body for error info
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
return nil, nil, fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
// Read and parse the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading response: %v", err)
|
||||
return nil, nil, fmt.Errorf("error reading response: %v", err)
|
||||
}
|
||||
if len(bodyBytes) == 0 {
|
||||
return nil, nil, fmt.Errorf("received empty response body")
|
||||
}
|
||||
|
||||
// Debug output
|
||||
fmt.Printf("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")
|
||||
}
|
||||
// Remove ANSI escape codes if present (some servers return colored output)
|
||||
cleanBody := removeANSIEscapeCodes(bodyBytes)
|
||||
|
||||
// Try to decode the JSON response
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return "", fmt.Errorf("error decoding response: %v", err)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(cleanBody, &body); err != nil {
|
||||
return nil, nil, fmt.Errorf("error decoding signed response: %v", err)
|
||||
}
|
||||
|
||||
// Format the result
|
||||
return formatter.Format(data)
|
||||
return bodyBytes, body, nil
|
||||
}
|
||||
|
||||
// fetchActivityPubObjectDirect is a helper function to fetch content without signatures
|
||||
// This is used as a fallback when signing fails
|
||||
func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error) {
|
||||
func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) ([]byte, map[string]interface{}, error) {
|
||||
fmt.Printf("Fetching ActivityPub object directly from: %s\n", objectURL)
|
||||
|
||||
// Create a custom client that doesn't follow redirects automatically
|
||||
|
@ -137,18 +121,18 @@ func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error
|
|||
// Create the request
|
||||
req, err := http.NewRequest("GET", objectURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating request: %v", err)
|
||||
return nil, nil, fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
// Set Accept headers to request ActivityPub data
|
||||
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json")
|
||||
req.Header.Set("Accept", "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
// Perform the request
|
||||
fmt.Printf("Sending direct request with headers: %v\n", req.Header)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error fetching content: %v", err)
|
||||
return nil, nil, fmt.Errorf("error fetching content: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
@ -169,109 +153,43 @@ func (r *Resolver) fetchActivityPubObjectDirect(objectURL string) (string, error
|
|||
if resp.StatusCode != http.StatusOK {
|
||||
// Read body for error info
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
return nil, nil, fmt.Errorf("request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
// Read and parse the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading response: %v", err)
|
||||
return nil, nil, fmt.Errorf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
// Debug output
|
||||
fmt.Printf("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")
|
||||
if len(bodyBytes) == 0 {
|
||||
return nil, nil, fmt.Errorf("received empty response body")
|
||||
}
|
||||
|
||||
// Remove ANSI escape codes if present (some servers return colored output)
|
||||
cleanBody := removeANSIEscapeCodes(bodyBytes)
|
||||
|
||||
// Try to decode the JSON response
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return "", fmt.Errorf("error decoding response: %v", err)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(cleanBody, &body); err != nil {
|
||||
return nil, nil, fmt.Errorf("error decoding response: %v", err)
|
||||
}
|
||||
|
||||
// Format the result
|
||||
return formatter.Format(data)
|
||||
}
|
||||
|
||||
// extractActorURLFromObjectURL extracts the actor URL from an object URL
|
||||
func (r *Resolver) extractActorURLFromObjectURL(objectURL string) (string, error) {
|
||||
// This is a simplified approach - in a real app, we would parse the object URL properly
|
||||
// For now, we'll assume the actor URL is the base domain with the username
|
||||
|
||||
// Basic URL pattern: https://domain.tld/@username/postid
|
||||
parts := strings.Split(objectURL, "/")
|
||||
if len(parts) < 4 {
|
||||
return "", fmt.Errorf("invalid object URL format: %s", objectURL)
|
||||
}
|
||||
|
||||
// Extract domain and username
|
||||
domain := parts[2]
|
||||
username := parts[3]
|
||||
|
||||
// Handle different URL formats
|
||||
if strings.HasPrefix(username, "@") {
|
||||
// Format: https://domain.tld/@username/postid
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
||||
// Check for cross-instance handles like @user@domain.tld
|
||||
if strings.Contains(username, "@") {
|
||||
userParts := strings.Split(username, "@")
|
||||
if len(userParts) == 2 {
|
||||
username = userParts[0]
|
||||
domain = userParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Try common URL patterns
|
||||
actorURLs := []string{
|
||||
fmt.Sprintf("https://%s/users/%s", domain, username),
|
||||
fmt.Sprintf("https://%s/@%s", domain, username),
|
||||
fmt.Sprintf("https://%s/user/%s", domain, username),
|
||||
fmt.Sprintf("https://%s/accounts/%s", domain, username),
|
||||
fmt.Sprintf("https://%s/profile/%s", domain, username),
|
||||
}
|
||||
|
||||
// Try each URL pattern
|
||||
for _, actorURL := range actorURLs {
|
||||
fmt.Printf("Trying potential actor URL: %s\n", actorURL)
|
||||
// Check if this URL returns a valid actor
|
||||
actorData, err := r.fetchActorData(actorURL)
|
||||
if err == nil && actorData != nil {
|
||||
return actorURL, nil
|
||||
}
|
||||
|
||||
// Add a small delay between requests to avoid rate limiting
|
||||
fmt.Println("Waiting 1 second before trying next actor URL...")
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// If we couldn't find a valid actor URL, try WebFinger
|
||||
fmt.Printf("Trying WebFinger resolution for: %s@%s\n", username, domain)
|
||||
return r.resolveActorViaWebFinger(username, domain)
|
||||
} else if username == "users" || username == "user" || username == "accounts" || username == "profile" {
|
||||
// Format: https://domain.tld/users/username/postid
|
||||
if len(parts) < 5 {
|
||||
return "", fmt.Errorf("invalid user URL format: %s", objectURL)
|
||||
}
|
||||
actorURL := fmt.Sprintf("https://%s/%s/%s", domain, username, parts[4])
|
||||
return actorURL, nil
|
||||
}
|
||||
|
||||
// If we get here, we couldn't determine the actor URL
|
||||
return "", fmt.Errorf("could not determine actor URL from: %s", objectURL)
|
||||
return bodyBytes, body, nil
|
||||
}
|
||||
|
||||
// fetchActorData fetches actor data from an actor URL
|
||||
func (r *Resolver) fetchActorData(actorURL string) (map[string]interface{}, error) {
|
||||
func (r *Resolver) fetchActorData(actorURL string) ([]byte, map[string]interface{}, error) {
|
||||
fmt.Printf("Fetching actor data from: %s\n", actorURL)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("GET", actorURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %v", err)
|
||||
return nil, nil, fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
|
@ -281,27 +199,27 @@ func (r *Resolver) fetchActorData(actorURL string) (map[string]interface{}, erro
|
|||
// Send the request
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching actor data: %v", err)
|
||||
return nil, nil, fmt.Errorf("error fetching actor data: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("actor request failed with status: %s", resp.Status)
|
||||
return nil, nil, fmt.Errorf("actor request failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read and parse the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading actor response: %v", err)
|
||||
return nil, nil, fmt.Errorf("error reading actor response: %v", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("error parsing actor data: %v", err)
|
||||
if err := json.Unmarshal(bodyBytes, &data); err != nil {
|
||||
return nil, nil, fmt.Errorf("error parsing actor data: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return bodyBytes, data, nil
|
||||
}
|
||||
|
||||
// extractPublicKey extracts the public key ID from actor data
|
||||
|
@ -490,14 +408,6 @@ func (r *Resolver) fetchNodeInfo(domain string) ([]byte, map[string]interface{},
|
|||
|
||||
// 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 {
|
||||
|
@ -516,7 +426,17 @@ func FormatHelperResult(raw []byte, nodeinfo map[string]interface{}) (string, er
|
|||
return formatter.Format(nodeinfo)
|
||||
}
|
||||
|
||||
// formatCanonicalResultHelper is used by resolver.go to format ActivityPub objects without importing formatter there
|
||||
func formatCanonicalResultHelper(jsonData []byte, data map[string]interface{}) (string, error) {
|
||||
return formatter.Format(data)
|
||||
// Try to always format (ideally the body data, or in the worse case the raw data to string)
|
||||
func formatResult(raw []byte, data map[string]interface{}) string {
|
||||
formatted, err := formatter.Format(data)
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
// removeANSIEscapeCodes strips ANSI escape codes from a byte slice
|
||||
func removeANSIEscapeCodes(input []byte) []byte {
|
||||
ansiEscape := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||
return ansiEscape.ReplaceAll(input, []byte{})
|
||||
}
|
||||
|
|
|
@ -45,10 +45,7 @@ func (r *Resolver) Resolve(input string) (string, error) {
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
formatted, ferr := r.formatCanonicalResultHelper(raw, nodeinfo)
|
||||
if ferr != nil {
|
||||
return string(raw), nil
|
||||
}
|
||||
formatted := formatResult(raw, nodeinfo)
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
|
@ -198,10 +195,10 @@ func (r *Resolver) resolveURL(inputURL string) (string, error) {
|
|||
// that differs from the requested URL, recursively fetches that canonical URL. Max depth is used to prevent infinite loops.
|
||||
func (r *Resolver) resolveCanonicalActivityPub(objectURL string, depth int) (string, error) {
|
||||
if depth > 3 {
|
||||
return "", fmt.Errorf("Too many canonical redirects (possible loop)")
|
||||
return "", fmt.Errorf("too many canonical redirects (possible loop)")
|
||||
}
|
||||
fmt.Printf("Fetching ActivityPub object for canonical resolution: %s\n", objectURL)
|
||||
jsonData, err := r.fetchActivityPubObjectWithSignatureRaw(objectURL)
|
||||
jsonData, err := r.fetchActivityPubObjectRaw(objectURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -215,48 +212,29 @@ func (r *Resolver) resolveCanonicalActivityPub(objectURL string, depth int) (str
|
|||
return r.resolveCanonicalActivityPub(idVal, depth+1)
|
||||
}
|
||||
// If no id or already canonical, format and return using helpers.go
|
||||
return r.formatCanonicalResultHelper(jsonData, data)
|
||||
formatted := formatResult(jsonData, data)
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
// fetchActivityPubObjectWithSignatureRaw fetches an ActivityPub object and returns the raw JSON []byte (not formatted)
|
||||
func (r *Resolver) fetchActivityPubObjectWithSignatureRaw(objectURL string) ([]byte, error) {
|
||||
fmt.Printf("Fetching ActivityPub object with HTTP signatures from: %s\n", objectURL)
|
||||
// fetchActivityPubObjectRaw fetches an ActivityPub object and returns the raw JSON []byte (not formatted)
|
||||
func (r *Resolver) fetchActivityPubObjectRaw(objectURL string) ([]byte, error) {
|
||||
fmt.Printf("Fetching ActivityPub object with HTTP from: %s\n", objectURL)
|
||||
|
||||
actorURL, err := r.extractActorURLFromObjectURL(objectURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not extract actor URL: %v", err)
|
||||
}
|
||||
actorData, err := r.fetchActorData(actorURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not fetch actor data: %v", err)
|
||||
}
|
||||
keyID, _, err := r.extractPublicKey(actorData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not extract public key: %v", err)
|
||||
}
|
||||
privateKey, err := generateRSAKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not generate RSA key: %v", err)
|
||||
}
|
||||
// Sign and send the request
|
||||
req, err := http.NewRequest("GET", objectURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating signed request: %v", err)
|
||||
return nil, fmt.Errorf("error creating get request: %v", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/ld+json, application/activity+json")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
|
||||
if err := signRequest(req, keyID, privateKey); err != nil {
|
||||
return nil, fmt.Errorf("Could not sign request: %v", err)
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending signed request: %v", err)
|
||||
return nil, fmt.Errorf("error sending get request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("signed request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
return nil, fmt.Errorf("get request failed with status: %s, body: %s", resp.Status, string(body))
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
@ -268,12 +246,6 @@ func (r *Resolver) fetchActivityPubObjectWithSignatureRaw(objectURL string) ([]b
|
|||
return body, nil
|
||||
}
|
||||
|
||||
// formatCanonicalResultHelper formats the ActivityPub object for display
|
||||
func (r *Resolver) formatCanonicalResultHelper(jsonData []byte, data map[string]interface{}) (string, error) {
|
||||
// Call the new helper method from helpers.go
|
||||
return formatCanonicalResultHelper(jsonData, data)
|
||||
}
|
||||
|
||||
// fetchActivityPubObject fetches an ActivityPub object from a URL
|
||||
// This function now uses a signature-first approach by default
|
||||
func (r *Resolver) fetchActivityPubObject(objectURL string) (string, error) {
|
||||
|
@ -291,5 +263,10 @@ func (r *Resolver) fetchActivityPubObject(objectURL string) (string, error) {
|
|||
}
|
||||
|
||||
// Use our signature-first approach by default
|
||||
return r.fetchActivityPubObjectWithSignature(objectURL)
|
||||
raw, body, err := r.fetchActivityPubObjectWithSignature(objectURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error fetching ActivityPub object: %v", err)
|
||||
}
|
||||
formatted := formatResult(raw, body)
|
||||
return formatted, nil
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue