Files
wanderer/db/federation/actor.go

431 lines
12 KiB
Go

package federation
import (
"context"
"crypto/x509"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"pocketbase/util"
"strings"
"time"
pub "github.com/go-ap/activitypub"
"github.com/go-fed/httpsig"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
var ErrProfilePrivate = errors.New("profile is private")
var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response")
type WebfingerResponse struct {
Subject string `json:"subject"`
Links []struct {
Rel string `json:"rel"`
Href string `json:"href"`
} `json:"links"`
}
func validateActorResponse(actor *pub.Actor) error {
if actor == nil {
return ErrInvalidActorResponse
}
if actor.GetID().String() == "" {
return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse)
}
if actor.PreferredUsername.String() == "" && actor.Name.String() == "" {
return fmt.Errorf("%w: missing username or name", ErrInvalidActorResponse)
}
if util.ItemID(actor.Inbox) == "" {
return fmt.Errorf("%w: missing inbox", ErrInvalidActorResponse)
}
if util.ItemID(actor.Outbox) == "" {
return fmt.Errorf("%w: missing outbox", ErrInvalidActorResponse)
}
if actor.PublicKey.PublicKeyPem == "" {
return fmt.Errorf("%w: missing public key", ErrInvalidActorResponse)
}
return nil
}
func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) {
username, domain := util.SplitHandle(handle)
filter := "preferred_username={:username}&&"
if domain != "" {
filter += "domain={:domain}"
} else {
filter += "isLocal=true"
}
var dbActor *core.Record
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", filter, dbx.Params{"username": username, "domain": domain})
if err != nil && err == sql.ErrNoRows {
collection, err := app.FindCollectionByNameOrId("activitypub_actors")
if err != nil {
return nil, err
}
dbActor = core.NewRecord(collection)
dbActor.Set("isLocal", false)
iri, err := iriFromHandle(ctx, domain, username)
if err != nil {
return nil, err
}
dbActor.Set("iri", iri)
} else if err != nil {
return nil, err
}
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
}
func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows bool) (*core.Record, error) {
var dbActor *core.Record
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
if err != nil && err == sql.ErrNoRows {
collection, err := app.FindCollectionByNameOrId("activitypub_actors")
if err != nil {
return nil, err
}
dbActor = core.NewRecord(collection)
dbActor.Set("isLocal", false)
dbActor.Set("iri", iri)
} else if err != nil {
return nil, err
}
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
}
func iriFromHandle(ctx context.Context, domain string, username string) (string, error) {
client := util.SafeHTTPClient()
u := &url.URL{
Scheme: "https",
Host: domain,
Path: "/.well-known/webfinger",
}
q := u.Query()
q.Set("resource", fmt.Sprintf("acct:%s@%s", username, domain))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("webfinger request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
limitedReader := io.LimitReader(resp.Body, 102400)
var wf WebfingerResponse
if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil {
return "", fmt.Errorf("failed to decode JSON: %w", err)
}
for _, link := range wf.Links {
if link.Rel == "self" {
if _, err := url.Parse(link.Href); err != nil {
return "", fmt.Errorf("invalid IRI in response")
}
return link.Href, nil
}
}
return "", fmt.Errorf("no iri in response")
}
func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, includeFollows bool) (*core.Record, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
return nil, fmt.Errorf("ORIGIN environment variable not set")
}
private := false
if dbActor.GetBool("isLocal") {
user, err := app.FindRecordById("users", dbActor.GetString("user"))
if err != nil {
return nil, err
}
settings, err := app.FindFirstRecordByData("settings", "user", user.Id)
if err != nil {
return nil, err
}
if user.GetString("avatar") != "" {
dbActor.Set("icon", fmt.Sprintf("%s/api/v1/files/users/%s/%s", origin, user.Id, user.GetString("avatar")))
}
dbActor.Set("summary", settings.GetString("bio"))
followerCount, err := app.CountRecords("follows", dbx.NewExp("followee={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
if err != nil {
return nil, err
}
dbActor.Set("follower_count", followerCount)
followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
if err != nil {
return nil, err
}
dbActor.Set("following_count", followingCount)
dbActor.Set("last_fetched", time.Now())
privacy := settings.GetString("privacy")
result := make(map[string]interface{})
json.Unmarshal([]byte(privacy), &result)
// check that it's not our own profile
private = result["account"] == "private" && dbActor.Id != strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
} else {
// check if value is still cached
twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
return dbActor, nil
}
pubActor, followers, following, err := fetchRemoteActor(app, ctx, dbActor.GetString("iri"), includeFollows)
if err != nil {
if dbActor.Id != "" {
return dbActor, err
}
return nil, err
}
icon := ""
if pub.IsObject(pubActor.Icon) {
iconObject, err := pub.ToObject(pubActor.Icon)
if err == nil && iconObject.URL != nil {
icon = iconObject.URL.GetID().String()
}
}
parsedUrl, err := url.Parse(dbActor.GetString("iri"))
if err != nil {
return nil, err
}
domain := strings.TrimPrefix(parsedUrl.Hostname(), "www.")
// this is a race condition that gets triggered when the profile is opened for the first time
existingActor, _ := app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
if existingActor != nil {
dbActor = existingActor
}
dbActor.Set("domain", domain)
dbActor.Set("followers", util.ItemID(pubActor.Followers))
dbActor.Set("inbox", util.ItemID(pubActor.Inbox))
dbActor.Set("iri", pubActor.GetID().String())
dbActor.Set("username", pubActor.Name.String())
dbActor.Set("preferred_username", pubActor.PreferredUsername.String())
dbActor.Set("following", util.ItemID(pubActor.Following))
dbActor.Set("summary", pubActor.Summary.String())
dbActor.Set("outbox", util.ItemID(pubActor.Outbox))
dbActor.Set("icon", icon)
dbActor.Set("published", pubActor.Published.String())
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
dbActor.Set("last_fetched", time.Now())
if includeFollows {
dbActor.Set("follower_count", int(followers.TotalItems))
dbActor.Set("following_count", int(following.TotalItems))
}
}
err := app.Save(dbActor)
if err != nil {
return nil, err
}
if private {
return dbActor, ErrProfilePrivate
}
return dbActor, nil
}
// Fetches an AP actor and optionally followers/following collections
func fetchRemoteActor(app core.App, ctx context.Context, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
}
client := util.SafeHTTPClient()
req, err := http.NewRequestWithContext(ctx, "GET", iri, nil)
headers := map[string]string{
"Accept": "application/ld+json",
"Content-Type": "application/activity+json",
"Date": strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT"),
"Host": req.Host,
}
for k, v := range headers {
req.Header.Add(k, v)
}
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
if userActor != nil && userActor.GetString("private_key") != "" {
dbPrivateKey := userActor.GetString("private_key")
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
expiresIn := 60
signer, _, err := httpsig.NewSigner(algs, httpsig.DigestSha256, postHeaders, httpsig.Signature, int64(expiresIn))
if err != nil {
return nil, nil, nil, err
}
decryptedPrivateKey, err := security.Decrypt(dbPrivateKey, encryptionKey)
if err != nil {
return nil, nil, nil, err
}
privateKey, err := x509.ParsePKCS1PrivateKey(decryptedPrivateKey)
if err != nil {
return nil, nil, nil, err
}
pubID := userActor.GetString("iri") + "#main-key"
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
return nil, nil, nil, err
}
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, nil, fmt.Errorf("actor fetch failed: %v", err)
} else if resp.StatusCode != http.StatusOK {
return nil, nil, nil, fmt.Errorf("actor fetch failed: status %v", resp.StatusCode)
}
defer resp.Body.Close()
var pubActor pub.Actor
if err := json.NewDecoder(resp.Body).Decode(&pubActor); err != nil {
return nil, nil, nil, err
}
// Validate actor response has required fields
if err := validateActorResponse(&pubActor); err != nil {
return nil, nil, nil, fmt.Errorf("actor validation failed for %s: %w", iri, err)
}
var followers, following pub.OrderedCollection
if includeFollows {
// Fetch followers
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Followers)); err == nil {
followers = *data
}
// Fetch following
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Following)); err == nil {
following = *data
}
}
return &pubActor, &followers, &following, nil
}
func FetchCollection(app core.App, ctx context.Context, collectionURL string) (*pub.OrderedCollection, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
}
req, err := http.NewRequestWithContext(ctx, "GET", collectionURL, nil)
headers := map[string]string{
"Accept": "application/ld+json",
"Content-Type": "application/activity+json",
"Date": strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT"),
"Host": req.Host,
}
for k, v := range headers {
req.Header.Add(k, v)
}
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
if userActor != nil && userActor.GetString("private_key") != "" {
dbPrivateKey := userActor.GetString("private_key")
if dbPrivateKey != "" {
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
expiresIn := 60
signer, _, err := httpsig.NewSigner(algs, httpsig.DigestSha256, postHeaders, httpsig.Signature, int64(expiresIn))
if err != nil {
return nil, err
}
decryptedPrivateKey, err := security.Decrypt(dbPrivateKey, encryptionKey)
if err != nil {
return nil, err
}
privateKey, err := x509.ParsePKCS1PrivateKey(decryptedPrivateKey)
if err != nil {
return nil, err
}
pubID := userActor.GetString("iri") + "#main-key"
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
return nil, err
}
}
}
client := util.SafeHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("collection fetch failed for %s: %v", collectionURL, err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, ErrProfilePrivate
}
return nil, fmt.Errorf("collection fetch %s returned: %v", collectionURL, resp.StatusCode)
}
defer resp.Body.Close()
var collection pub.OrderedCollection
if err := json.NewDecoder(resp.Body).Decode(&collection); err != nil {
return nil, err
}
return &collection, nil
}