Add comment functionality

This commit is contained in:
Jan-Lukas Else 2021-01-23 17:24:47 +01:00
parent 6a0eda2184
commit 72f676dda2
20 changed files with 444 additions and 37 deletions

View File

@ -22,13 +22,7 @@ func jwtKey() []byte {
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check basic auth
username, password, basicauth := r.BasicAuth()
if basicauth && checkCredentials(username, password) {
next.ServeHTTP(w, r)
return
}
// 2. Check JWT
// 1. Check JWT
if tokenCookie, err := r.Cookie("token"); err == nil {
if tkn, err := jwt.Parse(tokenCookie.Value, func(t *jwt.Token) (interface{}, error) {
return jwtKey(), nil
@ -37,7 +31,7 @@ func authMiddleware(next http.Handler) http.Handler {
return
}
}
// 3. Show login form
// 2. Show login form
w.WriteHeader(http.StatusUnauthorized)
h, _ := json.Marshal(r.Header.Clone())
b, _ := ioutil.ReadAll(io.LimitReader(r.Body, 2000000)) // Only allow 20 Megabyte
@ -68,8 +62,7 @@ func checkIsLogin(next http.Handler) http.Handler {
func checkLogin(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodPost &&
r.Header.Get(contentType) == contentTypeWWWForm &&
r.FormValue("loginaction") == "login" &&
checkCredentials(r.FormValue("username"), r.FormValue("password")) {
r.FormValue("loginaction") == "login" {
// Do original request
loginbody, _ := base64.StdEncoding.DecodeString(r.FormValue("loginbody"))
req, _ := http.NewRequest(r.FormValue("loginmethod"), r.RequestURI, bytes.NewReader(loginbody))
@ -80,10 +73,18 @@ func checkLogin(w http.ResponseWriter, r *http.Request) bool {
for k, v := range headers {
req.Header[k] = v
}
// Set basic auth
req.SetBasicAuth(r.FormValue("username"), r.FormValue("password"))
// Send cookie
sendTokenCookie(w, r)
// Check credential
if checkCredentials(r.FormValue("username"), r.FormValue("password")) {
tokenCookie, err := createTokenCookie()
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return true
}
// Add cookie to original request
req.AddCookie(tokenCookie)
// Send cookie
http.SetCookie(w, tokenCookie)
}
// Serve original request
d.ServeHTTP(w, req)
return true
@ -91,20 +92,18 @@ func checkLogin(w http.ResponseWriter, r *http.Request) bool {
return false
}
func sendTokenCookie(w http.ResponseWriter, r *http.Request) {
func createTokenCookie() (*http.Cookie, error) {
expiration := time.Now().Add(7 * 24 * time.Hour)
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{ExpiresAt: expiration.Unix()}).SignedString(jwtKey())
if err != nil {
serveError(w, r, "Failed to sign JWT", http.StatusInternalServerError)
return
return nil, err
}
http.SetCookie(w, &http.Cookie{
return &http.Cookie{
Name: "token",
Value: tokenString,
Expires: expiration,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
return
}, nil
}

107
captcha.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"time"
"github.com/dchest/captcha"
"github.com/dgrijalva/jwt-go"
)
func initCaptcha() {
}
func captchaMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check JWT
if captchaCookie, err := r.Cookie("captcha"); err == nil {
if tkn, err := jwt.Parse(captchaCookie.Value, func(t *jwt.Token) (interface{}, error) {
return jwtKey(), nil
}); err == nil && tkn.Valid {
next.ServeHTTP(w, r)
return
}
}
// 2. Show Captcha
w.WriteHeader(http.StatusUnauthorized)
h, _ := json.Marshal(r.Header.Clone())
b, _ := ioutil.ReadAll(io.LimitReader(r.Body, 2000000)) // Only allow 20 Megabyte
_ = r.Body.Close()
if len(b) == 0 {
// Maybe it's a form
_ = r.ParseForm()
b = []byte(r.PostForm.Encode())
}
render(w, templateCaptcha, &renderData{
Data: map[string]string{
"captchamethod": r.Method,
"captchaheaders": base64.StdEncoding.EncodeToString(h),
"captchabody": base64.StdEncoding.EncodeToString(b),
"captchaid": captcha.New(),
},
})
})
}
func checkIsCaptcha(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !checkCaptcha(rw, r) {
next.ServeHTTP(rw, r)
}
})
}
func checkCaptcha(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodPost &&
r.Header.Get(contentType) == contentTypeWWWForm &&
r.FormValue("captchaaction") == "captcha" {
// Do original request
captchabody, _ := base64.StdEncoding.DecodeString(r.FormValue("captchabody"))
req, _ := http.NewRequest(r.FormValue("captchamethod"), r.RequestURI, bytes.NewReader(captchabody))
// Copy original headers
captchaheaders, _ := base64.StdEncoding.DecodeString(r.FormValue("captchaheaders"))
var headers http.Header
json.Unmarshal(captchaheaders, &headers)
for k, v := range headers {
req.Header[k] = v
}
// Check captcha
if captcha.VerifyString(r.FormValue("captchaid"), r.FormValue("digits")) {
// Create cookie
captchaCookie, err := createCaptchaCookie()
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return true
}
// Add cookie to original request
req.AddCookie(captchaCookie)
// Send cookie
http.SetCookie(w, captchaCookie)
}
// Serve original request
d.ServeHTTP(w, req)
return true
}
return false
}
func createCaptchaCookie() (*http.Cookie, error) {
expiration := time.Now().Add(24 * time.Hour)
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{ExpiresAt: expiration.Unix()}).SignedString(jwtKey())
if err != nil {
return nil, err
}
return &http.Cookie{
Name: "captcha",
Value: tokenString,
Expires: expiration,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}, nil
}

156
comments.go Normal file
View File

@ -0,0 +1,156 @@
package main
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/microcosm-cc/bluemonday"
)
type comment struct {
ID int
Target string
Name string
Website string
Comment string
}
func serveComment(blog string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest)
return
}
row, err := appDbQueryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id))
if err != nil {
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 {
serve404(w, r)
return
} else if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
render(w, templateComment, &renderData{
BlogString: blog,
Data: comment,
})
}
}
func createComment(blog, commentsPath string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Check target
target := checkCommentTarget(w, r)
if target == "" {
return
}
// Check comment
comment := r.FormValue("comment")
if comment == "" {
serveError(w, r, "Comment is empty", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
name = "Anonymous"
}
website := r.FormValue("website")
// Clean
strict := bluemonday.StrictPolicy()
name = strict.Sanitize(name)
website = strict.Sanitize(website)
comment = strict.Sanitize(comment)
// Insert
result, err := appDbExec("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 {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
commentID, err := result.LastInsertId()
commentAddress := fmt.Sprintf("%s/%d", commentsPath, commentID)
// Send webmention
createWebmention(appConfig.Server.PublicAddress+commentAddress, appConfig.Server.PublicAddress+target)
// Redirect to comment
http.Redirect(w, r, commentAddress, http.StatusFound)
}
}
func checkCommentTarget(w http.ResponseWriter, r *http.Request) string {
target := r.FormValue("target")
if target == "" {
serveError(w, r, "No target specified", http.StatusBadRequest)
return ""
}
postExists := 0
row, err := appDbQueryRow("select exists(select 1 from posts where path = @path)", sql.Named("path", target))
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return ""
}
if err = row.Scan(&postExists); err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return ""
}
if postExists != 1 {
serveError(w, r, "Post does not exist", http.StatusBadRequest)
return ""
}
return target
}
func commentsAdmin(w http.ResponseWriter, r *http.Request) {
comments, err := getComments()
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
render(w, templateCommentsAdmin, &renderData{
Data: comments,
})
}
func getComments() ([]*comment, error) {
comments := []*comment{}
rows, err := appDbQuery("select id, target, name, website, comment from comments")
if err != nil {
return nil, err
}
for rows.Next() {
c := &comment{}
err = rows.Scan(&c.ID, &c.Target, &c.Name, &c.Website, &c.Comment)
if err != nil {
return nil, err
}
comments = append(comments, c)
}
return comments, nil
}
func commentsAdminDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.FormValue("commentid"))
if err != nil {
serveError(w, r, err.Error(), http.StatusBadRequest)
return
}
err = deleteComment(id)
if err != nil {
serveError(w, r, err.Error(), http.StatusInternalServerError)
return
}
purgeCache()
http.Redirect(w, r, ".", http.StatusFound)
return
}
func deleteComment(id int) error {
_, err := appDbExec("delete from comments where id = @id", sql.Named("id", id))
return err
}

View File

@ -64,6 +64,7 @@ type configBlog struct {
Telegram *configTelegram `mapstructure:"telegram"`
PostAsHome bool `mapstructure:"postAsHome"`
RandomPost *randomPost `mapstructure:"randomPost"`
Comments *comments `mapstructure:"comments"`
}
type section struct {
@ -124,6 +125,10 @@ type randomPost struct {
Path string `mapstructure:"path"`
}
type comments struct {
Enabled bool `mapstructure:"enabled"`
}
type configUser struct {
Nick string `mapstructure:"nick"`
Name string `mapstructure:"name"`

View File

@ -121,6 +121,15 @@ func migrateDb() error {
return err
},
},
&migrator.Migration{
Name: "00010",
Func: func(tx *sql.Tx) error {
_, err := tx.Exec(`
create table comments (id integer primary key autoincrement, target text not null, name text not null, website text not null, comment text not null);
`)
return err
},
},
),
)
if err != nil {

5
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/andybalholm/cascadia v1.2.0 // indirect
github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4
github.com/caddyserver/certmagic v0.12.0
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v1.5.1
github.com/go-fed/httpsig v1.1.0
@ -31,7 +32,8 @@ require (
github.com/lopezator/migrator v0.3.0
github.com/magiconair/properties v1.8.4 // indirect
github.com/mattn/go-sqlite3 v1.14.6
github.com/mholt/acmez v0.1.2 // indirect
github.com/mholt/acmez v0.1.3 // indirect
github.com/microcosm-cc/bluemonday v1.0.4
github.com/miekg/dns v1.1.35 // indirect
github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/pelletier/go-toml v1.8.1 // indirect
@ -54,6 +56,7 @@ require (
golang.org/x/mod v0.4.1 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619 // indirect
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
golang.org/x/text v0.3.5 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect

16
go.sum
View File

@ -32,6 +32,8 @@ github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4/go.mod h1:hMAUZF
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
@ -41,6 +43,8 @@ github.com/caddyserver/certmagic v0.12.0/go.mod h1:tr26xh+9fY5dN0J6IPAlMj07qpog2
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@ -51,6 +55,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
@ -110,6 +116,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
@ -209,8 +217,10 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mholt/acmez v0.1.1 h1:KQODCqk+hBn3O7qfCRPj6L96uG65T5BSS95FKNEqtdA=
github.com/mholt/acmez v0.1.1/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM=
github.com/mholt/acmez v0.1.2 h1:26ncYNBt59D+59cMUHuGa/Fzjmu6FFrBm6kk/8hdXt0=
github.com/mholt/acmez v0.1.2/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM=
github.com/mholt/acmez v0.1.3 h1:J7MmNIk4Qf9b8mAGqAh4XkNeowv3f1zW816yf4zt7Qk=
github.com/mholt/acmez v0.1.3/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM=
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo=
github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
@ -435,6 +445,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619 h1:yLLDsUUPDliIQpKl7BjVb1igwngIMH2GBjo1VpwLTE0=
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

17
http.go
View File

@ -8,6 +8,7 @@ import (
"sync/atomic"
"github.com/caddyserver/certmagic"
"github.com/dchest/captcha"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
)
@ -85,6 +86,7 @@ func buildHandler() (http.Handler, error) {
r.Use(middleware.NoCache)
}
r.Use(checkIsLogin)
r.Use(checkIsCaptcha)
// Profiler
if appConfig.Server.Debug {
@ -186,6 +188,9 @@ func buildHandler() (http.Handler, error) {
// Media files
r.Get(`/m/{file:[0-9a-fA-F]+(\.[0-9a-zA-Z]+)?}`, serveMediaFile)
// Captcha
r.Handle("/captcha/*", captcha.Server(500, 250))
// Short paths
r.With(cacheMiddleware).Get("/s/{id:[0-9a-fA-F]+}", redirectToLongPath)
@ -333,6 +338,18 @@ func buildHandler() (http.Handler, error) {
mpRouter.Get("/", serveEditor(blog))
mpRouter.Post("/", serveEditorPost(blog))
})
// Comments
if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled {
commentsPath := blogPath + "/comment"
r.Route(commentsPath, func(cr chi.Router) {
cr.With(cacheMiddleware, minifier.Middleware).Get("/{id:[0-9]+}", serveComment(blog))
cr.With(captchaMiddleware).Post("/", createComment(blog, commentsPath))
// Admin
cr.With(minifier.Middleware, authMiddleware).Get("/", commentsAdmin)
cr.With(authMiddleware).Post("/delete", commentsAdminDelete)
})
}
}
// Sitemap

View File

@ -35,6 +35,9 @@ const templateEditor = "editor"
const templateLogin = "login"
const templateStaticHome = "statichome"
const templateBlogStats = "blogstats"
const templateComment = "comment"
const templateCaptcha = "captcha"
const templateCommentsAdmin = "commentsadmin"
var templates map[string]*template.Template
var templateFunctions template.FuncMap
@ -199,6 +202,9 @@ func initRendering() error {
}
return parsed
},
"commentsenabled": func(blog *configBlog) bool {
return blog.Comments != nil && blog.Comments.Enabled
},
}
templates = map[string]*template.Template{}

View File

@ -154,6 +154,10 @@ footer * {
height: 400px;
}
.captchaimg {
background-color: white;
}
/* Print */
@media print {
html {

View File

@ -181,6 +181,10 @@ footer {
height: 400px;
}
.captchaimg {
background-color: white;
}
/* Print */
@media print {
html {

23
templates/captcha.gohtml Normal file
View File

@ -0,0 +1,23 @@
{{ define "title" }}
<title>{{ string .Blog.Lang "captcha" }} - {{ .Blog.Title }}</title>
{{ end }}
{{ define "main" }}
<main>
<h1>{{ string .Blog.Lang "captcha" }}</h1>
<img src="/captcha/{{ .Data.captchaid }}.png" class="captchaimg">
<form class="fw-form p" method="post">
<input type="hidden" name="captchaaction" value="captcha">
<input type="hidden" name="captchamethod" value="{{ .Data.captchamethod }}">
<input type="hidden" name="captchaheaders" value="{{ .Data.captchaheaders }}">
<input type="hidden" name="captchabody" value="{{ .Data.captchabody }}">
<input type="hidden" name="captchaid" value="{{ .Data.captchaid }}">
<input type="text" name="digits" placeholder="{{ string .Blog.Lang "captchainstructions" }}">
<input class="fw" type="submit" value="{{ string .Blog.Lang "submit" }}">
</form>
</main>
{{ end }}
{{ define "captcha" }}
{{ template "base" . }}
{{ end }}

20
templates/comment.gohtml Normal file
View File

@ -0,0 +1,20 @@
{{ define "title" }}
<title>{{ string .Blog.Lang "acommentby" }} {{ .Data.Name }} - {{ .Blog.Title }}</title>
{{ end }}
{{ define "main" }}
<main class=h-entry>
<p><a class="u-in-reply-to" href="{{ absolute .Data.Target }}">{{ absolute .Data.Target }}</a></p>
<div class="p-author h-card p">
{{ string .Blog.Lang "acommentby" }}
{{ if .Data.Website }}<a href="{{ .Data.Website }}" class="p-name u-url" target="_blank" rel="nofollow noopener noreferrer ugc">{{ .Data.Name }}</a>{{ else }}<span class="p-name">{{ .Data.Name }}</span>{{ end }}:
</div>
<div class="e-content p">
{{ .Data.Comment }}
</div>
</main>
{{ end }}
{{ define "comment" }}
{{ template "base" . }}
{{ end }}

View File

@ -0,0 +1,28 @@
{{ define "title" }}
<title>{{ string .Blog.Lang "comments" }} - {{ .Blog.Title }}</title>
{{ end }}
{{ define "main" }}
<main>
<h1>{{ string .Blog.Lang "comments" }}</h1>
{{ $blog := .Blog }}
{{ range $i, $comment := .Data }}
<div class="p">
<p>
Target: <a href="{{ $comment.Target }}" target="_blank">{{ $comment.Target }}</a><br/>
Name: {{ $comment.Name }}<br/>
Website: {{ $comment.Website }}<br/>
Comment: {{ $comment.Comment }}
</p>
<form method="post">
<input type="hidden" name="commentid" value="{{ $comment.ID }}">
<input type="submit" formaction="comment/delete" value="{{ string $blog.Lang "delete" }}">
</form>
</div>
{{ end }}
</main>
{{ end }}
{{ define "commentsadmin" }}
{{ template "base" . }}
{{ end }}

View File

@ -6,7 +6,7 @@
{{ if $mentions }}
<ul>
{{ range $i, $mention := $mentions }}
<li><a href="{{$mention.Source}}" target="_blank" rel="nofollow noopener noreferrer">
<li><a href="{{$mention.Source}}" target="_blank" rel="nofollow noopener noreferrer ugc">
{{ if $mention.Author }}
{{ $mention.Author }}
{{ else }}
@ -16,12 +16,18 @@
{{ end }}
</ul>
{{ end }}
<form class="fw-form" method="post" action="/webmention">
<form class="fw-form p" method="post" action="/webmention">
<label for="wm-source" class="p">{{ string .Blog.Lang "interactionslabel" }}</label>
<input id="wm-source" type="url" name="source" placeholder="URL">
<input id="wm-source" type="url" name="source" placeholder="URL" required>
<input type="hidden" name="target" value="{{ $postperma }}">
<input class="fw" type="submit" value="{{ string .Blog.Lang "send" }}">
</form>
<a class="p button fw ct" href="https://quill.p3k.io/?dontask=1&me=https://commentpara.de&reply={{ $postperma }}">{{ string .Blog.Lang "anoncomment" }}</a>
<form class="fw-form p" method="post" action="{{ blogrelative .Blog "/comment" }}">
<input type="hidden" name="target" value="{{ .Data.Path }}">
<input type="text" name="name" placeholder="{{ string .Blog.Lang "nameopt" }}">
<input type="url" name="website" placeholder="{{ string .Blog.Lang "websiteopt" }}">
<textarea name="comment" required placeholder="{{ string .Blog.Lang "comment" }}"></textarea>
<input class="fw" type="submit" value="{{ string .Blog.Lang "docomment" }}">
</form>
</details>
{{ end }}

View File

@ -27,7 +27,9 @@
</article>
{{ include "author" . }}
</main>
{{ include "interactions" . }}
{{ if commentsenabled .Blog }}
{{ include "interactions" . }}
{{ end }}
{{ end }}
{{ define "post" }}

View File

@ -1,7 +1,10 @@
anoncomment: "Du kannst auch einen anonymen Kommentar verfassen."
acommentby: "Ein Kommentar von"
comment: "Kommentar"
comments: "Kommentare"
count: "Anzahl"
create: "Erstellen"
delete: "Löschen"
docomment: "Kommentieren"
drafts: "Entwürfe"
editor: "Editor"
interactions: "Interaktionen & Kommentare"

View File

@ -1,10 +1,15 @@
anoncomment: "You can also create an anonymous comment."
acommentby: "A comment by"
approve: "Approve"
approved: "Approved"
authenticate: "Authenticate"
captcha: "Captcha"
captchainstructions: "Please enter the digits from the image above"
comment: "Comment"
comments: "Comments"
count: "Count"
create: "Create"
delete: "Delete"
docomment: "Comment"
drafts: "Drafts"
editor: "Editor"
indieauth: "IndieAuth"
@ -12,6 +17,7 @@ interactions: "Interactions & Comments"
interactionslabel: "Have you published a response to this? Paste the URL here."
likeof: "Like of"
login: "Login"
nameopt: "Name (optional)"
next: "Next"
oldcontent: "⚠️ This entry is already over one year old. It may no longer be up to date. Opinions may have changed."
password: "Password"
@ -25,6 +31,7 @@ share: "Share"
shorturl: "Short link:"
speak: "Read to me, please."
stopspeak: "Stop speaking!"
submit: "Submit"
total: "Total"
translations: "Translations"
update: "Update"
@ -34,4 +41,5 @@ username: "Username"
verified: "Verified"
view: "View"
webmentions: "Webmentions"
websiteopt: "Website (optional)"
year: "Year"

View File

@ -117,7 +117,7 @@ func webmentionAdminDelete(w http.ResponseWriter, r *http.Request) {
return
}
purgeCache()
http.Redirect(w, r, "/webmention", http.StatusFound)
http.Redirect(w, r, ".", http.StatusFound)
return
}
@ -133,7 +133,7 @@ func webmentionAdminApprove(w http.ResponseWriter, r *http.Request) {
return
}
purgeCache()
http.Redirect(w, r, "/webmention", http.StatusFound)
http.Redirect(w, r, ".", http.StatusFound)
return
}

View File

@ -10,7 +10,6 @@ import (
"net/http"
"net/url"
"os"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/joncrlsn/dque"
@ -87,10 +86,6 @@ func (m *mention) verifyMention() error {
m.Title = m.Title[0:57] + "…"
}
newStatus := webmentionStatusVerified
if strings.HasPrefix(m.Source, appConfig.Server.PublicAddress) {
// Approve if it's server-intern
newStatus = webmentionStatusApproved
}
if webmentionExists(m.Source, m.Target) {
_, err = appDbExec("update webmentions set status = @status, title = @title, content = @content, author = @author where source = @source and target = @target",
sql.Named("status", newStatus), sql.Named("title", m.Title), sql.Named("content", m.Content), sql.Named("author", m.Author), sql.Named("source", m.Source), sql.Named("target", m.Target))