Support rel=me verification on blogs

This allows setting a URL, and then renders a <link> element
in the head of the blog. It requires a database migration.

Ref T744
This commit is contained in:
Matt Baer 2023-09-21 19:04:34 -04:00
parent e0c165ff1e
commit 264bef03b1
10 changed files with 169 additions and 11 deletions

View File

@ -862,9 +862,6 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
return ErrCollectionNotFound return ErrCollectionNotFound
} }
// Add collection properties
c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
silenced, err := app.db.IsUserSilenced(u.ID) silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil { if err != nil {
if err == ErrUserNotFound { if err == ErrUserNotFound {

View File

@ -23,16 +23,19 @@ import (
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/activity/streams" "github.com/writeas/activity/streams"
"github.com/writeas/activityserve"
"github.com/writeas/httpsig" "github.com/writeas/httpsig"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitypub"
"github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/id" "github.com/writeas/web-core/id"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/silobridge"
) )
const ( const (
@ -60,6 +63,7 @@ type RemoteUser struct {
ActorID string ActorID string
Inbox string Inbox string
SharedInbox string SharedInbox string
URL string
Handle string Handle string
} }
@ -452,7 +456,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
followerID = remoteUser.ID followerID = remoteUser.ID
} else { } else {
// Add follower locally, since it wasn't found before // 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 err != nil {
// if duplicate key, res will be nil and panic on // if duplicate key, res will be nil and panic on
// res.LastInsertId below // 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) { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
u := RemoteUser{ActorID: actorID} u := RemoteUser{ActorID: actorID}
var handle sql.NullString var urlVal, 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) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} 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 return nil, err
} }
u.URL = urlVal.String
u.Handle = handle.String u.Handle = handle.String
return &u, nil return &u, nil
@ -783,7 +788,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
// from the @user@server.tld handle // from the @user@server.tld handle
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) { func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
u := RemoteUser{Handle: handle} 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, ErrRemoteUserNotFound 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) log.Error("Couldn't get remote user %s: %v", handle, err)
return nil, err return nil, err
} }
u.URL = urlVal.String
return &u, nil return &u, nil
} }
@ -824,6 +831,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
return actor, remoteUser, nil 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 // unmarshal actor normalizes the actor response to conform to
// the type Person from github.com/writeas/web-core/activitysteams // the type Person from github.com/writeas/web-core/activitysteams
// //

View File

@ -59,6 +59,7 @@ type (
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Monetization string `json:"monetization_pointer,omitempty"` Monetization string `json:"monetization_pointer,omitempty"`
Verification string `json:"verification_link"`
db *datastore db *datastore
hostName string hostName string
@ -98,6 +99,7 @@ type (
Script *sql.NullString `schema:"script" json:"script"` Script *sql.NullString `schema:"script" json:"script"`
Signature *sql.NullString `schema:"signature" json:"signature"` Signature *sql.NullString `schema:"signature" json:"signature"`
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Verification *string `schema:"verification_link" json:"verification_link"`
Visibility *int `schema:"visibility" json:"public"` Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"` 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 != nil {
if err, ok := err.(impart.HTTPError); ok { if err, ok := err.(impart.HTTPError); ok {
if reqJSON { if reqJSON {

View File

@ -17,6 +17,7 @@ import (
"github.com/writeas/web-core/silobridge" "github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db" wf_db "github.com/writefreely/writefreely/db"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -95,7 +96,7 @@ type writestore interface {
GetCollection(alias string) (*Collection, error) GetCollection(alias string) (*Collection, error)
GetCollectionForPad(alias string) (*Collection, error) GetCollectionForPad(alias string) (*Collection, error)
GetCollectionByID(id int64) (*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 DeleteCollection(alias string, userID int64) error
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos 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.Format = format.String
c.Public = c.IsPublic() c.Public = c.IsPublic()
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer") c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
c.Verification = db.GetCollectionAttribute(c.ID, "verification_link")
c.db = db c.db = db
@ -851,7 +853,7 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
return db.GetCollectionBy("host = ?", host) 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(). q := query.NewUpdate().
SetStringPtr(c.Title, "title"). SetStringPtr(c.Title, "title").
SetStringPtr(c.Description, "description"). 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 // Update Monetization value
if c.Monetization != nil { if c.Monetization != nil {
skipUpdate := false skipUpdate := false
@ -2811,6 +2851,7 @@ func handleFailedPostInsert(err error) error {
return err 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) { func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
handle = strings.TrimLeft(handle, "@") handle = strings.TrimLeft(handle, "@")
actorIRI := "" actorIRI := ""

2
go.mod
View File

@ -22,7 +22,7 @@ require (
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7 github.com/urfave/cli/v2 v2.25.7
github.com/writeas/activity v0.1.2 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-strip-markdown/v2 v2.1.1
github.com/writeas/go-webfinger v1.1.0 github.com/writeas/go-webfinger v1.1.0
github.com/writeas/httpsig v1.0.0 github.com/writeas/httpsig v1.0.0

2
go.sum
View File

@ -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/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 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= 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 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= 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= github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=

View File

@ -67,6 +67,7 @@ var migrations = []Migration{
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
New("support post signatures", supportPostSignatures), // V9 -> V10 New("support post signatures", supportPostSignatures), // V9 -> V10
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 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 // CurrentVer returns the current migration version the application is on

33
migrations/v12.go Normal file
View File

@ -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
}

View File

@ -3,6 +3,9 @@
{{if .Monetization -}} {{if .Monetization -}}
<meta name="monetization" content="{{.DisplayMonetization}}" /> <meta name="monetization" content="{{.DisplayMonetization}}" />
{{- end}} {{- end}}
{{if .Verification -}}
<link rel="me" href="{{.Verification}}" />
{{- end}}
{{end}} {{end}}
{{define "highlighting"}} {{define "highlighting"}}

View File

@ -153,6 +153,15 @@ textarea.section.norm {
</div> </div>
</div> </div>
<div class="option">
<h2>Verification</h2>
<div class="section">
<p class="explain">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 &mdash; it will show up as <a href="https://joinmastodon.org/verification" target="mastoverified">verified</a> there.</p>
<input type="text" name="verification_link" style="width:100%" value="{{.Verification}}" placeholder="https://writing.exchange/@writefreely" />
<p class="explain">This adds a <code>rel="me"</code> code in your blog's <code>&lt;head&gt;</code>.</p>
</div>
</div>
{{if .UserPage.StaticPage.AppCfg.Monetization}} {{if .UserPage.StaticPage.AppCfg.Monetization}}
<div class="option"> <div class="option">
<h2>Web Monetization</h2> <h2>Web Monetization</h2>