From 264bef03b1afb80c3b6f3deb860a32fc2be8980a Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Thu, 21 Sep 2023 19:04:34 -0400 Subject: [PATCH] Support rel=me verification on blogs This allows setting a URL, and then renders a element in the head of the blog. It requires a database migration. Ref T744 --- account.go | 3 -- activitypub.go | 78 ++++++++++++++++++++++++++++-- collections.go | 4 +- database.go | 45 ++++++++++++++++- go.mod | 2 +- go.sum | 2 + migrations/migrations.go | 1 + migrations/v12.go | 33 +++++++++++++ templates/include/post-render.tmpl | 3 ++ templates/user/collection.tmpl | 9 ++++ 10 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 migrations/v12.go diff --git a/account.go b/account.go index 91a8ace..b68d586 100644 --- a/account.go +++ b/account.go @@ -862,9 +862,6 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } - // Add collection properties - c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") - silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { diff --git a/activitypub.go b/activitypub.go index efc34f3..684f903 100644 --- a/activitypub.go +++ b/activitypub.go @@ -23,16 +23,19 @@ import ( "net/url" "path/filepath" "strconv" + "strings" "time" "github.com/gorilla/mux" "github.com/writeas/activity/streams" + "github.com/writeas/activityserve" "github.com/writeas/httpsig" "github.com/writeas/impart" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" + "github.com/writeas/web-core/silobridge" ) const ( @@ -60,6 +63,7 @@ type RemoteUser struct { ActorID string Inbox string SharedInbox string + URL string Handle string } @@ -452,7 +456,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request followerID = remoteUser.ID } else { // Add follower locally, since it wasn't found before - res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox) + res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) if err != nil { // if duplicate key, res will be nil and panic on // res.LastInsertId below @@ -764,8 +768,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { u := RemoteUser{ActorID: actorID} - var handle sql.NullString - err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle) + var urlVal, handle sql.NullString + err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} @@ -774,6 +778,7 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { return nil, err } + u.URL = urlVal.String u.Handle = handle.String return &u, nil @@ -783,7 +788,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { // from the @user@server.tld handle func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { u := RemoteUser{Handle: handle} - err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox) + var urlVal sql.NullString + err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal) switch { case err == sql.ErrNoRows: return nil, ErrRemoteUserNotFound @@ -791,6 +797,7 @@ func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { log.Error("Couldn't get remote user %s: %v", handle, err) return nil, err } + u.URL = urlVal.String return &u, nil } @@ -824,6 +831,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, return actor, remoteUser, nil } +func GetProfileURLFromHandle(app *App, handle string) (string, error) { + handle = strings.TrimLeft(handle, "@") + actorIRI := "" + parts := strings.Split(handle, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid handle format") + } + domain := parts[1] + + // Check non-AP instances + if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { + return siloProfileURL, nil + } + + remoteUser, err := getRemoteUserFromHandle(app, handle) + if err != nil { + // can't find using handle in the table but the table may already have this user without + // handle from a previous version + // TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all + actorIRI = RemoteLookup(handle) + _, errRemoteUser := getRemoteUser(app, actorIRI) + // if it exists then we need to update the handle + if errRemoteUser == nil { + _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) + if err != nil { + log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) + } + } else { + // this probably means we don't have the user in the table so let's try to insert it + // here we need to ask the server for the inboxes + remoteActor, err := activityserve.NewRemoteActor(actorIRI) + if err != nil { + log.Error("Couldn't fetch remote actor: %v", err) + } + if debugging { + log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) + } + _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle) + if err != nil { + log.Error("Couldn't insert remote user: %v", err) + return "", err + } + actorIRI = remoteActor.URL() + } + } else if remoteUser.URL == "" { + log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID) + newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID) + if err != nil { + log.Error("Couldn't fetch remote actor: %v", err) + } else { + _, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID) + if err != nil { + log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) + } else { + actorIRI = newRemoteActor.URL() + } + } + } else { + actorIRI = remoteUser.URL + } + return actorIRI, nil +} + // unmarshal actor normalizes the actor response to conform to // the type Person from github.com/writeas/web-core/activitysteams // diff --git a/collections.go b/collections.go index bc63df4..b20db85 100644 --- a/collections.go +++ b/collections.go @@ -59,6 +59,7 @@ type ( URL string `json:"url,omitempty"` Monetization string `json:"monetization_pointer,omitempty"` + Verification string `json:"verification_link"` db *datastore hostName string @@ -98,6 +99,7 @@ type ( Script *sql.NullString `schema:"script" json:"script"` Signature *sql.NullString `schema:"signature" json:"signature"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` + Verification *string `schema:"verification_link" json:"verification_link"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } @@ -1132,7 +1134,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } } - err = app.db.UpdateCollection(&c, collAlias) + err = app.db.UpdateCollection(app, &c, collAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if reqJSON { diff --git a/database.go b/database.go index 6b178bf..28cca46 100644 --- a/database.go +++ b/database.go @@ -17,6 +17,7 @@ import ( "github.com/writeas/web-core/silobridge" wf_db "github.com/writefreely/writefreely/db" "net/http" + "net/url" "strings" "time" @@ -95,7 +96,7 @@ type writestore interface { GetCollection(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error) GetCollectionByID(id int64) (*Collection, error) - UpdateCollection(c *SubmittedCollection, alias string) error + UpdateCollection(app *App, c *SubmittedCollection, alias string) error DeleteCollection(alias string, userID int64) error UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error @@ -815,6 +816,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c.Format = format.String c.Public = c.IsPublic() c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") + c.Verification = db.GetCollectionAttribute(c.ID, "verification_link") c.db = db @@ -851,7 +853,7 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) { return db.GetCollectionBy("host = ?", host) } -func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error { +func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias string) error { q := query.NewUpdate(). SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). @@ -910,6 +912,44 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update Verification link value + if c.Verification != nil { + skipUpdate := false + if *c.Verification != "" { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.Verification) + if strings.HasPrefix(trimmed, "@") && strings.Count(trimmed, "@") == 2 { + // This looks like a fediverse handle, so resolve profile URL + profileURL, err := GetProfileURLFromHandle(app, trimmed) + if err != nil || profileURL == "" { + log.Error("Couldn't find user %s: %v", trimmed, err) + skipUpdate = true + } else { + c.Verification = &profileURL + } + } else { + if !strings.HasPrefix(trimmed, "http") { + trimmed = "https://" + trimmed + } + vu, err := url.Parse(trimmed) + if err != nil { + // Value appears invalid, so don't update + skipUpdate = true + } else { + s := vu.String() + c.Verification = &s + } + } + } + if !skipUpdate { + err = db.SetCollectionAttribute(collID, "verification_link", *c.Verification) + if err != nil { + log.Error("Unable to insert verification_link value: %v", err) + return err + } + } + } + // Update Monetization value if c.Monetization != nil { skipUpdate := false @@ -2811,6 +2851,7 @@ func handleFailedPostInsert(err error) error { return err } +// Deprecated: use GetProfileURLFromHandle() instead, which returns user-facing URL instead of actor_id func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { handle = strings.TrimLeft(handle, "@") actorIRI := "" diff --git a/go.mod b/go.mod index 7a88f9d..886846c 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/urfave/cli/v2 v2.25.7 github.com/writeas/activity v0.1.2 - github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 + github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 github.com/writeas/go-strip-markdown/v2 v2.1.1 github.com/writeas/go-webfinger v1.1.0 github.com/writeas/httpsig v1.0.0 diff --git a/go.sum b/go.sum index d99549b..5f6cdd8 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7Dg github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0= github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= +github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw= +github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= diff --git a/migrations/migrations.go b/migrations/migrations.go index 9a20528..d2da8f4 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -67,6 +67,7 @@ var migrations = []Migration{ New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 New("support post signatures", supportPostSignatures), // V9 -> V10 New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 + New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v12.go b/migrations/v12.go new file mode 100644 index 0000000..bed93fd --- /dev/null +++ b/migrations/v12.go @@ -0,0 +1,33 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func fediverseVerifyProfile(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox")) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index 5b84845..daf3984 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -3,6 +3,9 @@ {{if .Monetization -}} {{- end}} + {{if .Verification -}} + + {{- end}} {{end}} {{define "highlighting"}} diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 6b12d25..fe32df6 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -153,6 +153,15 @@ textarea.section.norm { +
+

Verification

+
+

Verify that you own another site on the open web, fediverse, etc. For example, enter your Mastodon profile address here, then on Mastodon add a link back to this blog — it will show up as verified there.

+ +

This adds a rel="me" code in your blog's <head>.

+
+
+ {{if .UserPage.StaticPage.AppCfg.Monetization}}

Web Monetization