Skip to content

Commit

Permalink
allow full post editing and use a tags for mentions (fixes #35)
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Jan 4, 2024
1 parent 604938d commit ac9ec33
Show file tree
Hide file tree
Showing 15 changed files with 893 additions and 138 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ User A is allowed to send a message to user B only if B follows A.

### Post Editing

/users/edit only changes the content and the last update timestamp of a post. It does **not** change the post audience and mentioned users.
/users/edit cannot remove recipients from the post audience, only add more. If a post that mentions only `@a` is edited to mention only `@b`, both `a` and `b` will receive the updated post.

### Polls

Expand Down
14 changes: 1 addition & 13 deletions ap/mention.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -30,15 +30,3 @@ type Mention struct {
Href string `json:"href,omitempty"`
Icon *Attachment `json:"icon,omitempty"`
}

type Mentions []Mention

func (l Mentions) Contains(m Mention) bool {
for _, m2 := range l {
if m2.Name == m.Name && m2.Href == m.Href && m2.Type == m.Type {
return true
}
}

return false
}
4 changes: 2 additions & 2 deletions front/dm.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -64,5 +64,5 @@ func dm(w text.Writer, r *request) {

cc := ap.Audience{}

post(w, r, nil, to, cc, "Message")
post(w, r, nil, nil, to, cc, "Message")
}
31 changes: 19 additions & 12 deletions front/edit.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -23,8 +23,6 @@ import (
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/cfg"
"github.com/dimkr/tootik/front/text"
"github.com/dimkr/tootik/front/text/plain"
"github.com/dimkr/tootik/outbox"
"math"
"net/url"
"path/filepath"
Expand Down Expand Up @@ -80,12 +78,6 @@ func edit(w text.Writer, r *request) {
return
}

if note.Type == ap.QuestionObject {
r.Log.Warn("Cannot edit polls", "poll", note.ID)
w.Status(40, "Cannot edit polls")
return
}

var edits int
if err := r.QueryRow(`select count(*) from outbox where activity->>'object.id' = ? and (activity->>'type' = 'Update' or activity->>'type' = 'Create')`, note.ID).Scan(&edits); err != nil {
r.Log.Warn("Failed to count post edits", "hash", hash, "error", err)
Expand All @@ -105,11 +97,26 @@ func edit(w text.Writer, r *request) {
return
}

if err := outbox.Edit(r.Context, r.DB, &note, plain.ToHTML(content)); err != nil {
r.Log.Error("Failed to update post", "note", note.ID, "error", err)
if note.InReplyTo == "" {
post(w, r, &note, nil, note.To, note.CC, "Post content")
return
}

if err := r.QueryRow(`select object from notes where id = ?`, note.InReplyTo).Scan(&noteString); err != nil && errors.Is(err, sql.ErrNoRows) {
r.Log.Warn("Parent post does not exist", "parent", note.InReplyTo)
} else if err != nil {
r.Log.Warn("Failed to fetch parent post", "parent", note.InReplyTo, "error", err)
w.Error()
return
}

var parent ap.Object
if err := json.Unmarshal([]byte(noteString), &parent); err != nil {
r.Log.Warn("Failed to unmarshal parent post", "parent", note.InReplyTo, "error", err)
w.Error()
return
}

w.Redirectf("/users/view/%s", hash)
// the starting point is the original value of to and cc: recipients can be added but not removed when editing
post(w, r, &note, &parent, note.To, note.CC, "Post content")
}
62 changes: 38 additions & 24 deletions front/post.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -41,40 +41,42 @@ const (
)

var (
mentionRegex = regexp.MustCompile(`\B@(\w+)(?:@(\w+\.\w+(?::\d{1,5}){0,1})){0,1}\b`)
mentionRegex = regexp.MustCompile(`\B@(\w+)(?:@((?:\w+\.)+\w+(?::\d{1,5}){0,1})){0,1}\b`)
hashtagRegex = regexp.MustCompile(`\B#\w{1,32}\b`)
pollRegex = regexp.MustCompile(`^\[(?:(?i)POLL)\s+(.+)\s*\]\s*(.+)`)
)

func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap.Audience, prompt string) {
func post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo *ap.Object, to ap.Audience, cc ap.Audience, prompt string) {
if r.User == nil {
w.Redirect("/users")
return
}

now := ap.Time{Time: time.Now()}

var today, last sql.NullInt64
if err := r.QueryRow(`select count(*), max(inserted) from outbox where activity->>'actor' = ? and activity->>'type' = 'Create' and inserted > ?`, r.User.ID, now.Add(-24*time.Hour).Unix()).Scan(&today, &last); err != nil {
r.Log.Warn("Failed to check if new post needs to be throttled", "error", err)
w.Error()
return
}

if today.Valid && today.Int64 >= 30 {
r.Log.Warn("User has exceeded the daily posts quota", "posts", today.Int64)
w.Status(40, "Please wait before posting again")
return
}
if oldNote == nil {
var today, last sql.NullInt64
if err := r.QueryRow(`select count(*), max(inserted) from outbox where activity->>'actor' = ? and activity->>'type' = 'Create' and inserted > ?`, r.User.ID, now.Add(-24*time.Hour).Unix()).Scan(&today, &last); err != nil {
r.Log.Warn("Failed to check if new post needs to be throttled", "error", err)
w.Error()
return
}

if today.Valid && last.Valid {
t := time.Unix(last.Int64, 0)
interval := max(1, time.Duration(today.Int64/2)) * time.Minute
if now.Sub(t) < interval {
r.Log.Warn("User is posting too frequently", "last", t, "can", t.Add(interval))
if today.Valid && today.Int64 >= 30 {
r.Log.Warn("User has exceeded the daily posts quota", "posts", today.Int64)
w.Status(40, "Please wait before posting again")
return
}

if today.Valid && last.Valid {
t := time.Unix(last.Int64, 0)
interval := max(1, time.Duration(today.Int64/2)) * time.Minute
if now.Sub(t) < interval {
r.Log.Warn("User is posting too frequently", "last", t, "can", t.Add(interval))
w.Status(40, "Please wait before posting again")
return
}
}
}

if r.URL.RawQuery == "" {
Expand All @@ -93,9 +95,14 @@ func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap
return
}

postID := fmt.Sprintf("https://%s/post/%x", cfg.Domain, sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", r.User.ID, content, now.Unix()))))
var postID string
if oldNote == nil {
postID = fmt.Sprintf("https://%s/post/%x", cfg.Domain, sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", r.User.ID, content, now.Unix()))))
} else {
postID = oldNote.ID
}

tags := ap.Mentions{}
var tags []ap.Mention

for _, hashtag := range hashtagRegex.FindAllString(content, -1) {
tags = append(tags, ap.Mention{Type: ap.HashtagMention, Name: hashtag, Href: fmt.Sprintf("gemini://%s/hashtag/%s", cfg.Domain, hashtag[1:])})
Expand Down Expand Up @@ -195,10 +202,17 @@ func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap
}

if inReplyTo == nil || inReplyTo.Type != ap.QuestionObject {
note.Content = plain.ToHTML(note.Content)
note.Content = plain.ToHTML(note.Content, note.Tag)
}

if err := outbox.Create(r.Context, r.Log, r.DB, &note, r.User); err != nil {
if oldNote != nil {
note.Published = oldNote.Published
note.Updated = &now
err = outbox.Update(r.Context, r.DB, &note)
} else {
err = outbox.Create(r.Context, r.Log, r.DB, &note, r.User)
}
if err != nil {
r.Log.Error("Failed to insert post", "error", err)
if errors.Is(err, outbox.ErrDeliveryQueueFull) {
w.Status(40, "Please try again later")
Expand Down
11 changes: 8 additions & 3 deletions front/reply.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -29,7 +29,8 @@ func reply(w text.Writer, r *request) {
hash := filepath.Base(r.URL.Path)

var noteString string
if err := r.QueryRow(`select notes.object from notes join persons on persons.id = notes.author left join (select id, actor from persons where actor->>'type' = 'Group') groups on groups.id = notes.groupid where notes.hash = $1 and (notes.public = 1 or notes.author = $2 or $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'to') where value = $2)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'cc') where value = $2)) or exists (select 1 from (select persons.id, persons.actor->>'followers' as followers, persons.actor->>'type' as type from persons join follows on follows.followed = persons.id where follows.accepted = 1 and follows.follower = $2) follows where follows.followers in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'to') where value = follows.followers)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'cc') where value = follows.followers)) or (follows.id = notes.groupid and follows.type = 'Group')))`, hash, r.User.ID).Scan(&noteString); err != nil && errors.Is(err, sql.ErrNoRows) {
var group sql.NullString
if err := r.QueryRow(`select notes.object, notes.groupid from notes join persons on persons.id = notes.author left join (select id, actor from persons where actor->>'type' = 'Group') groups on groups.id = notes.groupid where notes.hash = $1 and (notes.public = 1 or notes.author = $2 or $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'to') where value = $2)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'cc') where value = $2)) or exists (select 1 from (select persons.id, persons.actor->>'followers' as followers, persons.actor->>'type' as type from persons join follows on follows.followed = persons.id where follows.accepted = 1 and follows.follower = $2) follows where follows.followers in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'to') where value = follows.followers)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'cc') where value = follows.followers)) or (follows.id = notes.groupid and follows.type = 'Group')))`, hash, r.User.ID).Scan(&noteString, &group); err != nil && errors.Is(err, sql.ErrNoRows) {
r.Log.Warn("Post does not exist", "hash", hash)
w.Status(40, "Post not found")
return
Expand Down Expand Up @@ -77,5 +78,9 @@ func reply(w text.Writer, r *request) {
return
}

post(w, r, &note, to, cc, "Reply content")
if group.Valid {
cc.Add(group.String)
}

post(w, r, nil, &note, to, cc, "Reply content")
}
4 changes: 2 additions & 2 deletions front/say.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -28,5 +28,5 @@ func say(w text.Writer, r *request) {
to.Add(ap.Public)
cc.Add(r.User.Followers)

post(w, r, nil, to, cc, "Post content")
post(w, r, nil, nil, to, cc, "Post content")
}
34 changes: 32 additions & 2 deletions front/text/plain/convert.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023 Dima Krasner
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -18,6 +18,7 @@ package plain

import (
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/data"
"html"
"regexp"
Expand All @@ -38,6 +39,7 @@ var (
closeTags = regexp.MustCompile(`(?:<\/[a-zA-Z0-9]+\s*[^>]*>)+`)
urlRegex = regexp.MustCompile(`\b(https|http|gemini|gopher|gophers):\/\/\S+\b`)
pDelim = regexp.MustCompile(`([^\n])\n\n+([^\n])`)
mentionRegex = regexp.MustCompile(`\B@(\w+)(?:@(?:(?:\w+\.)+\w+(?::\d{1,5}){0,1})){0,1}\b`)
)

func FromHTML(text string) (string, data.OrderedMap[string, string]) {
Expand Down Expand Up @@ -123,7 +125,7 @@ func getPlainLinks(text string) map[string]struct{} {
return links
}

func ToHTML(text string) string {
func ToHTML(text string, mentions []ap.Mention) string {
if text == "" {
return ""
}
Expand All @@ -132,6 +134,34 @@ func ToHTML(text string) string {
text = strings.ReplaceAll(text, link, fmt.Sprintf(`<a href="%s" target="_blank">%s</a>`, link, link))
}

if len(mentions) > 0 {
var b strings.Builder
mentions:
for _, mention := range mentions {
if mention.Type != ap.MentionMention {
continue
}
for {
loc := mentionRegex.FindStringSubmatchIndex(text)
if loc == nil {
break mentions
}
b.WriteString(text[:loc[0]])
if text[loc[0]:loc[1]] == mention.Name {
b.WriteString(fmt.Sprintf(`<a href="%s" rel="nofollow noopener noreferrer">%s</a>`, mention.Href, text[loc[0]:loc[1]]))
text = text[loc[1]:]
break
}

b.WriteString(text[loc[0]:loc[1]])
text = text[loc[1]:]
}
}
b.WriteString(text)

text = b.String()
}

text = pDelim.ReplaceAllString(text, "$1</p><p>$2")
text = strings.ReplaceAll(text, "\n", "<br/>")
return fmt.Sprintf("<p>%s</p>", text)
Expand Down
Loading

0 comments on commit ac9ec33

Please sign in to comment.