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" "strings"
"time" "time"
ap "github.com/go-ap/activitypub"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-fed/httpsig" "github.com/go-fed/httpsig"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/spf13/cast"
"go.goblog.app/app/pkgs/bufferpool" "go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype" "go.goblog.app/app/pkgs/contenttype"
) )
@ -168,73 +168,86 @@ func (a *goBlog) apHandleInbox(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse activity // Parse activity
activity := map[string]any{} limit := int64(10 * 1000 * 1000) // 10 MB
err = json.NewDecoder(r.Body).Decode(&activity) body, err := io.ReadAll(io.LimitReader(r.Body, limit))
_ = r.Body.Close() if err != nil {
a.serveError(w, r, "Failed to read body", http.StatusBadRequest)
return
}
apItem, err := ap.UnmarshalJSON(body)
if err != nil { if err != nil {
a.serveError(w, r, "Failed to decode body", http.StatusBadRequest) a.serveError(w, r, "Failed to decode body", http.StatusBadRequest)
return return
} }
// Get and check activity actor // Check if it's an activity
activityActor, ok := activity["actor"].(string) activity, err := ap.ToActivity(apItem)
if !ok { if err != nil {
a.serveError(w, r, "actor in activity is no string", http.StatusBadRequest) a.serveError(w, r, "No activity", http.StatusBadRequest)
return 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) a.serveError(w, r, "Request actor isn't activity actor", http.StatusForbidden)
return return
} }
// Do // Handle activity
switch activity["type"] { switch activity.GetType() {
case "Follow": case ap.FollowType:
a.apAccept(blogName, blogIri, blog, activity) a.apAccept(blogName, blogIri, blog, activity)
case "Undo": case ap.UndoType:
if object, ok := activity["object"].(map[string]any); ok { if activity.Object.IsObject() {
ot := cast.ToString(object["type"]) objectActivity, err := ap.ToActivity(activity.Object)
actor := cast.ToString(object["actor"]) if err == nil && objectActivity.GetType() == ap.FollowType && objectActivity.Actor.GetID() == activityActor {
if ot == "Follow" && actor == activityActor { _ = a.db.apRemoveFollower(blogName, activityActor.String())
_ = a.db.apRemoveFollower(blogName, activityActor)
} }
} }
case "Create": case ap.CreateType, ap.UpdateType:
if object, ok := activity["object"].(map[string]any); ok { if activity.Object.IsObject() {
baseUrl := cast.ToString(object["id"]) object, err := ap.ToObject(activity.Object)
if ou := cast.ToString(object["url"]); ou != "" { if err == nil && (object.GetType() == ap.NoteType || object.GetType() == ap.ArticleType) {
baseUrl = ou objectLink := object.GetID()
} if replyTo := object.InReplyTo.GetID(); objectLink != "" && replyTo != "" && strings.HasPrefix(replyTo.String(), blogIri) {
if r := cast.ToString(object["inReplyTo"]); r != "" && baseUrl != "" && strings.HasPrefix(r, blogIri) { target := replyTo.String()
// It's an ActivityPub reply; save reply as webmention original := objectLink.String()
_ = a.createWebmention(baseUrl, r) name := requestActor.Name.First().Value.String()
} else if content := cast.ToString(object["content"]); content != "" && baseUrl != "" { if username := requestActor.PreferredUsername.First().String(); name == "" && username != "" {
// May be a mention; find links to blog and save them as webmentions name = username
if links, err := allLinksFromHTMLString(content, baseUrl); err == nil {
for _, link := range links {
if strings.HasPrefix(link, a.cfg.Server.PublicAddress) {
_ = a.createWebmention(baseUrl, link)
}
} }
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": case ap.DeleteType, ap.BlockType:
if o := cast.ToString(activity["object"]); o == activityActor { if activity.Object.GetID() == activityActor {
_ = a.db.apRemoveFollower(blogName, activityActor) _ = a.db.apRemoveFollower(blogName, activityActor.String())
} } else {
case "Like": // Check if comment exists
if o := cast.ToString(activity["object"]); o != "" && strings.HasPrefix(o, blogIri) { exists, commentId, err := a.db.commentIdByOriginal(activity.Object.GetID().String())
a.sendNotification(fmt.Sprintf("%s liked %s", activityActor, o)) if err == nil && exists {
} _ = a.db.deleteComment(commentId)
case "Announce": _ = a.db.deleteWebmentionUUrl(activity.Object.GetID().String())
if o := cast.ToString(activity["object"]); o != "" && strings.HasPrefix(o, blogIri) { }
a.sendNotification(fmt.Sprintf("%s announced %s", activityActor, o))
} }
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 // Return 200
w.WriteHeader(http.StatusOK) 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) verifier, err := httpsig.NewVerifier(r)
if err != nil { if err != nil {
// Error with signature header etc. // 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 // Actor not found or something else bad
return nil, keyID, statusCode, err 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") return nil, keyID, 0, errors.New("actor has no public key")
} }
block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem)) 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>`) _, _ = 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("")) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, iri, strings.NewReader(""))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -292,8 +305,17 @@ func (a *goBlog) apGetRemoteActor(iri, ownBlogIri string) (*asPerson, int, error
if !apRequestIsSuccess(resp.StatusCode) { if !apRequestIsSuccess(resp.StatusCode) {
return nil, resp.StatusCode, nil return nil, resp.StatusCode, nil
} }
actor := &asPerson{} // Parse response
err = json.NewDecoder(resp.Body).Decode(actor) 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -352,35 +374,28 @@ func (db *database) apRemoveInbox(inbox string) error {
} }
func (a *goBlog) apPost(p *post) { func (a *goBlog) apPost(p *post) {
n := a.toASNote(p) blogConfig := a.cfg.Blogs[p.Blog]
a.apSendToAllFollowers(p.Blog, map[string]any{ note := a.toAPNote(p)
"@context": []string{asContext}, create := ap.CreateNew(a.activityPubId(p), note)
"actor": a.apIri(a.cfg.Blogs[p.Blog]), create.Actor = a.apAPIri(blogConfig)
"id": a.activityPubId(p), create.Published = time.Now()
"published": n.Published, a.apSendToAllFollowers(p.Blog, create)
"type": "Create",
"object": n,
})
} }
func (a *goBlog) apUpdate(p *post) { func (a *goBlog) apUpdate(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]any{ blogConfig := a.cfg.Blogs[p.Blog]
"@context": []string{asContext}, note := a.toAPNote(p)
"actor": a.apIri(a.cfg.Blogs[p.Blog]), update := ap.UpdateNew(a.activityPubId(p), note)
"id": a.activityPubId(p), update.Actor = a.apAPIri(blogConfig)
"published": time.Now().Format("2006-01-02T15:04:05-07:00"), update.Published = time.Now()
"type": "Update", a.apSendToAllFollowers(p.Blog, update)
"object": a.toASNote(p),
})
} }
func (a *goBlog) apDelete(p *post) { func (a *goBlog) apDelete(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]any{ blogConfig := a.cfg.Blogs[p.Blog]
"@context": []string{asContext}, delete := ap.DeleteNew(a.apNewID(blogConfig), a.activityPubId(p))
"actor": a.apIri(a.cfg.Blogs[p.Blog]), delete.Actor = a.apAPIri(blogConfig)
"type": "Delete", a.apSendToAllFollowers(p.Blog, delete)
"object": a.activityPubId(p),
})
} }
func (a *goBlog) apUndelete(p *post) { func (a *goBlog) apUndelete(p *post) {
@ -397,55 +412,45 @@ func (a *goBlog) apUndelete(p *post) {
a.apPost(p) a.apPost(p)
} }
func (a *goBlog) apAccept(blogName, blogIri string, blog *configBlog, follow map[string]any) { func (a *goBlog) apAccept(blogName, blogIri string, blog *configBlog, follow *ap.Activity) {
// it's a follow, write it down newFollower := follow.Actor.GetID()
newFollower := follow["actor"].(string) log.Println("New follow request:", newFollower.String())
log.Println("New follow request:", newFollower) // Get remote actor
// check we aren't following ourselves follower, status, err := a.apGetRemoteActor(newFollower.String(), blogIri)
if newFollower == follow["object"] {
// actor and object are equal
return
}
follower, status, err := a.apGetRemoteActor(newFollower, blogIri)
if err != nil || status != 0 { if err != nil || status != 0 {
// Couldn't retrieve remote actor info // Couldn't retrieve remote actor info
log.Println("Failed to retrieve remote actor info:", newFollower) log.Println("Failed to retrieve remote actor info:", newFollower)
return return
} }
// Add or update follower // Add or update follower
inbox := follower.Inbox inbox := follower.Inbox.GetID()
if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != "" { if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != nil && endpoints.SharedInbox.GetID() != "" {
inbox = endpoints.SharedInbox 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 return
} }
// Send accept response to the new follower // Send accept response to the new follower
accept := map[string]any{ accept := ap.AcceptNew(a.apNewID(blog), follow)
"@context": []string{asContext}, accept.To = append(accept.To, newFollower)
"type": "Accept", accept.Actor = a.apAPIri(blog)
"to": follow["actor"], _ = a.apQueueSendSigned(a.apIri(blog), inbox.String(), accept)
"actor": a.apIri(blog),
"object": follow,
}
_, accept["id"] = a.apNewID(blog)
_ = a.apQueueSendSigned(a.apIri(blog), inbox, accept)
} }
func (a *goBlog) apSendProfileUpdates() { func (a *goBlog) apSendProfileUpdates() {
for blog, config := range a.cfg.Blogs { for blog, config := range a.cfg.Blogs {
person := a.toAsPerson(blog) person := a.toApPerson(blog)
a.apSendToAllFollowers(blog, map[string]any{ update := ap.UpdateNew(a.apNewID(config), person)
"@context": []string{asContext}, update.Actor = a.apAPIri(config)
"actor": a.apIri(config), update.Published = time.Now()
"published": time.Now().Format("2006-01-02T15:04:05-07:00"), a.apSendToAllFollowers(blog, update)
"type": "Update",
"object": person,
})
} }
} }
func (a *goBlog) apSendToAllFollowers(blog string, activity any) { func (a *goBlog) apSendToAllFollowers(blog string, activity *ap.Activity) {
inboxes, err := a.db.apGetAllInboxes(blog) inboxes, err := a.db.apGetAllInboxes(blog)
if err != nil { if err != nil {
log.Println("Failed to retrieve inboxes:", err.Error()) 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) 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 { for _, i := range inboxes {
go func(inbox string) { go func(inbox string) {
_ = a.apQueueSendSigned(blogIri, inbox, activity) _ = 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) { func (a *goBlog) apNewID(blog *configBlog) ap.ID {
return hash, a.apIri(blog) + "#" + uuid.NewString() return ap.ID(a.apIri(blog) + "#" + uuid.NewString())
} }
func (a *goBlog) apIri(b *configBlog) string { func (a *goBlog) apIri(b *configBlog) string {
return a.getFullAddress(b.getRelativePath("")) return a.getFullAddress(b.getRelativePath(""))
} }
func (a *goBlog) apAPIri(b *configBlog) ap.IRI {
return ap.IRI(a.apIri(b))
}
func apRequestIsSuccess(code int) bool { func apRequestIsSuccess(code int) bool {
return code == http.StatusOK || code == http.StatusCreated || code == http.StatusAccepted || code == http.StatusNoContent 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" "bytes"
"context" "context"
"encoding/gob" "encoding/gob"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"time" "time"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"go.goblog.app/app/pkgs/bufferpool" "go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype" "go.goblog.app/app/pkgs/contenttype"
) )
@ -47,7 +48,7 @@ func (a *goBlog) initAPSendQueue() {
} }
func (a *goBlog) apQueueSendSigned(blogIri, to string, activity any) error { 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 { if err != nil {
return err 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 package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"net/http" "net/http"
"github.com/araddon/dateparse" "github.com/araddon/dateparse"
ct "github.com/elnormous/contenttype" 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" "go.goblog.app/app/pkgs/contenttype"
) )
const asContext = "https://www.w3.org/ns/activitystreams"
const asRequestKey contextKey = "asRequest" const asRequestKey contextKey = "asRequest"
func (a *goBlog) checkActivityStreamsRequest(next http.Handler) http.Handler { 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 { func (a *goBlog) serveActivityStreamsPost(p *post, w http.ResponseWriter, r *http.Request) {
Context any `json:"@context,omitempty"` note := a.toAPNote(p)
To []string `json:"to,omitempty"` // Encode
InReplyTo string `json:"inReplyTo,omitempty"` binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(note)
Name string `json:"name,omitempty"` if err != nil {
Type string `json:"type,omitempty"` a.serveError(w, r, "Encoding failed", http.StatusInternalServerError)
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)
return return
} }
// Send response
w.Header().Set(contentType, contenttype.ASUTF8) 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 // Create a Note object
as := &asNote{ note := ap.ObjectNew(ap.NoteType)
Context: []string{asContext}, note.To.Append(ap.PublicNS)
To: []string{"https://www.w3.org/ns/activitystreams#Public"}, note.MediaType = ap.MimeType(contenttype.HTML)
MediaType: contenttype.HTML, note.ID = a.activityPubId(p)
ID: a.activityPubId(p), note.URL = ap.IRI(a.fullPostURL(p))
URL: a.fullPostURL(p), note.AttributedTo = a.apAPIri(a.cfg.Blogs[p.Blog])
AttributedTo: a.apIri(a.cfg.Blogs[p.Blog]),
}
// Name and Type // Name and Type
if title := p.RenderedTitle; title != "" { if title := p.RenderedTitle; title != "" {
as.Name = title note.Type = ap.ArticleType
as.Type = "Article" note.Name.Add(ap.DefaultLangRef(title))
} else {
as.Type = "Note"
} }
// Content // Content
as.Content = a.postHtml(p, true) note.Content.Add(ap.DefaultLangRef(a.postHtml(p, true)))
// Attachments // Attachments
if images := p.Parameters[a.cfg.Micropub.PhotoParam]; len(images) > 0 { if images := p.Parameters[a.cfg.Micropub.PhotoParam]; len(images) > 0 {
var attachments ap.ItemCollection
for _, image := range images { for _, image := range images {
as.Attachment = append(as.Attachment, &asAttachment{ apImage := ap.ObjectNew(ap.ImageType)
Type: "Image", apImage.URL = ap.IRI(image)
URL: image, attachments.Append(apImage)
})
} }
note.Attachment = attachments
} }
// Tags // Tags
for _, tagTax := range a.cfg.ActivityPub.TagsTaxonomies { for _, tagTax := range a.cfg.ActivityPub.TagsTaxonomies {
for _, tag := range p.Parameters[tagTax] { for _, tag := range p.Parameters[tagTax] {
as.Tag = append(as.Tag, &asTag{ apTag := &ap.Object{Type: "Hashtag"}
Type: "Hashtag", apTag.Name.Add(ap.DefaultLangRef(tag))
Name: tag, apTag.URL = ap.IRI(a.getFullAddress(a.getRelativePath(p.Blog, fmt.Sprintf("/%s/%s", tagTax, urlize(tag)))))
Href: a.getFullAddress(a.getRelativePath(p.Blog, fmt.Sprintf("/%s/%s", tagTax, urlize(tag)))), note.Tag.Append(apTag)
})
} }
} }
// Dates // Dates
dateFormat := "2006-01-02T15:04:05-07:00"
if p.Published != "" { if p.Published != "" {
if t, err := dateparse.ParseLocal(p.Published); err == nil { if t, err := dateparse.ParseLocal(p.Published); err == nil {
as.Published = t.Format(dateFormat) note.Published = t
} }
} }
if p.Updated != "" { if p.Updated != "" {
if t, err := dateparse.ParseLocal(p.Updated); err == nil { if t, err := dateparse.ParseLocal(p.Updated); err == nil {
as.Updated = t.Format(dateFormat) note.Published = t
} }
} }
// Reply // Reply
if replyLink := p.firstParameter(a.cfg.Micropub.ReplyParam); replyLink != "" { if replyLink := p.firstParameter(a.cfg.Micropub.ReplyParam); replyLink != "" {
as.InReplyTo = replyLink note.InReplyTo = ap.IRI(replyLink)
} }
return as return note
} }
const activityPubVersionParam = "activitypubversion" const activityPubVersionParam = "activitypubversion"
func (a *goBlog) activityPubId(p *post) string { func (a *goBlog) activityPubId(p *post) ap.IRI {
fu := a.fullPostURL(p) fu := a.fullPostURL(p)
if version := p.firstParameter(activityPubVersionParam); version != "" { 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] b := a.cfg.Blogs[blog]
asBlog := &asPerson{
Context: []string{asContext}, apIri := a.apAPIri(b)
Type: "Person",
ID: a.apIri(b), apBlog := ap.PersonNew(apIri)
URL: a.apIri(b), apBlog.URL = apIri
Name: a.renderMdTitle(b.Title),
Summary: b.Description, apBlog.Name.Set(ap.DefaultLang, ap.Content(a.renderMdTitle(b.Title)))
PreferredUsername: blog, apBlog.Summary.Set(ap.DefaultLang, ap.Content(b.Description))
Inbox: a.getFullAddress("/activitypub/inbox/" + blog), apBlog.PreferredUsername.Set(ap.DefaultLang, ap.Content(blog))
PublicKey: &asPublicKey{
Owner: a.apIri(b), apBlog.Inbox = ap.IRI(a.getFullAddress("/activitypub/inbox/" + blog))
ID: a.apIri(b) + "#main-key", apBlog.PublicKey.Owner = apIri
PublicKeyPem: string(pem.EncodeToMemory(&pem.Block{ apBlog.PublicKey.ID = ap.IRI(a.apIri(b) + "#main-key")
Type: "PUBLIC KEY", apBlog.PublicKey.PublicKeyPem = string(pem.EncodeToMemory(&pem.Block{
Headers: nil, Type: "PUBLIC KEY",
Bytes: a.apPubKeyBytes, Headers: nil,
})), Bytes: a.apPubKeyBytes,
}, }))
}
if pic := a.cfg.User.Picture; pic != "" { if pic := a.cfg.User.Picture; pic != "" {
asBlog.Icon = &asAttachment{ icon := &ap.Image{}
Type: "Image", icon.Type = ap.ImageType
URL: pic, icon.MediaType = ap.MimeType(mimeTypeFromUrl(pic))
MediaType: 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) { func (a *goBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *http.Request) {
person := a.toAsPerson(blog) person := a.toApPerson(blog)
// Encode // Encode
buf := bufferpool.Get() binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
defer bufferpool.Put(buf) if err != nil {
if err := json.NewEncoder(buf).Encode(person); err != nil {
a.serveError(w, r, "Encoding failed", http.StatusInternalServerError) a.serveError(w, r, "Encoding failed", http.StatusInternalServerError)
return return
} }
// Send response
w.Header().Set(contentType, contenttype.ASUTF8) 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 ( import (
"database/sql" "database/sql"
"errors"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
@ -15,11 +16,12 @@ import (
const commentPath = "/comment" const commentPath = "/comment"
type comment struct { type comment struct {
ID int ID int
Target string Target string
Name string Name string
Website string Website string
Comment string Comment string
Original string
} }
func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) { 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) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return 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 { if err != nil {
a.serveError(w, r, err.Error(), http.StatusInternalServerError) a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return return
} }
comment := &comment{} 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) a.serve404(w, r)
return return
} else if err != nil { } else if err != nil {
@ -42,60 +44,102 @@ func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
return return
} }
_, bc := a.getBlog(r) _, 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{ a.render(w, r, a.renderComment, &renderData{
Canonical: a.getFullAddress(bc.getRelativePath(path.Join(commentPath, strconv.Itoa(id)))), Canonical: canonical,
Data: comment, Data: comment,
}) })
} }
func (a *goBlog) createComment(w http.ResponseWriter, r *http.Request) { func (a *goBlog) createCommentFromRequest(w http.ResponseWriter, r *http.Request) {
// Check target target := r.FormValue("target")
target := a.checkCommentTarget(w, r) comment := r.FormValue("comment")
if target == "" { 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 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 // Check and clean comment
comment := cleanHTMLText(r.FormValue("comment")) comment = cleanHTMLText(comment)
if comment == "" { if comment == "" {
a.serveError(w, r, "Comment is empty", http.StatusBadRequest) return "", http.StatusBadRequest, errors.New("comment is empty")
return }
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 // 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 updateId == -1 {
if err != nil { result, err := a.db.Exec(
a.serveError(w, r, err.Error(), http.StatusInternalServerError) "insert into comments (target, comment, name, website, original) values (@target, @comment, @name, @website, @original)",
return sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website), sql.Named("original", original),
} )
if commentID, err := result.LastInsertId(); err != nil { if err != nil {
// Serve error return "", http.StatusInternalServerError, errors.New("failed to save comment to database")
a.serveError(w, r, err.Error(), http.StatusInternalServerError) }
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 { } else {
_, bc := a.getBlog(r) _, err := a.db.Exec(
commentAddress := bc.getRelativePath(path.Join(commentPath, strconv.Itoa(int(commentID)))) "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 // Send webmention
_ = a.createWebmention(a.getFullAddress(commentAddress), a.getFullAddress(target)) _ = a.createWebmention(a.getFullAddress(commentAddress), a.getFullAddress(target))
// Redirect to comment // Return comment path
http.Redirect(w, r, commentAddress, http.StatusFound) return commentAddress, 0, nil
} }
} }
func (a *goBlog) checkCommentTarget(w http.ResponseWriter, r *http.Request) string { func (a *goBlog) checkCommentTarget(target string) (string, int, error) {
target := r.FormValue("target")
if target == "" { if target == "" {
a.serveError(w, r, "No target specified", http.StatusBadRequest) return "", http.StatusBadRequest, errors.New("no target specified")
return ""
} else if !strings.HasPrefix(target, a.cfg.Server.PublicAddress) { } else if !strings.HasPrefix(target, a.cfg.Server.PublicAddress) {
a.serveError(w, r, "Bad target", http.StatusBadRequest) return "", http.StatusBadRequest, errors.New("bad target")
return ""
} }
targetURL, err := url.Parse(target) targetURL, err := url.Parse(target)
if err != nil { if err != nil {
a.serveError(w, r, err.Error(), http.StatusBadRequest) return "", http.StatusBadRequest, errors.New("failed to parse URL")
return ""
} }
return targetURL.Path return targetURL.Path, 0, nil
} }
type commentsRequestConfig struct { type commentsRequestConfig struct {
@ -105,7 +149,7 @@ type commentsRequestConfig struct {
func buildCommentsQuery(config *commentsRequestConfig) (query string, args []any) { func buildCommentsQuery(config *commentsRequestConfig) (query string, args []any) {
queryBuilder := bufferpool.Get() queryBuilder := bufferpool.Get()
defer bufferpool.Put(queryBuilder) 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 { if config.limit != 0 || config.offset != 0 {
queryBuilder.WriteString(" limit @limit offset @offset") queryBuilder.WriteString(" limit @limit offset @offset")
args = append(args, sql.Named("limit", config.limit), sql.Named("offset", config.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() { for rows.Next() {
c := &comment{} 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 { if err != nil {
return nil, err return nil, err
} }
@ -147,6 +191,20 @@ func (db *database) deleteComment(id int) error {
return err 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 { func (a *goBlog) commentsEnabledForBlog(blog *configBlog) bool {
return blog.Comments != nil && blog.Comments.Enabled 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) req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder() 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() res := rec.Result()
assert.Equal(t, http.StatusFound, res.StatusCode) assert.Equal(t, http.StatusFound, res.StatusCode)
@ -117,7 +117,7 @@ func Test_comments(t *testing.T) {
req.Header.Add(contentType, contenttype.WWWForm) req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder() 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() res := rec.Result()
assert.Equal(t, http.StatusFound, res.StatusCode) assert.Equal(t, http.StatusFound, res.StatusCode)
@ -153,7 +153,7 @@ func Test_comments(t *testing.T) {
req.Header.Add(contentType, contenttype.WWWForm) req.Header.Add(contentType, contenttype.WWWForm)
rec := httptest.NewRecorder() 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) 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/elnormous/contenttype v1.0.3
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-smtp v0.15.0 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-chi/chi/v5 v5.0.7
github.com/go-fed/httpsig v1.1.0 github.com/go-fed/httpsig v1.1.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
@ -70,6 +72,7 @@ require (
) )
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/andybalholm/cascadia v1.3.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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/dustin/go-humanize v1.0.0 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // 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/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.0 // indirect
@ -104,9 +108,10 @@ require (
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdewolff/parse/v2 v2.6.4 // 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/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // 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 golang.org/x/sys v0.2.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // 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/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 h1:zrGLEeWzv7bzGRUKsS42akQpszXwEU+8nXV2Z2iDSJM=
git.jlel.se/jlelse/template-strings v0.0.0-20220211095702-c012e3b5045b/go.mod h1:UNLE8cup2GTHbsE89xezRwq3GhKspPI9NyckPbgJEmw= 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/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/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= 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-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 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 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 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 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= 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 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 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/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 h1:l5j4nE6rosbObXB/uPmzxOQ2z5uXlFOgttlHJ2YL/0w=
github.com/vcraescu/go-paginator v1.0.1-0.20201114172518-2cfc59fe05c2/go.mod h1:NEDNuq1asYbAeX+uy6w56MDQSFmBQz9k+N9Hy6m4r2U= 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= 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-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-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-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.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= 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-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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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), middleware.WithValue(pathKey, commentsPath),
) )
r.With(a.cacheMiddleware, noIndexHeader).Get("/{id:[0-9]+}", a.serveComment) 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) { r.Group(func(r chi.Router) {
// Admin // Admin
r.Use(a.authMiddleware) 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) a.serveActivityStreams(p.Blog, w, r)
return return
} }
a.serveActivityStreamsPost(p, w) a.serveActivityStreamsPost(p, w, r)
return return
} }
canonical := p.firstParameter("original") 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.WriteElementOpen("p", "class", "e-content")
hb.WriteUnescaped(c.Comment) // Already escaped hb.WriteUnescaped(c.Comment) // Already escaped
hb.WriteElementClose("p") 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") hb.WriteElementClose("main")
// Original
// Interactions // Interactions
if a.commentsEnabledForBlog(rd.Blog) { if a.commentsEnabledForBlog(rd.Blog) {
a.renderInteractions(hb, rd) a.renderInteractions(hb, rd)
@ -1215,7 +1224,7 @@ func (a *goBlog) renderCommentsAdmin(hb *htmlbuilder.HtmlBuilder, rd *renderData
hb.WriteElementOpen("h1") hb.WriteElementOpen("h1")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comments")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comments"))
hb.WriteElementClose("h1") hb.WriteElementClose("h1")
// Notifications // Comments
for _, c := range crd.comments { for _, c := range crd.comments {
hb.WriteElementOpen("div", "class", "p") hb.WriteElementOpen("div", "class", "p")
// ID, Target, Name // ID, Target, Name
@ -1236,6 +1245,13 @@ func (a *goBlog) renderCommentsAdmin(hb *htmlbuilder.HtmlBuilder, rd *renderData
if c.Website != "" { if c.Website != "" {
hb.WriteElementClose("a") 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") hb.WriteElementClose("p")
// Comment // Comment
hb.WriteElementOpen("p") hb.WriteElementOpen("p")

View File

@ -186,6 +186,11 @@ func (db *database) deleteWebmentionId(id int) error {
return err 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 { func (db *database) deleteWebmention(m *mention) error {
_, err := db.Exec( _, err := db.Exec(
"delete from webmentions where lowerunescaped(source) in (lowerunescaped(@source), lowerunescaped(@newsource)) and lowerunescaped(target) in (lowerunescaped(@target), lowerunescaped(@newtarget))", "delete from webmentions where lowerunescaped(source) in (lowerunescaped(@source), lowerunescaped(@newsource)) and lowerunescaped(target) in (lowerunescaped(@target), lowerunescaped(@newtarget))",