diff --git a/authentication.go b/authentication.go index e904c22..b42efd3 100644 --- a/authentication.go +++ b/authentication.go @@ -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 } diff --git a/captcha.go b/captcha.go new file mode 100644 index 0000000..02a752c --- /dev/null +++ b/captcha.go @@ -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 +} diff --git a/comments.go b/comments.go new file mode 100644 index 0000000..7092dfe --- /dev/null +++ b/comments.go @@ -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 +} diff --git a/config.go b/config.go index c55d13b..e1ab7d6 100644 --- a/config.go +++ b/config.go @@ -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"` diff --git a/databaseMigrations.go b/databaseMigrations.go index 3969e6e..48ba813 100644 --- a/databaseMigrations.go +++ b/databaseMigrations.go @@ -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 { diff --git a/go.mod b/go.mod index 4b9757f..c9b5056 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6b32f2a..fae54ac 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/http.go b/http.go index 65e42da..09febf3 100644 --- a/http.go +++ b/http.go @@ -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 diff --git a/render.go b/render.go index a21500f..523013f 100644 --- a/render.go +++ b/render.go @@ -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{} diff --git a/templates/assets/css/styles.css b/templates/assets/css/styles.css index db8aea0..c70495d 100644 --- a/templates/assets/css/styles.css +++ b/templates/assets/css/styles.css @@ -154,6 +154,10 @@ footer * { height: 400px; } +.captchaimg { + background-color: white; +} + /* Print */ @media print { html { diff --git a/templates/assets/css/styles.scss b/templates/assets/css/styles.scss index fc0a4ea..f8b0b11 100644 --- a/templates/assets/css/styles.scss +++ b/templates/assets/css/styles.scss @@ -181,6 +181,10 @@ footer { height: 400px; } +.captchaimg { + background-color: white; +} + /* Print */ @media print { html { diff --git a/templates/captcha.gohtml b/templates/captcha.gohtml new file mode 100644 index 0000000..95adcd5 --- /dev/null +++ b/templates/captcha.gohtml @@ -0,0 +1,23 @@ +{{ define "title" }} + {{ string .Blog.Lang "captcha" }} - {{ .Blog.Title }} +{{ end }} + +{{ define "main" }} +
+

{{ string .Blog.Lang "captcha" }}

+ +
+ + + + + + + +
+
+{{ end }} + +{{ define "captcha" }} + {{ template "base" . }} +{{ end }} \ No newline at end of file diff --git a/templates/comment.gohtml b/templates/comment.gohtml new file mode 100644 index 0000000..dd586d1 --- /dev/null +++ b/templates/comment.gohtml @@ -0,0 +1,20 @@ +{{ define "title" }} + {{ string .Blog.Lang "acommentby" }} {{ .Data.Name }} - {{ .Blog.Title }} +{{ end }} + +{{ define "main" }} +
+

{{ absolute .Data.Target }}

+
+ {{ string .Blog.Lang "acommentby" }} + {{ if .Data.Website }}{{ .Data.Name }}{{ else }}{{ .Data.Name }}{{ end }}: +
+
+ {{ .Data.Comment }} +
+
+{{ end }} + +{{ define "comment" }} + {{ template "base" . }} +{{ end }} \ No newline at end of file diff --git a/templates/commentsadmin.gohtml b/templates/commentsadmin.gohtml new file mode 100644 index 0000000..baf757a --- /dev/null +++ b/templates/commentsadmin.gohtml @@ -0,0 +1,28 @@ +{{ define "title" }} + {{ string .Blog.Lang "comments" }} - {{ .Blog.Title }} +{{ end }} + +{{ define "main" }} +
+

{{ string .Blog.Lang "comments" }}

+ {{ $blog := .Blog }} + {{ range $i, $comment := .Data }} +
+

+ Target: {{ $comment.Target }}
+ Name: {{ $comment.Name }}
+ Website: {{ $comment.Website }}
+ Comment: {{ $comment.Comment }} +

+
+ + +
+
+ {{ end }} +
+{{ end }} + +{{ define "commentsadmin" }} + {{ template "base" . }} +{{ end }} \ No newline at end of file diff --git a/templates/interactions.gohtml b/templates/interactions.gohtml index 0eeade5..dfba404 100644 --- a/templates/interactions.gohtml +++ b/templates/interactions.gohtml @@ -6,7 +6,7 @@ {{ if $mentions }} {{ end }} -
+ - +
-
{{ string .Blog.Lang "anoncomment" }} +
+ + + + + +
{{ end }} \ No newline at end of file diff --git a/templates/post.gohtml b/templates/post.gohtml index 5dc516a..58cbf02 100644 --- a/templates/post.gohtml +++ b/templates/post.gohtml @@ -27,7 +27,9 @@ {{ include "author" . }} - {{ include "interactions" . }} + {{ if commentsenabled .Blog }} + {{ include "interactions" . }} + {{ end }} {{ end }} {{ define "post" }} diff --git a/templates/strings/de.yaml b/templates/strings/de.yaml index 11bf211..cfbdc5d 100644 --- a/templates/strings/de.yaml +++ b/templates/strings/de.yaml @@ -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" diff --git a/templates/strings/default.yaml b/templates/strings/default.yaml index 47e7d2b..1a679ef 100644 --- a/templates/strings/default.yaml +++ b/templates/strings/default.yaml @@ -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" \ No newline at end of file diff --git a/webmention.go b/webmention.go index b12fbee..4fd2d4b 100644 --- a/webmention.go +++ b/webmention.go @@ -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 } diff --git a/webmentionVerification.go b/webmentionVerification.go index dd09596..5726120 100644 --- a/webmentionVerification.go +++ b/webmentionVerification.go @@ -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))