diff --git a/app/rest/api/admin.go b/app/rest/api/admin.go new file mode 100644 index 00000000..62d9efae --- /dev/null +++ b/app/rest/api/admin.go @@ -0,0 +1,158 @@ +package api + +import ( + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/render" + + "github.com/umputun/remark/app/migrator" + "github.com/umputun/remark/app/rest" + "github.com/umputun/remark/app/store" +) + +// admin provides router for all requests available for admin users only +type admin struct { + dataService store.Service + exporter migrator.Exporter + importer migrator.Importer + cache rest.LoadingCache + defAvatarURL string +} + +func (a *admin) routes(middlewares ...func(http.Handler) http.Handler) chi.Router { + router := chi.NewRouter() + router.Use(middlewares...) + router.Delete("/comment/{id}", a.deleteCommentCtrl) + router.Put("/user/{userid}", a.setBlockCtrl) + router.Get("/export", a.exportCtrl) + router.Post("/import", a.importCtrl) + router.Put("/pin/{id}", a.setPinCtrl) + router.Get("/blocked", a.blockedUsersCtrl) + return router +} + +// DELETE /comment/{id}?site=siteID&url=post-url - removes comment +func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { + + id := chi.URLParam(r, "id") + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + log.Printf("[INFO] delete comment %s", id) + + err := a.dataService.Delete(locator, id) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete comment") + return + } + a.cache.Flush() + render.Status(r, http.StatusOK) + render.JSON(w, r, JSON{"id": id, "locator": locator}) +} + +// PUT /user/{userid}?site=side-id&block=1 - block or unblock user +func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userid") + siteID := r.URL.Query().Get("site") + blockStatus := r.URL.Query().Get("block") == "1" + + if err := a.dataService.SetBlock(siteID, userID, blockStatus); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set blocking status") + return + } + a.cache.Flush() + render.JSON(w, r, JSON{"user_id": userID, "site_id": siteID, "block": blockStatus}) +} + +// GET /blocked?site=siteID - list blocked users +func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) { + siteID := r.URL.Query().Get("site") + users, err := a.dataService.Blocked(siteID) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users") + return + } + render.JSON(w, r, users) +} + +// PUT /pin/{id}?site=siteID&url=post-url&pin=1 +// mark/unmark comment as a special +func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) { + commentID := chi.URLParam(r, "id") + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + pinStatus := r.URL.Query().Get("pin") == "1" + + if err := a.dataService.SetPin(locator, commentID, pinStatus); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set pin status") + return + } + a.cache.Flush() + render.JSON(w, r, JSON{"id": commentID, "locator": locator, "pin": pinStatus}) +} + +// GET /export?site=site-id?mode=file|stream +// exports all comments for siteID as json stream or gz file +func (a *admin) exportCtrl(w http.ResponseWriter, r *http.Request) { + siteID := r.URL.Query().Get("site") + var writer io.Writer = w + if r.URL.Query().Get("mode") == "file" { + exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102")) + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", "attachment;filename="+exportFile) + w.WriteHeader(http.StatusOK) + writer = gzip.NewWriter(w) + } + + if err := a.exporter.Export(writer, siteID); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed") + } +} + +// POST /import?site=site-id +// imports comments from post body. +func (a *admin) importCtrl(w http.ResponseWriter, r *http.Request) { + siteID := r.URL.Query().Get("site") + if err := a.importer.Import(r.Body, siteID); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "import failed") + } + a.cache.Flush() +} + +func (a *admin) checkBlocked(siteID string, user store.User) bool { + return a.dataService.IsBlocked(siteID, user.ID) +} + +// processes comments and hides text of all comments for blocked users. +// resets score and votes too. Also hides sensitive info for non-admin users +func (a *admin) alterComments(comments []store.Comment, r *http.Request) (res []store.Comment) { + res = make([]store.Comment, len(comments)) + + user, err := rest.GetUserInfo(r) + isAdmin := err == nil && user.Admin // make separate cache key for admins + + for i, c := range comments { + + // process blocked users + if a.dataService.IsBlocked(c.Locator.SiteID, c.User.ID) { + c.Mask() + c.User.Blocked = true + } + + // set default avatar + if c.User.Picture == "" { + c.User.Picture = a.defAvatarURL + } + + // hide info from non-admins + if !isAdmin { + c.User.IP = "" + } + + res[i] = c + } + return res +} diff --git a/app/rest/api/admin_test.go b/app/rest/api/admin_test.go new file mode 100644 index 00000000..ac591035 --- /dev/null +++ b/app/rest/api/admin_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/umputun/remark/app/store" +) + +func TestAdmin_Delete(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + + id1 := addComment(t, c1, port) + addComment(t, c2, port) + + client := http.Client{} + req, err := http.NewRequest(http.MethodDelete, + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/comment/%s?site=radio-t&url=https://radio-t.com/blah", + port, id1), nil) + assert.Nil(t, err) + resp, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", port, id1)) + assert.Equal(t, 200, code) + cr := store.Comment{} + err = json.Unmarshal([]byte(body), &cr) + assert.Nil(t, err) + assert.Equal(t, "this comment was deleted", cr.Text) + assert.True(t, cr.Deleted) +} + +func TestAdmin_Pin(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + + id1 := addComment(t, c1, port) + addComment(t, c2, port) + + pin := func(val int) int { + client := http.Client{} + req, err := http.NewRequest(http.MethodPut, + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/pin/%s?site=radio-t&url=https://radio-t.com/blah&pin=%d", port, id1, val), nil) + assert.Nil(t, err) + resp, err := client.Do(req) + assert.Nil(t, err) + return resp.StatusCode + } + + code := pin(1) + assert.Equal(t, 200, code) + + body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", port, id1)) + assert.Equal(t, 200, code) + cr := store.Comment{} + err := json.Unmarshal([]byte(body), &cr) + assert.Nil(t, err) + assert.True(t, cr.Pin) + + code = pin(-1) + assert.Equal(t, 200, code) + body, code = get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", port, id1)) + assert.Equal(t, 200, code) + cr = store.Comment{} + err = json.Unmarshal([]byte(body), &cr) + assert.Nil(t, err) + assert.False(t, cr.Pin) +} + +func TestAdmin_Block(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}} + + _, err := srv.DataService.Create(c1) + assert.Nil(t, err) + _, err = srv.DataService.Create(c2) + assert.Nil(t, err) + + block := func(val int) (code int, body []byte) { + client := http.Client{} + req, err := http.NewRequest(http.MethodPut, + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/user/%s?site=radio-t&block=%d", port, "user1", val), nil) + assert.Nil(t, err) + resp, err := client.Do(req) + require.Nil(t, err) + body, err = ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + resp.Body.Close() + return resp.StatusCode, body + } + + code, body := block(1) + require.Equal(t, 200, code) + j := JSON{} + err = json.Unmarshal(body, &j) + assert.Nil(t, err) + assert.Equal(t, "user1", j["user_id"]) + assert.Equal(t, true, j["block"]) + assert.Equal(t, "radio-t", j["site_id"]) + + res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time", port)) + assert.Equal(t, 200, code) + comments := []store.Comment{} + err = json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments), "should have 2 comments") + assert.Equal(t, "this comment was deleted", comments[0].Text) + + code, body = block(-1) + require.Equal(t, 200, code) + err = json.Unmarshal(body, &j) + assert.Nil(t, err) + assert.Equal(t, false, j["block"]) +} + +func TestAdmin_Export(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + + addComment(t, c1, port) + addComment(t, c2, port) + + body, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/admin/export?site=radio-t&mode=stream", port)) + assert.Equal(t, 200, code) + assert.Equal(t, 2, strings.Count(body, "\n")) + assert.Equal(t, 2, strings.Count(body, "\"text\"")) + t.Logf("%s", body) +} diff --git a/app/rest/api/middleware.go b/app/rest/api/middleware.go new file mode 100644 index 00000000..7af8d6ec --- /dev/null +++ b/app/rest/api/middleware.go @@ -0,0 +1,159 @@ +package api + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "regexp" + "runtime/debug" + "strings" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/umputun/remark/app/rest" +) + +// JSON is a map alias, just for convenience +type JSON map[string]interface{} + +// AppInfo adds custom app-info to the response header +func AppInfo(app string, version string) func(http.Handler) http.Handler { + f := func(h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Org", "Umputun") + w.Header().Set("App-Name", app) + w.Header().Set("App-Version", version) + if mhost := os.Getenv("MHOST"); mhost != "" { + w.Header().Set("Host", mhost) + } + h.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } + return f +} + +// Ping middleware response with pong to /ping. Stops chain if ping request detected +func Ping(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + + if r.Method == "GET" && strings.HasSuffix(strings.ToLower(r.URL.Path), "/ping") { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("pong")); err != nil { + log.Printf("[WARN] can't send pong, %s", err) + } + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) +} + +// Recoverer is a middleware that recovers from panics, logs the panic and returns a HTTP 500 status if possible. +func Recoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + log.Printf("[WARN] request panic, %v", rvr) + debug.PrintStack() + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) +} + +// LoggerFlag type +type LoggerFlag int + +// logger flags enum +const ( + LogAll LoggerFlag = iota + LogUser + LogBody +) +const maxBody = 1024 + +var reMultWhtsp = regexp.MustCompile(`[\s\p{Zs}]{2,}`) + +// Logger middleware prints http log. Customized by set of LoggerFlag +func Logger(flags ...LoggerFlag) func(http.Handler) http.Handler { + + inFlags := func(f LoggerFlag) bool { + for _, flg := range flags { + if flg == LogAll || flg == f { + return true + } + } + return false + } + + f := func(h http.Handler) http.Handler { + + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, 1) + + body, user := func() (body string, user string) { + ctx := r.Context() + if ctx == nil { + return "", "" + } + + if inFlags(LogBody) { + if content, err := ioutil.ReadAll(r.Body); err == nil { + body = string(content) + r.Body = ioutil.NopCloser(bytes.NewReader(content)) + + if len(body) > 0 { + body = strings.Replace(body, "\n", " ", -1) + body = reMultWhtsp.ReplaceAllString(body, " ") + } + + if len(body) > maxBody { + body = body[:maxBody] + "..." + } + } + } + + if inFlags(LogUser) { + u, err := rest.GetUserInfo(r) + if err == nil && u.Name != "" { + user = fmt.Sprintf(" - %s %q", u.ID, u.Name) + } + } + + return body, user + }() + + t1 := time.Now() + defer func() { + t2 := time.Now() + + q := r.URL.String() + if qun, err := url.QueryUnescape(q); err == nil { + q = qun + } + + remoteIP := strings.Split(r.RemoteAddr, ":")[0] + if strings.HasPrefix(r.RemoteAddr, "[") { + remoteIP = strings.Split(r.RemoteAddr, "]:")[0] + "]" + } + + log.Printf("[INFO] REST %s - %s - %s - %d (%d) - %v %s %s", + r.Method, q, remoteIP, ww.Status(), ww.BytesWritten(), t2.Sub(t1), user, body) + }() + + h.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) + } + + return f + +} diff --git a/app/rest/api/rest.go b/app/rest/api/rest.go new file mode 100644 index 00000000..2199a47d --- /dev/null +++ b/app/rest/api/rest.go @@ -0,0 +1,507 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/didip/tollbooth" + "github.com/didip/tollbooth_chi" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/render" + "github.com/gorilla/context" + "github.com/pkg/errors" + "gopkg.in/russross/blackfriday.v2" + + "github.com/umputun/remark/app/migrator" + "github.com/umputun/remark/app/notifier" + "github.com/umputun/remark/app/rest" + "github.com/umputun/remark/app/rest/auth" + "github.com/umputun/remark/app/store" +) + +// Rest is a rest access server +type Rest struct { + Version string + + DataService store.Service + Authenticator auth.Authenticator + Exporter migrator.Exporter + Cache rest.LoadingCache + Notifier notifier.Interface + + httpServer *http.Server + mod admin +} + +// Run the lister and request's router, activate rest server +func (s *Rest) Run(port int) { + log.Print("[INFO] activate rest server") + + if len(s.Authenticator.Admins) > 0 { + log.Printf("[DEBUG] admins %+v", s.Authenticator.Admins) + } + + router := chi.NewRouter() + router.Use(middleware.RealIP, Recoverer) + router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second)) + router.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + + // all request by default allow anonymous access + router.Use(s.Authenticator.Auth(false)) + + router.Use(AppInfo("remark42", s.Version), Ping, Logger(LogAll)) + router.Use(context.ClearHandler) // if you aren't using gorilla/mux, you need to wrap your handlers with context.ClearHandler + + // auth routes for all providers + router.Route("/auth", func(r chi.Router) { + for _, provider := range s.Authenticator.Providers { + r.Mount("/"+provider.Name, provider.Routes()) // mount auth providers as /auth/{name} + } + if len(s.Authenticator.Providers) > 0 { + // shortcut, can be any of providers, all logouts do the same - removes cookie + r.Get("/logout", s.Authenticator.Providers[0].LogoutHandler) + } + }) + + router.Mount(s.Authenticator.AvatarProxy.Routes()) + + // api routes + router.Route("/api/v1", func(rapi chi.Router) { + + // open routes + rapi.Get("/find", s.findCommentsCtrl) + rapi.Get("/id/{id}", s.commentByIDCtrl) + rapi.Get("/comments", s.findUserCommentsCtrl) + rapi.Get("/last/{max}", s.lastCommentsCtrl) + rapi.Get("/count", s.countCtrl) + rapi.Get("/list", s.listCtrl) + rapi.Get("/config", s.configCtrl) + + // protected routes, require auth + rapi.With(s.Authenticator.Auth(true)).Group(func(rauth chi.Router) { + rauth.Post("/comment", s.createCommentCtrl) + rauth.Put("/comment/{id}", s.updateCommentCtrl) + rauth.Get("/user", s.userInfoCtrl) + rauth.Put("/vote/{id}", s.voteCtrl) + rauth.Put("/notify", s.notifyActionCtrl) + rauth.Get("/notify", s.notifyStatusCtrl) + // admin routes, admin users only + s.mod = admin{ + dataService: s.DataService, + exporter: s.Exporter, + cache: s.Cache, + defAvatarURL: s.Authenticator.AvatarProxy.Default(), + } + rauth.Mount("/admin", s.mod.routes(s.Authenticator.AdminOnly)) + }) + }) + + // add robots and file server for static content from /web + router.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) { + render.PlainText(w, r, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\n") + }) + s.addFileServer(router, "/web", http.Dir(filepath.Join(".", "web"))) + + s.httpServer = &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: router} + err := s.httpServer.ListenAndServe() + log.Printf("[WARN] http server terminated, %s", err) +} + +// POST /comment - adds comment, resets all immutable fields +func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { + + comment := store.Comment{} + if err := render.DecodeJSON(r.Body, &comment); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment") + return + } + + user, err := rest.GetUserInfo(r) + if err != nil { // this not suppose to happen (handled by Auth), just dbl-check + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + + // reset comment to initial state + func() { + comment.ID = "" // don't allow user to define ID, force auto-gen + comment.Timestamp = time.Time{} // reset time, force auto-gen + comment.Votes = make(map[string]bool) + comment.Score = 0 + comment.Edit = nil + comment.Pin = false + }() + + comment.User = user + comment.User.IP = strings.Split(r.RemoteAddr, ":")[0] + + // render markdown + comment.Text = string(blackfriday.Run([]byte(comment.Text), blackfriday.WithNoExtensions())) + + log.Printf("[DEBUG] create comment %+v", comment) + + // check if user blocked + if s.mod.checkBlocked(comment.Locator.SiteID, comment.User) { + rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked") + return + } + + id, err := s.DataService.Create(comment) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save comment") + return + } + + if err = s.Notifier.OnUpdate(comment); err != nil { + log.Printf("[WARN] can't send notify event for %+v, %s", comment.Locator, err) + } + + s.Cache.Flush() // reset all caches + render.Status(r, http.StatusCreated) + render.JSON(w, r, JSON{"id": id, "locator": comment.Locator}) +} + +// PUT /comment/{id}?site=siteID&url=post-url - update comment +func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { + + edit := struct { + Text string + Summary string + }{} + + if err := render.DecodeJSON(r.Body, &edit); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment") + return + } + + user, err := rest.GetUserInfo(r) + if err != nil { // this not suppose to happen (handled by Auth), just dbl-check + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + id := chi.URLParam(r, "id") + + // render markdown + edit.Text = string(blackfriday.Run([]byte(edit.Text), blackfriday.WithNoExtensions())) + + log.Printf("[DEBUG] update comment %s, %+v", id, edit) + + var currComment store.Comment + if currComment, err = s.DataService.Get(locator, id); err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comment") + return + } + + if currComment.User.ID != user.ID { + rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "can not edit comments for other users") + return + } + + res, err := s.DataService.EditComment(locator, id, edit.Text, store.Edit{Summary: edit.Summary}) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't update comment") + return + } + + s.Cache.Flush() // reset all caches + render.JSON(w, r, res) +} + +// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score] +// find comments for given post. Returns in tree or plain formats, sorted +func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + log.Printf("[DEBUG] get comments for %+v", locator) + + data, err := s.Cache.Get(s.urlKey(r), time.Hour, func() ([]byte, error) { + comments, e := s.DataService.Find(locator, r.URL.Query().Get("sort")) + if e != nil { + return nil, e + } + maskedComments := s.mod.alterComments(comments, r) + var b []byte + switch r.URL.Query().Get("format") { + case "tree": + b, e = encodeJSONWithHTML(rest.MakeTree(maskedComments, r.URL.Query().Get("sort"))) + default: + b, e = encodeJSONWithHTML(maskedComments) + } + return b, e + }) + + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comments") + return + } + renderJSONFromBytes(w, r, data) +} + +// GET /last/{max}?site=siteID - last comments for the siteID, across all posts, sorted by time +func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { + + log.Printf("[DEBUG] get last comments for %s", r.URL.Query().Get("site")) + + max, err := strconv.Atoi(chi.URLParam(r, "max")) + if err != nil { + max = 0 + } + + data, err := s.Cache.Get(s.urlKey(r), time.Hour, func() ([]byte, error) { + comments, e := s.DataService.Last(r.URL.Query().Get("site"), max) + if e != nil { + return nil, e + } + comments = s.mod.alterComments(comments, r) + return encodeJSONWithHTML(comments) + }) + + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get last comments") + return + } + renderJSONFromBytes(w, r, data) +} + +// GET /id/{id}?site=siteID&url=post-url - gets a comment by id +func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { + + id := chi.URLParam(r, "id") + siteID := r.URL.Query().Get("site") + url := r.URL.Query().Get("url") + + log.Printf("[DEBUG] get comments by id %s, %s %s", id, siteID, url) + + comment, err := s.DataService.Get(store.Locator{SiteID: siteID, URL: url}, id) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id") + return + } + comment = s.mod.alterComments([]store.Comment{comment}, r)[0] + render.Status(r, http.StatusOK) + renderJSONWithHTML(w, r, comment) +} + +// GET /comments?site=siteID&user=id - returns comments for given userID +func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { + + userID := r.URL.Query().Get("user") + siteID := r.URL.Query().Get("site") + + resp := struct { + Comments []store.Comment + Count int + }{} + + log.Printf("[DEBUG] get comments for userID %s, %s", userID, siteID) + + data, err := s.Cache.Get(s.urlKey(r), time.Hour, func() ([]byte, error) { + comments, count, e := s.DataService.User(siteID, userID) + if e != nil { + return nil, e + } + comments = s.mod.alterComments(comments, r) + resp.Comments, resp.Count = comments, count + return encodeJSONWithHTML(resp) + }) + + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by user id") + return + } + renderJSONFromBytes(w, r, data) +} + +// GET /config?site=siteID - returns configuration +func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { + type config struct { + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + Admins []string `json:"admins"` + Auth []string `json:"auth_providers"` + } + + cnf := config{ + Version: s.Version, + EditDuration: int(s.DataService.EditDuration.Seconds()), + Admins: s.Authenticator.Admins, + } + authNames := []string{} + for _, ap := range s.Authenticator.Providers { + authNames = append(authNames, ap.Name) + } + cnf.Auth = authNames + render.Status(r, http.StatusOK) + render.JSON(w, r, cnf) +} + +// GET /user - returns user info +func (s *Rest) userInfoCtrl(w http.ResponseWriter, r *http.Request) { + user, err := rest.GetUserInfo(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + render.JSON(w, r, user) +} + +// GET /count?site=siteID&url=post-url - get number of comments for given post +func (s *Rest) countCtrl(w http.ResponseWriter, r *http.Request) { + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + count, err := s.DataService.Count(locator) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count") + return + } + render.JSON(w, r, JSON{"count": count, "locator": locator}) +} + +// GET /list?site=siteID - list posts with comments +func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { + + siteID := r.URL.Query().Get("site") + data, err := s.Cache.Get(s.urlKey(r), 8*time.Hour, func() ([]byte, error) { + posts, e := s.DataService.List(siteID) + if e != nil { + return nil, e + } + return encodeJSONWithHTML(posts) + }) + + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get list of comments for "+siteID) + return + } + renderJSONFromBytes(w, r, data) +} + +// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment +func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) { + + user, err := rest.GetUserInfo(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + id := chi.URLParam(r, "id") + log.Printf("[DEBUG] vote for comment %s", id) + + vote := r.URL.Query().Get("vote") == "1" + + comment, err := s.DataService.Vote(locator, id, user.ID, vote) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment") + return + } + s.Cache.Flush() + render.JSON(w, r, JSON{"id": comment.ID, "score": comment.Score}) +} + +// PUT /notify?site=siteID&url=post-url&action=1 - subscribe/unsubscribe to notification +func (s *Rest) notifyActionCtrl(w http.ResponseWriter, r *http.Request) { + user, err := rest.GetUserInfo(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + action := "unknown" + switch r.URL.Query().Get("action") { + case "1": + err = s.Notifier.Subscribe(locator, user) + action = "subscribe" + case "0": + err = s.Notifier.UnSubscribe(locator, user) + action = "unsubscribe" + } + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't subscribe/unsubscribe for notifications") + return + } + render.JSON(w, r, JSON{"locator": locator, "user": user.ID, "action": action}) +} + +// GET /notify?site=siteID&url=post-url - get notification status +func (s *Rest) notifyStatusCtrl(w http.ResponseWriter, r *http.Request) { + user, err := rest.GetUserInfo(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusUnauthorized, err, "can't get user info") + return + } + locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} + status := "not subscribed" + if st, err := s.Notifier.Status(locator, user); err == nil && st { + status = "subscribed" + } + render.JSON(w, r, JSON{"locator": locator, "user": user.ID, "status": status}) +} + +// serves static files from /web +func (s *Rest) addFileServer(r chi.Router, path string, root http.FileSystem) { + log.Printf("[INFO] run file server for %s", root) + fs := http.StripPrefix(path, http.FileServer(root)) + if path != "/" && path[len(path)-1] != '/' { + r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP) + path += "/" + } + path += "*" + + r.Get(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // don't show dirs, just serve files + if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != "/web/" { + http.NotFound(w, r) + return + } + fs.ServeHTTP(w, r) + })) +} + +// urlKey gets url from request to use is as cache key +// admins will have separate keys in order tp prevent leak of admin-only data to regular users +func (s *Rest) urlKey(r *http.Request) string { + key := r.URL.String() + if user, err := rest.GetUserInfo(r); err == nil && user.Admin { // make seprate cache key for admins + key = "admin!!" + key + } + return key +} + +// renderJSONWithHTML allows html tags and forces charset=utf-8 +func renderJSONWithHTML(w http.ResponseWriter, r *http.Request, v interface{}) { + data, err := encodeJSONWithHTML(v) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't render json response") + return + } + renderJSONFromBytes(w, r, data) +} + +func encodeJSONWithHTML(v interface{}) ([]byte, error) { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, errors.Wrap(err, "can't encode to json") + } + return buf.Bytes(), nil +} + +// renderJSONWithHTML allows html tags and forces charset=utf-8 +func renderJSONFromBytes(w http.ResponseWriter, r *http.Request, data []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if status, ok := r.Context().Value(render.StatusCtxKey).(int); ok { + w.WriteHeader(status) + } + if _, err := w.Write(data); err != nil { + log.Printf("[WARN] can't send response to %s, %s", r.RemoteAddr, err) + } +} diff --git a/app/rest/api/rest_test.go b/app/rest/api/rest_test.go new file mode 100644 index 00000000..c04b05c3 --- /dev/null +++ b/app/rest/api/rest_test.go @@ -0,0 +1,405 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/gorilla/sessions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/umputun/remark/app/migrator" + "github.com/umputun/remark/app/notifier" + "github.com/umputun/remark/app/rest/auth" + "github.com/umputun/remark/app/store" +) + +var testDb = "/tmp/test-remark.db" + +func TestServer_Ping(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/ping", port)) + assert.Equal(t, "pong", res) + assert.Equal(t, 200, code) +} + +func TestServer_Create(t *testing.T) { + srv, port := prep(t) + require.NotNil(t, srv) + defer cleanup(srv) + + r := strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", r) + assert.Nil(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + b, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + c := JSON{} + err = json.Unmarshal(b, &c) + assert.Nil(t, err) + loc := c["locator"].(map[string]interface{}) + assert.Equal(t, "radio-t", loc["site"]) + assert.Equal(t, "https://radio-t.com/blah1", loc["url"]) + assert.True(t, len(c["id"].(string)) > 8) +} + +func TestServer_CreateAndGet(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + // create comment + r := strings.NewReader(`{"text": "**test** *123* http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", r) + assert.Nil(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + c := JSON{} + err = json.Unmarshal(b, &c) + assert.Nil(t, err) + + id := c["id"].(string) + + // get created comment by id + res, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) + assert.Equal(t, 200, code) + comment := store.Comment{} + err = json.Unmarshal([]byte(res), &comment) + assert.Nil(t, err) + assert.Equal(t, "

test 123 http://radio-t.com

", comment.Text) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", + Picture: "https://friends.radio-t.com/resources/images/rt_logo_64.png", + Profile: "https://radio-t.com/info/", Admin: true, Blocked: false, IP: "127.0.0.1"}, + comment.User) + t.Logf("%+v", comment) +} + +func TestServer_Find(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + _, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/find?site=radio-t&url=https://radio-t.com/blah1", port)) + assert.Equal(t, 400, code, "nothing in") + + c1 := store.Comment{Text: "test test #1", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + + id1 := addComment(t, c1, port) + id2 := addComment(t, c2, port) + assert.NotEqual(t, id1, id2) + + // get sorted by +time + res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&sort=+time", port)) + assert.Equal(t, 200, code) + comments := []store.Comment{} + err := json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments), "should have 2 comments") + assert.Equal(t, id1, comments[0].ID) + assert.Equal(t, id2, comments[1].ID) + + // get sorted by -time + res, code = get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&sort=-time", port)) + assert.Equal(t, 200, code) + err = json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments), "should have 2 comments") + assert.Equal(t, id1, comments[1].ID) + assert.Equal(t, id2, comments[0].ID) +} + +func TestServer_Update(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + id := addComment(t, c1, port) + + client := http.Client{} + req, err := http.NewRequest(http.MethodPut, + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment/"+id+"?site=radio-t&url=https://radio-t.com/blah1", port), + strings.NewReader(`{"text":"updated text", "summary":"my edit"}`)) + assert.Nil(t, err) + b, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(b.Body) + assert.Nil(t, err) + assert.Equal(t, 200, b.StatusCode, string(body)) + + // comments returned by update + c2 := store.Comment{} + err = json.Unmarshal(body, &c2) + assert.Nil(t, err) + assert.Equal(t, id, c2.ID) + assert.Equal(t, "

updated text

", c2.Text) + assert.Equal(t, "my edit", c2.Edit.Summary) + assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second) + + // read updated comment + res, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah1", port, id)) + assert.Equal(t, 200, code) + c3 := store.Comment{} + err = json.Unmarshal([]byte(res), &c3) + assert.Nil(t, err) + assert.Equal(t, c2, c3, "same as response from update") +} + +func TestServer_Last(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + + // add 3 comments + addComment(t, c1, port) + id1 := addComment(t, c1, port) + id2 := addComment(t, c2, port) + + res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/last/2?site=radio-t", port)) + assert.Equal(t, 200, code) + comments := []store.Comment{} + err := json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments), "should have 2 comments") + assert.Equal(t, id1, comments[1].ID) + assert.Equal(t, id2, comments[0].ID) + + res, code = get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/last/5?site=radio-t", port)) + assert.Equal(t, 200, code) + err = json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 3, len(comments), "should have 3 comments") +} + +func TestServer_FindUserComments(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #3", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + + // add 3 comments + addComment(t, c1, port) + addComment(t, c2, port) + addComment(t, c2, port) + + _, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/comments?site=radio-t&user=blah", port)) + assert.Equal(t, 400, code, "noting for user blah") + + res, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/comments?site=radio-t&user=dev", port)) + assert.Equal(t, 200, code) + + resp := struct { + Comments []store.Comment + Count int + }{} + + err := json.Unmarshal([]byte(res), &resp) + assert.Nil(t, err) + assert.Equal(t, 3, len(resp.Comments), "should have 3 comments") + assert.Equal(t, 3, resp.Count, "should have 3 count") +} + +func TestServer_UserInfo(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + body, code := get(t, fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/user?site=radio-t", port)) + assert.Equal(t, 200, code) + user := store.User{} + err := json.Unmarshal([]byte(body), &user) + assert.Nil(t, err) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", + Picture: "https://friends.radio-t.com/resources/images/rt_logo_64.png", Profile: "https://radio-t.com/info/", + Admin: true, Blocked: false, IP: ""}, user) +} + +func TestServer_Vote(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}} + + id1 := addComment(t, c1, port) + addComment(t, c2, port) + + vote := func(val int) int { + client := http.Client{} + req, err := http.NewRequest(http.MethodPut, + fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/vote/%s?site=radio-t&url=https://radio-t.com/blah&vote=%d", + port, id1, val), nil) + assert.Nil(t, err) + resp, err := client.Do(req) + assert.Nil(t, err) + return resp.StatusCode + } + + assert.Equal(t, 200, vote(1), "first vote allowed") + assert.Equal(t, 400, vote(1), "second vote rejected") + body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", port, id1)) + assert.Equal(t, 200, code) + cr := store.Comment{} + err := json.Unmarshal([]byte(body), &cr) + assert.Nil(t, err) + assert.Equal(t, 1, cr.Score) + assert.Equal(t, map[string]bool{"dev": true}, cr.Votes) + + assert.Equal(t, 200, vote(-1), "opposite vote allowed") + body, code = get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/id/%s?site=radio-t&url=https://radio-t.com/blah", port, id1)) + assert.Equal(t, 200, code) + cr = store.Comment{} + err = json.Unmarshal([]byte(body), &cr) + assert.Nil(t, err) + assert.Equal(t, 0, cr.Score) + assert.Equal(t, map[string]bool{}, cr.Votes) + +} + +func TestServer_Count(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + + addComment(t, c1, port) + addComment(t, c1, port) + addComment(t, c1, port) + addComment(t, c2, port) + addComment(t, c2, port) + + body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/count?site=radio-t&url=https://radio-t.com/blah1", port)) + assert.Equal(t, 200, code) + j := JSON{} + err := json.Unmarshal([]byte(body), &j) + assert.Nil(t, err) + assert.Equal(t, 3.0, j["count"]) + + body, code = get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/count?site=radio-t&url=https://radio-t.com/blah2", port)) + assert.Equal(t, 200, code) + err = json.Unmarshal([]byte(body), &j) + assert.Nil(t, err) + assert.Equal(t, 2.0, j["count"]) +} + +func TestServer_List(t *testing.T) { + srv, port := prep(t) + assert.NotNil(t, srv) + defer cleanup(srv) + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + + addComment(t, c1, port) + addComment(t, c1, port) + addComment(t, c1, port) + addComment(t, c2, port) + addComment(t, c2, port) + + body, code := get(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/list?site=radio-t", port)) + assert.Equal(t, 200, code) + pi := []store.PostInfo{} + err := json.Unmarshal([]byte(body), &pi) + assert.Nil(t, err) + assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah1", Count: 3}, {URL: "https://radio-t.com/blah2", Count: 2}}, pi) +} + +func prep(t *testing.T) (srv *Rest, port int) { + dataStore, err := store.NewBoltDB(store.BoltSite{FileName: testDb, SiteID: "radio-t"}) + require.Nil(t, err) + srv = &Rest{ + DataService: store.Service{Interface: dataStore, EditDuration: 5 * time.Minute}, + Authenticator: auth.Authenticator{ + SessionStore: sessions.NewFilesystemStore("/tmp", []byte("blah")), + DevEnabled: true, + DevPasswd: "password", + Providers: nil, + AvatarProxy: &auth.AvatarProxy{StorePath: "/tmp", RoutePath: "/api/v1/avatar"}, + }, + Exporter: &migrator.Remark{DataStore: dataStore}, + Cache: &mockCache{}, + Notifier: notifier.NewNoOperation(), + } + go func() { + port = rand.Intn(50000) + 1025 + srv.Run(port) + }() + time.Sleep(100 * time.Millisecond) + return srv, port +} + +func get(t *testing.T, url string) (string, int) { + r, err := http.Get(url) + assert.Nil(t, err) + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + assert.Nil(t, err) + return string(body), r.StatusCode +} + +func addComment(t *testing.T, c store.Comment, port int) string { + + b, err := json.Marshal(c) + assert.Nil(t, err, "can't marshal comment %+v", c) + resp, err := http.Post(fmt.Sprintf("http://dev:password@127.0.0.1:%d/api/v1/comment", port), "application/json", bytes.NewBuffer(b)) + assert.Nil(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + b, err = ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + + crResp := JSON{} + err = json.Unmarshal(b, &crResp) + assert.Nil(t, err) + time.Sleep(time.Nanosecond * 10) + return crResp["id"].(string) +} + +func cleanup(srv *Rest) { + srv.httpServer.Close() + srv.httpServer.Shutdown(context.Background()) + os.Remove(testDb) +} + +type mockCache struct{} + +func (mc *mockCache) Get(key string, ttl time.Duration, fn func() ([]byte, error)) (data []byte, err error) { + return fn() +} + +func (mc *mockCache) Flush() {}