├── README.md └── src ├── comment.go ├── go.mod ├── go.sum └── main.go /README.md: -------------------------------------------------------------------------------- 1 | Comma: a super simple comment server for static websites, in go 2 | 3 | This means your website is no longer purely static, but it's a simple, fairly pragmatic solution to dynamically save and load comments (using javascript) 4 | 5 | # API 6 | 7 | * GET /foo/bar/whatever/my-post-slug 8 | provides all comments for post `my-post-slug` as JSON. 9 | 10 | * POST 11 | reads out the following form fields, creates a comment and returns the comment as JSON. 12 | - post 13 | - message 14 | - name 15 | - email 16 | - url 17 | - company (honey-pot field to prevent spam, must be empty) 18 | 19 | # Storage 20 | 21 | Files are stored in xml files compatible with [pyblosxom's comment files](http://pyblosxom.github.io/) 22 | 23 | email addresses and ip addresses of comments are never served up, though the md5 of email addresses is, 24 | so you can use gravatar. 25 | 26 | # Html/javascript based commenting feature on top of this server 27 | 28 | Integrating this in your website takes less than 100 lines of javascript. 29 | You can either: 30 | 31 | * use jquery, like [on my old blog](https://github.com/Dieterbe/hugo-theme-blog/blob/master/layouts/partials/comments.html) 32 | * use pure javascript, like [on my new blog](https://github.com/Dieterbe/dieterblog/blob/master/layouts/partials/comments.html) 33 | 34 | 35 | See it in action on [dieter.plaetinck.be](http://dieter.plaetinck.be/) 36 | 37 | ## How to run 38 | 39 | I use a systemd unit like this: 40 | ``` 41 | [Unit] 42 | Description=comma backend 43 | After=network-online.target 44 | Wants=network-online.target 45 | 46 | [Service] 47 | ExecStart=/home/dieter/comma /home/dieter/ : [form value for "special" form fields] 48 | Restart=always 49 | RestartSec=1 50 | User=dieter 51 | Group=dieter 52 | 53 | [Install] 54 | WantedBy=graphical.target 55 | ``` 56 | 57 | the optional "special" argument is a basic spam prevention mechanism. if the value is provided, and the "special" form value doesn't match this value, 58 | the comment is rejected. 59 | -------------------------------------------------------------------------------- /src/comment.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/xml" 6 | "fmt" 7 | "io/ioutil" 8 | "math/rand" 9 | "os" 10 | "regexp" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | // Comment contains the data for a comment 16 | // the xml schema matches what pyblosxom comments use 17 | // the json schema is for public serving and hides email address and ip address 18 | // it does provide an md5 hash of email address, so gravatar can be used 19 | type Comment struct { 20 | XMLName xml.Name `xml:"item"` 21 | Parent string `xml:"parent"` 22 | Ts time.Time `xml:"w3cdate"` 23 | Seq uint `xml:"-"` 24 | Message string `xml:"description"` 25 | Ipaddress string `xml:"ipaddress" json:"-"` 26 | Author string `xml:"author"` 27 | Email string `xml:"email" json:"-"` 28 | Hash string `xml:"-"` 29 | Link string `xml:"link"` 30 | } 31 | 32 | func (c Comment) String() string { 33 | return fmt.Sprintf("", c.Ts, c.Author) 34 | 35 | } 36 | 37 | type ByTsAsc []Comment 38 | 39 | func (a ByTsAsc) Len() int { return len(a) } 40 | func (a ByTsAsc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 41 | func (a ByTsAsc) Less(i, j int) bool { return a[i].Ts.Before(a[j].Ts) } 42 | 43 | func NewComment(re *regexp.Regexp, path, fileName string) (*Comment, error) { 44 | res := re.FindStringSubmatch(fileName) 45 | ts, _ := strconv.Atoi(res[1]) 46 | seq, _ := strconv.Atoi(res[2]) 47 | content, err := ioutil.ReadFile(path + "/" + fileName) 48 | if err != nil { 49 | return nil, err 50 | } 51 | c := Comment{} 52 | err = xml.Unmarshal([]byte(content), &c) 53 | if err != nil { 54 | return nil, err 55 | } 56 | c.Ts = time.Unix(int64(ts), 0) 57 | c.Seq = uint(seq) 58 | c.Hash = fmt.Sprintf("%x", md5.Sum([]byte(c.Email))) 59 | return &c, nil 60 | } 61 | 62 | func (c Comment) tmpName() string { 63 | return fmt.Sprintf("%s-%d.%d.tmp", c.Parent, c.Ts.Unix(), c.Seq) 64 | } 65 | 66 | func (c Comment) fileName() string { 67 | return fmt.Sprintf("%s-%d.%d.cmt", c.Parent, c.Ts.Unix(), c.Seq) 68 | } 69 | 70 | func (c Comment) Save(path string) error { 71 | data, err := xml.Marshal(c) 72 | if err != nil { 73 | fmt.Printf("comment %s can't be marshalled: %s\n", c, err) 74 | return err 75 | } 76 | if c.Seq == 0 { 77 | rand.Seed(time.Now().UnixNano()) 78 | c.Seq = uint(rand.Uint32()) 79 | } 80 | fullName := path + "/" + c.tmpName() 81 | f, err := os.Create(fullName) 82 | if err != nil { 83 | fmt.Printf("comment %s can't open file %s: %s\n", c, fullName, err) 84 | return err 85 | } 86 | defer f.Close() 87 | fmt.Println("saving", c) 88 | _, err = f.Write(data) 89 | if err != nil { 90 | fmt.Printf("comment %s can't be written: %s\n", c, err) 91 | return err 92 | } 93 | 94 | err = os.Rename(fullName, path+"/"+c.fileName()) 95 | if err != nil { 96 | fmt.Printf("comment %s can't be renamed to final file: %s\n", c, err) 97 | } 98 | return err 99 | } 100 | 101 | func FindComments(path, slug string) ([]Comment, error) { 102 | re := regexp.MustCompile("^" + slug + "-([0-9]{10})\\.([0-9]+)\\.cmt$") 103 | dir, err := os.Open(path) 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer dir.Close() 108 | fi, err := dir.Stat() 109 | if err != nil { 110 | return nil, err 111 | } 112 | comments := make([]Comment, 0) 113 | if fi.IsDir() { 114 | fis, err := dir.Readdir(-1) // -1 means return all the FileInfos 115 | if err != nil { 116 | return nil, err 117 | } 118 | for _, fileinfo := range fis { 119 | if !fileinfo.IsDir() && re.MatchString(fileinfo.Name()) { 120 | c, err := NewComment(re, path, fileinfo.Name()) 121 | if err != nil { 122 | return nil, err 123 | } 124 | comments = append(comments, *c) 125 | } 126 | } 127 | } 128 | return comments, nil 129 | } 130 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module comma 2 | 3 | go 1.21.6 4 | 5 | require github.com/rs/cors v1.10.1 6 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= 2 | github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 3 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/rs/cors" 15 | ) 16 | 17 | var path string 18 | var addr string 19 | var special string 20 | 21 | func main() { 22 | if len(os.Args) != 3 && len(os.Args) != 4 { 23 | fmt.Fprintf(os.Stderr, "%s [special-value]\n", os.Args[0]) 24 | os.Exit(2) 25 | } 26 | path = strings.TrimSuffix(os.Args[1], "/") 27 | 28 | addr = os.Args[2] 29 | fmt.Println("looking for comments in", path) 30 | fmt.Println("will listen for http traffic on", addr) 31 | if len(os.Args) == 4 { 32 | special = os.Args[3] 33 | } 34 | 35 | http.Handle("/", cors.Default().Handler( 36 | http.HandlerFunc( 37 | func(w http.ResponseWriter, r *http.Request) { 38 | if r.Method == "POST" { 39 | handlePost(w, r) 40 | } else { 41 | handleGet(w, r) 42 | } 43 | }, 44 | ), 45 | ), 46 | ) 47 | http.ListenAndServe(addr, nil) 48 | } 49 | 50 | func handlePost(w http.ResponseWriter, r *http.Request) { 51 | err := r.ParseMultipartForm(1024 * 10) 52 | if err != nil { 53 | fmt.Println(r.URL.Path, "POST", "unparseable form", err) 54 | http.Error(w, "Couldn't parse form: "+err.Error(), http.StatusBadRequest) 55 | return 56 | } 57 | 58 | if special != "" && r.Form.Get("special") != special { 59 | fmt.Println(r.URL.Path, "POST", "bad 'special' value", r.Form.Get("special")) 60 | http.Error(w, "incorrect value provided", http.StatusForbidden) 61 | return 62 | } 63 | 64 | c, err := CommentFromForm(r.Form) 65 | if err != nil { 66 | fmt.Println(r.URL.Path, "POST", "invalid submission", err) 67 | http.Error(w, "Invalid submission: "+err.Error(), http.StatusBadRequest) 68 | return 69 | } 70 | 71 | err = c.Save(path) 72 | if err != nil { 73 | fmt.Println(r.URL.Path, "POST", "Error: Couldn't save comment: ", err) 74 | http.Error(w, "Couldn't save comment: "+err.Error(), http.StatusInternalServerError) 75 | return 76 | } 77 | bytes, e := json.Marshal(c) 78 | if e != nil { 79 | fmt.Println(r.URL.Path, "POST", "Error marshalling JSON: ", err) 80 | http.Error(w, fmt.Sprintf("Error marshalling JSON:'%s'", e), http.StatusInternalServerError) 81 | return 82 | } 83 | w.Header().Set("Content-Type", "application/json") 84 | w.Write(bytes) 85 | } 86 | 87 | func CommentFromForm(form url.Values) (Comment, error) { 88 | if form.Get("message") == "" { 89 | return Comment{}, fmt.Errorf("message is required") 90 | } 91 | if form.Get("name") == "" { 92 | return Comment{}, fmt.Errorf("name is required") 93 | } 94 | if form.Get("email") == "" { 95 | return Comment{}, fmt.Errorf("email is required") 96 | } 97 | if form.Get("post") == "" { 98 | return Comment{}, fmt.Errorf("post is required") 99 | } 100 | return Comment{ 101 | Parent: form.Get("post"), 102 | Ts: time.Now(), 103 | Message: form.Get("message"), 104 | Ipaddress: "", // TODO 105 | Author: form.Get("name"), 106 | Email: form.Get("email"), 107 | Link: form.Get("url"), 108 | Hash: fmt.Sprintf("%x", md5.Sum([]byte(form.Get("email")))), 109 | }, nil 110 | 111 | } 112 | 113 | func handleGet(w http.ResponseWriter, r *http.Request) { 114 | // strip out all /prependend/path/separators/if/any so that it works under arbitrary paths, for proxying etc 115 | cutoff := strings.LastIndex(r.URL.Path, "/") 116 | slug := r.URL.Path[cutoff+1:] 117 | if slug == "" { 118 | fmt.Println(r.URL.Path, "GET", "No slug specified") 119 | http.Error(w, "specify a slug, you dufus", http.StatusBadRequest) 120 | return 121 | } 122 | comments, err := FindComments(path, slug) 123 | if err != nil { 124 | fmt.Println(r.URL.Path, "GET", "Error: FindComments() failed: "+err.Error()) 125 | http.Error(w, err.Error(), http.StatusInternalServerError) 126 | return 127 | } 128 | sort.Sort(ByTsAsc(comments)) 129 | fmt.Printf("> %s -> serving %d comments\n", slug, len(comments)) 130 | bytes, e := json.Marshal(comments) 131 | if e != nil { 132 | fmt.Println(r.URL.Path, "GET", "Error marshalling JSON: "+err.Error()) 133 | http.Error(w, fmt.Sprintf("Error marshalling JSON:'%s'", e), http.StatusInternalServerError) 134 | return 135 | } 136 | w.Header().Set("Content-Type", "application/json") 137 | w.Write(bytes) 138 | } 139 | --------------------------------------------------------------------------------