Rework activitypub using library and support replies using the comment system

This commit is contained in:
Jan-Lukas Else 2022-11-23 22:16:56 +01:00
parent e5d0d136c0
commit e005cda096
13 changed files with 360 additions and 304 deletions

View File

@ -18,10 +18,10 @@ import (
"strings"
"time"
ap "github.com/go-ap/activitypub"
"github.com/go-chi/chi/v5"
"github.com/go-fed/httpsig"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
)
@ -168,73 +168,86 @@ func (a *goBlog) apHandleInbox(w http.ResponseWriter, r *http.Request) {
return
}
// Parse activity
activity := map[string]any{}
err = json.NewDecoder(r.Body).Decode(&activity)
_ = r.Body.Close()
limit := int64(10 * 1000 * 1000) // 10 MB
body, err := io.ReadAll(io.LimitReader(r.Body, limit))
if err != nil {
a.serveError(w, r, "Failed to read body", http.StatusBadRequest)
return
}
apItem, err := ap.UnmarshalJSON(body)
if err != nil {
a.serveError(w, r, "Failed to decode body", http.StatusBadRequest)
return
}
// Get and check activity actor
activityActor, ok := activity["actor"].(string)
if !ok {
a.serveError(w, r, "actor in activity is no string", http.StatusBadRequest)
// Check if it's an activity
activity, err := ap.ToActivity(apItem)
if err != nil {
a.serveError(w, r, "No activity", http.StatusBadRequest)
return
}
if activityActor != requestActor.ID {
// Check actor
activityActor := activity.Actor.GetID()
if activity.Actor == nil || (!activity.Actor.IsLink() && !activity.Actor.IsObject()) {
a.serveError(w, r, "Activity has no actor", http.StatusBadRequest)
return
}
if activityActor != requestActor.GetID() {
a.serveError(w, r, "Request actor isn't activity actor", http.StatusForbidden)
return
}
// Do
switch activity["type"] {
case "Follow":
// Handle activity
switch activity.GetType() {
case ap.FollowType:
a.apAccept(blogName, blogIri, blog, activity)
case "Undo":
if object, ok := activity["object"].(map[string]any); ok {
ot := cast.ToString(object["type"])
actor := cast.ToString(object["actor"])
if ot == "Follow" && actor == activityActor {
_ = a.db.apRemoveFollower(blogName, activityActor)
case ap.UndoType:
if activity.Object.IsObject() {
objectActivity, err := ap.ToActivity(activity.Object)
if err == nil && objectActivity.GetType() == ap.FollowType && objectActivity.Actor.GetID() == activityActor {
_ = a.db.apRemoveFollower(blogName, activityActor.String())
}
}
case "Create":
if object, ok := activity["object"].(map[string]any); ok {
baseUrl := cast.ToString(object["id"])
if ou := cast.ToString(object["url"]); ou != "" {
baseUrl = ou
}
if r := cast.ToString(object["inReplyTo"]); r != "" && baseUrl != "" && strings.HasPrefix(r, blogIri) {
// It's an ActivityPub reply; save reply as webmention
_ = a.createWebmention(baseUrl, r)
} else if content := cast.ToString(object["content"]); content != "" && baseUrl != "" {
// May be a mention; find links to blog and save them as webmentions
if links, err := allLinksFromHTMLString(content, baseUrl); err == nil {
for _, link := range links {
if strings.HasPrefix(link, a.cfg.Server.PublicAddress) {
_ = a.createWebmention(baseUrl, link)
}
case ap.CreateType, ap.UpdateType:
if activity.Object.IsObject() {
object, err := ap.ToObject(activity.Object)
if err == nil && (object.GetType() == ap.NoteType || object.GetType() == ap.ArticleType) {
objectLink := object.GetID()
if replyTo := object.InReplyTo.GetID(); objectLink != "" && replyTo != "" && strings.HasPrefix(replyTo.String(), blogIri) {
target := replyTo.String()
original := objectLink.String()
name := requestActor.Name.First().Value.String()
if username := requestActor.PreferredUsername.First().String(); name == "" && username != "" {
name = username
}
website := requestActor.GetLink().String()
if actorUrl := requestActor.URL.GetLink(); actorUrl != "" {
website = actorUrl.String()
}
content := object.Content.First().Value.String()
a.createComment(blog, target, content, name, website, original)
}
}
}
case "Delete", "Block":
if o := cast.ToString(activity["object"]); o == activityActor {
_ = a.db.apRemoveFollower(blogName, activityActor)
}
case "Like":
if o := cast.ToString(activity["object"]); o != "" && strings.HasPrefix(o, blogIri) {
a.sendNotification(fmt.Sprintf("%s liked %s", activityActor, o))
}
case "Announce":
if o := cast.ToString(activity["object"]); o != "" && strings.HasPrefix(o, blogIri) {
a.sendNotification(fmt.Sprintf("%s announced %s", activityActor, o))
case ap.DeleteType, ap.BlockType:
if activity.Object.GetID() == activityActor {
_ = a.db.apRemoveFollower(blogName, activityActor.String())
} else {
// Check if comment exists
exists, commentId, err := a.db.commentIdByOriginal(activity.Object.GetID().String())
if err == nil && exists {
_ = a.db.deleteComment(commentId)
_ = a.db.deleteWebmentionUUrl(activity.Object.GetID().String())
}
}
case ap.AnnounceType:
a.sendNotification(fmt.Sprintf("%s announced %s", activityActor, activity.Object.GetID()))
case ap.LikeType:
a.sendNotification(fmt.Sprintf("%s liked %s", activityActor, activity.Object.GetID()))
}
// Return 200
w.WriteHeader(http.StatusOK)
}
func (a *goBlog) apVerifySignature(r *http.Request, blogIri string) (*asPerson, string, int, error) {
func (a *goBlog) apVerifySignature(r *http.Request, blogIri string) (*ap.Actor, string, int, error) {
verifier, err := httpsig.NewVerifier(r)
if err != nil {
// Error with signature header etc.
@ -246,7 +259,7 @@ func (a *goBlog) apVerifySignature(r *http.Request, blogIri string) (*asPerson,
// Actor not found or something else bad
return nil, keyID, statusCode, err
}
if actor.PublicKey == nil || actor.PublicKey.PublicKeyPem == "" {
if actor.PublicKey.PublicKeyPem == "" {
return nil, keyID, 0, errors.New("actor has no public key")
}
block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem))
@ -267,7 +280,7 @@ func handleWellKnownHostMeta(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="https://`+r.Host+`/.well-known/webfinger?resource={uri}"/></XRD>`)
}
func (a *goBlog) apGetRemoteActor(iri, ownBlogIri string) (*asPerson, int, error) {
func (a *goBlog) apGetRemoteActor(iri, ownBlogIri string) (*ap.Actor, int, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, iri, strings.NewReader(""))
if err != nil {
return nil, 0, err
@ -292,8 +305,17 @@ func (a *goBlog) apGetRemoteActor(iri, ownBlogIri string) (*asPerson, int, error
if !apRequestIsSuccess(resp.StatusCode) {
return nil, resp.StatusCode, nil
}
actor := &asPerson{}
err = json.NewDecoder(resp.Body).Decode(actor)
// Parse response
limit := int64(10 * 1000 * 1000) // 10 MB
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, 0, err
}
apObject, err := ap.UnmarshalJSON(body)
if err != nil {
return nil, 0, err
}
actor, err := ap.ToActor(apObject)
if err != nil {
return nil, 0, err
}
@ -352,35 +374,28 @@ func (db *database) apRemoveInbox(inbox string) error {
}
func (a *goBlog) apPost(p *post) {
n := a.toASNote(p)
a.apSendToAllFollowers(p.Blog, map[string]any{
"@context": []string{asContext},
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.activityPubId(p),
"published": n.Published,
"type": "Create",
"object": n,
})
blogConfig := a.cfg.Blogs[p.Blog]
note := a.toAPNote(p)
create := ap.CreateNew(a.activityPubId(p), note)
create.Actor = a.apAPIri(blogConfig)
create.Published = time.Now()
a.apSendToAllFollowers(p.Blog, create)
}
func (a *goBlog) apUpdate(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]any{
"@context": []string{asContext},
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.activityPubId(p),
"published": time.Now().Format("2006-01-02T15:04:05-07:00"),
"type": "Update",
"object": a.toASNote(p),
})
blogConfig := a.cfg.Blogs[p.Blog]
note := a.toAPNote(p)
update := ap.UpdateNew(a.activityPubId(p), note)
update.Actor = a.apAPIri(blogConfig)
update.Published = time.Now()
a.apSendToAllFollowers(p.Blog, update)
}
func (a *goBlog) apDelete(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]any{
"@context": []string{asContext},
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
"type": "Delete",
"object": a.activityPubId(p),
})
blogConfig := a.cfg.Blogs[p.Blog]
delete := ap.DeleteNew(a.apNewID(blogConfig), a.activityPubId(p))
delete.Actor = a.apAPIri(blogConfig)
a.apSendToAllFollowers(p.Blog, delete)
}
func (a *goBlog) apUndelete(p *post) {
@ -397,55 +412,45 @@ func (a *goBlog) apUndelete(p *post) {
a.apPost(p)
}
func (a *goBlog) apAccept(blogName, blogIri string, blog *configBlog, follow map[string]any) {
// it's a follow, write it down
newFollower := follow["actor"].(string)
log.Println("New follow request:", newFollower)
// check we aren't following ourselves
if newFollower == follow["object"] {
// actor and object are equal
return
}
follower, status, err := a.apGetRemoteActor(newFollower, blogIri)
func (a *goBlog) apAccept(blogName, blogIri string, blog *configBlog, follow *ap.Activity) {
newFollower := follow.Actor.GetID()
log.Println("New follow request:", newFollower.String())
// Get remote actor
follower, status, err := a.apGetRemoteActor(newFollower.String(), blogIri)
if err != nil || status != 0 {
// Couldn't retrieve remote actor info
log.Println("Failed to retrieve remote actor info:", newFollower)
return
}
// Add or update follower
inbox := follower.Inbox
if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != "" {
inbox = endpoints.SharedInbox
inbox := follower.Inbox.GetID()
if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != nil && endpoints.SharedInbox.GetID() != "" {
inbox = endpoints.SharedInbox.GetID()
}
if err = a.db.apAddFollower(blogName, follower.ID, inbox); err != nil {
if inbox == "" {
return
}
if err = a.db.apAddFollower(blogName, follower.GetID().String(), inbox.String()); err != nil {
return
}
// Send accept response to the new follower
accept := map[string]any{
"@context": []string{asContext},
"type": "Accept",
"to": follow["actor"],
"actor": a.apIri(blog),
"object": follow,
}
_, accept["id"] = a.apNewID(blog)
_ = a.apQueueSendSigned(a.apIri(blog), inbox, accept)
accept := ap.AcceptNew(a.apNewID(blog), follow)
accept.To = append(accept.To, newFollower)
accept.Actor = a.apAPIri(blog)
_ = a.apQueueSendSigned(a.apIri(blog), inbox.String(), accept)
}
func (a *goBlog) apSendProfileUpdates() {
for blog, config := range a.cfg.Blogs {
person := a.toAsPerson(blog)
a.apSendToAllFollowers(blog, map[string]any{
"@context": []string{asContext},
"actor": a.apIri(config),
"published": time.Now().Format("2006-01-02T15:04:05-07:00"),
"type": "Update",
"object": person,
})
person := a.toApPerson(blog)
update := ap.UpdateNew(a.apNewID(config), person)
update.Actor = a.apAPIri(config)
update.Published = time.Now()
a.apSendToAllFollowers(blog, update)
}
}
func (a *goBlog) apSendToAllFollowers(blog string, activity any) {
func (a *goBlog) apSendToAllFollowers(blog string, activity *ap.Activity) {
inboxes, err := a.db.apGetAllInboxes(blog)
if err != nil {
log.Println("Failed to retrieve inboxes:", err.Error())
@ -454,7 +459,7 @@ func (a *goBlog) apSendToAllFollowers(blog string, activity any) {
a.apSendTo(a.apIri(a.cfg.Blogs[blog]), activity, inboxes)
}
func (a *goBlog) apSendTo(blogIri string, activity any, inboxes []string) {
func (a *goBlog) apSendTo(blogIri string, activity *ap.Activity, inboxes []string) {
for _, i := range inboxes {
go func(inbox string) {
_ = a.apQueueSendSigned(blogIri, inbox, activity)
@ -462,14 +467,18 @@ func (a *goBlog) apSendTo(blogIri string, activity any, inboxes []string) {
}
}
func (a *goBlog) apNewID(blog *configBlog) (hash string, url string) {
return hash, a.apIri(blog) + "#" + uuid.NewString()
func (a *goBlog) apNewID(blog *configBlog) ap.ID {
return ap.ID(a.apIri(blog) + "#" + uuid.NewString())
}
func (a *goBlog) apIri(b *configBlog) string {
return a.getFullAddress(b.getRelativePath(""))
}
func (a *goBlog) apAPIri(b *configBlog) ap.IRI {
return ap.IRI(a.apIri(b))
}
func apRequestIsSuccess(code int) bool {
return code == http.StatusOK || code == http.StatusCreated || code == http.StatusAccepted || code == http.StatusNoContent
}
@ -519,23 +528,3 @@ func (a *goBlog) loadActivityPubPrivateKey() error {
}),
)
}
func (a *goBlog) apShowFollowers(w http.ResponseWriter, r *http.Request) {
blogName := chi.URLParam(r, "blog")
blog, ok := a.cfg.Blogs[blogName]
if !ok || blog == nil {
a.serveError(w, r, "Blog not found", http.StatusNotFound)
return
}
followers, err := a.db.apGetAllFollowers(blogName)
if err != nil {
a.serveError(w, r, "Failed to get followers", http.StatusInternalServerError)
return
}
a.render(w, r, a.renderActivityPubFollowers, &renderData{
BlogString: blogName,
Data: &activityPubFollowersRenderData{
followers: followers,
},
})
}

View File

@ -4,13 +4,14 @@ import (
"bytes"
"context"
"encoding/gob"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
)
@ -47,7 +48,7 @@ func (a *goBlog) initAPSendQueue() {
}
func (a *goBlog) apQueueSendSigned(blogIri, to string, activity any) error {
body, err := json.Marshal(activity)
body, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(activity)
if err != nil {
return err
}

27
activityPubTools.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func (a *goBlog) apShowFollowers(w http.ResponseWriter, r *http.Request) {
blogName := chi.URLParam(r, "blog")
blog, ok := a.cfg.Blogs[blogName]
if !ok || blog == nil {
a.serveError(w, r, "Blog not found", http.StatusNotFound)
return
}
followers, err := a.db.apGetAllFollowers(blogName)
if err != nil {
a.serveError(w, r, "Failed to get followers", http.StatusInternalServerError)
return
}
a.render(w, r, a.renderActivityPubFollowers, &renderData{
BlogString: blogName,
Data: &activityPubFollowersRenderData{
followers: followers,
},
})
}

View File

@ -1,20 +1,19 @@
package main
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"github.com/araddon/dateparse"
ct "github.com/elnormous/contenttype"
"go.goblog.app/app/pkgs/bufferpool"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"go.goblog.app/app/pkgs/contenttype"
)
const asContext = "https://www.w3.org/ns/activitystreams"
const asRequestKey contextKey = "asRequest"
func (a *goBlog) checkActivityStreamsRequest(next http.Handler) http.Handler {
@ -37,177 +36,122 @@ func (a *goBlog) checkActivityStreamsRequest(next http.Handler) http.Handler {
})
}
type asNote struct {
Context any `json:"@context,omitempty"`
To []string `json:"to,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
MediaType string `json:"mediaType,omitempty"`
Attachment []*asAttachment `json:"attachment,omitempty"`
Published string `json:"published,omitempty"`
Updated string `json:"updated,omitempty"`
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
AttributedTo string `json:"attributedTo,omitempty"`
Tag []*asTag `json:"tag,omitempty"`
}
type asPerson struct {
Context any `json:"@context,omitempty"`
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Summary string `json:"summary,omitempty"`
PreferredUsername string `json:"preferredUsername,omitempty"`
Icon *asAttachment `json:"icon,omitempty"`
Inbox string `json:"inbox,omitempty"`
PublicKey *asPublicKey `json:"publicKey,omitempty"`
Endpoints *asEndpoints `json:"endpoints,omitempty"`
}
type asAttachment struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
MediaType string `json:"mediaType,omitempty"`
}
type asTag struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Href string `json:"href,omitempty"`
}
type asPublicKey struct {
ID string `json:"id,omitempty"`
Owner string `json:"owner,omitempty"`
PublicKeyPem string `json:"publicKeyPem,omitempty"`
}
type asEndpoints struct {
SharedInbox string `json:"sharedInbox,omitempty"`
}
func (a *goBlog) serveActivityStreamsPost(p *post, w http.ResponseWriter) {
buf := bufferpool.Get()
defer bufferpool.Put(buf)
if err := json.NewEncoder(buf).Encode(a.toASNote(p)); err != nil {
http.Error(w, "Encoding failed", http.StatusInternalServerError)
func (a *goBlog) serveActivityStreamsPost(p *post, w http.ResponseWriter, r *http.Request) {
note := a.toAPNote(p)
// Encode
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(note)
if err != nil {
a.serveError(w, r, "Encoding failed", http.StatusInternalServerError)
return
}
// Send response
w.Header().Set(contentType, contenttype.ASUTF8)
_ = a.min.Get().Minify(contenttype.AS, w, buf)
_ = a.min.Get().Minify(contenttype.AS, w, bytes.NewReader(binary))
}
func (a *goBlog) toASNote(p *post) *asNote {
func (a *goBlog) toAPNote(p *post) *ap.Note {
// Create a Note object
as := &asNote{
Context: []string{asContext},
To: []string{"https://www.w3.org/ns/activitystreams#Public"},
MediaType: contenttype.HTML,
ID: a.activityPubId(p),
URL: a.fullPostURL(p),
AttributedTo: a.apIri(a.cfg.Blogs[p.Blog]),
}
note := ap.ObjectNew(ap.NoteType)
note.To.Append(ap.PublicNS)
note.MediaType = ap.MimeType(contenttype.HTML)
note.ID = a.activityPubId(p)
note.URL = ap.IRI(a.fullPostURL(p))
note.AttributedTo = a.apAPIri(a.cfg.Blogs[p.Blog])
// Name and Type
if title := p.RenderedTitle; title != "" {
as.Name = title
as.Type = "Article"
} else {
as.Type = "Note"
note.Type = ap.ArticleType
note.Name.Add(ap.DefaultLangRef(title))
}
// Content
as.Content = a.postHtml(p, true)
note.Content.Add(ap.DefaultLangRef(a.postHtml(p, true)))
// Attachments
if images := p.Parameters[a.cfg.Micropub.PhotoParam]; len(images) > 0 {
var attachments ap.ItemCollection
for _, image := range images {
as.Attachment = append(as.Attachment, &asAttachment{
Type: "Image",
URL: image,
})
apImage := ap.ObjectNew(ap.ImageType)
apImage.URL = ap.IRI(image)
attachments.Append(apImage)
}
note.Attachment = attachments
}
// Tags
for _, tagTax := range a.cfg.ActivityPub.TagsTaxonomies {
for _, tag := range p.Parameters[tagTax] {
as.Tag = append(as.Tag, &asTag{
Type: "Hashtag",
Name: tag,
Href: a.getFullAddress(a.getRelativePath(p.Blog, fmt.Sprintf("/%s/%s", tagTax, urlize(tag)))),
})
apTag := &ap.Object{Type: "Hashtag"}
apTag.Name.Add(ap.DefaultLangRef(tag))
apTag.URL = ap.IRI(a.getFullAddress(a.getRelativePath(p.Blog, fmt.Sprintf("/%s/%s", tagTax, urlize(tag)))))
note.Tag.Append(apTag)
}
}
// Dates
dateFormat := "2006-01-02T15:04:05-07:00"
if p.Published != "" {
if t, err := dateparse.ParseLocal(p.Published); err == nil {
as.Published = t.Format(dateFormat)
note.Published = t
}
}
if p.Updated != "" {
if t, err := dateparse.ParseLocal(p.Updated); err == nil {
as.Updated = t.Format(dateFormat)
note.Published = t
}
}
// Reply
if replyLink := p.firstParameter(a.cfg.Micropub.ReplyParam); replyLink != "" {
as.InReplyTo = replyLink
note.InReplyTo = ap.IRI(replyLink)
}
return as
return note
}
const activityPubVersionParam = "activitypubversion"
func (a *goBlog) activityPubId(p *post) string {
func (a *goBlog) activityPubId(p *post) ap.IRI {
fu := a.fullPostURL(p)
if version := p.firstParameter(activityPubVersionParam); version != "" {
return fu + "?activitypubversion=" + version
return ap.IRI(fu + "?activitypubversion=" + version)
}
return fu
return ap.IRI(fu)
}
func (a *goBlog) toAsPerson(blog string) *asPerson {
func (a *goBlog) toApPerson(blog string) *ap.Person {
b := a.cfg.Blogs[blog]
asBlog := &asPerson{
Context: []string{asContext},
Type: "Person",
ID: a.apIri(b),
URL: a.apIri(b),
Name: a.renderMdTitle(b.Title),
Summary: b.Description,
PreferredUsername: blog,
Inbox: a.getFullAddress("/activitypub/inbox/" + blog),
PublicKey: &asPublicKey{
Owner: a.apIri(b),
ID: a.apIri(b) + "#main-key",
PublicKeyPem: string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Headers: nil,
Bytes: a.apPubKeyBytes,
})),
},
}
apIri := a.apAPIri(b)
apBlog := ap.PersonNew(apIri)
apBlog.URL = apIri
apBlog.Name.Set(ap.DefaultLang, ap.Content(a.renderMdTitle(b.Title)))
apBlog.Summary.Set(ap.DefaultLang, ap.Content(b.Description))
apBlog.PreferredUsername.Set(ap.DefaultLang, ap.Content(blog))
apBlog.Inbox = ap.IRI(a.getFullAddress("/activitypub/inbox/" + blog))
apBlog.PublicKey.Owner = apIri
apBlog.PublicKey.ID = ap.IRI(a.apIri(b) + "#main-key")
apBlog.PublicKey.PublicKeyPem = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Headers: nil,
Bytes: a.apPubKeyBytes,
}))
if pic := a.cfg.User.Picture; pic != "" {
asBlog.Icon = &asAttachment{
Type: "Image",
URL: pic,
MediaType: mimeTypeFromUrl(pic),
}
icon := &ap.Image{}
icon.Type = ap.ImageType
icon.MediaType = ap.MimeType(mimeTypeFromUrl(pic))
icon.URL = ap.IRI(pic)
apBlog.Icon = icon
}
return asBlog
return apBlog
}
func (a *goBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *http.Request) {
person := a.toAsPerson(blog)
person := a.toApPerson(blog)
// Encode
buf := bufferpool.Get()
defer bufferpool.Put(buf)
if err := json.NewEncoder(buf).Encode(person); err != nil {
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
if err != nil {
a.serveError(w, r, "Encoding failed", http.StatusInternalServerError)
return
}
// Send response
w.Header().Set(contentType, contenttype.ASUTF8)
_ = a.min.Get().Minify(contenttype.AS, w, buf)
_ = a.min.Get().Minify(contenttype.AS, w, bytes.NewReader(binary))
}

View File

@ -2,6 +2,7 @@ package main
import (
"database/sql"
"errors"
"net/http"
"net/url"
"path"
@ -15,11 +16,12 @@ import (
const commentPath = "/comment"
type comment struct {
ID int
Target string
Name string
Website string
Comment string
ID int
Target string
Name string
Website string
Comment string
Original string
}
func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
@ -28,13 +30,13 @@ func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
a.serveError(w, r, err.Error(), http.StatusBadRequest)
return
}
row, err := a.db.QueryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id))
row, err := a.db.QueryRow("select id, target, name, website, comment, original from comments where id = @id", sql.Named("id", id))
if err != nil {
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
comment := &comment{}
if err = row.Scan(&comment.ID, &comment.Target, &comment.Name, &comment.Website, &comment.Comment); err == sql.ErrNoRows {
if err = row.Scan(&comment.ID, &comment.Target, &comment.Name, &comment.Website, &comment.Comment, &comment.Original); err == sql.ErrNoRows {
a.serve404(w, r)
return
} else if err != nil {
@ -42,60 +44,102 @@ func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
return
}
_, bc := a.getBlog(r)
canonical := a.getFullAddress(bc.getRelativePath(path.Join(commentPath, strconv.Itoa(id))))
if comment.Original != "" {
canonical = comment.Original
}
a.render(w, r, a.renderComment, &renderData{
Canonical: a.getFullAddress(bc.getRelativePath(path.Join(commentPath, strconv.Itoa(id)))),
Canonical: canonical,
Data: comment,
})
}
func (a *goBlog) createComment(w http.ResponseWriter, r *http.Request) {
// Check target
target := a.checkCommentTarget(w, r)
if target == "" {
func (a *goBlog) createCommentFromRequest(w http.ResponseWriter, r *http.Request) {
target := r.FormValue("target")
comment := r.FormValue("comment")
name := r.FormValue("name")
website := r.FormValue("website")
_, bc := a.getBlog(r)
// Create comment
result, errStatus, err := a.createComment(bc, target, comment, name, website, "")
if err != nil {
a.serveError(w, r, err.Error(), errStatus)
return
}
// Redirect to comment
http.Redirect(w, r, result, http.StatusFound)
}
func (a *goBlog) createComment(bc *configBlog, target, comment, name, website, original string) (string, int, error) {
updateId := -1
// Check target
target, status, err := a.checkCommentTarget(target)
if err != nil {
return "", status, err
}
// Check and clean comment
comment := cleanHTMLText(r.FormValue("comment"))
comment = cleanHTMLText(comment)
if comment == "" {
a.serveError(w, r, "Comment is empty", http.StatusBadRequest)
return
return "", http.StatusBadRequest, errors.New("comment is empty")
}
name = defaultIfEmpty(cleanHTMLText(name), "Anonymous")
website = cleanHTMLText(website)
original = cleanHTMLText(original)
if original != "" {
// Check if comment already exists
exists, id, err := a.db.commentIdByOriginal(original)
if err != nil {
return "", http.StatusInternalServerError, errors.New("failed to check the database")
}
if exists {
updateId = id
}
}
name := defaultIfEmpty(cleanHTMLText(r.FormValue("name")), "Anonymous")
website := cleanHTMLText(r.FormValue("website"))
// Insert
result, err := a.db.Exec("insert into comments (target, comment, name, website) values (@target, @comment, @name, @website)", sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website))
if err != nil {
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
if commentID, err := result.LastInsertId(); err != nil {
// Serve error
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
if updateId == -1 {
result, err := a.db.Exec(
"insert into comments (target, comment, name, website, original) values (@target, @comment, @name, @website, @original)",
sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website), sql.Named("original", original),
)
if err != nil {
return "", http.StatusInternalServerError, errors.New("failed to save comment to database")
}
if commentID, err := result.LastInsertId(); err != nil {
return "", http.StatusInternalServerError, errors.New("failed to save comment to database")
} else {
commentAddress := bc.getRelativePath(path.Join(commentPath, strconv.Itoa(int(commentID))))
// Send webmention
_ = a.createWebmention(a.getFullAddress(commentAddress), a.getFullAddress(target))
// Return comment path
return commentAddress, 0, nil
}
} else {
_, bc := a.getBlog(r)
commentAddress := bc.getRelativePath(path.Join(commentPath, strconv.Itoa(int(commentID))))
_, err := a.db.Exec(
"update comments set target = @target, comment = @comment, name = @name, website = @website where id = @id",
sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website), sql.Named("id", updateId),
)
if err != nil {
return "", http.StatusInternalServerError, errors.New("failed to update comment in database")
}
commentAddress := bc.getRelativePath(path.Join(commentPath, strconv.Itoa(updateId)))
// Send webmention
_ = a.createWebmention(a.getFullAddress(commentAddress), a.getFullAddress(target))
// Redirect to comment
http.Redirect(w, r, commentAddress, http.StatusFound)
// Return comment path
return commentAddress, 0, nil
}
}
func (a *goBlog) checkCommentTarget(w http.ResponseWriter, r *http.Request) string {
target := r.FormValue("target")
func (a *goBlog) checkCommentTarget(target string) (string, int, error) {
if target == "" {
a.serveError(w, r, "No target specified", http.StatusBadRequest)
return ""
return "", http.StatusBadRequest, errors.New("no target specified")
} else if !strings.HasPrefix(target, a.cfg.Server.PublicAddress) {
a.serveError(w, r, "Bad target", http.StatusBadRequest)
return ""
return "", http.StatusBadRequest, errors.New("bad target")
}
targetURL, err := url.Parse(target)
if err != nil {
a.serveError(w, r, err.Error(), http.StatusBadRequest)
return ""
return "", http.StatusBadRequest, errors.New("failed to parse URL")
}
return targetURL.Path
return targetURL.Path, 0, nil
}
type commentsRequestConfig struct {
@ -105,7 +149,7 @@ type commentsRequestConfig struct {
func buildCommentsQuery(config *commentsRequestConfig) (query string, args []any) {
queryBuilder := bufferpool.Get()
defer bufferpool.Put(queryBuilder)
queryBuilder.WriteString("select id, target, name, website, comment from comments order by id desc")
queryBuilder.WriteString("select id, target, name, website, comment, original from comments order by id desc")
if config.limit != 0 || config.offset != 0 {
queryBuilder.WriteString(" limit @limit offset @offset")
args = append(args, sql.Named("limit", config.limit), sql.Named("offset", config.offset))
@ -122,7 +166,7 @@ func (db *database) getComments(config *commentsRequestConfig) ([]*comment, erro
}
for rows.Next() {
c := &comment{}
err = rows.Scan(&c.ID, &c.Target, &c.Name, &c.Website, &c.Comment)
err = rows.Scan(&c.ID, &c.Target, &c.Name, &c.Website, &c.Comment, &c.Original)
if err != nil {
return nil, err
}
@ -147,6 +191,20 @@ func (db *database) deleteComment(id int) error {
return err
}
func (db *database) commentIdByOriginal(original string) (bool, int, error) {
var id int
row, err := db.QueryRow("select id from comments where original = @original", sql.Named("original", original))
if err != nil {
return false, 0, err
}
if err := row.Scan(&id); err != nil && errors.Is(err, sql.ErrNoRows) {
return false, 0, nil
} else if err != nil {
return false, 0, err
}
return true, id, nil
}
func (a *goBlog) commentsEnabledForBlog(blog *configBlog) bool {
return blog.Comments != nil && blog.Comments.Enabled
}

View File

@ -48,7 +48,7 @@ func Test_comments(t *testing.T) {
req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder()
app.createComment(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
app.createCommentFromRequest(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
res := rec.Result()
assert.Equal(t, http.StatusFound, res.StatusCode)
@ -117,7 +117,7 @@ func Test_comments(t *testing.T) {
req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder()
app.createComment(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
app.createCommentFromRequest(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
res := rec.Result()
assert.Equal(t, http.StatusFound, res.StatusCode)
@ -153,7 +153,7 @@ func Test_comments(t *testing.T) {
req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder()
app.createComment(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
app.createCommentFromRequest(rec, req.WithContext(context.WithValue(req.Context(), blogKey, "en")))
assert.Equal(t, http.StatusBadRequest, rec.Code)

1
dbmigrations/00032.sql Normal file
View File

@ -0,0 +1 @@
alter table comments add original text not null default "";

7
go.mod
View File

@ -21,6 +21,8 @@ require (
github.com/elnormous/contenttype v1.0.3
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-smtp v0.15.0
github.com/go-ap/activitypub v0.0.0-20221119120906-cb8207231e18
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.0.7
github.com/go-fed/httpsig v1.1.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
@ -70,6 +72,7 @@ require (
)
require (
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
@ -79,6 +82,7 @@ require (
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
@ -104,9 +108,10 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdewolff/parse/v2 v2.6.4 // indirect
github.com/valyala/fastjson v1.6.3 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect
golang.org/x/oauth2 v0.1.0 // indirect
golang.org/x/sys v0.2.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect

14
go.sum
View File

@ -44,6 +44,8 @@ git.jlel.se/jlelse/goldmark-mark v0.0.0-20210522162520-9788c89266a4 h1:p3c/vCY6M
git.jlel.se/jlelse/goldmark-mark v0.0.0-20210522162520-9788c89266a4/go.mod h1:ZFhxwbX+afhgbzh5rpkSJUp6vIduNPtIGDrsWpIcHTE=
git.jlel.se/jlelse/template-strings v0.0.0-20220211095702-c012e3b5045b h1:zrGLEeWzv7bzGRUKsS42akQpszXwEU+8nXV2Z2iDSJM=
git.jlel.se/jlelse/template-strings v0.0.0-20220211095702-c012e3b5045b/go.mod h1:UNLE8cup2GTHbsE89xezRwq3GhKspPI9NyckPbgJEmw=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
@ -122,6 +124,12 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-ap/activitypub v0.0.0-20221119120906-cb8207231e18 h1:ViTJy/5JI0zKtpeju09rAM0HPTc0Xmnh+uYtxYZ+tfs=
github.com/go-ap/activitypub v0.0.0-20221119120906-cb8207231e18/go.mod h1:acWwxqPWwm9hmjSlva1N1na1J6eGeC9kKTS3YEBOVJI=
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 h1:oySiT87Q2cd0o5O8er2zyjiRcTQA0KuOgw1N9+RQqG0=
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
@ -382,6 +390,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc=
github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vcraescu/go-paginator v1.0.1-0.20201114172518-2cfc59fe05c2 h1:l5j4nE6rosbObXB/uPmzxOQ2z5uXlFOgttlHJ2YL/0w=
github.com/vcraescu/go-paginator v1.0.1-0.20201114172518-2cfc59fe05c2/go.mod h1:NEDNuq1asYbAeX+uy6w56MDQSFmBQz9k+N9Hy6m4r2U=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
@ -495,8 +505,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -369,7 +369,7 @@ func (a *goBlog) blogCommentsRouter(conf *configBlog) func(r chi.Router) {
middleware.WithValue(pathKey, commentsPath),
)
r.With(a.cacheMiddleware, noIndexHeader).Get("/{id:[0-9]+}", a.serveComment)
r.With(a.captchaMiddleware).Post("/", a.createComment)
r.With(a.captchaMiddleware).Post("/", a.createCommentFromRequest)
r.Group(func(r chi.Router) {
// Admin
r.Use(a.authMiddleware)

View File

@ -66,7 +66,7 @@ func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) {
a.serveActivityStreams(p.Blog, w, r)
return
}
a.serveActivityStreamsPost(p, w)
a.serveActivityStreamsPost(p, w, r)
return
}
canonical := p.firstParameter("original")

18
ui.go
View File

@ -358,7 +358,16 @@ func (a *goBlog) renderComment(h *htmlbuilder.HtmlBuilder, rd *renderData) {
hb.WriteElementOpen("p", "class", "e-content")
hb.WriteUnescaped(c.Comment) // Already escaped
hb.WriteElementClose("p")
// Original
if c.Original != "" {
hb.WriteElementOpen("p", "class", "")
hb.WriteElementOpen("a", "class", "u-url", "target", "_blank", "rel", "nofollow noopener noreferrer ugc", "href", c.Original)
hb.WriteEscaped(c.Original)
hb.WriteElementClose("a")
hb.WriteElementClose("p")
}
hb.WriteElementClose("main")
// Original
// Interactions
if a.commentsEnabledForBlog(rd.Blog) {
a.renderInteractions(hb, rd)
@ -1215,7 +1224,7 @@ func (a *goBlog) renderCommentsAdmin(hb *htmlbuilder.HtmlBuilder, rd *renderData
hb.WriteElementOpen("h1")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comments"))
hb.WriteElementClose("h1")
// Notifications
// Comments
for _, c := range crd.comments {
hb.WriteElementOpen("div", "class", "p")
// ID, Target, Name
@ -1236,6 +1245,13 @@ func (a *goBlog) renderCommentsAdmin(hb *htmlbuilder.HtmlBuilder, rd *renderData
if c.Website != "" {
hb.WriteElementClose("a")
}
if c.Original != "" {
hb.WriteElementOpen("br")
hb.WriteEscaped("Original: ")
hb.WriteElementOpen("a", "href", c.Website, "target", "_blank", "rel", "nofollow noopener noreferrer ugc")
hb.WriteEscaped(c.Original)
hb.WriteElementClose("a")
}
hb.WriteElementClose("p")
// Comment
hb.WriteElementOpen("p")

View File

@ -186,6 +186,11 @@ func (db *database) deleteWebmentionId(id int) error {
return err
}
func (db *database) deleteWebmentionUUrl(uUrl string) error {
_, err := db.Exec("delete from webmentions where url = @url", sql.Named("url", uUrl))
return err
}
func (db *database) deleteWebmention(m *mention) error {
_, err := db.Exec(
"delete from webmentions where lowerunescaped(source) in (lowerunescaped(@source), lowerunescaped(@newsource)) and lowerunescaped(target) in (lowerunescaped(@target), lowerunescaped(@newtarget))",