From e005cda096a43eecf1d66c4395ad13ffee77ef56 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Else Date: Wed, 23 Nov 2022 22:16:56 +0100 Subject: [PATCH] Rework activitypub using library and support replies using the comment system --- activityPub.go | 245 ++++++++++++++++++++--------------------- activityPubSending.go | 5 +- activityPubTools.go | 27 +++++ activityStreams.go | 194 ++++++++++++-------------------- comments.go | 138 ++++++++++++++++------- comments_test.go | 6 +- dbmigrations/00032.sql | 1 + go.mod | 7 +- go.sum | 14 ++- httpRouters.go | 2 +- posts.go | 2 +- ui.go | 18 ++- webmention.go | 5 + 13 files changed, 360 insertions(+), 304 deletions(-) create mode 100644 activityPubTools.go create mode 100644 dbmigrations/00032.sql diff --git a/activityPub.go b/activityPub.go index 40091c4..eb9e7c3 100644 --- a/activityPub.go +++ b/activityPub.go @@ -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, ``) } -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, - }, - }) -} diff --git a/activityPubSending.go b/activityPubSending.go index 8dc48f5..8fbbfca 100644 --- a/activityPubSending.go +++ b/activityPubSending.go @@ -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 } diff --git a/activityPubTools.go b/activityPubTools.go new file mode 100644 index 0000000..1a8fce6 --- /dev/null +++ b/activityPubTools.go @@ -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, + }, + }) +} diff --git a/activityStreams.go b/activityStreams.go index 6f87559..5ac64f1 100644 --- a/activityStreams.go +++ b/activityStreams.go @@ -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)) } diff --git a/comments.go b/comments.go index 7964bb1..427e7f1 100644 --- a/comments.go +++ b/comments.go @@ -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 } diff --git a/comments_test.go b/comments_test.go index 816ac73..4d3aefb 100644 --- a/comments_test.go +++ b/comments_test.go @@ -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) diff --git a/dbmigrations/00032.sql b/dbmigrations/00032.sql new file mode 100644 index 0000000..cc37fb4 --- /dev/null +++ b/dbmigrations/00032.sql @@ -0,0 +1 @@ +alter table comments add original text not null default ""; \ No newline at end of file diff --git a/go.mod b/go.mod index bda931b..564946d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f32157b..e7a7ddd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/httpRouters.go b/httpRouters.go index 42f2a1a..ff87cff 100644 --- a/httpRouters.go +++ b/httpRouters.go @@ -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) diff --git a/posts.go b/posts.go index 86a1a25..bef1acb 100644 --- a/posts.go +++ b/posts.go @@ -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") diff --git a/ui.go b/ui.go index 97d747a..7fa0984 100644 --- a/ui.go +++ b/ui.go @@ -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") diff --git a/webmention.go b/webmention.go index e16a5f4..020f1ae 100644 --- a/webmention.go +++ b/webmention.go @@ -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))",