rename backend to pkg
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
log "github.com/alecthomas/log4go"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/torkelo/grafana-pro/pkg/components"
|
||||
"github.com/torkelo/grafana-pro/pkg/stores"
|
||||
)
|
||||
|
||||
type HttpServer struct {
|
||||
port string
|
||||
shutdown chan bool
|
||||
store stores.Store
|
||||
renderer *components.PhantomRenderer
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
var sessionStore = sessions.NewCookieStore([]byte("something-very-secret"))
|
||||
|
||||
// var hashKey = []byte("very-secret")
|
||||
// var blockKey = []byte("a-lot-secret")
|
||||
// var s = securecookie.New(hashKey, blockKey)
|
||||
|
||||
func NewHttpServer(port string, store stores.Store) *HttpServer {
|
||||
self := &HttpServer{}
|
||||
self.port = port
|
||||
self.store = store
|
||||
self.renderer = &components.PhantomRenderer{ImagesDir: "data/png", PhantomDir: "_vendor/phantomjs"}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *HttpServer) ListenAndServe() {
|
||||
log.Info("Starting Http Listener on port %v", self.port)
|
||||
defer func() { self.shutdown <- true }()
|
||||
|
||||
self.router = gin.Default()
|
||||
self.router.Use(CacheHeadersMiddleware())
|
||||
|
||||
self.router.Static("/public", "./public")
|
||||
self.router.Static("/app", "./public/app")
|
||||
self.router.Static("/img", "./public/img")
|
||||
|
||||
// register & parse templates
|
||||
templates := template.New("templates")
|
||||
templates.Delims("[[", "]]")
|
||||
templates.ParseFiles("./views/index.html")
|
||||
self.router.SetHTMLTemplate(templates)
|
||||
|
||||
for _, fn := range routeHandlers {
|
||||
fn(self)
|
||||
}
|
||||
|
||||
// register default route
|
||||
self.router.GET("/", self.authMiddleware(), self.index)
|
||||
self.router.GET("/dashboard/*_", self.authMiddleware(), self.index)
|
||||
|
||||
self.router.Run(":" + self.port)
|
||||
}
|
||||
|
||||
func (self *HttpServer) index(c *gin.Context) {
|
||||
c.HTML(200, "index.html", &indexViewModel{title: "hello from go"})
|
||||
}
|
||||
|
||||
func CacheHeadersMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Add("Cache-Control", "max-age=0, public, must-revalidate, proxy-revalidate")
|
||||
}
|
||||
}
|
||||
|
||||
// Api Handler Registration
|
||||
var routeHandlers = make([]routeHandlerRegisterFn, 0)
|
||||
|
||||
type routeHandlerRegisterFn func(self *HttpServer)
|
||||
|
||||
func addRoutes(fn routeHandlerRegisterFn) {
|
||||
routeHandlers = append(routeHandlers, fn)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/torkelo/grafana-pro/pkg/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
addRoutes(func(self *HttpServer) {
|
||||
self.router.GET("/api/dashboards/:id", self.getDashboard)
|
||||
self.router.GET("/api/search/", self.search)
|
||||
self.router.POST("/api/dashboard", self.postDashboard)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *HttpServer) getDashboard(c *gin.Context) {
|
||||
id := c.Params.ByName("id")
|
||||
|
||||
dash, err := self.store.GetById(id)
|
||||
if err != nil {
|
||||
c.JSON(404, newErrorResponse("Dashboard not found"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, dash.Data)
|
||||
}
|
||||
|
||||
func (self *HttpServer) search(c *gin.Context) {
|
||||
query := c.Params.ByName("q")
|
||||
|
||||
results, err := self.store.Query(query)
|
||||
if err != nil {
|
||||
c.JSON(500, newErrorResponse("Failed"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, results)
|
||||
}
|
||||
|
||||
func (self *HttpServer) postDashboard(c *gin.Context) {
|
||||
var command saveDashboardCommand
|
||||
|
||||
if c.EnsureBody(&command) {
|
||||
err := self.store.Save(&models.Dashboard{Data: command.Dashboard})
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{"status": "saved"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(500, gin.H{"error": "bad request"})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func init() {
|
||||
addRoutes(func(self *HttpServer) {
|
||||
self.router.GET("/login/*_", self.index)
|
||||
self.router.POST("/login", self.loginPost)
|
||||
})
|
||||
}
|
||||
|
||||
type loginJsonModel struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Remember bool `json:"remember"`
|
||||
}
|
||||
|
||||
func (self *HttpServer) loginPost(c *gin.Context) {
|
||||
var loginModel loginJsonModel
|
||||
|
||||
if c.EnsureBody(&loginModel) {
|
||||
if loginModel.Email == "manu" && loginModel.Password == "123" {
|
||||
|
||||
session, _ := sessionStore.Get(c.Request, "grafana-session")
|
||||
session.Values["login"] = true
|
||||
session.Save(c.Request, c.Writer)
|
||||
|
||||
c.JSON(200, gin.H{"status": "you are logged in"})
|
||||
} else {
|
||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HttpServer) authMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session, _ := sessionStore.Get(c.Request, "grafana-session")
|
||||
|
||||
if c.Request.URL.Path != "/login" && session.Values["login"] == nil {
|
||||
c.Writer.Header().Set("Location", "/login")
|
||||
c.Abort(302)
|
||||
}
|
||||
|
||||
session.Save(c.Request, c.Writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
type saveDashboardCommand struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Dashboard map[string]interface{}
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type indexViewModel struct {
|
||||
title string
|
||||
}
|
||||
|
||||
func newErrorResponse(message string) *errorResponse {
|
||||
return &errorResponse{Message: message}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func init() {
|
||||
addRoutes(func(self *HttpServer) {
|
||||
self.router.GET("/api/render", self.renderToPng)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *HttpServer) renderToPng(c *gin.Context) {
|
||||
qs := c.Request.URL.Query()
|
||||
url := qs["url"][0]
|
||||
pngPath, err := self.renderer.RenderToPng(url)
|
||||
if err != nil {
|
||||
c.HTML(500, "error.html", nil)
|
||||
}
|
||||
|
||||
c.File(pngPath)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package api
|
||||
@@ -0,0 +1,67 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
log "github.com/alecthomas/log4go"
|
||||
)
|
||||
|
||||
type PhantomRenderer struct {
|
||||
ImagesDir string
|
||||
PhantomDir string
|
||||
}
|
||||
|
||||
func (self *PhantomRenderer) RenderToPng(url string) (string, error) {
|
||||
log.Info("PhantomRenderer::renderToPng url %v", url)
|
||||
binPath, _ := filepath.Abs(filepath.Join(self.PhantomDir, "phantomjs"))
|
||||
scriptPath, _ := filepath.Abs(filepath.Join(self.PhantomDir, "render.js"))
|
||||
pngPath, _ := filepath.Abs(filepath.Join(self.ImagesDir, getHash(url)))
|
||||
pngPath = pngPath + ".png"
|
||||
|
||||
cmd := exec.Command(binPath, scriptPath, "url="+url, "width=100", "height=100", "png="+pngPath)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
go io.Copy(os.Stdout, stdout)
|
||||
go io.Copy(os.Stdout, stderr)
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
log.Error("failed to kill: %v", err)
|
||||
}
|
||||
case <-done:
|
||||
}
|
||||
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
func getHash(text string) string {
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(text))
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestPhantomRender(t *testing.T) {
|
||||
|
||||
Convey("Can render url", t, func() {
|
||||
tempDir, _ := ioutil.TempDir("", "img")
|
||||
renderer := &PhantomRenderer{ImagesDir: tempDir, PhantomDir: "../../_vendor/phantomjs/"}
|
||||
png, err := renderer.RenderToPng("http://www.google.com")
|
||||
So(err, ShouldBeNil)
|
||||
So(exists(png), ShouldEqual, true)
|
||||
|
||||
//_, err = os.Stat(store.getFilePathForDashboard("hello"))
|
||||
//So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package configuration
|
||||
|
||||
type Cfg struct {
|
||||
httpPort string
|
||||
DashboardSource DashboardSourceCfg
|
||||
}
|
||||
|
||||
type DashboardSourceCfg struct {
|
||||
sourceType string
|
||||
path string
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Dashboard struct {
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Type string `json:"title"`
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func NewDashboard(title string) *Dashboard {
|
||||
dash := &Dashboard{}
|
||||
dash.Data = make(map[string]interface{})
|
||||
dash.Data["title"] = title
|
||||
|
||||
return dash
|
||||
}
|
||||
|
||||
func NewFromJson(reader io.Reader) (*Dashboard, error) {
|
||||
dash := NewDashboard("temp")
|
||||
jsonParser := json.NewDecoder(reader)
|
||||
|
||||
if err := jsonParser.Decode(&dash.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dash, nil
|
||||
}
|
||||
|
||||
/*type DashboardServices struct {
|
||||
}
|
||||
|
||||
type DashboardServicesFilter struct {
|
||||
}
|
||||
|
||||
type DashboardServicesFilterTime struct {
|
||||
From string To string
|
||||
}*/
|
||||
|
||||
func (dash *Dashboard) GetString(prop string) string {
|
||||
return dash.Data[prop].(string)
|
||||
}
|
||||
|
||||
func (dash *Dashboard) Title() string {
|
||||
return dash.GetString("title")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/torkelo/grafana-pro/pkg/api"
|
||||
"github.com/torkelo/grafana-pro/pkg/stores"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
HttpServer *api.HttpServer
|
||||
Store stores.Store
|
||||
}
|
||||
|
||||
func NewServer(port string) (*Server, error) {
|
||||
store := stores.New()
|
||||
httpServer := api.NewHttpServer(port, store)
|
||||
|
||||
return &Server{
|
||||
HttpServer: httpServer,
|
||||
Store: store,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (self *Server) ListenAndServe() error {
|
||||
self.HttpServer.ListenAndServe()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package stores
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/alecthomas/log4go"
|
||||
"github.com/torkelo/grafana-pro/pkg/models"
|
||||
)
|
||||
|
||||
type fileStore struct {
|
||||
dataDir string
|
||||
dashDir string
|
||||
cache map[string]*models.Dashboard
|
||||
}
|
||||
|
||||
func NewFileStore(dataDir string) *fileStore {
|
||||
|
||||
if dirDoesNotExist(dataDir) {
|
||||
log.Crashf("FileStore failed to initialize, dataDir does not exist %v", dataDir)
|
||||
}
|
||||
|
||||
dashDir := filepath.Join(dataDir, "dashboards")
|
||||
|
||||
if dirDoesNotExist(dashDir) {
|
||||
log.Debug("Did not find dashboard dir, creating...")
|
||||
err := os.Mkdir(dashDir, 0777)
|
||||
if err != nil {
|
||||
log.Crashf("FileStore failed to initialize, could not create directory %v, error: %v", dashDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
store := &fileStore{}
|
||||
store.dataDir = dataDir
|
||||
store.dashDir = dashDir
|
||||
store.cache = make(map[string]*models.Dashboard)
|
||||
go store.scanFiles()
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func (store *fileStore) scanFiles() {
|
||||
visitor := func(path string, f os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(f.Name(), ".json") {
|
||||
err = store.loadDashboardIntoCache(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := filepath.Walk(store.dashDir, visitor)
|
||||
if err != nil {
|
||||
log.Error("FileStore::updateCache failed %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (store fileStore) loadDashboardIntoCache(filename string) error {
|
||||
log.Info("Loading dashboard file %v into cache", filename)
|
||||
dash, err := loadDashboardFromFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.cache[dash.Title()] = dash
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fileStore) Close() {
|
||||
|
||||
}
|
||||
|
||||
func (store *fileStore) GetById(id string) (*models.Dashboard, error) {
|
||||
log.Debug("FileStore::GetById id = %v", id)
|
||||
filename := store.getFilePathForDashboard(id)
|
||||
|
||||
return loadDashboardFromFile(filename)
|
||||
}
|
||||
|
||||
func (store *fileStore) Save(dash *models.Dashboard) error {
|
||||
filename := store.getFilePathForDashboard(dash.Title())
|
||||
|
||||
log.Debug("Saving dashboard %v to %v", dash.Title(), filename)
|
||||
|
||||
var err error
|
||||
var data []byte
|
||||
if data, err = json.Marshal(dash.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeFile(filename, data)
|
||||
}
|
||||
|
||||
func (store *fileStore) Query(query string) ([]*models.SearchResult, error) {
|
||||
results := make([]*models.SearchResult, 0, 50)
|
||||
|
||||
for _, dash := range store.cache {
|
||||
item := &models.SearchResult{
|
||||
Id: dash.Title(),
|
||||
Type: "dashboard",
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func loadDashboardFromFile(filename string) (*models.Dashboard, error) {
|
||||
log.Debug("FileStore::loading dashboard from file %v", filename)
|
||||
|
||||
configFile, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewFromJson(configFile)
|
||||
}
|
||||
|
||||
func (store *fileStore) getFilePathForDashboard(id string) string {
|
||||
id = strings.ToLower(id)
|
||||
id = strings.Replace(id, " ", "-", -1)
|
||||
return filepath.Join(store.dashDir, id) + ".json"
|
||||
}
|
||||
|
||||
func dirDoesNotExist(dir string) bool {
|
||||
_, err := os.Stat(dir)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func writeFile(filename string, data []byte) error {
|
||||
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := f.Write(data)
|
||||
if err == nil && n < len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err1 := f.Close(); err == nil {
|
||||
err = err1
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package stores
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/torkelo/grafana-pro/pkg/models"
|
||||
)
|
||||
|
||||
func TestFileStore(t *testing.T) {
|
||||
|
||||
GivenFileStore("When saving a dashboard", t, func(store *fileStore) {
|
||||
dashboard := models.NewDashboard("hello")
|
||||
|
||||
err := store.Save(dashboard)
|
||||
|
||||
Convey("should be saved to disk", func() {
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
_, err = os.Stat(store.getFilePathForDashboard("hello"))
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
GivenFileStore("When getting a saved dashboard", t, func(store *fileStore) {
|
||||
copyDashboardToTempData("default.json", "", store.dashDir)
|
||||
dash, err := store.GetById("default")
|
||||
|
||||
Convey("should be read from disk", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(dash, ShouldNotBeNil)
|
||||
|
||||
So(dash.Title(), ShouldEqual, "Grafana Play Home")
|
||||
})
|
||||
})
|
||||
|
||||
GivenFileStore("when getting dashboard with capital letters", t, func(store *fileStore) {
|
||||
copyDashboardToTempData("annotations.json", "", store.dashDir)
|
||||
dash, err := store.GetById("AnnoTations")
|
||||
|
||||
Convey("should be read from disk", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(dash, ShouldNotBeNil)
|
||||
|
||||
So(dash.Title(), ShouldEqual, "Annotations")
|
||||
})
|
||||
})
|
||||
|
||||
GivenFileStore("When copying dashboards into data dir", t, func(store *fileStore) {
|
||||
copyDashboardToTempData("annotations.json", "", store.dashDir)
|
||||
copyDashboardToTempData("default.json", "", store.dashDir)
|
||||
copyDashboardToTempData("graph-styles.json", "", store.dashDir)
|
||||
store.scanFiles()
|
||||
|
||||
Convey("scan should generate index of all dashboards", func() {
|
||||
|
||||
result, err := store.Query("*")
|
||||
So(err, ShouldBeNil)
|
||||
So(len(result), ShouldEqual, 3)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func copyDashboardToTempData(name string, destName string, dir string) {
|
||||
if destName == "" {
|
||||
destName = name
|
||||
}
|
||||
source, _ := filepath.Abs("../../data/dashboards/" + name)
|
||||
dest := filepath.Join(dir, destName)
|
||||
err := copyFile(dest, source)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to copy file %v", name))
|
||||
}
|
||||
}
|
||||
|
||||
func GivenFileStore(desc string, t *testing.T, f func(store *fileStore)) {
|
||||
Convey(desc, t, func() {
|
||||
tempDir, _ := ioutil.TempDir("", "store")
|
||||
|
||||
store := NewFileStore(tempDir)
|
||||
|
||||
f(store)
|
||||
|
||||
Reset(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(dst, src string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
cerr := out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cerr
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package stores
|
||||
|
||||
import (
|
||||
"github.com/torkelo/grafana-pro/pkg/models"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
GetById(id string) (*models.Dashboard, error)
|
||||
Save(dash *models.Dashboard) error
|
||||
Query(query string) ([]*models.SearchResult, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
func New() Store {
|
||||
return NewFileStore("data")
|
||||
}
|
||||
Reference in New Issue
Block a user