diff --git a/Dockerfile b/Dockerfile index 381fbe5..6983ccd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config.go b/config.go index 9a88214..81be721 100644 --- a/config.go +++ b/config.go @@ -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"` diff --git a/database.go b/database.go index 25f9d91..6c84adb 100644 --- a/database.go +++ b/database.go @@ -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 } diff --git a/dbmigrations/00027.sql b/dbmigrations/00027.sql new file mode 100644 index 0000000..d70f530 --- /dev/null +++ b/dbmigrations/00027.sql @@ -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 +); \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md index ba0722e..062470f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,4 +43,8 @@ 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/example-config.yml b/example-config.yml index 7974ac2..05c3dc5 100644 --- a/example-config.yml +++ b/example-config.yml @@ -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: diff --git a/httpRouters.go b/httpRouters.go index 834cae4..a662cbe 100644 --- a/httpRouters.go +++ b/httpRouters.go @@ -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 diff --git a/original-assets/styles/styles.scss b/original-assets/styles/styles.scss index 22418d6..b39353c 100644 --- a/original-assets/styles/styles.scss +++ b/original-assets/styles/styles.scss @@ -280,6 +280,11 @@ details summary { } } +#reactions button:focus { + outline: none; + box-shadow: none; +} + // Print @media print { html { diff --git a/postsDb.go b/postsDb.go index de40538..c71a355 100644 --- a/postsDb.go +++ b/postsDb.go @@ -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 { diff --git a/reactions.go b/reactions.go new file mode 100644 index 0000000..c223469 --- /dev/null +++ b/reactions.go @@ -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 +} diff --git a/reactions_test.go b/reactions_test.go new file mode 100644 index 0000000..84fd3b3 --- /dev/null +++ b/reactions_test.go @@ -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()) + +} diff --git a/templates/assets/css/styles.css b/templates/assets/css/styles.css index 4e99751..43140cb 100644 --- a/templates/assets/css/styles.css +++ b/templates/assets/css/styles.css @@ -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; diff --git a/templates/assets/js/reactions.js b/templates/assets/js/reactions.js new file mode 100644 index 0000000..e7d9eab --- /dev/null +++ b/templates/assets/js/reactions.js @@ -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() + +})() \ No newline at end of file diff --git a/ui.go b/ui.go index 5249af7..00fceb0 100644 --- a/ui.go +++ b/ui.go @@ -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") diff --git a/uiComponents.go b/uiComponents.go index eb2d68b..6d3bab1 100644 --- a/uiComponents.go +++ b/uiComponents.go @@ -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") +}