Reactions

This commit is contained in:
Jan-Lukas Else 2022-04-16 21:42:09 +02:00
parent ddd097f809
commit 1e03474539
15 changed files with 373 additions and 10 deletions

View File

@ -18,7 +18,7 @@ RUN go build -ldflags '-w -s' -o GoBlog
FROM build as test
RUN go test -timeout 15s -cover ./...
RUN go test -timeout 20s -cover ./...
FROM alpine:3.15 as base

View File

@ -29,6 +29,7 @@ type config struct {
EasterEgg *configEasterEgg `mapstructure:"easterEgg"`
MapTiles *configMapTiles `mapstructure:"mapTiles"`
TTS *configTTS `mapstructure:"tts"`
Reactions *configReactions `mapstructure:"reactions"`
Pprof *configPprof `mapstructure:"pprof"`
Debug bool `mapstructure:"debug"`
initialized bool
@ -311,6 +312,10 @@ type configTTS struct {
GoogleAPIKey string `mapstructure:"googleApiKey"`
}
type configReactions struct {
Enabled bool `mapstructure:"enabled"`
}
type configPprof struct {
Enabled bool `mapstructure:"enabled"`
Address string `mapstructure:"address"`

View File

@ -84,7 +84,7 @@ func (a *goBlog) openDatabase(file string, logging bool) (*database, error) {
},
})
// Open db
db, err := sql.Open(dbDriverName, file+"?mode=rwc&_journal_mode=WAL&_busy_timeout=100&cache=shared")
db, err := sql.Open(dbDriverName, file+"?mode=rwc&_journal=WAL&_timeout=100&cache=shared&_fk=1")
if err != nil {
return nil, err
}

7
dbmigrations/00027.sql Normal file
View File

@ -0,0 +1,7 @@
create table reactions (
path text not null,
reaction text not null,
count integer default 0,
primary key (path, reaction),
foreign key (path) references posts(path) on update cascade on delete cascade
);

View File

@ -44,3 +44,7 @@ If configured, GoBlog will also send a notification using a Telegram Bot or [Ntf
## Tor Hidden Services
GoBlog can be configured to provide a Tor Hidden Service. This is useful if you want to offer your visitors a way to connect to your blog from censored networks or countries. See the `example-config.yml` file for how to enable the Tor Hidden Service. If you don't need to hide your server, you can enable the Single Hop mode.
## Reactions
It's possible to enable post reactions. GoBlog currently has a hardcoded list of reactions: "❤️", "👍", "👎", "😂" and "😱". If enabled, users can react to a post by clicking on the reaction button below the post. If you want to disable reactions for a single post, you can set the `reactions` parameter to `false` in the post's metadata.

View File

@ -168,6 +168,10 @@ tts:
enabled: true
googleApiKey: "xxxxxxxx"
# Reactions (see docs for more info)
reactions:
enabled: true # Enable reactions (default is false)
# Blogs
defaultBlog: en # Default blog (needed because you can define multiple blogs)
blogs:

View File

@ -99,8 +99,16 @@ func (a *goBlog) mediaFilesRouter(r chi.Router) {
// Various other routes
func (a *goBlog) otherRoutesRouter(r chi.Router) {
r.Use(a.privateModeHandler)
// Leaflet
r.Get("/tiles/{s}/{z}/{x}/{y}.png", a.proxyTiles())
r.With(cacheLoggedIn, a.cacheMiddleware).HandleFunc("/leaflet/*", a.serveFs(leafletFiles, "/-/"))
// Reactions
if a.reactionsEnabled() {
r.Get("/reactions", a.getReactions)
r.Post("/reactions", a.postReaction)
}
}
// Blog

View File

@ -280,6 +280,11 @@ details summary {
}
}
#reactions button:focus {
outline: none;
box-shadow: none;
}
// Print
@media print {
html {

View File

@ -175,14 +175,19 @@ func (db *database) savePost(p *post, o *postCreationOptions) error {
var sqlArgs = []any{dbNoCache}
// Start transaction
sqlBuilder.WriteString("begin;")
// Delete old post
if !o.new {
sqlBuilder.WriteString("delete from posts where path = ?;delete from post_parameters where path = ?;")
sqlArgs = append(sqlArgs, o.oldPath, o.oldPath)
// Update or create post
if o.new {
// New post, create it
sqlBuilder.WriteString("insert into posts (path, content, published, updated, blog, section, status, priority) values (?, ?, ?, ?, ?, ?, ?, ?);")
sqlArgs = append(sqlArgs, p.Path, p.Content, toUTCSafe(p.Published), toUTCSafe(p.Updated), p.Blog, p.Section, p.Status, p.Priority)
} else {
// Update old post
sqlBuilder.WriteString("update posts set path = ?, content = ?, published = ?, updated = ?, blog = ?, section = ?, status = ?, priority = ? where path = ?;")
sqlArgs = append(sqlArgs, p.Path, p.Content, toUTCSafe(p.Published), toUTCSafe(p.Updated), p.Blog, p.Section, p.Status, p.Priority, o.oldPath)
// Delete post parameters
sqlBuilder.WriteString("delete from post_parameters where path = ?;")
sqlArgs = append(sqlArgs, o.oldPath)
}
// Insert new post
sqlBuilder.WriteString("insert into posts (path, content, published, updated, blog, section, status, priority) values (?, ?, ?, ?, ?, ?, ?, ?);")
sqlArgs = append(sqlArgs, p.Path, p.Content, toUTCSafe(p.Published), toUTCSafe(p.Updated), p.Blog, p.Section, p.Status, p.Priority)
// Insert post parameters
for param, value := range p.Parameters {
for _, value := range value {

105
reactions.go Normal file
View File

@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"github.com/samber/lo"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
)
// Hardcoded for now
var allowedReactions = []string{
"❤️",
"👍",
"🎉",
"😂",
"😱",
}
func (a *goBlog) reactionsEnabled() bool {
return a.cfg.Reactions != nil && a.cfg.Reactions.Enabled
}
const reactionsPostParam = "reactions"
func (a *goBlog) reactionsEnabledForPost(post *post) bool {
return a.reactionsEnabled() && post != nil && post.firstParameter(reactionsPostParam) != "false"
}
func (a *goBlog) postReaction(w http.ResponseWriter, r *http.Request) {
path := r.FormValue("path")
reaction := r.FormValue("reaction")
if path == "" || reaction == "" {
a.serveError(w, r, "", http.StatusBadRequest)
return
}
err := a.saveReaction(reaction, path)
if err != nil {
a.serveError(w, r, "", http.StatusBadRequest)
return
}
}
func (a *goBlog) saveReaction(reaction, path string) error {
// Check if reaction is allowed
if !lo.Contains(allowedReactions, reaction) {
return errors.New("reaction not allowed")
}
// Insert reaction
_, err := a.db.exec("insert into reactions (path, reaction, count) values (?, ?, 1) on conflict (path, reaction) do update set count=count+1", path, reaction)
return err
}
func (a *goBlog) getReactions(w http.ResponseWriter, r *http.Request) {
path := r.FormValue("path")
reactions, err := a.getReactionsFromDatabase(path)
if err != nil {
a.serveError(w, r, "", http.StatusInternalServerError)
return
}
buf := bufferpool.Get()
defer bufferpool.Put(buf)
err = json.NewEncoder(buf).Encode(reactions)
if err != nil {
a.serveError(w, r, "", http.StatusInternalServerError)
return
}
w.Header().Set(contentType, contenttype.JSONUTF8)
_ = a.min.Get().Minify(contenttype.JSON, w, buf)
}
func (a *goBlog) getReactionsFromDatabase(path string) (map[string]int, error) {
sqlBuf := bufferpool.Get()
defer bufferpool.Put(sqlBuf)
sqlArgs := []any{}
sqlBuf.WriteString("select reaction, count from reactions where path=? and reaction in (")
sqlArgs = append(sqlArgs, path)
for i, reaction := range allowedReactions {
if i > 0 {
sqlBuf.WriteString(",")
}
sqlBuf.WriteString("?")
sqlArgs = append(sqlArgs, reaction)
}
sqlBuf.WriteString(") and path not in (select path from post_parameters where parameter=? and value=?)")
sqlArgs = append(sqlArgs, reactionsPostParam, "false")
rows, err := a.db.query(sqlBuf.String(), sqlArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
reactions := map[string]int{}
for rows.Next() {
var reaction string
var count int
err = rows.Scan(&reaction, &count)
if err != nil {
return nil, err
}
reactions[reaction] = count
}
return reactions, nil
}

148
reactions_test.go Normal file
View File

@ -0,0 +1,148 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_reactionsLowLevel(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
}
_ = app.initConfig()
_ = app.initDatabase(false)
defer app.db.close()
app.initComponents(false)
err := app.saveReaction("🖕", "/testpost")
assert.ErrorContains(t, err, "not allowed")
err = app.saveReaction("❤️", "/testpost")
assert.ErrorContains(t, err, "constraint failed")
// Create a post
err = app.createPost(&post{
Path: "/testpost",
Content: "test",
Status: statusPublished,
})
require.NoError(t, err)
// Create 4 reactions
for i := 0; i < 4; i++ {
err = app.saveReaction("❤️", "/testpost")
assert.NoError(t, err)
}
// Check if reaction count is 4
reacts, err := app.getReactionsFromDatabase("/testpost")
require.NoError(t, err)
assert.Equal(t, 1, len(reacts))
assert.Equal(t, 4, reacts["❤️"])
// Change post path
err = app.replacePost(&post{
Path: "/newpost",
Content: "test",
Status: statusPublished,
}, "/testpost", statusPublished)
require.NoError(t, err)
// Check if reaction count is 4
reacts, err = app.getReactionsFromDatabase("/newpost")
require.NoError(t, err)
assert.Equal(t, 1, len(reacts))
assert.Equal(t, 4, reacts["❤️"])
// Delete post
err = app.deletePost("/newpost")
require.NoError(t, err)
err = app.deletePost("/newpost")
require.NoError(t, err)
// Check if reaction count is 0
reacts, err = app.getReactionsFromDatabase("/newpost")
require.NoError(t, err)
assert.Equal(t, 0, len(reacts))
// Create a post with disabled reactions
err = app.createPost(&post{
Path: "/testpost2",
Content: "test",
Status: statusPublished,
Parameters: map[string][]string{
"reactions": {"false"},
},
})
require.NoError(t, err)
// Create reaction
err = app.saveReaction("❤️", "/testpost2")
require.NoError(t, err)
// Check if reaction count is 0
reacts, err = app.getReactionsFromDatabase("/testpost2")
require.NoError(t, err)
assert.Equal(t, 0, len(reacts))
}
func Test_reactionsHighLevel(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
}
_ = app.initConfig()
_ = app.initDatabase(false)
defer app.db.close()
app.initComponents(false)
// Send unsuccessful reaction
form := url.Values{
"reaction": {"❤️"},
"path": {"/testpost"},
}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
app.postReaction(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
// Create a post
err := app.createPost(&post{
Path: "/testpost",
Content: "test",
})
require.NoError(t, err)
// Send successful reaction
form = url.Values{
"reaction": {"❤️"},
"path": {"/testpost"},
}
req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
app.postReaction(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Check if reaction count is 1
req = httptest.NewRequest(http.MethodGet, "/?path=/testpost", nil)
rec = httptest.NewRecorder()
app.getReactions(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, `{"❤️":1}`, rec.Body.String())
// Get reactions for a non-existing post
req = httptest.NewRequest(http.MethodGet, "/?path=/non-existing-post", nil)
rec = httptest.NewRecorder()
app.getReactions(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, `{}`, rec.Body.String())
}

View File

@ -233,6 +233,11 @@ details summary > *:first-child {
padding: 5px;
text-align: center;
}
#reactions button:focus, #reactions .button:focus {
outline: none;
box-shadow: none;
}
@media print {
html {
--background: #fff;

View File

@ -0,0 +1,54 @@
(function () {
// Get reactions element
let reactions = document.querySelector('#reactions')
// Get post path
let path = reactions.dataset.path
// Define update counts function
let updateCounts = function () {
// Fetch reactions json
fetch('/-/reactions?path=' + encodeURI(path))
.then(response => response.json())
.then(json => {
// For every reaction
for (let reaction in json) {
// Get reaction buttons
let button = document.querySelector('#reactions button[data-reaction="' + reaction + '"]')
// Set reaction count
button.innerText = reaction + ' ' + json[reaction]
}
})
}
// Get allowed reactions
let allowed = reactions.dataset.allowed.split(',')
allowed.forEach(allowedReaction => {
// Create reaction button
let button = document.createElement('button')
button.dataset.reaction = allowedReaction
// Set click event
button.addEventListener('click', function () {
// Send reaction to server
let data = new FormData()
data.append('path', path)
data.append('reaction', allowedReaction)
fetch('/-/reactions', { method: 'POST', body: data })
.then(updateCounts)
})
// Set reaction text
button.innerText = allowedReaction
// Add button to reactions element
reactions.appendChild(button)
})
// Update reaction counts
updateCounts()
})()

2
ui.go
View File

@ -914,6 +914,8 @@ func (a *goBlog) renderPost(hb *htmlBuilder, rd *renderData) {
// Author
a.renderAuthor(hb)
hb.writeElementClose("main")
// Reactions
a.renderPostReactions(hb, p)
// Post edit actions
if rd.LoggedIn() {
hb.writeElementOpen("div", "class", "actions")

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"strings"
"time"
"go.goblog.app/app/pkgs/bufferpool"
@ -465,3 +466,13 @@ func (a *goBlog) renderPostGPX(hb *htmlBuilder, p *post, b *configBlog) {
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/geomap.js"))
hb.writeElementClose("script")
}
func (a *goBlog) renderPostReactions(hb *htmlBuilder, p *post) {
if !a.reactionsEnabledForPost(p) {
return
}
hb.writeElementOpen("div", "id", "reactions", "class", "actions", "data-path", p.Path, "data-allowed", strings.Join(allowedReactions, ","))
hb.writeElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/reactions.js"))
hb.writeElementClose("script")
}