feature/ts - read-only support (#57)
* extend post info with ts and add info implemenation to engine * info controller * remove unused * docker froendly new test * tree find to include post info struct * remove leftovers from codeconv * test for empty tree request * add func name to error rest report * test with disabled proxy * add read only age and reject updates on old posts * lint: shaddow err * make info calls fast. needs migration! * fix backup restore script * increase limiter timeout * add local exporter * migrate list to new info * fix rest list tests * add manual backup script * default backup path * fix eait on sec change failing rss tests * add RO status bucket and rest * add admin RO controller test
This commit is contained in:
@@ -5,7 +5,6 @@ install:
|
||||
script:
|
||||
- docker build
|
||||
--build-arg COVERALLS_TOKEN=$COVERALLS_TOKEN
|
||||
--build-arg CODECOV_TOKEN=$CODECOV_TOKEN
|
||||
--build-arg CI=$CI
|
||||
--build-arg TRAVIS=$TRAVIS
|
||||
--build-arg TRAVIS_BRANCH=$TRAVIS_BRANCH
|
||||
|
||||
+3
-1
@@ -50,10 +50,12 @@ WORKDIR /srv
|
||||
|
||||
ADD scripts/import-disqus.sh /srv/import-disqus.sh
|
||||
ADD scripts/restore-backup.sh /srv/restore-backup.sh
|
||||
ADD scripts/migrate-data.sh /srv/migrate-data.sh
|
||||
ADD scripts/create-backup.sh /srv/create-backup.sh
|
||||
|
||||
ADD start.sh /srv/start.sh
|
||||
|
||||
RUN chmod +x /srv/start.sh /srv/import-disqus.sh /srv/restore-backup.sh
|
||||
RUN chmod +x /srv/start.sh /srv/import-disqus.sh /srv/restore-backup.sh /srv/migrate-data.sh /srv/create-backup.sh
|
||||
|
||||
COPY --from=build-backend /go/src/github.com/umputun/remark/remark /srv/
|
||||
COPY --from=build-frontend /srv/web/public/ /srv/web
|
||||
|
||||
@@ -33,13 +33,14 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
|
||||
| Command line | Environment | Default | Multi | Description |
|
||||
| ----------------- | -------------------- | ---------------------- | ----- | --------------------------------------- |
|
||||
| --url | REMARK_URL | `https://remark42.com` | no | url to remark server |
|
||||
| --bolt | BOLTDB_PATH | `/tmp` | no | path to data directory |
|
||||
| --bolt | BOLTDB_PATH | `./var` | no | path to data directory |
|
||||
| --site | SITE | `remark` | yes | site name(s) |
|
||||
| --admin | ADMIN | | yes | admin names (list of user ids) |
|
||||
| --backup | BACKUP_PATH | `/tmp` | no | backups location |
|
||||
| --backup | BACKUP_PATH | `./var/backup` | no | backups location |
|
||||
| --max-back | MAX_BACKUP_FILES | `10` | no | max backup files to keep |
|
||||
| --max-cache-items | MAX_CACHE_ITEMS | `1000` | no | max number of cached items, 0-unlimited |
|
||||
| --max-cache-value | MAX_CACHE_VALUE | `65536` | no | max size of cached value, o-unlimited |
|
||||
| --avatars | AVATAR_STORE | `./var/avatars` | no | avatars location |
|
||||
| --secret | SECRET | | no | secret key, required |
|
||||
| --max-comment | MAX_COMMENT_SIZE | 2048 | no | comment's size limit |
|
||||
| --google-cid | REMARK_GOOGLE_CID | | no | Google OAuth client ID |
|
||||
@@ -104,6 +105,26 @@ _instructions for google oauth2 setup borrowed from [oauth2_proxy](https://githu
|
||||
2. Move this file to your remark42 host within `.var` and unzip, i.e. `gunzip <disqus-export-name>.xml.gz`.
|
||||
3. Run import command - `docker-compose exec remark /srv/import-disqus.sh <disqus-export-name>.xml <your site id>`
|
||||
|
||||
#### Backup and restore
|
||||
|
||||
##### Automatic backups
|
||||
Remark42 by default makes daily backup files under `${BACKUP_PATH}` (default `./var/backup`). Backups kept up to `${MAX_BACKUP_FILES}` (default 10). Each backup file contains exported and gzipped content, i.e., all comments. At any point, the user can restore such backup and revert all comments to the desirable state. Note: restore procedure cleans the current data store and replaces all comments with comments from the backup file.
|
||||
|
||||
For safety and security reasons restore functionality not exposed outside of your server by default. The recommended way to restore from the backup is to use provided `scripts/restore-backup.sh`. It can run inside the container:
|
||||
|
||||
`docker-compose exec remark /srv/restore-backup.sh <backup-filename.gz> <your site id>`
|
||||
|
||||
##### Schema migration
|
||||
|
||||
One special case for backup/restore is schema migration. Some versions or remark42 may extend or change the schema
|
||||
and for such upgrades migration required. Provided migration script `scripts/migrate-data.sh` makes a fresh backup and then loads it back to your remark42 instance.
|
||||
|
||||
`docker-compose exec remark /srv/migrate-data.sh <your site id>`
|
||||
|
||||
##### Manual backup
|
||||
|
||||
|
||||
|
||||
|
||||
#### Admin users
|
||||
|
||||
@@ -284,6 +305,7 @@ In plain format result will be sorted list of `Comment`. In tree format this is
|
||||
```go
|
||||
type Tree struct {
|
||||
Nodes []Node `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
@@ -318,8 +340,11 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
|
||||
* `GET /api/v1/list?site=site-id&limit=5&skip=2` - list commented posts, returns array or `PostInfo`, limit=0 will return all posts
|
||||
```go
|
||||
type PostInfo struct {
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
FirstTS time.Time `json:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time,omitempty"`
|
||||
}
|
||||
```
|
||||
* `GET /api/v1/user` - get user info, _auth required_
|
||||
@@ -336,6 +361,7 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
|
||||
CriticalScore int `json:"critical_score"`
|
||||
}
|
||||
```
|
||||
* `GET /api/v1/info?site=site-idd&url=post-ur` - returns `PostInfo` for site and url
|
||||
|
||||
### RSS feeds
|
||||
|
||||
@@ -358,6 +384,7 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
|
||||
* `POST /api/v1/admin/import?site=side-id` - import comments from the backup.
|
||||
* `PUT /api/v1/admin/pin/{id}?site=site-id&url=post-url&pin=1` - pin or unpin comment.
|
||||
* `DELETE /api/v1/admin/user/{userid}?site=site-id&block=1` - delete all user's comments.
|
||||
* `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status
|
||||
|
||||
_all admin calls require auth and admin privilege_
|
||||
|
||||
|
||||
+16
-14
@@ -46,15 +46,15 @@ type Opts struct {
|
||||
SecretKey string `long:"secret" env:"SECRET" required:"true" description:"secret key"`
|
||||
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
|
||||
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
|
||||
|
||||
GoogleCID string `long:"google-cid" env:"REMARK_GOOGLE_CID" description:"Google OAuth client ID"`
|
||||
GoogleCSEC string `long:"google-csec" env:"REMARK_GOOGLE_CSEC" description:"Google OAuth client secret"`
|
||||
GithubCID string `long:"github-cid" env:"REMARK_GITHUB_CID" description:"Github OAuth client ID"`
|
||||
GithubCSEC string `long:"github-csec" env:"REMARK_GITHUB_CSEC" description:"Github OAuth client secret"`
|
||||
FacebookCID string `long:"facebook-cid" env:"REMARK_FACEBOOK_CID" description:"Facebook OAuth client ID"`
|
||||
FacebookCSEC string `long:"facebook-csec" env:"REMARK_FACEBOOK_CSEC" description:"Facebook OAuth client secret"`
|
||||
DisqusCID string `long:"disqus-cid" env:"REMARK_DISQUS_CID" description:"Disqus OAuth client ID"`
|
||||
DisqusCSEC string `long:"disqus-csec" env:"REMARK_DISQUS_CSEC" description:"Disqus OAuth client secret"`
|
||||
ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments"`
|
||||
GoogleCID string `long:"google-cid" env:"REMARK_GOOGLE_CID" description:"Google OAuth client ID"`
|
||||
GoogleCSEC string `long:"google-csec" env:"REMARK_GOOGLE_CSEC" description:"Google OAuth client secret"`
|
||||
GithubCID string `long:"github-cid" env:"REMARK_GITHUB_CID" description:"Github OAuth client ID"`
|
||||
GithubCSEC string `long:"github-csec" env:"REMARK_GITHUB_CSEC" description:"Github OAuth client secret"`
|
||||
FacebookCID string `long:"facebook-cid" env:"REMARK_FACEBOOK_CID" description:"Facebook OAuth client ID"`
|
||||
FacebookCSEC string `long:"facebook-csec" env:"REMARK_FACEBOOK_CSEC" description:"Facebook OAuth client secret"`
|
||||
DisqusCID string `long:"disqus-cid" env:"REMARK_DISQUS_CID" description:"Disqus OAuth client ID"`
|
||||
DisqusCSEC string `long:"disqus-csec" env:"REMARK_DISQUS_CSEC" description:"Disqus OAuth client secret"`
|
||||
|
||||
Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"`
|
||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||
@@ -66,7 +66,7 @@ var revision = "unknown"
|
||||
type Application struct {
|
||||
Opts
|
||||
srv *api.Rest
|
||||
importer *api.Import
|
||||
migrator *api.Migrator
|
||||
exporter migrator.Exporter
|
||||
terminated chan struct{}
|
||||
}
|
||||
@@ -127,11 +127,12 @@ func New(opts Opts) (*Application, error) {
|
||||
|
||||
exporter := &migrator.Remark{DataStore: &dataService}
|
||||
|
||||
importer := &api.Import{
|
||||
migrator := &api.Migrator{
|
||||
Version: revision,
|
||||
Cache: cache,
|
||||
NativeImporter: &migrator.Remark{DataStore: &dataService},
|
||||
DisqusImporter: &migrator.Disqus{DataStore: &dataService},
|
||||
NativeExported: &migrator.Remark{DataStore: &dataService},
|
||||
SecretKey: opts.SecretKey,
|
||||
}
|
||||
|
||||
@@ -142,6 +143,7 @@ func New(opts Opts) (*Application, error) {
|
||||
WebRoot: opts.WebRoot,
|
||||
ImageProxy: &proxy.Image{Enabled: opts.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: opts.RemarkURL},
|
||||
AvatarProxy: avatarProxy,
|
||||
ReadOnlyAge: opts.ReadOnlyAge,
|
||||
Authenticator: auth.Authenticator{
|
||||
JWTService: jwtService,
|
||||
Admins: opts.Admins,
|
||||
@@ -152,7 +154,7 @@ func New(opts Opts) (*Application, error) {
|
||||
}
|
||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = opts.LowScore, opts.CriticalScore
|
||||
tch := make(chan struct{})
|
||||
return &Application{srv: srv, importer: importer, exporter: exporter, Opts: opts, terminated: tch}, nil
|
||||
return &Application{srv: srv, migrator: migrator, exporter: exporter, Opts: opts, terminated: tch}, nil
|
||||
}
|
||||
|
||||
// Run all application objects
|
||||
@@ -165,10 +167,10 @@ func (a *Application) Run(ctx context.Context) error {
|
||||
// shutdown on context cancellation
|
||||
<-ctx.Done()
|
||||
a.srv.Shutdown()
|
||||
a.importer.Shutdown()
|
||||
a.migrator.Shutdown()
|
||||
}()
|
||||
a.activateBackup(ctx) // runs in goroutine for each site
|
||||
go a.importer.Run(a.Port + 1)
|
||||
go a.migrator.Run(a.Port + 1)
|
||||
a.srv.Run(a.Port)
|
||||
close(a.terminated)
|
||||
return nil
|
||||
|
||||
@@ -35,6 +35,7 @@ func (a *admin) routes(middlewares ...func(http.Handler) http.Handler) chi.Route
|
||||
router.Get("/export", a.exportCtrl)
|
||||
router.Put("/pin/{id}", a.setPinCtrl)
|
||||
router.Get("/blocked", a.blockedUsersCtrl)
|
||||
router.Put("/readonly", a.setReadOnlyCtrl)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -96,6 +97,19 @@ func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, users)
|
||||
}
|
||||
|
||||
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
||||
func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
roStatus := r.URL.Query().Get("ro") == "1"
|
||||
|
||||
if err := a.dataService.SetReadOnly(locator, roStatus); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set readonly status")
|
||||
return
|
||||
}
|
||||
a.cache.Flush(locator.SiteID)
|
||||
render.JSON(w, r, JSON{"locator": locator, "read-only": roStatus})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -231,6 +231,50 @@ func TestAdmin_BlockedList(t *testing.T) {
|
||||
assert.Equal(t, "user2", users[1].ID)
|
||||
}
|
||||
|
||||
func TestAdmin_ReadOnly(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
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)
|
||||
|
||||
info, err := srv.DataService.Info(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, info.ReadOnly)
|
||||
|
||||
client := http.Client{}
|
||||
|
||||
// set post to read-only
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
|
||||
assert.Nil(t, err)
|
||||
withBasicAuth(req, "dev", "password")
|
||||
_, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, info.ReadOnly)
|
||||
|
||||
// resset post's read-only
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=radio-t&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
|
||||
assert.Nil(t, err)
|
||||
withBasicAuth(req, "dev", "password")
|
||||
_, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, info.ReadOnly)
|
||||
}
|
||||
|
||||
func TestAdmin_ExportStream(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/umputun/remark/app/migrator"
|
||||
"github.com/umputun/remark/app/rest"
|
||||
"github.com/umputun/remark/app/rest/cache"
|
||||
)
|
||||
|
||||
// Import rest runs on unexposed port and available for local requests only
|
||||
type Import struct {
|
||||
Version string
|
||||
Cache cache.LoadingCache
|
||||
NativeImporter migrator.Importer
|
||||
DisqusImporter migrator.Importer
|
||||
SecretKey string
|
||||
|
||||
httpServer *http.Server
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// Run the listener and request's router, activate rest server
|
||||
// this server doesn't have any authentication and SHOULDN'T BE EXPOSED in any way
|
||||
func (s *Import) Run(port int) {
|
||||
log.Printf("[INFO] activate import server on port %d", port)
|
||||
router := s.routes()
|
||||
|
||||
s.lock.Lock()
|
||||
s.httpServer = &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: router}
|
||||
s.lock.Unlock()
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
log.Printf("[WARN] http server terminated, %s", err)
|
||||
}
|
||||
|
||||
// Shutdown import http server
|
||||
func (s *Import) Shutdown() {
|
||||
log.Print("[WARN] shutdown import server")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.lock.Lock()
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("[DEBUG] importer shutdown error, %s", err)
|
||||
}
|
||||
s.lock.Unlock()
|
||||
|
||||
log.Print("[DEBUG] shutdown import server completed")
|
||||
}
|
||||
|
||||
func (s *Import) routes() chi.Router {
|
||||
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)))
|
||||
router.Use(AppInfo("remark42-importer", s.Version), Ping, Logger(LogAll))
|
||||
router.Post("/api/v1/admin/import", s.importCtrl)
|
||||
return router
|
||||
}
|
||||
|
||||
// POST /import?secret=key&site=site-id&provider=disqus|remark
|
||||
// imports comments from post body.
|
||||
func (s *Import) importCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if strings.TrimSpace(secret) == "" || secret != s.SecretKey {
|
||||
render.Status(r, http.StatusForbidden)
|
||||
render.JSON(w, r, JSON{"status": "error", "details": "secret key"})
|
||||
return
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
importer := s.NativeImporter
|
||||
if r.URL.Query().Get("provider") == "disqus" {
|
||||
importer = s.DisqusImporter
|
||||
}
|
||||
|
||||
size, err := importer.Import(r.Body, siteID)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "import failed")
|
||||
return
|
||||
}
|
||||
s.Cache.Flush(siteID)
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, JSON{"status": "ok", "size": size})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/umputun/remark/app/migrator"
|
||||
"github.com/umputun/remark/app/rest"
|
||||
"github.com/umputun/remark/app/rest/cache"
|
||||
)
|
||||
|
||||
// Migrator rest runs on unexposed port and available for local requests only
|
||||
type Migrator struct {
|
||||
Version string
|
||||
Cache cache.LoadingCache
|
||||
NativeImporter migrator.Importer
|
||||
DisqusImporter migrator.Importer
|
||||
NativeExported migrator.Exporter
|
||||
SecretKey string
|
||||
|
||||
httpServer *http.Server
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// Run the listener and request's router, activate rest server
|
||||
// this server doesn't have any authentication and SHOULDN'T BE EXPOSED in any way
|
||||
func (m *Migrator) Run(port int) {
|
||||
log.Printf("[INFO] activate import server on port %d", port)
|
||||
router := m.routes()
|
||||
|
||||
m.lock.Lock()
|
||||
m.httpServer = &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: router}
|
||||
m.lock.Unlock()
|
||||
|
||||
err := m.httpServer.ListenAndServe()
|
||||
log.Printf("[WARN] http server terminated, %s", err)
|
||||
}
|
||||
|
||||
// Shutdown import http server
|
||||
func (m *Migrator) Shutdown() {
|
||||
log.Print("[WARN] shutdown import server")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
m.lock.Lock()
|
||||
if err := m.httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("[DEBUG] importer shutdown error, %s", err)
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
log.Print("[DEBUG] shutdown import server completed")
|
||||
}
|
||||
|
||||
func (m *Migrator) routes() chi.Router {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RealIP, Recoverer)
|
||||
router.Use(middleware.Throttle(1000), middleware.Timeout(15*time.Minute))
|
||||
router.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
||||
router.Use(AppInfo("remark42-importer", m.Version), Ping, Logger(LogAll))
|
||||
router.Post("/api/v1/admin/import", m.importCtrl)
|
||||
router.Get("/api/v1/admin/export", m.exportCtrl)
|
||||
return router
|
||||
}
|
||||
|
||||
// POST /import?secret=key&site=site-id&provider=disqus|remark
|
||||
// imports comments from post body.
|
||||
func (m *Migrator) importCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if strings.TrimSpace(secret) == "" || secret != m.SecretKey {
|
||||
render.Status(r, http.StatusForbidden)
|
||||
render.JSON(w, r, JSON{"status": "error", "details": "secret key"})
|
||||
return
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
importer := m.NativeImporter
|
||||
if r.URL.Query().Get("provider") == "disqus" {
|
||||
importer = m.DisqusImporter
|
||||
}
|
||||
|
||||
size, err := importer.Import(r.Body, siteID)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "import failed")
|
||||
return
|
||||
}
|
||||
m.Cache.Flush(siteID)
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, JSON{"status": "ok", "size": size})
|
||||
}
|
||||
|
||||
// GET /export?site=site-id&secret=12345
|
||||
// exports all comments for siteID as json stream or gz file
|
||||
func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if strings.TrimSpace(secret) == "" || secret != m.SecretKey {
|
||||
render.Status(r, http.StatusForbidden)
|
||||
render.JSON(w, r, JSON{"status": "error", "details": "secret key"})
|
||||
return
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
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)
|
||||
gzWriter := gzip.NewWriter(w)
|
||||
defer func() {
|
||||
if e := gzWriter.Close(); e != nil {
|
||||
log.Printf("[WARN] can't close gzip writer, %s", e)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := m.NativeExported.Export(gzWriter, siteID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
"github.com/umputun/remark/app/store/service"
|
||||
)
|
||||
|
||||
func TestImport(t *testing.T) {
|
||||
func TestMigrator_Import(t *testing.T) {
|
||||
srv, ts := prepImportSrv(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanupImportSrv(srv, ts)
|
||||
@@ -38,7 +39,7 @@ func TestImport(t *testing.T) {
|
||||
assert.Equal(t, `{"size":2,"status":"ok"}`+"\n", string(b))
|
||||
}
|
||||
|
||||
func TestImportRejected(t *testing.T) {
|
||||
func TestMigrator_ImportRejected(t *testing.T) {
|
||||
srv, ts := prepImportSrv(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanupImportSrv(srv, ts)
|
||||
@@ -54,8 +55,45 @@ func TestImportRejected(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestImportShutdown(t *testing.T) {
|
||||
srv := Import{}
|
||||
func TestMigrator_Export(t *testing.T) {
|
||||
srv, ts := prepImportSrv(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanupImportSrv(srv, ts)
|
||||
|
||||
r := strings.NewReader(`{"id":"2aa0478c-df1b-46b1-b561-03d507cf482c","pid":"","text":"<p>test test #1</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah1"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}
|
||||
{"id":"83fd97fd-ff64-48d1-9fb7-ca7769c77037","pid":"p1","text":"<p>test test #2</p>","user":{"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"radio-t","url":"https://radio-t.com/blah2"},"score":0,"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native&secret=123456", r)
|
||||
require.Nil(t, err)
|
||||
resp, err := client.Do(req)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=radio-t&secret=123456", nil)
|
||||
require.Nil(t, err)
|
||||
resp, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
require.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
|
||||
|
||||
ungzReader, err := gzip.NewReader(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
ungzBody, err := ioutil.ReadAll(ungzReader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, strings.Count(string(ungzBody), "\n"))
|
||||
assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\""))
|
||||
t.Logf("%s", string(ungzBody))
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=radio-t&secret=bad", nil)
|
||||
require.Nil(t, err)
|
||||
resp, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 403, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestMigrator_Shutdown(t *testing.T) {
|
||||
srv := Migrator{}
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srv.Shutdown()
|
||||
@@ -65,13 +103,14 @@ func TestImportShutdown(t *testing.T) {
|
||||
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
|
||||
}
|
||||
|
||||
func prepImportSrv(t *testing.T) (svc *Import, ts *httptest.Server) {
|
||||
func prepImportSrv(t *testing.T) (svc *Migrator, ts *httptest.Server) {
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"})
|
||||
require.Nil(t, err)
|
||||
dataStore := &service.DataStore{Interface: b}
|
||||
svc = &Import{
|
||||
svc = &Migrator{
|
||||
DisqusImporter: &migrator.Disqus{DataStore: dataStore},
|
||||
NativeImporter: &migrator.Remark{DataStore: dataStore},
|
||||
NativeExported: &migrator.Remark{DataStore: dataStore},
|
||||
Cache: &mockCache{},
|
||||
SecretKey: "123456",
|
||||
}
|
||||
@@ -81,7 +120,7 @@ func prepImportSrv(t *testing.T) (svc *Import, ts *httptest.Server) {
|
||||
return svc, ts
|
||||
}
|
||||
|
||||
func cleanupImportSrv(srv *Import, ts *httptest.Server) {
|
||||
func cleanupImportSrv(srv *Migrator, ts *httptest.Server) {
|
||||
ts.Close()
|
||||
os.Remove(testDb)
|
||||
}
|
||||
+41
-10
@@ -33,15 +33,15 @@ import (
|
||||
|
||||
// Rest is a rest access server
|
||||
type Rest struct {
|
||||
Version string
|
||||
DataService service.DataStore
|
||||
Authenticator auth.Authenticator
|
||||
Exporter migrator.Exporter
|
||||
Cache cache.LoadingCache
|
||||
AvatarProxy *proxy.Avatar
|
||||
ImageProxy *proxy.Image
|
||||
WebRoot string
|
||||
|
||||
Version string
|
||||
DataService service.DataStore
|
||||
Authenticator auth.Authenticator
|
||||
Exporter migrator.Exporter
|
||||
Cache cache.LoadingCache
|
||||
AvatarProxy *proxy.Avatar
|
||||
ImageProxy *proxy.Image
|
||||
WebRoot string
|
||||
ReadOnlyAge int
|
||||
ScoreThresholds struct {
|
||||
Low int
|
||||
Critical int
|
||||
@@ -142,6 +142,8 @@ func (s *Rest) routes() chi.Router {
|
||||
ropen.Get("/list", s.listCtrl)
|
||||
ropen.Get("/config", s.configCtrl)
|
||||
ropen.Post("/preview", s.previewCommentCtrl)
|
||||
ropen.Get("/info", s.infoCtrl)
|
||||
|
||||
ropen.Mount("/rss", s.rssRoutes())
|
||||
ropen.Mount("/img", s.ImageProxy.Routes())
|
||||
})
|
||||
@@ -201,6 +203,13 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.ReadOnlyAge > 0 {
|
||||
if info, e := s.DataService.Info(comment.Locator, s.ReadOnlyAge); e == nil && info.ReadOnly {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "old post, read-only")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.DataService.Create(comment)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save comment")
|
||||
@@ -247,6 +256,26 @@ func (s *Rest) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
render.HTML(w, r, comment.Text)
|
||||
}
|
||||
|
||||
// GET /info?site=siteID&url=post-url - get info about the post
|
||||
func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
|
||||
data, err := s.Cache.Get(cache.Key(cache.URLKey(r), locator.SiteID, locator.URL), 4*time.Hour, func() ([]byte, error) {
|
||||
info, e := s.DataService.Info(locator, s.ReadOnlyAge)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return encodeJSONWithHTML(info)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get post info")
|
||||
return
|
||||
}
|
||||
|
||||
renderJSONFromBytes(w, r, data)
|
||||
}
|
||||
|
||||
// PUT /comment/{id}?site=siteID&url=post-url - update comment
|
||||
func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -318,7 +347,7 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
var b []byte
|
||||
switch r.URL.Query().Get("format") {
|
||||
case "tree":
|
||||
b, e = encodeJSONWithHTML(rest.MakeTree(maskedComments, sort))
|
||||
b, e = encodeJSONWithHTML(rest.MakeTree(maskedComments, sort, s.ReadOnlyAge))
|
||||
default:
|
||||
b, e = encodeJSONWithHTML(maskedComments)
|
||||
}
|
||||
@@ -432,6 +461,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Auth []string `json:"auth_providers"`
|
||||
LowScore int `json:"low_score"`
|
||||
CriticalScore int `json:"critical_score"`
|
||||
ReadOnlyAge int `json:"readonly_age"`
|
||||
}
|
||||
|
||||
cnf := config{
|
||||
@@ -441,6 +471,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Admins: s.Authenticator.Admins,
|
||||
LowScore: s.ScoreThresholds.Low,
|
||||
CriticalScore: s.ScoreThresholds.Critical,
|
||||
ReadOnlyAge: s.ReadOnlyAge,
|
||||
}
|
||||
|
||||
cnf.Auth = []string{}
|
||||
|
||||
+146
-22
@@ -29,7 +29,7 @@ import (
|
||||
var testDb = "/tmp/test-remark.db"
|
||||
var testHTML = "/tmp/test-remark.html"
|
||||
|
||||
func TestServer_Ping(t *testing.T) {
|
||||
func TestRest_Ping(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -39,7 +39,7 @@ func TestServer_Ping(t *testing.T) {
|
||||
assert.Equal(t, 200, code)
|
||||
}
|
||||
|
||||
func TestServer_Create(t *testing.T) {
|
||||
func TestRest_Create(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -47,7 +47,7 @@ func TestServer_Create(t *testing.T) {
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Nil(t, err)
|
||||
@@ -60,7 +60,41 @@ func TestServer_Create(t *testing.T) {
|
||||
assert.True(t, len(c["id"].(string)) > 8)
|
||||
}
|
||||
|
||||
func TestServer_CreateTooBig(t *testing.T) {
|
||||
func TestRest_CreateOldPost(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
// make old, but not too old comment
|
||||
old := store.Comment{Text: "test test old", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -5),
|
||||
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
|
||||
_, err := srv.DataService.Create(old)
|
||||
assert.Nil(t, err)
|
||||
|
||||
comments, err := srv.DataService.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, "time")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(comments))
|
||||
|
||||
// try to add new comment to the same old post
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
`{"text": "test 123", "locator":{"site": "radio-t","url": "https://radio-t.com/blah1"}}`)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
assert.Nil(t, srv.DataService.DeleteAll("radio-t"))
|
||||
// make too old comment
|
||||
old = store.Comment{Text: "test test old", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -15),
|
||||
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
|
||||
_, err = srv.DataService.Create(old)
|
||||
assert.Nil(t, err)
|
||||
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment",
|
||||
`{"text": "test 123", "locator":{"site": "radio-t","url": "https://radio-t.com/blah1"}}`)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRest_CreateTooBig(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -80,7 +114,7 @@ func TestServer_CreateTooBig(t *testing.T) {
|
||||
assert.Equal(t, "invalid comment", c["details"])
|
||||
}
|
||||
|
||||
func TestServer_Preview(t *testing.T) {
|
||||
func TestRest_Preview(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -93,7 +127,7 @@ func TestServer_Preview(t *testing.T) {
|
||||
assert.Equal(t, "<p>test 123</p>\n", string(b))
|
||||
}
|
||||
|
||||
func TestServer_PreviewWithMD(t *testing.T) {
|
||||
func TestRest_PreviewWithMD(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -102,7 +136,7 @@ func TestServer_PreviewWithMD(t *testing.T) {
|
||||
# h1
|
||||
|
||||
BKT
|
||||
func TestServer_Preview(t *testing.T) {
|
||||
func TestRest_Preview(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
require.NotNil(t, srv)
|
||||
}
|
||||
@@ -118,10 +152,10 @@ BKT
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "<h1>h1</h1>\n\n<pre><code>func TestServer_Preview(t *testing.T) {\nsrv, ts := prep(t)\n require.NotNil(t, srv)\n}\n</code></pre>\n", string(b))
|
||||
assert.Equal(t, "<h1>h1</h1>\n\n<pre><code>func TestRest_Preview(t *testing.T) {\nsrv, ts := prep(t)\n require.NotNil(t, srv)\n}\n</code></pre>\n", string(b))
|
||||
}
|
||||
|
||||
func TestServer_CreateAndGet(t *testing.T) {
|
||||
func TestRest_CreateAndGet(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -153,7 +187,7 @@ func TestServer_CreateAndGet(t *testing.T) {
|
||||
t.Logf("%+v", comment)
|
||||
}
|
||||
|
||||
func TestServer_Find(t *testing.T) {
|
||||
func TestRest_Find(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -198,9 +232,44 @@ func TestServer_Find(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(tree.Nodes))
|
||||
assert.Equal(t, 1, len(tree.Nodes[0].Replies))
|
||||
assert.Equal(t, 2, tree.Info.Count)
|
||||
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
|
||||
assert.False(t, tree.Info.ReadOnly, "post is fresh")
|
||||
}
|
||||
|
||||
func TestServer_Update(t *testing.T) {
|
||||
func TestRest_FindAge(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
c1 := store.Comment{Text: "test test #1", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -5),
|
||||
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, User: store.User{ID: "u1"}}
|
||||
_, err := srv.DataService.Create(c1)
|
||||
require.Nil(t, err)
|
||||
|
||||
c2 := store.Comment{Text: "test test #2", ParentID: "", Timestamp: time.Now().AddDate(0, 0, -15),
|
||||
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}, User: store.User{ID: "u1"}}
|
||||
_, err = srv.DataService.Create(c2)
|
||||
require.Nil(t, err)
|
||||
|
||||
tree := rest.Tree{}
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah1&format=tree")
|
||||
assert.Equal(t, 200, code)
|
||||
err = json.Unmarshal([]byte(res), &tree)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
|
||||
assert.False(t, tree.Info.ReadOnly, "post is fresh")
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah2&format=tree")
|
||||
assert.Equal(t, 200, code)
|
||||
err = json.Unmarshal([]byte(res), &tree)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://radio-t.com/blah2", tree.Info.URL)
|
||||
assert.True(t, tree.Info.ReadOnly, "post is old")
|
||||
}
|
||||
|
||||
func TestRest_Update(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -239,7 +308,7 @@ func TestServer_Update(t *testing.T) {
|
||||
assert.Equal(t, c2, c3, "same as response from update")
|
||||
}
|
||||
|
||||
func TestServer_Last(t *testing.T) {
|
||||
func TestRest_Last(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -268,9 +337,23 @@ func TestServer_Last(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, len(comments), "should have 3 comments")
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/last/X?site=radio-t")
|
||||
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")
|
||||
|
||||
err = srv.DataService.Delete(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, id1, store.SoftDelete)
|
||||
assert.Nil(t, err)
|
||||
res, code = get(t, ts.URL+"/api/v1/last/5?site=radio-t")
|
||||
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")
|
||||
}
|
||||
|
||||
func TestServer_FindUserComments(t *testing.T) {
|
||||
func TestRest_FindUserComments(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -302,7 +385,7 @@ func TestServer_FindUserComments(t *testing.T) {
|
||||
assert.Equal(t, 3, resp.Count, "should have 3 count")
|
||||
}
|
||||
|
||||
func TestServer_UserInfo(t *testing.T) {
|
||||
func TestRest_UserInfo(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -316,7 +399,7 @@ func TestServer_UserInfo(t *testing.T) {
|
||||
Picture: "/api/v1/avatar/remark.image", Admin: true, Blocked: false, IP: ""}, user)
|
||||
}
|
||||
|
||||
func TestServer_Vote(t *testing.T) {
|
||||
func TestRest_Vote(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -360,7 +443,7 @@ func TestServer_Vote(t *testing.T) {
|
||||
assert.Equal(t, map[string]bool{}, cr.Votes)
|
||||
}
|
||||
|
||||
func TestServer_Count(t *testing.T) {
|
||||
func TestRest_Count(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -390,7 +473,7 @@ func TestServer_Count(t *testing.T) {
|
||||
assert.Equal(t, 2.0, j["count"])
|
||||
}
|
||||
|
||||
func TestServer_Counts(t *testing.T) {
|
||||
func TestRest_Counts(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -420,7 +503,7 @@ func TestServer_Counts(t *testing.T) {
|
||||
{URL: "https://radio-t.com/blah2", Count: 2}}), j)
|
||||
}
|
||||
|
||||
func TestServer_List(t *testing.T) {
|
||||
func TestRest_List(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -441,10 +524,13 @@ func TestServer_List(t *testing.T) {
|
||||
pi := []store.PostInfo{}
|
||||
err := json.Unmarshal([]byte(body), &pi)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah2", Count: 2}, {URL: "https://radio-t.com/blah1", Count: 3}}, pi)
|
||||
assert.Equal(t, "https://radio-t.com/blah2", pi[0].URL)
|
||||
assert.Equal(t, 2, pi[0].Count)
|
||||
assert.Equal(t, "https://radio-t.com/blah1", pi[1].URL)
|
||||
assert.Equal(t, 3, pi[1].Count)
|
||||
}
|
||||
|
||||
func TestServer_Config(t *testing.T) {
|
||||
func TestRest_Config(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -459,10 +545,47 @@ func TestServer_Config(t *testing.T) {
|
||||
assert.Equal(t, 4000., j["max_comment_size"])
|
||||
assert.Equal(t, -5., j["low_score"])
|
||||
assert.Equal(t, -10., j["critical_score"])
|
||||
assert.Equal(t, 10., j["readonly_age"])
|
||||
t.Logf("%+v", j)
|
||||
}
|
||||
|
||||
func TestServer_FileServer(t *testing.T) {
|
||||
func TestRest_Info(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
user := store.User{ID: "user1", Name: "user name 1"}
|
||||
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
|
||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 10, 0, time.Local)}
|
||||
c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
|
||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 20, 0, time.Local)}
|
||||
c3 := store.Comment{User: user, Text: "test test #3", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
|
||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
|
||||
|
||||
_, err := srv.DataService.Create(c1)
|
||||
require.Nil(t, err, "%+v", err)
|
||||
_, err = srv.DataService.Create(c2)
|
||||
require.Nil(t, err)
|
||||
_, err = srv.DataService.Create(c3)
|
||||
require.Nil(t, err)
|
||||
|
||||
body, code := get(t, ts.URL+"/api/v1/info?site=radio-t&url=https://radio-t.com/blah1")
|
||||
assert.Equal(t, 200, code)
|
||||
|
||||
info := store.PostInfo{}
|
||||
err = json.Unmarshal([]byte(body), &info)
|
||||
assert.Nil(t, err)
|
||||
exp := store.PostInfo{URL: "https://radio-t.com/blah1", Count: 3,
|
||||
FirstTS: time.Date(2018, 05, 27, 1, 14, 10, 0, time.Local), LastTS: time.Date(2018, 05, 27, 1, 14, 25, 0, time.Local)}
|
||||
assert.Equal(t, exp, info)
|
||||
|
||||
_, code = get(t, ts.URL+"/api/v1/info?site=radio-t&url=https://radio-t.com/blah-no")
|
||||
assert.Equal(t, 400, code)
|
||||
_, code = get(t, ts.URL+"/api/v1/info?site=radio-t-no&url=https://radio-t.com/blah-no")
|
||||
assert.Equal(t, 400, code)
|
||||
}
|
||||
|
||||
func TestRest_FileServer(t *testing.T) {
|
||||
srv, ts := prep(t)
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
@@ -472,7 +595,7 @@ func TestServer_FileServer(t *testing.T) {
|
||||
assert.Equal(t, "some html", body)
|
||||
}
|
||||
|
||||
func TestServer_Shutdown(t *testing.T) {
|
||||
func TestRest_Shutdown(t *testing.T) {
|
||||
srv := Rest{Authenticator: auth.Authenticator{},
|
||||
AvatarProxy: &proxy.Avatar{StorePath: "/tmp", RoutePath: "/api/v1/avatar"}, ImageProxy: &proxy.Image{}}
|
||||
go func() {
|
||||
@@ -500,6 +623,7 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) {
|
||||
WebRoot: "/tmp",
|
||||
AvatarProxy: &proxy.Avatar{StorePath: "/tmp", RoutePath: "/api/v1/avatar"},
|
||||
ImageProxy: &proxy.Image{},
|
||||
ReadOnlyAge: 10,
|
||||
}
|
||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestServer_RssPost(t *testing.T) {
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
waitOnMinChange()
|
||||
waitOnSecChange()
|
||||
|
||||
c1 := store.Comment{
|
||||
Text: "test 123",
|
||||
@@ -57,7 +57,7 @@ func TestServer_RssSite(t *testing.T) {
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
waitOnMinChange()
|
||||
waitOnSecChange()
|
||||
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestServer_RssWithReply(t *testing.T) {
|
||||
assert.NotNil(t, srv)
|
||||
defer cleanup(ts)
|
||||
|
||||
waitOnMinChange()
|
||||
waitOnSecChange()
|
||||
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
@@ -158,9 +158,9 @@ func TestServer_RssWithReply(t *testing.T) {
|
||||
assert.Equal(t, expected, res)
|
||||
}
|
||||
|
||||
func waitOnMinChange() {
|
||||
func waitOnSecChange() {
|
||||
for {
|
||||
if time.Now().Nanosecond() > 500000000 {
|
||||
if time.Now().Nanosecond() < 100000000 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Nanosecond)
|
||||
|
||||
@@ -23,18 +23,22 @@ func errDetailsMsg(r *http.Request, code int, err error, details string) string
|
||||
if user, e := GetUserInfo(r); e == nil {
|
||||
uinfoStr = user.Name + "/" + user.ID + " - "
|
||||
}
|
||||
|
||||
q := r.URL.String()
|
||||
if qun, e := url.QueryUnescape(q); e == nil {
|
||||
q = qun
|
||||
}
|
||||
|
||||
srcFileInfo := ""
|
||||
if _, file, line, ok := runtime.Caller(2); ok {
|
||||
if pc, file, line, ok := runtime.Caller(2); ok {
|
||||
fnameElems := strings.Split(file, "/")
|
||||
srcFileInfo = fmt.Sprintf(" [caused by %s:%d]", strings.Join(fnameElems[len(fnameElems)-3:], "/"), line)
|
||||
funcNameElems := strings.Split(runtime.FuncForPC(pc).Name(), "/")
|
||||
srcFileInfo = fmt.Sprintf(" [caused by %s:%d %s]", strings.Join(fnameElems[len(fnameElems)-3:], "/"),
|
||||
line, funcNameElems[len(funcNameElems)-1])
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s - %v - %d - %s%s - %s%s",
|
||||
details, err, code, uinfoStr, strings.Split(r.RemoteAddr, ":")[0], q, srcFileInfo)
|
||||
remoteIP := r.RemoteAddr
|
||||
if pos := strings.Index(remoteIP, ":"); pos >= 0 {
|
||||
remoteIP = remoteIP[:pos]
|
||||
}
|
||||
return fmt.Sprintf("%s - %v - %d - %s%s - %s%s", details, err, code, uinfoStr, remoteIP, q, srcFileInfo)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/umputun/remark/app/store"
|
||||
)
|
||||
|
||||
func TestSendErrorJSON(t *testing.T) {
|
||||
@@ -39,8 +40,21 @@ func TestErrorDetailsMsg(t *testing.T) {
|
||||
callerFn := func() {
|
||||
req, err := http.NewRequest("GET", "https://example.com/test?k1=v1&k2=v2", nil)
|
||||
require.Nil(t, err)
|
||||
req.RemoteAddr = "1.2.3.4"
|
||||
msg := errDetailsMsg(req, 500, errors.New("error 500"), "error details 123456")
|
||||
assert.Equal(t, "error details 123456 - error 500 - 500 - - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:45]", msg)
|
||||
assert.Equal(t, "error details 123456 - error 500 - 500 - 1.2.3.4 - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:47 rest.TestErrorDetailsMsg]", msg)
|
||||
}
|
||||
callerFn()
|
||||
}
|
||||
|
||||
func TestErrorDetailsMsgWithUser(t *testing.T) {
|
||||
callerFn := func() {
|
||||
req, err := http.NewRequest("GET", "https://example.com/test?k1=v1&k2=v2", nil)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req = SetUserInfo(req, store.User{Name: "test", ID: "id"})
|
||||
require.Nil(t, err)
|
||||
msg := errDetailsMsg(req, 500, errors.New("error 500"), "error details 123456")
|
||||
assert.Equal(t, "error details 123456 - error 500 - 500 - test/id - 127.0.0.1 - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:59 rest.TestErrorDetailsMsgWithUser]", msg)
|
||||
}
|
||||
callerFn()
|
||||
}
|
||||
|
||||
@@ -94,6 +94,14 @@ func TestPicture_Convert(t *testing.T) {
|
||||
|
||||
r = img.Convert(`<img src="https://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
|
||||
assert.Equal(t, `<img src="https://radio-t.com/img3.png"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
|
||||
|
||||
img = Image{Enabled: true, RoutePath: "/img", RemarkURL: "http://example.com"}
|
||||
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
|
||||
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "http:// remark url, no proxy")
|
||||
|
||||
img = Image{Enabled: false, RoutePath: "/img"}
|
||||
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz`)
|
||||
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "disabled, no proxy")
|
||||
}
|
||||
|
||||
func imgHTTPServer(t *testing.T) *httptest.Server {
|
||||
|
||||
+47
-19
@@ -10,25 +10,39 @@ import (
|
||||
|
||||
// Tree is formatter making tree from list of comments
|
||||
type Tree struct {
|
||||
Nodes []*Node `json:"comments"`
|
||||
Nodes []*Node `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
// Node is a comment with optional replies
|
||||
type Node struct {
|
||||
Comment store.Comment `json:"comment"`
|
||||
Replies []*Node `json:"replies,omitempty"`
|
||||
ts time.Time
|
||||
Comment store.Comment `json:"comment"`
|
||||
Replies []*Node `json:"replies,omitempty"`
|
||||
tsModified time.Time
|
||||
tsCreated time.Time
|
||||
}
|
||||
|
||||
// recurData wraps all fileds used in recursive processing as intermediate results
|
||||
type recurData struct {
|
||||
ts time.Time
|
||||
visible bool
|
||||
tsModified time.Time
|
||||
tsCreated time.Time
|
||||
visible bool
|
||||
}
|
||||
|
||||
// MakeTree gets unsorted list of comments and produces Tree
|
||||
func MakeTree(comments []store.Comment, sortType string) *Tree {
|
||||
res := Tree{}
|
||||
func MakeTree(comments []store.Comment, sortType string, readOnlyAge int) *Tree {
|
||||
if len(comments) == 0 {
|
||||
return &Tree{}
|
||||
}
|
||||
|
||||
res := Tree{
|
||||
Info: store.PostInfo{
|
||||
URL: comments[0].Locator.URL,
|
||||
Count: len(comments), // TODO: includes deleted?
|
||||
FirstTS: comments[0].Timestamp,
|
||||
LastTS: comments[0].Timestamp,
|
||||
},
|
||||
}
|
||||
|
||||
topComments := res.filter(comments, "")
|
||||
res.Nodes = []*Node{}
|
||||
@@ -36,12 +50,23 @@ func MakeTree(comments []store.Comment, sortType string) *Tree {
|
||||
node := Node{Comment: rootComment}
|
||||
|
||||
rd := recurData{}
|
||||
commentsTree, t := res.proc(comments, &node, &rd, rootComment.ID)
|
||||
commentsTree, tsModified, tsCreated := res.proc(comments, &node, &rd, rootComment.ID)
|
||||
// skip deleted with no sub-comments ar all sub-comments deleted
|
||||
if rootComment.Deleted && (len(commentsTree.Replies) == 0 || !rd.visible) {
|
||||
continue
|
||||
}
|
||||
commentsTree.ts = t
|
||||
|
||||
commentsTree.tsModified, commentsTree.tsCreated = tsModified, tsCreated
|
||||
if commentsTree.tsCreated.Before(res.Info.FirstTS) {
|
||||
res.Info.FirstTS = commentsTree.tsCreated
|
||||
}
|
||||
if commentsTree.tsModified.After(res.Info.LastTS) {
|
||||
res.Info.LastTS = commentsTree.tsModified
|
||||
}
|
||||
|
||||
res.Info.ReadOnly = readOnlyAge > 0 && !res.Info.FirstTS.IsZero() &&
|
||||
res.Info.FirstTS.AddDate(0, 0, readOnlyAge).Before(time.Now())
|
||||
|
||||
res.Nodes = append(res.Nodes, commentsTree)
|
||||
}
|
||||
|
||||
@@ -50,16 +75,19 @@ func MakeTree(comments []store.Comment, sortType string) *Tree {
|
||||
}
|
||||
|
||||
// proc makes tree for one top-level comment recursively
|
||||
func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentID string) (*Node, time.Time) {
|
||||
func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentID string) (*Node, time.Time, time.Time) {
|
||||
|
||||
if rd.ts.IsZero() {
|
||||
rd.ts = node.Comment.Timestamp
|
||||
if rd.tsModified.IsZero() || rd.tsCreated.IsZero() {
|
||||
rd.tsModified, rd.tsCreated = node.Comment.Timestamp, node.Comment.Timestamp
|
||||
}
|
||||
|
||||
repComments := t.filter(comments, parentID)
|
||||
for _, rc := range repComments {
|
||||
if rc.Timestamp.After(rd.ts) {
|
||||
rd.ts = rc.Timestamp
|
||||
if rc.Timestamp.After(rd.tsModified) {
|
||||
rd.tsModified = rc.Timestamp
|
||||
}
|
||||
if rc.Timestamp.Before(rd.tsCreated) {
|
||||
rd.tsCreated = rc.Timestamp
|
||||
}
|
||||
if !rc.Deleted {
|
||||
rd.visible = true
|
||||
@@ -72,7 +100,7 @@ func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentI
|
||||
sort.Slice(node.Replies, func(i, j int) bool {
|
||||
return node.Replies[i].Comment.Timestamp.Before(node.Replies[j].Comment.Timestamp)
|
||||
})
|
||||
return node, rd.ts
|
||||
return node, rd.tsModified, rd.tsCreated
|
||||
}
|
||||
|
||||
// filter returns comments for parentID
|
||||
@@ -87,7 +115,7 @@ func (t *Tree) filter(comments []store.Comment, parentID string) (f []store.Comm
|
||||
}
|
||||
|
||||
// sort list of nodes, i.e. top-level comments
|
||||
// time sort uses ts from latest reply
|
||||
// time sort uses tsModified from latest reply
|
||||
func (t *Tree) sortNodes(sortType string) {
|
||||
|
||||
sort.Slice(t.Nodes, func(i, j int) bool {
|
||||
@@ -100,9 +128,9 @@ func (t *Tree) sortNodes(sortType string) {
|
||||
|
||||
case "+active", "-active", "active":
|
||||
if strings.HasPrefix(sortType, "-") {
|
||||
return t.Nodes[i].ts.After(t.Nodes[j].ts)
|
||||
return t.Nodes[i].tsModified.After(t.Nodes[j].tsModified)
|
||||
}
|
||||
return t.Nodes[i].ts.Before(t.Nodes[j].ts)
|
||||
return t.Nodes[i].tsModified.Before(t.Nodes[j].tsModified)
|
||||
|
||||
case "+score", "-score", "score":
|
||||
if strings.HasPrefix(sortType, "-") {
|
||||
|
||||
+93
-45
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,36 +16,46 @@ import (
|
||||
|
||||
func TestMakeTree(t *testing.T) {
|
||||
|
||||
loc := store.Locator{URL: "url", SiteID: "site"}
|
||||
ts := func(min int, sec int) time.Time { return time.Date(2017, 12, 25, 19, min, sec, 0, time.UTC) }
|
||||
|
||||
// unsorted by purpose
|
||||
comments := []store.Comment{
|
||||
{ID: "14", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 14, 0, time.UTC)},
|
||||
{ID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 1, 0, time.UTC)},
|
||||
{ID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 2, 0, time.UTC)},
|
||||
{ID: "11", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 11, 0, time.UTC)},
|
||||
{ID: "13", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 13, 0, time.UTC)},
|
||||
{ID: "12", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 12, 0, time.UTC)},
|
||||
{ID: "131", ParentID: "13", Timestamp: time.Date(2017, 12, 25, 19, 46, 31, 0, time.UTC)},
|
||||
{ID: "132", ParentID: "13", Timestamp: time.Date(2017, 12, 25, 19, 46, 32, 0, time.UTC)},
|
||||
{ID: "21", ParentID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 21, 0, time.UTC)},
|
||||
{ID: "22", ParentID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)},
|
||||
{ID: "4", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)},
|
||||
{ID: "3", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)},
|
||||
{ID: "5", Deleted: true},
|
||||
{ID: "6", Deleted: true},
|
||||
{ID: "61", ParentID: "6", Deleted: true},
|
||||
{ID: "62", ParentID: "6", Deleted: true},
|
||||
{ID: "611", ParentID: "61", Deleted: true},
|
||||
{Locator: loc, ID: "14", ParentID: "1", Timestamp: ts(46, 14)},
|
||||
{Locator: loc, ID: "1", Timestamp: ts(46, 1)},
|
||||
{Locator: loc, ID: "2", Timestamp: ts(47, 2)},
|
||||
{Locator: loc, ID: "11", ParentID: "1", Timestamp: ts(46, 11)},
|
||||
{Locator: loc, ID: "13", ParentID: "1", Timestamp: ts(46, 13)},
|
||||
{Locator: loc, ID: "12", ParentID: "1", Timestamp: ts(46, 12)},
|
||||
{Locator: loc, ID: "131", ParentID: "13", Timestamp: ts(46, 31)},
|
||||
{Locator: loc, ID: "132", ParentID: "13", Timestamp: ts(46, 32)},
|
||||
{Locator: loc, ID: "21", ParentID: "2", Timestamp: ts(47, 21)},
|
||||
{Locator: loc, ID: "22", ParentID: "2", Timestamp: ts(47, 22)},
|
||||
{Locator: loc, ID: "4", Timestamp: ts(47, 22)},
|
||||
{Locator: loc, ID: "3", Timestamp: ts(47, 22)},
|
||||
{Locator: loc, ID: "5", Deleted: true},
|
||||
{Locator: loc, ID: "6", Deleted: true},
|
||||
{Locator: loc, ID: "61", ParentID: "6", Deleted: true},
|
||||
{Locator: loc, ID: "62", ParentID: "6", Deleted: true},
|
||||
{Locator: loc, ID: "611", ParentID: "61", Deleted: true},
|
||||
}
|
||||
|
||||
res := MakeTree(comments, "time")
|
||||
res := MakeTree(comments, "time", 0)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
err := enc.Encode(res)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, expJSON, buf.String())
|
||||
// t.Log(string(buf.Bytes()))
|
||||
expected, actual := cleanFormatting(expJSON, buf.String())
|
||||
assert.Equal(t, expected, actual)
|
||||
assert.Equal(t, store.PostInfo{URL: "url", Count: 17, FirstTS: ts(46, 1), LastTS: ts(47, 22)}, res.Info)
|
||||
|
||||
res = MakeTree([]store.Comment{}, "time", 0)
|
||||
assert.Equal(t, &Tree{}, res)
|
||||
|
||||
res = MakeTree(comments, "time", 10)
|
||||
assert.Equal(t, store.PostInfo{URL: "url", Count: 17, FirstTS: ts(46, 1), LastTS: ts(47, 22), ReadOnly: true}, res.Info)
|
||||
}
|
||||
|
||||
func TestTreeSortNodes(t *testing.T) {
|
||||
@@ -65,33 +77,39 @@ func TestTreeSortNodes(t *testing.T) {
|
||||
{ID: "5", Deleted: true},
|
||||
}
|
||||
|
||||
res := MakeTree(comments, "+active")
|
||||
res := MakeTree(comments, "+active", 0)
|
||||
assert.Equal(t, "2", res.Nodes[0].Comment.ID)
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].ts)
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
|
||||
|
||||
res = MakeTree(comments, "-active")
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].ts)
|
||||
res = MakeTree(comments, "-active", 0)
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
|
||||
assert.Equal(t, "1", res.Nodes[0].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "+time")
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].ts)
|
||||
res = MakeTree(comments, "-time")
|
||||
res = MakeTree(comments, "+time", 0)
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
|
||||
assert.Equal(t, "1", res.Nodes[0].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "-time", 0)
|
||||
assert.Equal(t, "6", res.Nodes[0].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "score")
|
||||
res = MakeTree(comments, "score", 0)
|
||||
assert.Equal(t, "4", res.Nodes[0].Comment.ID)
|
||||
assert.Equal(t, "3", res.Nodes[1].Comment.ID)
|
||||
assert.Equal(t, "6", res.Nodes[2].Comment.ID)
|
||||
assert.Equal(t, "1", res.Nodes[3].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "+score")
|
||||
res = MakeTree(comments, "+score", 0)
|
||||
assert.Equal(t, "4", res.Nodes[0].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "-score")
|
||||
res = MakeTree(comments, "-score", 0)
|
||||
assert.Equal(t, "2", res.Nodes[0].Comment.ID)
|
||||
assert.Equal(t, "1", res.Nodes[1].Comment.ID)
|
||||
assert.Equal(t, "3", res.Nodes[2].Comment.ID)
|
||||
assert.Equal(t, "6", res.Nodes[3].Comment.ID)
|
||||
|
||||
res = MakeTree(comments, "undefined", 0)
|
||||
t.Log(res.Nodes[0].Comment.ID, res.Nodes[0].tsModified)
|
||||
assert.Equal(t, "1", res.Nodes[0].Comment.ID)
|
||||
}
|
||||
|
||||
func BenchmarkTree(b *testing.B) {
|
||||
@@ -102,7 +120,7 @@ func BenchmarkTree(b *testing.B) {
|
||||
assert.Nil(b, err)
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
res := MakeTree(comments, "time")
|
||||
res := MakeTree(comments, "time", 0)
|
||||
assert.NotNil(b, res)
|
||||
}
|
||||
}
|
||||
@@ -120,8 +138,9 @@ const expJSON = `{
|
||||
"picture": "",
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"locator": {
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -140,7 +159,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -159,7 +179,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -178,7 +199,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -197,7 +219,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -216,7 +239,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -237,7 +261,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -258,7 +283,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -277,7 +303,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -296,7 +323,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -317,7 +345,8 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
@@ -336,13 +365,32 @@ const expJSON = `{
|
||||
"admin": false
|
||||
},
|
||||
"locator": {
|
||||
"url": ""
|
||||
"site": "site",
|
||||
"url": "url"
|
||||
},
|
||||
"score": 0,
|
||||
"votes": null,
|
||||
"time": "2017-12-25T19:47:22Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"info": {
|
||||
"url": "url",
|
||||
"count": 17,
|
||||
"first_time": "2017-12-25T19:46:01Z",
|
||||
"last_time": "2017-12-25T19:47:22Z"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func cleanFormatting(expected, actual string) (string, string) {
|
||||
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
|
||||
|
||||
expected = strings.Replace(expected, "\n", " ", -1)
|
||||
expected = strings.Replace(expected, "\t", " ", -1)
|
||||
expected = reSpaces.ReplaceAllString(expected, " ")
|
||||
|
||||
actual = strings.Replace(actual, "\n", " ", -1)
|
||||
actual = reSpaces.ReplaceAllString(actual, " ")
|
||||
return expected, actual
|
||||
}
|
||||
|
||||
@@ -38,8 +38,11 @@ type Edit struct {
|
||||
|
||||
// PostInfo holds summary for given post url
|
||||
type PostInfo struct {
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
FirstTS time.Time `json:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time,omitempty"`
|
||||
}
|
||||
|
||||
// BlockedUser holds id and ts for blocked user
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/bbolt"
|
||||
"github.com/pkg/errors"
|
||||
@@ -22,17 +22,19 @@ import (
|
||||
// is a nested bucket named userID with kv as ts:reference
|
||||
// - blocking info sits in "block" bucket. Key is userID, value - ts
|
||||
// - counts per post to keep number of comments. Key is post url, value - count
|
||||
// - readonly per post to keep status of manually set RO posts. Key is post url, value - ts
|
||||
type BoltDB struct {
|
||||
dbs map[string]*bolt.DB
|
||||
}
|
||||
|
||||
const (
|
||||
// top level buckets
|
||||
postsBucketName = "posts"
|
||||
lastBucketName = "last"
|
||||
userBucketName = "users"
|
||||
blocksBucketName = "block"
|
||||
countsBucketName = "counts"
|
||||
postsBucketName = "posts"
|
||||
lastBucketName = "last"
|
||||
userBucketName = "users"
|
||||
blocksBucketName = "block"
|
||||
infoBucketName = "info"
|
||||
readonlyBucketName = "readonly"
|
||||
|
||||
// limits
|
||||
lastLimit = 1000
|
||||
@@ -59,7 +61,8 @@ func NewBoltDB(options bolt.Options, sites ...BoltSite) (*BoltDB, error) {
|
||||
}
|
||||
|
||||
// make top-level buckets
|
||||
topBuckets := []string{postsBucketName, lastBucketName, userBucketName, blocksBucketName, countsBucketName}
|
||||
topBuckets := []string{postsBucketName, lastBucketName, userBucketName, blocksBucketName,
|
||||
infoBucketName, readonlyBucketName}
|
||||
err = db.Update(func(tx *bolt.Tx) error {
|
||||
for _, bktName := range topBuckets {
|
||||
if _, e := tx.CreateBucketIfNotExists([]byte(bktName)); e != nil {
|
||||
@@ -85,6 +88,11 @@ func (b *BoltDB) Create(comment store.Comment) (commentID string, err error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if b.IsReadOnly(comment.Locator) {
|
||||
return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
|
||||
}
|
||||
|
||||
err = bdb.Update(func(tx *bolt.Tx) error {
|
||||
|
||||
postBkt, e := b.makePostBucket(tx, comment.Locator.URL)
|
||||
@@ -122,11 +130,10 @@ func (b *BoltDB) Create(comment store.Comment) (commentID string, err error) {
|
||||
return errors.Wrapf(e, "failed to put user comment %s for %s", comment.ID, comment.User.ID)
|
||||
}
|
||||
|
||||
// increment comments count for post url
|
||||
if _, e = b.count(tx, comment.Locator.URL, 1); e != nil {
|
||||
return errors.Wrapf(e, "failed to increment count for %s", comment.Locator)
|
||||
// set info with countfor post url
|
||||
if _, e = b.setInfo(tx, comment); e != nil {
|
||||
return errors.Wrapf(e, "failed to set info for %s", comment.Locator)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -188,8 +195,8 @@ func (b *BoltDB) Last(siteID string, max int) (comments []store.Comment, err err
|
||||
return e
|
||||
}
|
||||
|
||||
comment, e := b.load(postBkt, []byte(commentID))
|
||||
if e != nil {
|
||||
comment := store.Comment{}
|
||||
if e := b.load(postBkt, []byte(commentID), &comment); e != nil {
|
||||
log.Printf("[WARN] can't load comment for %s from store %s", commentID, url)
|
||||
continue
|
||||
}
|
||||
@@ -244,11 +251,12 @@ func (b BoltDB) List(siteID string, limit, skip int) (list []store.PostInfo, err
|
||||
continue
|
||||
}
|
||||
postURL := string(k)
|
||||
count, e := b.count(tx, postURL, 0)
|
||||
if e != nil {
|
||||
return e
|
||||
infoBkt := tx.Bucket([]byte(infoBucketName))
|
||||
info := store.PostInfo{}
|
||||
if e := b.load(infoBkt, []byte(postURL), &info); e != nil {
|
||||
return errors.Wrapf(e, "can't load info for %s", postURL)
|
||||
}
|
||||
list = append(list, store.PostInfo{URL: postURL, Count: count})
|
||||
list = append(list, info)
|
||||
if limit > 0 && len(list) >= limit {
|
||||
break
|
||||
}
|
||||
@@ -259,6 +267,30 @@ func (b BoltDB) List(siteID string, limit, skip int) (list []store.PostInfo, err
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Info returns time range and count for locator
|
||||
func (b *BoltDB) Info(locator store.Locator, readOnlyAge int) (store.PostInfo, error) {
|
||||
bdb, err := b.db(locator.SiteID)
|
||||
if err != nil {
|
||||
return store.PostInfo{}, err
|
||||
}
|
||||
|
||||
info := store.PostInfo{}
|
||||
err = bdb.View(func(tx *bolt.Tx) error {
|
||||
infoBkt := tx.Bucket([]byte(infoBucketName))
|
||||
if e := b.load(infoBkt, []byte(locator.URL), &info); e != nil {
|
||||
return errors.Wrapf(e, "can't load info for %s", locator.URL)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// set read-only from age and manual bucket
|
||||
info.ReadOnly = readOnlyAge > 0 && !info.FirstTS.IsZero() && info.FirstTS.AddDate(0, 0, readOnlyAge).Before(time.Now())
|
||||
if b.IsReadOnly(locator) {
|
||||
info.ReadOnly = true
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// User extracts all comments for given site and given userID
|
||||
// "users" bucket has sub-bucket for each userID, and keeps it as ts:ref
|
||||
func (b *BoltDB) User(siteID string, userID string, limit int) (comments []store.Comment, totalComments int, err error) {
|
||||
@@ -325,8 +357,7 @@ func (b *BoltDB) Get(locator store.Locator, commentID string) (comment store.Com
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
comment, e = b.load(bucket, []byte(commentID))
|
||||
return e
|
||||
return b.load(bucket, []byte(commentID), &comment)
|
||||
})
|
||||
return comment, err
|
||||
}
|
||||
@@ -391,54 +422,66 @@ func (b *BoltDB) getUserBucket(tx *bolt.Tx, userID string) (*bolt.Bucket, error)
|
||||
return userIDBkt, nil
|
||||
}
|
||||
|
||||
// save comment to key for bucket. Should run in update tx
|
||||
func (b *BoltDB) save(bkt *bolt.Bucket, key []byte, comment store.Comment) (err error) {
|
||||
jdata, jerr := json.Marshal(&comment)
|
||||
// save marshaled value to key for bucket. Should run in update tx
|
||||
func (b *BoltDB) save(bkt *bolt.Bucket, key []byte, value interface{}) (err error) {
|
||||
if value == nil {
|
||||
return errors.Errorf("can't save nil value for %s", key)
|
||||
}
|
||||
jdata, jerr := json.Marshal(value)
|
||||
if jerr != nil {
|
||||
return errors.Wrap(jerr, "can't marshal comment")
|
||||
}
|
||||
if err = bkt.Put([]byte(comment.ID), jdata); err != nil {
|
||||
if err = bkt.Put(key, jdata); err != nil {
|
||||
return errors.Wrapf(err, "failed to save key %s", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load comment by key from bucket. Should run in view tx
|
||||
func (b *BoltDB) load(bkt *bolt.Bucket, key []byte) (comment store.Comment, err error) {
|
||||
commentVal := bkt.Get(key)
|
||||
if commentVal == nil {
|
||||
return comment, errors.Errorf("no comments for %s", key)
|
||||
// load and unmarshal json value by key from bucket. Should run in view tx
|
||||
func (b *BoltDB) load(bkt *bolt.Bucket, key []byte, res interface{}) error {
|
||||
value := bkt.Get(key)
|
||||
if value == nil {
|
||||
return errors.Errorf("no value for %s", key)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(commentVal, &comment); err != nil {
|
||||
return comment, errors.Wrap(err, "failed to unmarshal")
|
||||
if err := json.Unmarshal(value, &res); err != nil {
|
||||
return errors.Wrap(err, "failed to unmarshal")
|
||||
}
|
||||
return comment, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// count adds val to counts key postURL. val can be negative to subtract. if val 0 can be used as accessor
|
||||
// it uses separate counts bucket because boltdb Stat call is very slow
|
||||
func (b *BoltDB) count(tx *bolt.Tx, postURL string, val int) (int, error) {
|
||||
|
||||
btoi := func(v []byte) int {
|
||||
res, _ := strconv.Atoi(string(v))
|
||||
return res
|
||||
}
|
||||
infoBkt := tx.Bucket([]byte(infoBucketName))
|
||||
|
||||
itob := func(v int) []byte {
|
||||
return []byte(strconv.Itoa(v))
|
||||
}
|
||||
|
||||
countBkt := tx.Bucket([]byte(countsBucketName))
|
||||
countVal := countBkt.Get([]byte(postURL))
|
||||
if countVal == nil {
|
||||
countVal = itob(0)
|
||||
info := store.PostInfo{}
|
||||
if err := b.load(infoBkt, []byte(postURL), &info); err != nil {
|
||||
info = store.PostInfo{}
|
||||
}
|
||||
if val == 0 { // get current count, don't update
|
||||
return btoi(countVal), nil
|
||||
return info.Count, nil
|
||||
}
|
||||
updatedCount := btoi(countVal) + val
|
||||
return updatedCount, countBkt.Put([]byte(postURL), itob(updatedCount))
|
||||
info.Count += val
|
||||
|
||||
return info.Count, b.save(infoBkt, []byte(postURL), &info)
|
||||
}
|
||||
|
||||
func (b *BoltDB) setInfo(tx *bolt.Tx, comment store.Comment) (store.PostInfo, error) {
|
||||
infoBkt := tx.Bucket([]byte(infoBucketName))
|
||||
info := store.PostInfo{}
|
||||
if err := b.load(infoBkt, []byte(comment.Locator.URL), &info); err != nil {
|
||||
info = store.PostInfo{
|
||||
Count: 0,
|
||||
URL: comment.Locator.URL,
|
||||
FirstTS: comment.Timestamp,
|
||||
LastTS: comment.Timestamp,
|
||||
}
|
||||
}
|
||||
info.Count++
|
||||
info.LastTS = comment.Timestamp
|
||||
return info, b.save(infoBkt, []byte(comment.Locator.URL), &info)
|
||||
}
|
||||
|
||||
func (b *BoltDB) db(siteID string) (*bolt.DB, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/coreos/bbolt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/app/store"
|
||||
)
|
||||
@@ -32,6 +33,30 @@ func TestBoltDB_CreateAndFind(t *testing.T) {
|
||||
assert.EqualError(t, err, `site "radio-t-bad" not found`)
|
||||
}
|
||||
|
||||
func TestBoltDB_CreateReadOnly(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
var b = prep(t)
|
||||
|
||||
comment := store.Comment{
|
||||
ID: "id-ro",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/ro", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
err := b.SetReadOnly(comment.Locator, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = b.Create(comment)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "post https://radio-t.com/ro is read-only", err.Error())
|
||||
|
||||
err = b.SetReadOnly(comment.Locator, false)
|
||||
require.Nil(t, err)
|
||||
_, err = b.Create(comment)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestBoltDB_Get(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
@@ -73,6 +98,9 @@ func TestBoltDB_Put(t *testing.T) {
|
||||
|
||||
err = b.Put(store.Locator{URL: "https://radio-t.com", SiteID: "bad"}, comment)
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
|
||||
err = b.Put(store.Locator{URL: "https://radio-t.com-bad", SiteID: "radio-t"}, comment)
|
||||
assert.EqualError(t, err, `no bucket https://radio-t.com-bad in store`)
|
||||
}
|
||||
|
||||
func TestBoltDB_Last(t *testing.T) {
|
||||
@@ -124,26 +152,74 @@ func TestBoltDB_List(t *testing.T) {
|
||||
_, err := b.Create(comment)
|
||||
assert.Nil(t, err)
|
||||
|
||||
ts := func(sec int) time.Time { return time.Date(2017, 12, 20, 15, 18, sec, 0, time.Local) }
|
||||
|
||||
res, err := b.List("radio-t", 0, 0)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1}, {URL: "https://radio-t.com", Count: 2}}, res)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
|
||||
{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}},
|
||||
res)
|
||||
|
||||
res, err = b.List("radio-t", -1, -1)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1}, {URL: "https://radio-t.com", Count: 2}}, res)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
|
||||
{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
|
||||
|
||||
res, err = b.List("radio-t", 1, 0)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1}}, res)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)}}, res)
|
||||
|
||||
res, err = b.List("radio-t", 1, 1)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2}}, res)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
|
||||
|
||||
res, err = b.List("bad", 1, 1)
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
}
|
||||
|
||||
func TestBoltDB_Info(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t) // two comments for https://radio-t.com
|
||||
|
||||
ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local) }
|
||||
|
||||
// add one more for https://radio-t.com/2
|
||||
comment := store.Comment{
|
||||
ID: "12345",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 24, 0, time.Local),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Create(comment)
|
||||
assert.Nil(t, err)
|
||||
|
||||
r, err := b.Info(store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, 0)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, store.PostInfo{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24)}, r)
|
||||
|
||||
r, err = b.Info(store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, 10)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, store.PostInfo{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24), ReadOnly: true}, r)
|
||||
|
||||
r, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 0)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, store.PostInfo{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}, r)
|
||||
|
||||
_, err = b.Info(store.Locator{URL: "https://radio-t.com/error", SiteID: "radio-t"}, 0)
|
||||
require.NotNil(t, err)
|
||||
|
||||
_, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t-error"}, 0)
|
||||
require.NotNil(t, err)
|
||||
|
||||
err = b.SetReadOnly(store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, true)
|
||||
require.Nil(t, err)
|
||||
r, err = b.Info(store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, 0)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, store.PostInfo{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24), ReadOnly: true}, r)
|
||||
|
||||
}
|
||||
|
||||
func TestBoltDB_GetForUser(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
@@ -188,6 +264,11 @@ func TestBoltDB_Ref(t *testing.T) {
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestBoltDB_New(t *testing.T) {
|
||||
_, err := NewBoltDB(bolt.Options{}, BoltSite{FileName: "/tmp/no-such-place/tmp.db", SiteID: "radio-t"})
|
||||
assert.EqualError(t, err, "failed to make boltdb for /tmp/no-such-place/tmp.db: open /tmp/no-such-place/tmp.db: no such file or directory")
|
||||
}
|
||||
|
||||
// makes new boltdb, put two records
|
||||
func prep(t *testing.T) *BoltDB {
|
||||
os.Remove(testDb)
|
||||
|
||||
@@ -28,8 +28,8 @@ func (b *BoltDB) Delete(locator store.Locator, commentID string, mode store.Dele
|
||||
return e
|
||||
}
|
||||
|
||||
comment, err := b.load(postBkt, []byte(commentID))
|
||||
if err != nil {
|
||||
comment := store.Comment{}
|
||||
if err := b.load(postBkt, []byte(commentID), &comment); err != nil {
|
||||
return errors.Wrapf(err, "can't load key %s from bucket %s", commentID, locator.URL)
|
||||
}
|
||||
// set deleted status and clear fields
|
||||
@@ -63,7 +63,7 @@ func (b *BoltDB) DeleteAll(siteID string) error {
|
||||
}
|
||||
|
||||
// delete all buckets except blocked users
|
||||
toDelete := []string{postsBucketName, lastBucketName, userBucketName, countsBucketName}
|
||||
toDelete := []string{postsBucketName, lastBucketName, userBucketName, infoBucketName}
|
||||
|
||||
// delete top-level buckets
|
||||
err = bdb.Update(func(tx *bolt.Tx) error {
|
||||
@@ -217,3 +217,42 @@ func (b *BoltDB) Blocked(siteID string) (users []store.BlockedUser, err error) {
|
||||
|
||||
return users, err
|
||||
}
|
||||
|
||||
// SetReadOnly makes post read-only or reset the ro flag
|
||||
func (b *BoltDB) SetReadOnly(locator store.Locator, status bool) error {
|
||||
bdb, err := b.db(locator.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bdb.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(readonlyBucketName))
|
||||
switch status {
|
||||
case true:
|
||||
if e := bucket.Put([]byte(locator.URL), []byte(time.Now().Format(tsNano))); e != nil {
|
||||
return errors.Wrapf(e, "failed to set ro for %s to %s", locator.URL, status)
|
||||
}
|
||||
case false:
|
||||
if e := bucket.Delete([]byte(locator.URL)); e != nil {
|
||||
return errors.Wrapf(e, "failed to clean ro for %s", locator.URL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// IsReadOnly checks if user blocked
|
||||
func (b *BoltDB) IsReadOnly(locator store.Locator) (ro bool) {
|
||||
|
||||
bdb, err := b.db(locator.SiteID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_ = bdb.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(readonlyBucketName))
|
||||
ro = bucket.Get([]byte(locator.URL)) != nil
|
||||
return nil
|
||||
})
|
||||
return ro
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/app/store"
|
||||
)
|
||||
|
||||
func TestBoltDB_Delete(t *testing.T) {
|
||||
func TestBoltAdmin_Delete(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
@@ -43,9 +42,13 @@ func TestBoltDB_Delete(t *testing.T) {
|
||||
loc.SiteID = "bad"
|
||||
err = b.Delete(loc, res[0].ID, store.SoftDelete)
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
|
||||
loc = store.Locator{URL: "https://radio-t.com/bad", SiteID: "radio-t"}
|
||||
err = b.Delete(loc, res[0].ID, store.SoftDelete)
|
||||
assert.EqualError(t, err, `no bucket https://radio-t.com/bad in store`)
|
||||
}
|
||||
|
||||
func TestBoltDB_DeleteHard(t *testing.T) {
|
||||
func TestBoltAdmin_DeleteHard(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
@@ -65,7 +68,7 @@ func TestBoltDB_DeleteHard(t *testing.T) {
|
||||
assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, res[0].User)
|
||||
}
|
||||
|
||||
func TestBoltDB_DeleteAll(t *testing.T) {
|
||||
func TestBoltAdmin_DeleteAll(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
@@ -89,7 +92,7 @@ func TestBoltDB_DeleteAll(t *testing.T) {
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
}
|
||||
|
||||
func TestBoltDB_DeleteUser(t *testing.T) {
|
||||
func TestBoltAdmin_DeleteUser(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
err := b.DeleteUser("radio-t", "user1")
|
||||
@@ -112,9 +115,12 @@ func TestBoltDB_DeleteUser(t *testing.T) {
|
||||
comments, err := b.Last("radio-t", 10)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, len(comments), "nothing left")
|
||||
|
||||
err = b.DeleteUser("radio-t-bad", "user1")
|
||||
assert.EqualError(t, err, `site "radio-t-bad" not found`)
|
||||
}
|
||||
|
||||
func TestBoltDB_BlockUser(t *testing.T) {
|
||||
func TestBoltAdmin_BlockUser(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
@@ -130,9 +136,11 @@ func TestBoltDB_BlockUser(t *testing.T) {
|
||||
|
||||
assert.EqualError(t, b.SetBlock("bad", "user1", true), `site "bad" not found`)
|
||||
assert.NoError(t, b.SetBlock("radio-t", "userX", false))
|
||||
|
||||
assert.False(t, b.IsBlocked("radio-t-bad", "user1"), "nothing blocked on wrong site")
|
||||
}
|
||||
|
||||
func TestBoltDB_BlockList(t *testing.T) {
|
||||
func TestBoltAdmin_BlockList(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
@@ -151,3 +159,23 @@ func TestBoltDB_BlockList(t *testing.T) {
|
||||
_, err = b.Blocked("bad")
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
}
|
||||
|
||||
func TestBoltAdmin_ReadOnly(t *testing.T) {
|
||||
defer os.Remove(testDb)
|
||||
b := prep(t)
|
||||
|
||||
assert.False(t, b.IsReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1"}), "nothing ro")
|
||||
|
||||
assert.NoError(t, b.SetReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1"}, true))
|
||||
assert.True(t, b.IsReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1"}), "url-1 ro")
|
||||
|
||||
assert.False(t, b.IsReadOnly(store.Locator{SiteID: "radio-t", URL: "url-2"}), "url-2 still writable")
|
||||
|
||||
assert.NoError(t, b.SetReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1"}, false))
|
||||
assert.False(t, b.IsReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1"}), "url-1 writable")
|
||||
|
||||
assert.EqualError(t, b.SetReadOnly(store.Locator{SiteID: "bad", URL: "url-1"}, true), `site "bad" not found`)
|
||||
assert.NoError(t, b.SetReadOnly(store.Locator{SiteID: "radio-t", URL: "url-1xyz"}, false))
|
||||
|
||||
assert.False(t, b.IsReadOnly(store.Locator{SiteID: "radio-t-bad", URL: "url-1"}), "nothing blocked on wrong site")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Accessor interface {
|
||||
User(siteID string, userID string, limit int) ([]store.Comment, int, error) // comments by user, sorted by time
|
||||
Count(locator store.Locator) (int, error) // number of comments for the post
|
||||
List(siteID string, limit int, skip int) ([]store.PostInfo, error) // list of commented posts
|
||||
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) // get post info
|
||||
}
|
||||
|
||||
// Admin defines all store ops avail for admin only
|
||||
@@ -37,6 +38,8 @@ type Admin interface {
|
||||
SetBlock(siteID string, userID string, status bool) error // block or unblock user
|
||||
IsBlocked(siteID string, userID string) bool // check if user blocked
|
||||
Blocked(siteID string) ([]store.BlockedUser, error) // get list of blocked users
|
||||
SetReadOnly(locator store.Locator, status bool) error // set/reset read-only flag
|
||||
IsReadOnly(locator store.Locator) bool // check if post read-only
|
||||
}
|
||||
|
||||
// sortComments is for engines can't sort data internally
|
||||
|
||||
+5
-3
@@ -1,9 +1,9 @@
|
||||
|
||||
### find request with tree
|
||||
GET {{host}}/api/v1/find?site=remark&sort=-active&format=tree&url=https://remark42.com/demo/
|
||||
GET {{host}}/api/v1/find?site=radiot&sort=-active&format=tree&url=https://radio-t.com/p/2018/05/05/podcast-596/
|
||||
|
||||
### find request with plain
|
||||
GET {{host}}/api/v1/find?site=remark&sort=-score&format=plain&url=https://remark42.com/demo/
|
||||
GET {{host}}/api/v1/find?site=radiot&sort=-time&format=plain&url=https://radio-t.com/p/2018/05/08/prep-597/
|
||||
|
||||
### last 50 comments
|
||||
GET {{host}}/api/v1/last/50?site=remark
|
||||
@@ -84,7 +84,7 @@ Content-Type: application/json
|
||||
]
|
||||
|
||||
### list commented posts
|
||||
GET {{host}}/api/v1/list?site=remark&limit=10&skip=5
|
||||
GET {{host}}/api/v1/list?site=radiot&limit=10&skip=5
|
||||
|
||||
### get config
|
||||
GET {{host}}/api/v1/config
|
||||
@@ -101,6 +101,8 @@ GET {{host}}/api/v1/admin/blocked?site=remark
|
||||
### delete comment by id
|
||||
DELETE {{host}}/api/v1/admin/comment/3665976683?site=remark&url=https://remark42.com/demo/
|
||||
|
||||
### get post info
|
||||
GET {{host}}/api/v1/info?site=radiot&url=https://radio-t.com/p/2018/05/08/prep-597/
|
||||
|
||||
### post rss
|
||||
GET {{host}}/api/v1/rss/post?site=remark&url=https://remark42.com/demo/
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
# this scrips makes a backup file to /srv/var/userbackup-<site>-<timestamp>.gz
|
||||
|
||||
BACKUP_PATH=${BACKUP_PATH:-./var}
|
||||
backup_file=${BACKUP_PATH}/userbackup-${1}-$(date +%s).gz
|
||||
echo "make backup file for site $1 to $backup_file"
|
||||
curl "http://127.0.0.1:8081/api/v1/admin/export?site=${1}&secret=${SECRET}" > ${backup_file}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
# this scrips making a backup file to /tmp/export-remark.gz and loading it back
|
||||
# useful to migrate data schema in case if new version of data store incomaptible with the stored comments.
|
||||
|
||||
echo "make backup file for site $1"
|
||||
curl "http://127.0.0.1:8081/api/v1/admin/export?site=${1}&secret=${SECRET}" > /tmp/export-remark.gz
|
||||
|
||||
BOLTDB_PATH=${BOLTDB_PATH:-./var}
|
||||
BACKUP_PATH=${BACKUP_PATH:-./var}
|
||||
|
||||
cp ${BOLTDB_PATH}/${1}.db ${BACKUP_PATH}/${1}-$(date +%s).db
|
||||
|
||||
echo "import backup to site $1"
|
||||
echo "unpack /tmp/export-remark.gz"
|
||||
gunzip -c /tmp/export-remark.gz >/tmp/backup.remark
|
||||
ls -laH /tmp/backup.remark
|
||||
|
||||
echo "export to site $1"
|
||||
curl -X POST -H "Content-Type: application/json" --data-binary @/tmp/backup.remark "http://127.0.0.1:8081/api/v1/admin/import?site=${1}&provider=native&secret=${SECRET}"
|
||||
|
||||
rm -f /tmp/backup.remark
|
||||
rm -f /tmp/export-remark.gz
|
||||
@@ -7,5 +7,5 @@ gunzip -c /srv/var/backup/$1 >/tmp/backup.remark
|
||||
size=`stat -c "%s" /tmp/backup.remark`
|
||||
echo "source file size ${size}"
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" -d @/tmp/backup.remark "http://127.0.0.1:8081/api/v1/admin/import?site=${2}&provider=native&secret=${SECRET}"
|
||||
curl -X POST -H "Content-Type: application/json" --data-binary @/tmp/backup.remark "http://127.0.0.1:8081/api/v1/admin/import?site=${2}&provider=native&secret=${SECRET}"
|
||||
rm -fq /tmp/backup.remark
|
||||
|
||||
Reference in New Issue
Block a user