diff --git a/db/federation/activity.go b/db/federation/activity.go index ed15940c..e32669ed 100644 --- a/db/federation/activity.go +++ b/db/federation/activity.go @@ -108,9 +108,10 @@ func PostActivity(app core.App, actor *core.Record, activity *pub.Activity, reci if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) app.Logger().Error(fmt.Sprintf("Inbox %s responded with %d: %s", inbox, resp.StatusCode, body)) + } else { + app.Logger().Info(fmt.Sprintf("Sent %s to %s", activity.Type, inbox), "activity", activity) } - app.Logger().Info(fmt.Sprintf("Sent %s to %s", activity.Type, inbox)) }(v) } @@ -119,6 +120,10 @@ func PostActivity(app core.App, actor *core.Record, activity *pub.Activity, reci } func ProcessActivity(e *core.RequestEvent) error { + origin := os.Getenv("ORIGIN") + if origin == "" { + return fmt.Errorf("ORIGIN not set") + } body, err := io.ReadAll(e.Request.Body) if err != nil { @@ -127,10 +132,17 @@ func ProcessActivity(e *core.RequestEvent) error { var activity pub.Activity activity.UnmarshalJSON(body) + inbox := fmt.Sprintf("%s%s", origin, e.Request.Header.Get("X-Forwarded-Path")) + + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "inbox", inbox) + if err != nil { + return err + } + actor, err := e.App.FindFirstRecordByData("activitypub_actors", "iri", activity.Actor.GetID().String()) if err != nil { if err == sql.ErrNoRows { - actor, err = GetActorByIRI(e.App, activity.Actor.GetID().String(), false) + actor, err = GetActorByIRI(e.App, userActor, activity.Actor.GetID().String(), false) if err != nil { return err } @@ -140,8 +152,9 @@ func ProcessActivity(e *core.RequestEvent) error { } } - verified, err := verifySignature(e.Request, actor.GetString("public_key")) + verified, err := verifySignature(e.App, e.Request, actor.GetString("public_key")) if err != nil || !verified { + e.App.Logger().Error(err.Error()) return e.UnauthorizedError("Invalid http signature", err) } @@ -166,7 +179,7 @@ func ProcessActivity(e *core.RequestEvent) error { return e.JSON(http.StatusOK, nil) } -func verifySignature(req *http.Request, publicKeyPem string) (bool, error) { +func verifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) { origin := os.Getenv("ORIGIN") if origin == "" { return false, fmt.Errorf("ORIGIN not set") @@ -188,6 +201,8 @@ func verifySignature(req *http.Request, publicKeyPem string) (bool, error) { req.Header.Set("Host", url.Host) req.Host = url.Host + app.Logger().Info(req.Header.Get("signature")) + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return false, err diff --git a/db/federation/actor.go b/db/federation/actor.go index d678e31a..ea9dddc4 100644 --- a/db/federation/actor.go +++ b/db/federation/actor.go @@ -1,6 +1,7 @@ package federation import ( + "crypto/x509" "database/sql" "encoding/json" "fmt" @@ -11,9 +12,11 @@ import ( "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" ) type WebfingerResponse struct { @@ -40,7 +43,7 @@ func SplitHandle(handle string) (string, string) { return user, domain } -func GetActorByHandle(app core.App, handle string, includeFollows bool) (*core.Record, error) { +func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFollows bool) (*core.Record, error) { username, domain := SplitHandle(handle) filter := "preferred_username={:username}&&" @@ -70,10 +73,10 @@ func GetActorByHandle(app core.App, handle string, includeFollows bool) (*core.R return nil, err } - return assembleActor(dbActor, app, includeFollows) + return assembleActor(actor, dbActor, app, includeFollows) } -func GetActorByIRI(app core.App, iri string, includeFollows bool) (*core.Record, error) { +func GetActorByIRI(app core.App, actor *core.Record, 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 { @@ -90,13 +93,13 @@ func GetActorByIRI(app core.App, iri string, includeFollows bool) (*core.Record, return nil, err } - return assembleActor(dbActor, app, includeFollows) + return assembleActor(actor, dbActor, app, includeFollows) } func iriFromHandle(domain string, username string) (string, error) { client := &http.Client{} - webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain) + webfingerURL := fmt.Sprintf("http://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain) resp, err := client.Get(webfingerURL) if err != nil || resp.StatusCode != http.StatusOK { return "", fmt.Errorf("webfinger request failed: %v", err) @@ -116,7 +119,7 @@ func iriFromHandle(domain string, username string) (string, error) { return "", fmt.Errorf("no iri in response") } -func assembleActor(dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) { +func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) { origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN environment variable not set") @@ -163,7 +166,7 @@ func assembleActor(dbActor *core.Record, app core.App, includeFollows bool) (*co if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) { return dbActor, nil } - pubActor, followers, following, err := fetchRemoteActor(dbActor.GetString("iri"), includeFollows) + pubActor, followers, following, err := fetchRemoteActor(actor, dbActor.GetString("iri"), includeFollows) if err != nil { if dbActor.Id != "" { return dbActor, err @@ -224,16 +227,54 @@ func assembleActor(dbActor *core.Record, app core.App, includeFollows bool) (*co } // Fetches an AP actor and optionally followers/following collections -func fetchRemoteActor(iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) { - client := &http.Client{} - headers := map[string]string{ - "Accept": "application/ld+json", +func fetchRemoteActor(actor *core.Record, 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 := &http.Client{} req, _ := http.NewRequest("GET", iri, nil) - for k, v := range headers { - req.Header.Set(k, v) + + 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) + } + + dbPrivateKey := actor.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, 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 := actor.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) @@ -252,12 +293,12 @@ func fetchRemoteActor(iri string, includeFollows bool) (*pub.Actor, *pub.Ordered if includeFollows { // Fetch followers - if data, err := fetchCollection(pubActor.Followers.GetID().String(), headers); err == nil { + if data, err := FetchCollection(actor, pubActor.Followers.GetID().String()); err == nil { followers = *data } // Fetch following - if data, err := fetchCollection(pubActor.Following.GetID().String(), headers); err == nil { + if data, err := FetchCollection(actor, pubActor.Following.GetID().String()); err == nil { following = *data } } @@ -265,11 +306,53 @@ func fetchRemoteActor(iri string, includeFollows bool) (*pub.Actor, *pub.Ordered return &pubActor, &followers, &following, nil } -func fetchCollection(url string, headers map[string]string) (*pub.OrderedCollection, error) { - req, _ := http.NewRequest("GET", url, nil) - for k, v := range headers { - req.Header.Set(k, v) +func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, error) { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set") } + + req, _ := http.NewRequest("GET", url, 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) + } + + dbPrivateKey := actor.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 := actor.GetString("iri") + "#main-key" + + if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil { + return nil, err + } + + } + resp, err := http.DefaultClient.Do(req) if err != nil || resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("collection fetch failed for %s: %v", url, err) diff --git a/db/federation/create.go b/db/federation/create.go index 55626669..a929da56 100644 --- a/db/federation/create.go +++ b/db/federation/create.go @@ -21,7 +21,7 @@ import ( "golang.org/x/net/html" ) -func CreateTrailActivity(app core.App, trail *core.Record, typ pub.ActivityVocabularyType) error { +func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, typ pub.ActivityVocabularyType) error { if !trail.GetBool("public") { // only broadcast the trail if it is public return nil @@ -46,7 +46,7 @@ func CreateTrailActivity(app core.App, trail *core.Record, typ pub.ActivityVocab id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := "https://www.w3.org/ns/activitystreams#Public" - mentionedActors, handles, err := ActorsFromMentions(app, trail.GetString("description")) + mentionedActors, handles, err := ActorsFromMentions(app, actor, trail.GetString("description")) if err != nil { return err } @@ -108,7 +108,7 @@ func CreateTrailActivity(app core.App, trail *core.Record, typ pub.ActivityVocab return PostActivity(app, trailAuthor, activity, recipients) } -func CreateCommentActivity(app core.App, comment *core.Record, typ pub.ActivityVocabularyType) error { +func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Record, typ pub.ActivityVocabularyType) error { origin := os.Getenv("ORIGIN") if origin == "" { return fmt.Errorf("ORIGIN not set") @@ -134,7 +134,7 @@ func CreateCommentActivity(app core.App, comment *core.Record, typ pub.ActivityV id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId) to := "https://www.w3.org/ns/activitystreams#Public" - mentionedActors, handles, err := ActorsFromMentions(app, comment.GetString("text")) + mentionedActors, handles, err := ActorsFromMentions(app, actor, comment.GetString("text")) if err != nil { return err } @@ -193,7 +193,7 @@ func CreateCommentActivity(app core.App, comment *core.Record, typ pub.ActivityV } -func CreateSummitLogActivity(app core.App, summitLog *core.Record, typ pub.ActivityVocabularyType) error { +func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.Record, typ pub.ActivityVocabularyType) error { origin := os.Getenv("ORIGIN") if origin == "" { @@ -245,7 +245,7 @@ func CreateSummitLogActivity(app core.App, summitLog *core.Record, typ pub.Activ to.Append(pub.IRI(summitLogTrailAuthor.GetString("iri"))) } - mentionedActors, handles, err := ActorsFromMentions(app, summitLog.GetString("text")) + mentionedActors, handles, err := ActorsFromMentions(app, actor, summitLog.GetString("text")) if err != nil { return err } @@ -803,7 +803,7 @@ func processCreateOrUpdateListActivity(activity pub.Activity, app core.App, acto return err } -func ActorsFromMentions(app core.App, htmlStr string) ([]*core.Record, []string, error) { +func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*core.Record, []string, error) { doc, err := html.Parse(strings.NewReader(htmlStr)) if err != nil { return nil, nil, err @@ -838,7 +838,7 @@ func ActorsFromMentions(app core.App, htmlStr string) ([]*core.Record, []string, f(doc) for _, h := range handles { - actor, err := GetActorByHandle(app, h, false) + actor, err := GetActorByHandle(app, actor, h, false) if err != nil { continue } diff --git a/db/main.go b/db/main.go index 45946acf..2b1a4d46 100644 --- a/db/main.go +++ b/db/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "strconv" "strings" "time" @@ -236,7 +237,7 @@ func createTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv return err } - err = federation.CreateTrailActivity(e.App, e.Record, pub.CreateType) + err = federation.CreateTrailActivity(e.App, author, e.Record, pub.CreateType) if err != nil { return err } @@ -268,7 +269,7 @@ func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv return err } - err = federation.CreateTrailActivity(e.App, e.Record, pub.UpdateType) + err = federation.CreateTrailActivity(e.App, author, e.Record, pub.UpdateType) if err != nil { return err } @@ -308,7 +309,12 @@ func createSummitLogHandler() func(e *core.RecordRequestEvent) error { return err } - err = federation.CreateSummitLogActivity(e.App, e.Record, pub.CreateType) + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + + err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.CreateType) if err != nil { return err } @@ -325,7 +331,12 @@ func updateSummitLogHandler() func(e *core.RecordRequestEvent) error { return err } - err = federation.CreateSummitLogActivity(e.App, e.Record, pub.UpdateType) + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + + err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.UpdateType) if err != nil { return err } @@ -348,7 +359,12 @@ func createCommentHandler() func(e *core.RecordRequestEvent) error { e.Next() - err := federation.CreateCommentActivity(e.App, e.Record, pub.CreateType) + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + + err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.CreateType) if err != nil { return err } @@ -358,7 +374,12 @@ func createCommentHandler() func(e *core.RecordRequestEvent) error { func updateCommentHandler() func(e *core.RecordRequestEvent) error { return func(e *core.RecordRequestEvent) error { - err := federation.CreateCommentActivity(e.App, e.Record, pub.UpdateType) + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + + err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.UpdateType) if err != nil { return err } @@ -1042,12 +1063,16 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { iri := e.Request.URL.Query().Get("iri") follows := e.Request.URL.Query().Get("follows") == "true" + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + var actor *core.Record - var err error if resource != "" { - actor, err = federation.GetActorByHandle(e.App, resource, follows) + actor, err = federation.GetActorByHandle(e.App, userActor, resource, follows) } else { - actor, err = federation.GetActorByIRI(e.App, iri, follows) + actor, err = federation.GetActorByIRI(e.App, userActor, iri, follows) } if err != nil && actor == nil { if strings.HasPrefix(err.Error(), "webfinger") { @@ -1069,6 +1094,41 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil}) }) + se.Router.GET("/activitypub/actor/{id}/{follow}", func(e *core.RequestEvent) error { + id := e.Request.PathValue("id") + followType := e.Request.PathValue("follow") + page := e.Request.URL.Query().Get("page") + intPage := 0 + + if page != "" { + var err error + intPage, err = strconv.Atoi(page) + if err != nil { + return err + } + } + + actor, err := e.App.FindRecordById("activitypub_actors", id) + if err != nil { + return err + } + + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return err + } + + url := actor.GetString(followType) + + if url == "" { + return e.BadRequestError("unknown type: "+followType, nil) + } + collection, err := federation.FetchCollection(userActor, fmt.Sprintf("%s?page=%d", url, intPage)) + if err != nil { + return err + } + return e.JSON(http.StatusOK, collection) + }) se.Router.GET("/activitypub/trail/{id}", func(e *core.RequestEvent) error { id := e.Request.PathValue("id") diff --git a/web/src/routes/api/v1/activitypub/user/[handle]/inbox/+server.ts b/web/src/routes/api/v1/activitypub/user/[handle]/inbox/+server.ts index 63b90ca5..ab80f477 100644 --- a/web/src/routes/api/v1/activitypub/user/[handle]/inbox/+server.ts +++ b/web/src/routes/api/v1/activitypub/user/[handle]/inbox/+server.ts @@ -13,16 +13,19 @@ export async function POST(event: RequestEvent) { return json("Bad request", { status: 400 }); } + // Clone original headers to ensure no loss + const originalHeaders: Record = {}; + event.request.headers.forEach((value, key) => { + originalHeaders[key] = value + }); + + // Add forwarded path + originalHeaders['X-Forwarded-Path'] = event.url.pathname; + const success = await event.locals.pb.send("/activitypub/activity/process", { method: "POST", fetch: event.fetch, - headers: { - 'X-Forwarded-Path': event.url.pathname, - 'Content-Type': event.request.headers.get("content-type")!, - signature: event.request.headers.get("signature")!, - date: event.request.headers.get("date")!, - digest: event.request.headers.get("digest")! - }, + headers: originalHeaders, body: JSON.stringify(activity) }) diff --git a/web/src/routes/api/v1/follow/+server.ts b/web/src/routes/api/v1/follow/+server.ts index 0423b814..69eaa2ab 100644 --- a/web/src/routes/api/v1/follow/+server.ts +++ b/web/src/routes/api/v1/follow/+server.ts @@ -22,16 +22,8 @@ export async function GET(event: RequestEvent) { const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, }); const page = event.url.searchParams.get("page") ?? "1" - const headers = new Headers() - headers.set("Accept", 'application/ld+json') - - const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }), { headers }) - - if (!r.ok) { - const errorResponse = await r.json() - throw new ClientResponseError({ status: r.status, response: errorResponse }); - } - const followers: APOrderedCollectionPage = await r.json() + + const followers: APOrderedCollectionPage = await event.locals.pb.send(`/activitypub/actor/${actor.id}/${type}?page=${page}`, { method: "GET", fetch: event.fetch, }); const followerActors: Actor[] = [] for (const f of followers.orderedItems ?? []) {