|
|
|
package comments
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"crypto/subtle"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"html/template"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"strconv"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/peterbourgon/diskv"
|
|
|
|
)
|
|
|
|
|
|
|
|
type Comment struct {
|
|
|
|
Name, Text string
|
|
|
|
IP string
|
|
|
|
Date time.Time
|
|
|
|
Spam bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func MarshalJSON(c Comment) ([]byte, error) {
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
err := json.NewEncoder(buf).Encode(c)
|
|
|
|
return buf.Bytes(), err
|
|
|
|
}
|
|
|
|
|
|
|
|
func UnmarshalJSON(b []byte) (Comment, error) {
|
|
|
|
var c Comment
|
|
|
|
err := json.NewDecoder(bytes.NewBuffer(b)).Decode(&c)
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func Default() *Service {
|
|
|
|
return WithStorage(NewDiskv("comments"))
|
|
|
|
}
|
|
|
|
|
|
|
|
func WithStorage(storage Storage) *Service {
|
|
|
|
return &Service{Storage: storage}
|
|
|
|
}
|
|
|
|
|
|
|
|
type Service struct {
|
|
|
|
Storage Storage
|
|
|
|
Password string
|
|
|
|
}
|
|
|
|
|
|
|
|
type Storage interface {
|
|
|
|
Store(post string, c Comment) error
|
|
|
|
Retreive(post string) ([]Comment, error)
|
|
|
|
ReadAll(post string) (io.Reader, error)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) User(r *http.Request) (string, bool) {
|
|
|
|
if cs.Password == "" {
|
|
|
|
return "", true
|
|
|
|
}
|
|
|
|
|
|
|
|
user, providedPass, ok := r.BasicAuth()
|
|
|
|
if ok && subtle.ConstantTimeCompare([]byte(cs.Password), []byte(providedPass)) == 1 {
|
|
|
|
return user, true
|
|
|
|
}
|
|
|
|
|
|
|
|
return "", false
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) ServeComments(post string, w http.ResponseWriter, r *http.Request) error {
|
|
|
|
user, allowed := cs.User(r)
|
|
|
|
if !allowed {
|
|
|
|
fmt.Fprintf(w, "<a href='/login'>click here to enter your name and the comments password</a>")
|
|
|
|
w.WriteHeader(401)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if r.Method == "POST" {
|
|
|
|
return cs.Comment(post, Comment{
|
|
|
|
Name: user,
|
|
|
|
Text: readCommentText(r),
|
|
|
|
IP: r.RemoteAddr,
|
|
|
|
Date: time.Now(),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
_, err := cs.WriteHTML(post, w)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func readCommentText(r *http.Request) string {
|
|
|
|
comment := struct{ Text string }{}
|
|
|
|
json.NewDecoder(r.Body).Decode(&comment)
|
|
|
|
return comment.Text
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) Comment(post string, c Comment) error {
|
|
|
|
return cs.Storage.Store(post, c)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) Load(post string) ([]Comment, error) {
|
|
|
|
return cs.Storage.Retreive(post)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) WriteHTML(post string, w io.Writer) (int64, error) {
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
tmpl := template.Must(template.New("").Parse(
|
|
|
|
`<style>
|
|
|
|
comment {
|
|
|
|
margin: 10px 0;
|
|
|
|
}
|
|
|
|
comment, comment name, comment text {
|
|
|
|
display: block;
|
|
|
|
}
|
|
|
|
comment name::after {
|
|
|
|
content: " says:";
|
|
|
|
}
|
|
|
|
comment text {
|
|
|
|
margin-left: 5px;
|
|
|
|
padding-left: 5px;
|
|
|
|
border-left: 2px solid gray;
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
{{range .}}{{ if .Text }}<comment>
|
|
|
|
<name>{{.Name}}</name>
|
|
|
|
<text>{{.Text}}</text>
|
|
|
|
</comment>
|
|
|
|
{{end}}{{end}}
|
|
|
|
<form onsubmit="submitComment(event)">
|
|
|
|
<p>
|
|
|
|
<div>
|
|
|
|
<label for="comment">post a comment:</label>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<textarea placeholder="comment..." name="comment"></textarea>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<button type="submit">post</button>
|
|
|
|
</div>
|
|
|
|
</p>
|
|
|
|
</form>`))
|
|
|
|
|
|
|
|
comments, err := cs.Load(post)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
|
|
|
err = tmpl.Execute(buf, comments)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
return io.Copy(w, buf)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cs *Service) WriteJSON(post string, w io.Writer) (int64, error) {
|
|
|
|
r, err := cs.Storage.ReadAll(post)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return io.Copy(w, r)
|
|
|
|
}
|
|
|
|
|
|
|
|
type diskvStorage struct {
|
|
|
|
*diskv.Diskv
|
|
|
|
|
|
|
|
keyMtx sync.Mutex
|
|
|
|
keyLastKnown int64
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *diskvStorage) nextKey(post string) (string, error) {
|
|
|
|
d.keyMtx.Lock()
|
|
|
|
defer d.keyMtx.Unlock()
|
|
|
|
|
|
|
|
prefix := post + "-"
|
|
|
|
|
|
|
|
lastKey := prefix
|
|
|
|
|
|
|
|
keys := d.KeysPrefix(prefix, nil)
|
|
|
|
for key := range keys {
|
|
|
|
lastKey = key
|
|
|
|
}
|
|
|
|
|
|
|
|
if lastKey == prefix {
|
|
|
|
return prefix + "1", nil
|
|
|
|
}
|
|
|
|
|
|
|
|
minusPrefix := lastKey[len(prefix):]
|
|
|
|
lastIdx, err := strconv.Atoi(minusPrefix)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("comment key %q seems malformed", lastKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
key := prefix + fmt.Sprint(lastIdx+1)
|
|
|
|
err = d.WriteStream(key, &bytes.Buffer{}, true)
|
|
|
|
|
|
|
|
return key, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *diskvStorage) Store(post string, c Comment) error {
|
|
|
|
b, err := MarshalJSON(c)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error marshaling comment for storage: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
key, err := d.nextKey(post)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error recording comment: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = d.Write(key, b)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error writing comment to storage: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type multiReadCloser struct {
|
|
|
|
rcs []io.ReadCloser
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mrc *multiReadCloser) Read(b []byte) (int, error) {
|
|
|
|
if len(mrc.rcs) == 0 {
|
|
|
|
return 0, io.EOF
|
|
|
|
}
|
|
|
|
|
|
|
|
n, err := mrc.rcs[0].Read(b)
|
|
|
|
if n == len(b) { // we read enough
|
|
|
|
return n, err
|
|
|
|
}
|
|
|
|
|
|
|
|
for (err == nil || err == io.EOF) && len(mrc.rcs) > 1 {
|
|
|
|
var n2 int
|
|
|
|
closeErr := mrc.rcs[0].Close()
|
|
|
|
if closeErr != nil {
|
|
|
|
return n, closeErr
|
|
|
|
}
|
|
|
|
|
|
|
|
mrc.rcs = mrc.rcs[1:]
|
|
|
|
n2, err = mrc.rcs[0].Read(b[n:])
|
|
|
|
n += n2
|
|
|
|
}
|
|
|
|
|
|
|
|
return n, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mrc *multiReadCloser) Close() error {
|
|
|
|
var err error
|
|
|
|
|
|
|
|
for _, rc := range mrc.rcs {
|
|
|
|
err2 := rc.Close()
|
|
|
|
if err2 != nil {
|
|
|
|
err = err2
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *diskvStorage) ReadAll(post string) (io.Reader, error) {
|
|
|
|
prefix := post + "-"
|
|
|
|
|
|
|
|
var rcs []io.ReadCloser
|
|
|
|
|
|
|
|
keys := d.KeysPrefix(prefix, nil)
|
|
|
|
for key := range keys {
|
|
|
|
rc, err := d.ReadStream(key, false)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
rcs = append(rcs, rc)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &multiReadCloser{rcs}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *diskvStorage) Retreive(post string) ([]Comment, error) {
|
|
|
|
var comments []Comment
|
|
|
|
|
|
|
|
r, err := d.ReadAll(post)
|
|
|
|
if err != nil {
|
|
|
|
return comments, fmt.Errorf("error reading comment from storage: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
dec := json.NewDecoder(r)
|
|
|
|
for dec.More() {
|
|
|
|
comment := Comment{}
|
|
|
|
err := dec.Decode(&comment)
|
|
|
|
if err != nil {
|
|
|
|
return comments, fmt.Errorf("error unmarshaling comment: %v", err)
|
|
|
|
}
|
|
|
|
comments = append(comments, comment)
|
|
|
|
}
|
|
|
|
|
|
|
|
return comments, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewDiskv(path string) Storage {
|
|
|
|
return &diskvStorage{
|
|
|
|
Diskv: diskv.New(diskv.Options{
|
|
|
|
BasePath: path,
|
|
|
|
CacheSizeMax: 1024 * 1024,
|
|
|
|
}),
|
|
|
|
}
|
|
|
|
}
|