feature/verified (#61)

* add store methods for verification

* add verify rest and auth

* lint: fix double conversion of userID key

* test for middleware, fix log without auth info

* make all scripts with -e
This commit is contained in:
Umputun
2018-05-29 23:07:50 -05:00
committed by GitHub
parent de60edfa61
commit 0363fc4cd9
20 changed files with 260 additions and 59 deletions
+11 -9
View File
@@ -147,7 +147,7 @@ func New(opts Opts) (*Application, error) {
Authenticator: auth.Authenticator{
JWTService: jwtService,
Admins: opts.Admins,
Providers: makeAuthProviders(jwtService, avatarProxy, opts),
Providers: makeAuthProviders(jwtService, avatarProxy, dataService, opts),
DevPasswd: opts.DevPasswd,
},
Cache: cache,
@@ -237,20 +237,22 @@ func makeDirs(dirs ...string) error {
return nil
}
func makeAuthProviders(jwtService *auth.JWT, avatarProxy *proxy.Avatar, opts Opts) (providers []auth.Provider) {
func makeAuthProviders(jwtService *auth.JWT, avatarProxy *proxy.Avatar, ds service.DataStore, opts Opts) []auth.Provider {
makeParams := func(cid, secret string) auth.Params {
return auth.Params{
JwtService: jwtService,
AvatarProxy: avatarProxy,
RemarkURL: opts.RemarkURL,
Cid: cid,
Csecret: secret,
Admins: opts.Admins,
SecretKey: opts.SecretKey,
JwtService: jwtService,
AvatarProxy: avatarProxy,
RemarkURL: opts.RemarkURL,
Cid: cid,
Csecret: secret,
Admins: opts.Admins,
SecretKey: opts.SecretKey,
IsVerifiedFn: ds.IsVerifiedFn(),
}
}
providers := []auth.Provider{}
if opts.GoogleCID != "" && opts.GoogleCSEC != "" {
providers = append(providers, auth.NewGoogle(makeParams(opts.GoogleCID, opts.GoogleCSEC)))
}
+15
View File
@@ -32,6 +32,7 @@ func (a *admin) routes(middlewares ...func(http.Handler) http.Handler) chi.Route
router.Delete("/comment/{id}", a.deleteCommentCtrl)
router.Put("/user/{userid}", a.setBlockCtrl)
router.Delete("/user/{userid}", a.deleteUserCtrl)
router.Put("/verify/{userid}", a.setVerifyCtrl)
router.Get("/export", a.exportCtrl)
router.Put("/pin/{id}", a.setPinCtrl)
router.Get("/blocked", a.blockedUsersCtrl)
@@ -110,6 +111,20 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, JSON{"locator": locator, "read-only": roStatus})
}
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userid")
siteID := r.URL.Query().Get("site")
verifyStatus := r.URL.Query().Get("verified") == "1"
if err := a.dataService.SetVerified(siteID, userID, verifyStatus); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set verify status")
return
}
a.cache.Flush(siteID, userID)
render.JSON(w, r, JSON{"user": userID, "verified": verifyStatus})
}
// 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) {
+38
View File
@@ -275,6 +275,44 @@ func TestAdmin_ReadOnly(t *testing.T) {
assert.False(t, info.ReadOnly)
}
func TestAdmin_Verify(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)
verified := srv.DataService.IsVerified("radio-t", "user1")
assert.False(t, verified)
client := http.Client{}
req, err := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=1", ts.URL), nil)
assert.Nil(t, err)
withBasicAuth(req, "dev", "password")
_, err = client.Do(req)
require.Nil(t, err)
verified = srv.DataService.IsVerified("radio-t", "user1")
assert.True(t, verified)
req, err = http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=radio-t&verified=0", ts.URL), nil)
assert.Nil(t, err)
withBasicAuth(req, "dev", "password")
_, err = client.Do(req)
require.Nil(t, err)
verified = srv.DataService.IsVerified("radio-t", "user1")
assert.False(t, verified)
}
func TestAdmin_ExportStream(t *testing.T) {
srv, ts := prep(t)
assert.NotNil(t, srv)
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/app/rest"
"github.com/umputun/remark/app/store"
"github.com/stretchr/testify/require"
)
func TestMiddleware_AppInfo(t *testing.T) {
router := chi.NewRouter()
router.With(AppInfo("remark42", "12345")).Get("/blah", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("blah blah"))
})
ts := httptest.NewServer(router)
defer ts.Close()
resp, err := http.Get(ts.URL + "/blah")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "blah blah", string(b))
assert.Equal(t, "remark42", resp.Header.Get("App-Name"))
assert.Equal(t, "12345", resp.Header.Get("App-Version"))
assert.Equal(t, "Umputun", resp.Header.Get("Org"))
}
func TestMiddleware_GetBodyAndUser(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com/request", strings.NewReader("body"))
require.Nil(t, err)
body, user := getBodyAndUser(req, []LoggerFlag{LogAll})
assert.Equal(t, "body", body)
assert.Equal(t, "", user, "no user")
req = rest.SetUserInfo(req, store.User{ID: "id1", Name: "user1"})
body, user = getBodyAndUser(req, []LoggerFlag{LogAll})
assert.Equal(t, ` - id1 "user1"`, user, "no user")
body, user = getBodyAndUser(req, nil)
assert.Equal(t, "", body)
assert.Equal(t, "", user, "no user")
body, user = getBodyAndUser(req, []LoggerFlag{LogNone})
assert.Equal(t, "", body)
assert.Equal(t, "", user, "no user")
body, user = getBodyAndUser(req, []LoggerFlag{LogUser})
assert.Equal(t, "", body)
assert.Equal(t, ` - id1 "user1"`, user, "no user")
}
+4 -2
View File
@@ -128,11 +128,12 @@ func (s *Rest) routes() chi.Router {
// api routes
router.Route("/api/v1", func(rapi chi.Router) {
rapi.Use(Logger(LogAll), tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
rapi.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
// open routes
rapi.Group(func(ropen chi.Router) {
ropen.Use(s.Authenticator.Auth(false))
ropen.Use(Logger(LogAll))
ropen.Get("/find", s.findCommentsCtrl)
ropen.Get("/id/{id}", s.commentByIDCtrl)
ropen.Get("/comments", s.findUserCommentsCtrl)
@@ -151,13 +152,14 @@ func (s *Rest) routes() chi.Router {
// protected routes, require auth
rapi.Group(func(rauth chi.Router) {
rauth.Use(s.Authenticator.Auth(true))
rauth.Use(Logger(LogAll))
rauth.Post("/comment", s.createCommentCtrl)
rauth.Put("/comment/{id}", s.updateCommentCtrl)
rauth.Get("/user", s.userInfoCtrl)
rauth.Put("/vote/{id}", s.voteCtrl)
// admin routes, admin users only
rauth.Mount("/admin", s.adminService.routes(s.Authenticator.AdminOnly))
rauth.Mount("/admin", s.adminService.routes(s.Authenticator.AdminOnly, Logger(LogAll)))
})
})
+3 -2
View File
@@ -24,8 +24,9 @@ type CustomClaims struct {
User *store.User `json:"user,omitempty"`
// state and from used for oauth handshake
State string `json:"state,omitempty"`
From string `json:"from,omitempty"`
State string `json:"state,omitempty"`
From string `json:"from,omitempty"`
SiteID string `json:"site_id,omitempty"`
}
const jwtCookieName = "JWT"
+16 -10
View File
@@ -35,13 +35,14 @@ type Provider struct {
// Params to make initialized and ready to use provider
type Params struct {
RemarkURL string
AvatarProxy *proxy.Avatar
JwtService *JWT
SecretKey string
Admins []string
Cid string
Csecret string
RemarkURL string
AvatarProxy *proxy.Avatar
JwtService *JWT
IsVerifiedFn func(siteID string, userID string) bool
SecretKey string
Admins []string
Cid string
Csecret string
}
type userData map[string]interface{}
@@ -79,7 +80,7 @@ func (p Provider) Routes() chi.Router {
return router
}
// loginHandler - GET /login?from=redirect-back-url
// loginHandler - GET /login?from=redirect-back-url&site=siteID
func (p Provider) loginHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[DEBUG] login with %s", p.Name)
@@ -87,8 +88,9 @@ func (p Provider) loginHandler(w http.ResponseWriter, r *http.Request) {
state := p.randToken()
claims := CustomClaims{
State: state,
From: r.URL.Query().Get("from"),
State: state,
From: r.URL.Query().Get("from"),
SiteID: r.URL.Query().Get("site"),
StandardClaims: jwt.StandardClaims{
Id: p.randToken(),
Issuer: "remark42",
@@ -166,7 +168,11 @@ func (p Provider) authHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[WARN] failed to proxy avatar, %s", e)
}
}
u.Admin = isAdmin(u.ID, p.Admins)
if p.IsVerifiedFn != nil {
u.Verified = p.IsVerifiedFn(oauthClaims.SiteID, u.ID)
}
authClaims := &CustomClaims{
User: &u,
+5 -4
View File
@@ -29,7 +29,7 @@ func TestLogin(t *testing.T) {
jar, err := cookiejar.New(nil)
require.Nil(t, err)
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
resp, err := client.Get("http://localhost:8981/login")
resp, err := client.Get("http://localhost:8981/login?site=remark")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
@@ -50,7 +50,7 @@ func TestLogin(t *testing.T) {
Admin: false, Blocked: false, IP: ""}, u)
// check admin user
resp, err = client.Get("http://localhost:8981/login")
resp, err = client.Get("http://localhost:8981/login?site=remark")
assert.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, err = ioutil.ReadAll(resp.Body)
@@ -58,7 +58,7 @@ func TestLogin(t *testing.T) {
err = json.Unmarshal(body, &u)
assert.Nil(t, err)
assert.Equal(t, store.User{Name: "blah", ID: "mock_myuser2", Picture: "http://exmple.com/pic1.png",
Admin: true, Blocked: false, IP: ""}, u)
Admin: true, Blocked: false, IP: "", Verified: true}, u)
}
func TestLogout(t *testing.T) {
@@ -120,7 +120,8 @@ func mockProvider(t *testing.T, loginPort, authPort int) (*http.Server, *http.Se
},
}
params := Params{RemarkURL: "url", SecretKey: "123456", Cid: "cid", Csecret: "csecret",
JwtService: NewJWT("12345", false, time.Hour), Admins: []string{"mock_myuser2"}}
JwtService: NewJWT("12345", false, time.Hour), Admins: []string{"mock_myuser2"},
IsVerifiedFn: func(siteID, userID string) bool { return userID == "mock_myuser2" }}
provider = initProvider(params, provider)
ts := &http.Server{Addr: fmt.Sprintf(":%d", loginPort), Handler: provider.Routes()}
-1
View File
@@ -17,7 +17,6 @@ func GetUserInfo(r *http.Request) (user store.User, err error) {
if ctx == nil {
return store.User{}, errors.New("no info about user")
}
if u, ok := ctx.Value(contextKey("user")).(store.User); ok {
return u, nil
}
+2 -1
View File
@@ -35,6 +35,7 @@ const (
blocksBucketName = "block"
infoBucketName = "info"
readonlyBucketName = "readonly"
verifiedBucketName = "verified"
// limits
lastLimit = 1000
@@ -62,7 +63,7 @@ func NewBoltDB(options bolt.Options, sites ...BoltSite) (*BoltDB, error) {
// make top-level buckets
topBuckets := []string{postsBucketName, lastBucketName, userBucketName, blocksBucketName,
infoBucketName, readonlyBucketName}
infoBucketName, readonlyBucketName, verifiedBucketName}
err = db.Update(func(tx *bolt.Tx) error {
for _, bktName := range topBuckets {
if _, e := tx.CreateBucketIfNotExists([]byte(bktName)); e != nil {
+40 -1
View File
@@ -230,7 +230,7 @@ func (b *BoltDB) SetReadOnly(locator store.Locator, status bool) error {
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)
return errors.Wrapf(e, "failed to set ro for %s", locator.URL)
}
case false:
if e := bucket.Delete([]byte(locator.URL)); e != nil {
@@ -256,3 +256,42 @@ func (b *BoltDB) IsReadOnly(locator store.Locator) (ro bool) {
})
return ro
}
// SetVerified makes user verified or reset the flag
func (b *BoltDB) SetVerified(siteID string, userID string, status bool) error {
bdb, err := b.db(siteID)
if err != nil {
return err
}
return bdb.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(verifiedBucketName))
switch status {
case true:
if e := bucket.Put([]byte(userID), []byte(time.Now().Format(tsNano))); e != nil {
return errors.Wrapf(e, "failed to set verified status for %s", userID)
}
case false:
if e := bucket.Delete([]byte(userID)); e != nil {
return errors.Wrapf(e, "failed to clean verified status for %s", userID)
}
}
return nil
})
}
// IsVerified checks if user verified
func (b *BoltDB) IsVerified(siteID string, userID string) (verified bool) {
bdb, err := b.db(siteID)
if err != nil {
return false
}
_ = bdb.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(verifiedBucketName))
verified = bucket.Get([]byte(userID)) != nil
return nil
})
return verified
}
+19
View File
@@ -179,3 +179,22 @@ func TestBoltAdmin_ReadOnly(t *testing.T) {
assert.False(t, b.IsReadOnly(store.Locator{SiteID: "radio-t-bad", URL: "url-1"}), "nothing blocked on wrong site")
}
func TestBoltAdmin_Verified(t *testing.T) {
defer os.Remove(testDb)
b := prep(t)
assert.False(t, b.IsVerified("radio-t", "u1"), "nothing verified")
assert.NoError(t, b.SetVerified("radio-t", "u1", true))
assert.True(t, b.IsVerified("radio-t", "u1"), "u1 verified")
assert.False(t, b.IsVerified("radio-t", "u2"), "u2 still not verified")
assert.NoError(t, b.SetVerified("radio-t", "u1", false))
assert.False(t, b.IsVerified("radio-t", "u1"), "u1 not verified anymore")
assert.EqualError(t, b.SetVerified("bad", "u1", true), `site "bad" not found`)
assert.NoError(t, b.SetVerified("radio-t", "u1xyz", false))
assert.False(t, b.IsVerified("radio-t-bad", "u1"), "nothing verified on wrong site")
}
+2
View File
@@ -40,6 +40,8 @@ type Admin interface {
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
SetVerified(siteID string, userID string, status bool) error // set/reset verified flag
IsVerified(siteID string, userID string) bool // check verified status
}
// sortComments is for engines can't sort data internally
+10
View File
@@ -150,3 +150,13 @@ func (s *DataStore) ValidateComment(c *store.Comment) error {
}
return nil
}
// IsVerifiedFn returns func to check if user verified or not
func (s *DataStore) IsVerifiedFn() func(siteID string, userID string) bool {
return func(siteID string, userID string) bool {
if siteID == "" {
return false
}
return s.IsVerified(siteID, userID)
}
}
+7 -6
View File
@@ -11,12 +11,13 @@ import (
// User holds user-related info
type User struct {
Name string `json:"name"`
ID string `json:"id"`
Picture string `json:"picture"`
Admin bool `json:"admin"`
Blocked bool `json:"block,omitempty"`
IP string `json:"ip,omitempty"`
Name string `json:"name"`
ID string `json:"id"`
Picture string `json:"picture"`
Admin bool `json:"admin"`
Blocked bool `json:"block,omitempty"`
IP string `json:"ip,omitempty"`
Verified bool `json:"verified,omitempty"`
}
var reValidSha = regexp.MustCompile("^[a-fA-F0-9]{40}$")
+18 -18
View File
@@ -1,12 +1,12 @@
### find request with tree
GET {{host}}/api/v1/find?site=radiot&sort=-active&format=tree&url=https://radio-t.com/p/2018/05/05/podcast-596/
GET {{host}}/api/v1/find?site={{site}}&sort=-active&format=tree&url={{url}}
### find request with plain
GET {{host}}/api/v1/find?site=radiot&sort=-time&format=plain&url=https://radio-t.com/p/2018/05/08/prep-597/
GET {{host}}/api/v1/find?site={{site}}&sort=-time&format=plain&url={{url}}
### last 50 comments
GET {{host}}/api/v1/last/50?site=remark
GET {{host}}/api/v1/last/50?site={{site}}
### create comment
POST {{host}}/api/v1/comment
@@ -15,8 +15,8 @@ Content-Type: application/json
{
"text": "comment *blah* http://radio-t.com",
"locator": {
"url": "https://radio-t.com/blah1",
"site": "remark"
"url": "{{url}}",
"site": "{{site}}"
}
}
@@ -27,8 +27,8 @@ Content-Type: application/json
{
"text": "comment *blah* http://radio-t.com <img src=\"http://minionomaniya.ru/wp-content/uploads/2016/01/Кевин.jpg\">",
"locator": {
"url": "https://radio-t.com/blah1",
"site": "remark"
"url": "{{url}}",
"site": "{{site}}"
}
}
@@ -50,28 +50,28 @@ Content-Type: application/json
}
### pin comment
PUT {{host}}/api/v1/admin/pin/3665976683?site=remark&url=https://remark42.com/demo/&pin=1
PUT {{host}}/api/v1/admin/pin/3665976683?site=remark&url={{url}}&pin=1
### vote for comment
PUT {{host}}/api/v1/vote/3665976683?site=remark&url=https://remark42.com/demo/&vote=1
PUT {{host}}/api/v1/vote/3665976683?site=remark&url={{url}}&vote=1
### get user info
GET {{host}}/api/v1/user
### get comment by id
GET {{host}}/api/v1/id/3665976683?site=remark&url=https://remark42.com/demo/
GET {{host}}/api/v1/id/3665976683?site=remark&url={{url}}
### get comment by id 2
GET {{host}}/api/v1/id/a2ddb8d2f65008ee1a1e3af8df0f26beb042309c?site=remark&url=https://radio-t.com/blah1
### get comment by user id
GET {{host}}/api/v1/comments?site=remark&user=disqus_umputun&limit=5
GET {{host}}/api/v1/comments?site={{site}}&user=github_ef0f706a79cc24b17bbbb374cd234a691d034128&limit=5
### get comment by user id2
GET {{host}}/api/v1/comments?site=remark&user=disqus_kpmy
### get count
GET {{host}}/api/v1/count?site=remark&url=https://remark42.com/demo/
GET {{host}}/api/v1/count?site=remark&url={{url}}
### get counts for many
POST {{host}}/api/v1/counts?site=remark
@@ -80,11 +80,11 @@ Content-Type: application/json
[
"https://radio-t.com/p/2017/12/02/podcast-574/",
"https://radio-t.com/p/2017/12/09/podcast-575/",
"https://remark42.com/demo/"
"{{url}}"
]
### list commented posts
GET {{host}}/api/v1/list?site=radiot&limit=10&skip=5
GET {{host}}/api/v1/list?site={{site}}&limit=10&skip=5
### get config
GET {{host}}/api/v1/config
@@ -96,16 +96,16 @@ PUT {{host}}/api/v1/admin/user/disqus_grigorybakunov?site=remark&block=1
PUT {{host}}/api/v1/admin/user/disqus_grigorybakunov?site=remark&block=0
### list blocked user
GET {{host}}/api/v1/admin/blocked?site=remark
GET {{host}}/api/v1/admin/blocked?site={{site}}
### delete comment by id
DELETE {{host}}/api/v1/admin/comment/3665976683?site=remark&url=https://remark42.com/demo/
DELETE {{host}}/api/v1/admin/comment/3665976683?site=remark&url={{url}}
### get post info
GET {{host}}/api/v1/info?site=radiot&url=https://radio-t.com/p/2018/05/08/prep-597/
GET {{host}}/api/v1/info?site={{site}}&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/
GET {{host}}/api/v1/rss/post?site=remark&url={{url}}
### site rss
PUT {{host}}/api/v1/rss/site?site=remark
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# this scrips makes a backup file to /srv/var/userbackup-<site>-<timestamp>.gz
set -e
BACKUP_PATH=${BACKUP_PATH:-./var}
backup_file=${BACKUP_PATH}/userbackup-${1}-$(date +%s).gz
echo "make backup file for site $1 to $backup_file"
+1
View File
@@ -1,3 +1,4 @@
#!/bin/sh
set -e
echo "import disqus file $1 to site $2"
curl -X POST -H "Content-Type: application/json" -d @/srv/var/$1 "http://127.0.0.1:8081/api/v1/admin/import?site=${2}&provider=disqus&secret=${SECRET}"
+3 -3
View File
@@ -2,7 +2,7 @@
# 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.
set -e
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
@@ -19,5 +19,5 @@ 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
rm /tmp/backup.remark
rm /tmp/export-remark.gz
+2 -1
View File
@@ -1,4 +1,5 @@
#!/bin/sh
set -e
echo "import backup file $1 to site $2"
echo "unpack $1"
@@ -8,4 +9,4 @@ size=`stat -c "%s" /tmp/backup.remark`
echo "source file size ${size}"
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
rm /tmp/backup.remark