Support rel=me verification on blogsmain
@@ -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 { | |||
@@ -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 | |||
// | |||
@@ -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 { | |||
@@ -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 | |||
@@ -2274,7 +2314,7 @@ func (db *datastore) GetCollectionAttribute(id int64, attr string) string { | |||
} | |||
func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { | |||
_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v) | |||
_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", id, attr, v, v) | |||
if err != nil { | |||
log.Error("Unable to INSERT into collectionattributes: %v", err) | |||
return err | |||
@@ -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 := "" | |||
@@ -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 | |||
@@ -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= | |||
@@ -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 | |||
@@ -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 | |||
} |
@@ -139,6 +139,7 @@ type ( | |||
IsPinned bool | |||
IsCustomDomain bool | |||
Monetization string | |||
Verification string | |||
PinnedPosts *[]PublicPost | |||
IsFound bool | |||
IsAdmin bool | |||
@@ -516,9 +517,9 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { | |||
// newPost creates a new post with or without an owning Collection. | |||
// | |||
// Endpoints: | |||
// /posts | |||
// /posts?collection={alias} | |||
// ? /collections/{alias}/posts | |||
// - /posts | |||
// - /posts?collection={alias} | |||
// - ? /collections/{alias}/posts | |||
func newPost(app *App, w http.ResponseWriter, r *http.Request) error { | |||
reqJSON := IsJSON(r) | |||
vars := mux.Vars(r) | |||
@@ -1546,7 +1547,8 @@ Are you sure it was ever here?`, | |||
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) | |||
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) | |||
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) | |||
tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") | |||
tp.Monetization = coll.Monetization | |||
tp.Verification = coll.Verification | |||
if !postFound { | |||
w.WriteHeader(http.StatusNotFound) | |||
@@ -3,6 +3,9 @@ | |||
{{if .Monetization -}} | |||
<meta name="monetization" content="{{.DisplayMonetization}}" /> | |||
{{- end}} | |||
{{if .Verification -}} | |||
<link rel="me" href="{{.Verification}}" /> | |||
{{- end}} | |||
{{end}} | |||
{{define "highlighting"}} | |||
@@ -153,6 +153,15 @@ textarea.section.norm { | |||
</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 — 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><head></code>.</p> | |||
</div> | |||
</div> | |||
{{if .UserPage.StaticPage.AppCfg.Monetization}} | |||
<div class="option"> | |||
<h2>Web Monetization</h2> | |||