diff --git a/.github/workflows/ginkgo.yml b/.github/workflows/ginkgo.yml
new file mode 100644
index 0000000..273c337
--- /dev/null
+++ b/.github/workflows/ginkgo.yml
@@ -0,0 +1,29 @@
+name: test
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ -
+ id: vars
+ run: |
+ echo ::set-output name=go_version::$(cat go.mod | head -3 | tail -1 | cut -d ' ' -f 2)
+ echo "Using Go version ${{ steps.vars.outputs.go_version }}"
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: ${{ steps.vars.outputs.go_version }}
+ - run: go mod tidy && git diff --exit-code go.mod go.sum
+ - name: Install ginkgo
+ run: |
+ go get github.com/onsi/ginkgo/v2/ginkgo
+ go get github.com/onsi/gomega/...
+ - name: Print out Ginkgo version
+ run: ginkgo version
+ - run: ginkgo -r --randomize-all --randomize-suites --race --trace
\ No newline at end of file
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 1236df1..602fe0c 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -1,6 +1,6 @@
name: golangci-lint
on:
- # push:
+ push:
pull_request:
permissions:
contents: read
diff --git a/.gitignore b/.gitignore
index a2be67e..4b3710b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,6 @@
# TinShop specific
config.yaml
titles.US.en.json
-/dist
\ No newline at end of file
+/dist
+stats.db
+coverprofile.out
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5585a6d..8984508 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"asciicheck",
+ "bbolt",
"bodyclose",
"dblk",
"deadcode",
@@ -34,6 +35,7 @@
"ineffassign",
"Infof",
"interfacer",
+ "itob",
"logrus",
"logutils",
"mitchellh",
diff --git a/README.md b/README.md
index c2c44fb..eac27ee 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,15 @@
-
-
-
-
-
- Your own personal shop right into tinfoil!
-
-
-[](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml)
+
+

+Your own personal shop right into tinfoil!
+
+[](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml)
+[](https://github.com/DblK/tinshop/actions/workflows/ginkgo.yml)
[](https://github.com/DblK/tinshop)
[](https://godoc.org/github.com/DblK/tinshop)
[](https://goreportcard.com/report/github.com/DblK/tinshop)
[](https://GitHub.com/DblK/tinshop/releases/)
[](https://www.gnu.org/licenses/agpl-3.0)
+
# Disclaimer
@@ -48,7 +46,9 @@ Here is the list of all main features so far:
- [X] You can specify custom titledb to be merged with official one
- [X] Auto-watch for mounted directories
- [X] Add filters path for shop
-- [X] Simple ticket check in NSP/NSZ
+- [X] Simple ticket check in NSP/NSZ (based on titledb file)
+- [X] Collect basic statistics
+- [X] An API to query information about your shop
## Filtering
@@ -79,7 +79,8 @@ If you change an interface (or add a new one), do not forget to execute `./updat
## What to launch tests?
-You can run `ginkgo -r` for one shot or `ginkgo watch -r` during development.
+You can run `ginkgo -r` for one shot or `ginkgo watch -r` during development.
+Note: you can add `-cover` to have an idea of the code coverage.
# Roadmap
You can see the [roadmap here](https://github.com/DblK/tinshop/projects/1).
diff --git a/api/api.go b/api/api.go
new file mode 100644
index 0000000..0b77164
--- /dev/null
+++ b/api/api.go
@@ -0,0 +1,30 @@
+package api
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+
+ "github.com/DblK/tinshop/repository"
+)
+
+type endpoint struct {
+}
+
+// New returns a new api
+func New() repository.API {
+ return &endpoint{}
+}
+
+func (e *endpoint) Stats(w http.ResponseWriter, stats repository.StatsSummary) {
+ jsonResponse, jsonError := json.Marshal(stats)
+
+ if jsonError != nil {
+ log.Println("[API] Unable to encode JSON")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(jsonResponse)
+}
diff --git a/api/api_suite_test.go b/api/api_suite_test.go
new file mode 100644
index 0000000..7c19aaa
--- /dev/null
+++ b/api/api_suite_test.go
@@ -0,0 +1,13 @@
+package api_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestApi(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Api Suite")
+}
diff --git a/api/api_test.go b/api/api_test.go
new file mode 100644
index 0000000..a782d4e
--- /dev/null
+++ b/api/api_test.go
@@ -0,0 +1,42 @@
+package api_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/DblK/tinshop/api"
+ "github.com/DblK/tinshop/repository"
+)
+
+var _ = Describe("Api", func() {
+ var (
+ myAPI repository.API
+ writer *httptest.ResponseRecorder
+ )
+ BeforeEach(func() {
+ myAPI = api.New()
+ })
+ Describe("Stats", func() {
+ It("Test with empty stats", func() {
+ emptyStats := &repository.StatsSummary{}
+ writer = httptest.NewRecorder()
+
+ myAPI.Stats(writer, *emptyStats)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ Expect(writer.Body.String()).To(Equal("{}"))
+ })
+ It("Test with some stats", func() {
+ emptyStats := &repository.StatsSummary{
+ Visit: 42,
+ }
+ writer = httptest.NewRecorder()
+
+ myAPI.Stats(writer, *emptyStats)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ Expect(writer.Body.String()).To(Equal("{\"visit\":42}"))
+ })
+ })
+})
diff --git a/config/config_test.go b/config/config_test.go
index 60de853..e373077 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -134,7 +134,11 @@ var _ = Describe("Config", func() {
})
})
Context("Security for Blacklist/Whitelist tests", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
Describe("Blacklist tests", func() { //nolint:dupl
It("With empty blacklist", func() {
@@ -206,7 +210,12 @@ var _ = Describe("Config", func() {
})
})
Context("Security for theme", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
Describe("IsBannedTheme", func() {
It("should not be banned if empty config", func() {
Expect(myConfig.IsBannedTheme("myTheme")).To(BeFalse())
@@ -226,7 +235,12 @@ var _ = Describe("Config", func() {
})
})
Describe("Protocol", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.Protocol()).To(BeEmpty())
})
@@ -236,7 +250,12 @@ var _ = Describe("Config", func() {
})
})
Describe("Host", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.Host()).To(BeEmpty())
})
@@ -246,7 +265,12 @@ var _ = Describe("Config", func() {
})
})
Describe("Port", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.Port()).To(Equal(0))
})
@@ -256,7 +280,12 @@ var _ = Describe("Config", func() {
})
})
Describe("ShopTitle", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.ShopTitle()).To(BeEmpty())
})
@@ -266,7 +295,12 @@ var _ = Describe("Config", func() {
})
})
Describe("DebugNfs", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.DebugNfs()).To(BeFalse())
})
@@ -276,7 +310,12 @@ var _ = Describe("Config", func() {
})
})
Describe("VerifyNSP", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.VerifyNSP()).To(BeFalse())
})
@@ -286,7 +325,12 @@ var _ = Describe("Config", func() {
})
})
Describe("DebugNoSecurity", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.DebugNoSecurity()).To(BeFalse())
})
@@ -296,7 +340,12 @@ var _ = Describe("Config", func() {
})
})
Describe("DebugTicket", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.DebugTicket()).To(BeFalse())
})
@@ -306,7 +355,12 @@ var _ = Describe("Config", func() {
})
})
Describe("BannedTheme", func() {
- var myConfig = config.File{}
+ var myConfig config.File
+
+ BeforeEach(func() {
+ myConfig = config.File{}
+ })
+
It("Test with empty object", func() {
Expect(myConfig.BannedTheme()).To(HaveLen(0))
})
diff --git a/go.mod b/go.mod
index e910779..62d16c7 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/onsi/gomega v1.17.0
github.com/spf13/viper v1.10.1
github.com/vmware/go-nfs-client v0.0.0-20190605212624-d43b92724c1b
+ go.etcd.io/bbolt v1.3.6
gopkg.in/fsnotify.v1 v1.4.7
)
diff --git a/go.sum b/go.sum
index 7443680..cadb6d4 100644
--- a/go.sum
+++ b/go.sum
@@ -356,6 +356,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
+go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
@@ -529,6 +531,7 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/main.go b/main.go
index bda2757..3b1821d 100644
--- a/main.go
+++ b/main.go
@@ -11,10 +11,12 @@ import (
"os/signal"
"time"
+ "github.com/DblK/tinshop/api"
"github.com/DblK/tinshop/config"
collection "github.com/DblK/tinshop/gamescollection"
"github.com/DblK/tinshop/repository"
"github.com/DblK/tinshop/sources"
+ "github.com/DblK/tinshop/stats"
"github.com/DblK/tinshop/utils"
"github.com/gorilla/mux"
)
@@ -73,9 +75,12 @@ func createShop() TinShop {
r.HandleFunc("/games/{game}", shop.GamesHandler)
r.HandleFunc("/{filter}", shop.FilteringHandler)
r.HandleFunc("/{filter}/", shop.FilteringHandler)
+ r.HandleFunc("/api/{endpoint}", shop.APIHandler)
r.NotFoundHandler = http.HandlerFunc(notFound)
r.MethodNotAllowedHandler = http.HandlerFunc(notAllowed)
+ r.Use(shop.StatsMiddleware)
r.Use(shop.TinfoilMiddleware)
+ r.Use(shop.CORSMiddleware)
http.Handle("/", r)
srv := &http.Server{
@@ -103,7 +108,8 @@ func initShop() repository.Shop {
myShop.Config = config.New()
myShop.Collection = collection.New(myShop.Config)
myShop.Sources = sources.New(myShop.Collection)
- // ResetTinshop(myShop)
+ myShop.Stats = stats.New()
+ myShop.API = api.New()
// Load collection
myShop.Collection.Load()
@@ -114,6 +120,9 @@ func initShop() repository.Shop {
myShop.Config.AddBeforeHook(myShop.Sources.BeforeConfigUpdate)
myShop.Config.LoadConfig()
+ // Loading stats
+ myShop.Stats.Load()
+
return myShop
}
@@ -177,3 +186,43 @@ func (s *TinShop) FilteringHandler(w http.ResponseWriter, r *http.Request) {
serveCollection(w, s.Shop.Collection.Filter(vars["filter"]))
}
+
+// APIHandler handles api calls
+func (s *TinShop) APIHandler(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ if vars["endpoint"] == "stats" {
+ summary, err := s.Shop.Stats.Summary()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ log.Println(err)
+ return
+ }
+ s.Shop.API.Stats(w, summary)
+ return
+ }
+ // Everything not existing
+ w.WriteHeader(http.StatusBadRequest)
+}
+
+// StatsMiddleware is a middleware to collect statistics
+func (s *TinShop) StatsMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.RequestURI == "/" || utils.IsValidFilter(cleanPath(r.RequestURI)) {
+ console := &repository.Switch{
+ IP: utils.GetIPFromRequest(r),
+ UID: r.Header.Get("Uid"),
+ Theme: r.Header.Get("Theme"),
+ Version: r.Header.Get("Version"),
+ Language: r.Header.Get("Language"),
+ }
+ _ = s.Shop.Stats.ListVisit(console)
+ } else if r.RequestURI[0:7] == "/games/" {
+ vars := mux.Vars(r)
+ if s.Shop.Sources.HasGame(vars["game"]) {
+ _ = s.Shop.Stats.DownloadAsked(utils.GetIPFromRequest(r), vars["game"])
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/main_test.go b/main_test.go
index de9611e..bc12b4c 100644
--- a/main_test.go
+++ b/main_test.go
@@ -24,6 +24,7 @@ var _ = Describe("Main", func() {
myMockCollection *mock_repository.MockCollection
myMockSources *mock_repository.MockSources
myMockConfig *mock_repository.MockConfig
+ myMockStats *mock_repository.MockStats
ctrl *gomock.Controller
myShop *main.TinShop
)
@@ -33,6 +34,7 @@ var _ = Describe("Main", func() {
myMockCollection = mock_repository.NewMockCollection(ctrl)
myMockSources = mock_repository.NewMockSources(ctrl)
myMockConfig = mock_repository.NewMockConfig(ctrl)
+ myMockStats = mock_repository.NewMockStats(ctrl)
myShop = &main.TinShop{}
})
@@ -41,6 +43,7 @@ var _ = Describe("Main", func() {
myShop.Shop.Config = myMockConfig
myShop.Shop.Collection = myMockCollection
myShop.Shop.Sources = myMockSources
+ myShop.Shop.Stats = myMockStats
})
Context("With empty collection", func() {
@@ -276,4 +279,199 @@ var _ = Describe("Main", func() {
})
})
})
+ Describe("TinfoilMiddleware", func() {
+ var (
+ req *http.Request
+ handler http.Handler
+ writer *httptest.ResponseRecorder
+ myMockCollection *mock_repository.MockCollection
+ myMockSources *mock_repository.MockSources
+ myMockConfig *mock_repository.MockConfig
+ myMockStats *mock_repository.MockStats
+ ctrl *gomock.Controller
+ myShop *main.TinShop
+ )
+
+ BeforeEach(func() {
+ ctrl = gomock.NewController(GinkgoT())
+ myMockCollection = mock_repository.NewMockCollection(ctrl)
+ myMockSources = mock_repository.NewMockSources(ctrl)
+ myMockConfig = mock_repository.NewMockConfig(ctrl)
+ myMockStats = mock_repository.NewMockStats(ctrl)
+ myShop = &main.TinShop{}
+ })
+
+ JustBeforeEach(func() {
+ myShop.Shop = repository.Shop{}
+ myShop.Shop.Config = myMockConfig
+ myShop.Shop.Collection = myMockCollection
+ myShop.Shop.Sources = myMockSources
+ myShop.Shop.Stats = myMockStats
+ })
+ Context("Not handled endpoint", func() {
+ BeforeEach(func() {
+ r := mux.NewRouter()
+ r.Use(myShop.StatsMiddleware)
+ r.HandleFunc("/api/{endpoint}", myShop.HomeHandler) // Testing purpose
+ handler = r
+ })
+
+ It("Test with the api endpoint", func() {
+ req = httptest.NewRequest(http.MethodGet, "/api/stats", nil)
+ writer = httptest.NewRecorder()
+
+ emptyCollection := &repository.GameType{}
+
+ myMockCollection.EXPECT().
+ Games().
+ Return(*emptyCollection).
+ AnyTimes()
+
+ myMockSources.EXPECT().
+ HasGame(gomock.Any()).
+ Return(true).
+ Times(0)
+ myMockStats.EXPECT().
+ ListVisit(gomock.Any()).
+ Return(nil).
+ Times(0)
+ myMockStats.EXPECT().
+ DownloadAsked(gomock.Any(), gomock.Any()).
+ Return(nil).
+ Times(0)
+
+ handler.ServeHTTP(writer, req)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ })
+ })
+ Context("Games endpoint", func() {
+ BeforeEach(func() {
+ r := mux.NewRouter()
+ r.Use(myShop.StatsMiddleware)
+ r.HandleFunc("/games/{game}", myShop.HomeHandler) // Testing purpose
+ handler = r
+ })
+
+ It("Test with a not found game", func() {
+ req = httptest.NewRequest(http.MethodGet, "/games/notFound", nil)
+ writer = httptest.NewRecorder()
+
+ emptyCollection := &repository.GameType{}
+
+ myMockCollection.EXPECT().
+ Games().
+ Return(*emptyCollection).
+ AnyTimes()
+
+ myMockSources.EXPECT().
+ HasGame("notFound").
+ Return(false).
+ Times(1)
+ myMockStats.EXPECT().
+ ListVisit(gomock.Any()).
+ Return(nil).
+ Times(0)
+ myMockStats.EXPECT().
+ DownloadAsked(gomock.Any(), gomock.Any()).
+ Return(nil).
+ Times(0)
+
+ handler.ServeHTTP(writer, req)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ })
+ It("Test with a found game", func() {
+ req = httptest.NewRequest(http.MethodGet, "/games/existingGame", nil)
+ req.RemoteAddr = "10.0.0.10"
+ writer = httptest.NewRecorder()
+
+ emptyCollection := &repository.GameType{}
+
+ myMockCollection.EXPECT().
+ Games().
+ Return(*emptyCollection).
+ AnyTimes()
+
+ myMockSources.EXPECT().
+ HasGame("existingGame").
+ Return(true).
+ Times(1)
+ myMockStats.EXPECT().
+ ListVisit(gomock.Any()).
+ Return(nil).
+ Times(0)
+ myMockStats.EXPECT().
+ DownloadAsked("10.0.0.10", "existingGame").
+ Return(nil).
+ Times(1)
+
+ handler.ServeHTTP(writer, req)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ })
+ })
+ Context("Listing endpoint", func() {
+ BeforeEach(func() {
+ r := mux.NewRouter()
+ r.Use(myShop.StatsMiddleware)
+ r.HandleFunc("/", myShop.HomeHandler)
+ r.HandleFunc("/{filter}", myShop.HomeHandler) // Testing purpose
+ r.HandleFunc("/{filter}/", myShop.HomeHandler) // Testing purpose
+ handler = r
+ })
+
+ It("Test with root endpoint", func() {
+ req = httptest.NewRequest(http.MethodGet, "/", nil)
+ writer = httptest.NewRecorder()
+
+ emptyCollection := &repository.GameType{}
+
+ myMockCollection.EXPECT().
+ Games().
+ Return(*emptyCollection).
+ AnyTimes()
+
+ myMockSources.EXPECT().
+ HasGame(gomock.Any()).
+ Return(false).
+ Times(0)
+ myMockStats.EXPECT().
+ ListVisit(gomock.Any()).
+ Return(nil).
+ Times(1)
+ myMockStats.EXPECT().
+ DownloadAsked(gomock.Any(), gomock.Any()).
+ Return(nil).
+ Times(0)
+
+ handler.ServeHTTP(writer, req)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ })
+ It("Test with a filter endpoint", func() {
+ req = httptest.NewRequest(http.MethodGet, "/FR", nil)
+ writer = httptest.NewRecorder()
+
+ emptyCollection := &repository.GameType{}
+
+ myMockCollection.EXPECT().
+ Games().
+ Return(*emptyCollection).
+ AnyTimes()
+
+ myMockSources.EXPECT().
+ HasGame(gomock.Any()).
+ Return(true).
+ Times(0)
+ myMockStats.EXPECT().
+ ListVisit(gomock.Any()).
+ Return(nil).
+ Times(1)
+ myMockStats.EXPECT().
+ DownloadAsked(gomock.Any(), gomock.Any()).
+ Return(nil).
+ Times(0)
+
+ handler.ServeHTTP(writer, req)
+ Expect(writer.Code).To(Equal(http.StatusOK))
+ })
+ })
+ })
})
diff --git a/mock_repository/mock_api.go b/mock_repository/mock_api.go
new file mode 100644
index 0000000..1c90d66
--- /dev/null
+++ b/mock_repository/mock_api.go
@@ -0,0 +1,48 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/DblK/tinshop/repository (interfaces: API)
+
+// Package mock_repository is a generated GoMock package.
+package mock_repository
+
+import (
+ http "net/http"
+ reflect "reflect"
+
+ repository "github.com/DblK/tinshop/repository"
+ gomock "github.com/golang/mock/gomock"
+)
+
+// MockAPI is a mock of API interface.
+type MockAPI struct {
+ ctrl *gomock.Controller
+ recorder *MockAPIMockRecorder
+}
+
+// MockAPIMockRecorder is the mock recorder for MockAPI.
+type MockAPIMockRecorder struct {
+ mock *MockAPI
+}
+
+// NewMockAPI creates a new mock instance.
+func NewMockAPI(ctrl *gomock.Controller) *MockAPI {
+ mock := &MockAPI{ctrl: ctrl}
+ mock.recorder = &MockAPIMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
+ return m.recorder
+}
+
+// Stats mocks base method.
+func (m *MockAPI) Stats(arg0 http.ResponseWriter, arg1 repository.StatsSummary) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "Stats", arg0, arg1)
+}
+
+// Stats indicates an expected call of Stats.
+func (mr *MockAPIMockRecorder) Stats(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stats", reflect.TypeOf((*MockAPI)(nil).Stats), arg0, arg1)
+}
diff --git a/mock_repository/mock_sources.go b/mock_repository/mock_sources.go
index a14a070..31e884e 100644
--- a/mock_repository/mock_sources.go
+++ b/mock_repository/mock_sources.go
@@ -73,6 +73,20 @@ func (mr *MockSourcesMockRecorder) GetFiles() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFiles", reflect.TypeOf((*MockSources)(nil).GetFiles))
}
+// HasGame mocks base method.
+func (m *MockSources) HasGame(arg0 string) bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "HasGame", arg0)
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// HasGame indicates an expected call of HasGame.
+func (mr *MockSourcesMockRecorder) HasGame(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGame", reflect.TypeOf((*MockSources)(nil).HasGame), arg0)
+}
+
// OnConfigUpdate mocks base method.
func (m *MockSources) OnConfigUpdate(arg0 repository.Config) {
m.ctrl.T.Helper()
diff --git a/mock_repository/mock_stats.go b/mock_repository/mock_stats.go
new file mode 100644
index 0000000..b66b587
--- /dev/null
+++ b/mock_repository/mock_stats.go
@@ -0,0 +1,104 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/DblK/tinshop/repository (interfaces: Stats)
+
+// Package mock_repository is a generated GoMock package.
+package mock_repository
+
+import (
+ reflect "reflect"
+
+ repository "github.com/DblK/tinshop/repository"
+ gomock "github.com/golang/mock/gomock"
+)
+
+// MockStats is a mock of Stats interface.
+type MockStats struct {
+ ctrl *gomock.Controller
+ recorder *MockStatsMockRecorder
+}
+
+// MockStatsMockRecorder is the mock recorder for MockStats.
+type MockStatsMockRecorder struct {
+ mock *MockStats
+}
+
+// NewMockStats creates a new mock instance.
+func NewMockStats(ctrl *gomock.Controller) *MockStats {
+ mock := &MockStats{ctrl: ctrl}
+ mock.recorder = &MockStatsMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockStats) EXPECT() *MockStatsMockRecorder {
+ return m.recorder
+}
+
+// Close mocks base method.
+func (m *MockStats) Close() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Close")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockStatsMockRecorder) Close() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStats)(nil).Close))
+}
+
+// DownloadAsked mocks base method.
+func (m *MockStats) DownloadAsked(arg0, arg1 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DownloadAsked", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DownloadAsked indicates an expected call of DownloadAsked.
+func (mr *MockStatsMockRecorder) DownloadAsked(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAsked", reflect.TypeOf((*MockStats)(nil).DownloadAsked), arg0, arg1)
+}
+
+// ListVisit mocks base method.
+func (m *MockStats) ListVisit(arg0 *repository.Switch) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ListVisit", arg0)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// ListVisit indicates an expected call of ListVisit.
+func (mr *MockStatsMockRecorder) ListVisit(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVisit", reflect.TypeOf((*MockStats)(nil).ListVisit), arg0)
+}
+
+// Load mocks base method.
+func (m *MockStats) Load() {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "Load")
+}
+
+// Load indicates an expected call of Load.
+func (mr *MockStatsMockRecorder) Load() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockStats)(nil).Load))
+}
+
+// Summary mocks base method.
+func (m *MockStats) Summary() (repository.StatsSummary, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Summary")
+ ret0, _ := ret[0].(repository.StatsSummary)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Summary indicates an expected call of Summary.
+func (mr *MockStatsMockRecorder) Summary() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Summary", reflect.TypeOf((*MockStats)(nil).Summary))
+}
diff --git a/repository/interfaces.go b/repository/interfaces.go
index 293e16c..dc7040a 100644
--- a/repository/interfaces.go
+++ b/repository/interfaces.go
@@ -166,6 +166,7 @@ type Sources interface {
OnConfigUpdate(Config)
BeforeConfigUpdate(Config)
GetFiles() []FileDesc
+ HasGame(string) bool
DownloadGame(string, http.ResponseWriter, *http.Request)
}
@@ -185,9 +186,43 @@ type Collection interface {
ResetGamesCollection()
}
+// Switch holds all information about the switch
+type Switch struct {
+ IP string
+ UID string
+ Theme string
+ Version string
+ Language string
+}
+
+// StatsSummary holds all information about tinshop
+type StatsSummary struct {
+ Visit uint64 `json:"visit,omitempty"`
+ UniqueSwitch uint64 `json:"uniqueSwitch,omitempty"`
+ VisitPerSwitch map[string]interface{} `json:"visitPerSwitch,omitempty"`
+ DownloadAsked uint64 `json:"downloadAsked,omitempty"`
+ DownloadDetails map[string]interface{} `json:"downloadDetails,omitempty"`
+}
+
+// Stats holds all information about statistics
+type Stats interface {
+ Load()
+ Close() error
+ ListVisit(*Switch) error
+ DownloadAsked(string, string) error
+ Summary() (StatsSummary, error)
+}
+
// Shop holds all tinshop information
type Shop struct {
Collection Collection
Sources Sources
Config Config
+ Stats Stats
+ API API
+}
+
+// API holds all function for api
+type API interface {
+ Stats(http.ResponseWriter, StatsSummary)
}
diff --git a/security.go b/security.go
index e75c0d3..0c32cf9 100644
--- a/security.go
+++ b/security.go
@@ -9,6 +9,22 @@ import (
"github.com/DblK/tinshop/utils"
)
+// CORSMiddleware is a middleware to ensure right CORS headers
+func (s *TinShop) CORSMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.RequestURI, "/api/") {
+ w.Header().Set("Access-Control-Allow-Origin", s.Shop.Config.RootShop())
+ w.Header().Set("Vary", "Origin")
+ } else {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ }
+ if r.Method == http.MethodOptions {
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
// TinfoilMiddleware is a middleware to ensure not forged query and real tinfoil client
func (s *TinShop) TinfoilMiddleware(next http.Handler) http.Handler {
shopTemplate, _ := template.ParseFS(assetData, "assets/shop.tmpl")
diff --git a/security_test.go b/security_test.go
index f65c4d2..ab178a1 100644
--- a/security_test.go
+++ b/security_test.go
@@ -23,6 +23,7 @@ var _ = Describe("Security", func() {
myMockCollection *mock_repository.MockCollection
myMockSources *mock_repository.MockSources
myMockConfig *mock_repository.MockConfig
+ myMockStats *mock_repository.MockStats
ctrl *gomock.Controller
myShop *main.TinShop
)
@@ -32,6 +33,7 @@ var _ = Describe("Security", func() {
myMockCollection = mock_repository.NewMockCollection(ctrl)
myMockSources = mock_repository.NewMockSources(ctrl)
myMockConfig = mock_repository.NewMockConfig(ctrl)
+ myMockStats = mock_repository.NewMockStats(ctrl)
myShop = &main.TinShop{}
})
@@ -40,6 +42,7 @@ var _ = Describe("Security", func() {
myShop.Shop.Config = myMockConfig
myShop.Shop.Collection = myMockCollection
myShop.Shop.Sources = myMockSources
+ myShop.Shop.Stats = myMockStats
})
Context("No security", func() {
diff --git a/sources/sources.go b/sources/sources.go
index 558a6e2..613af09 100644
--- a/sources/sources.go
+++ b/sources/sources.go
@@ -72,6 +72,13 @@ func (s *allSources) GetFiles() []repository.FileDesc {
return mergedGameFiles
}
+func (s *allSources) HasGame(gameID string) bool {
+ idx := utils.Search(len(s.GetFiles()), func(index int) bool {
+ return s.GetFiles()[index].GameID == gameID
+ })
+ return idx != -1
+}
+
// DownloadGame method provide the file based on the source storage
func (s *allSources) DownloadGame(gameID string, w http.ResponseWriter, r *http.Request) {
idx := utils.Search(len(s.GetFiles()), func(index int) bool {
diff --git a/stats/stats.go b/stats/stats.go
new file mode 100644
index 0000000..627c679
--- /dev/null
+++ b/stats/stats.go
@@ -0,0 +1,151 @@
+package stats
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/DblK/tinshop/repository"
+ "github.com/DblK/tinshop/utils"
+ bolt "go.etcd.io/bbolt"
+)
+
+type stat struct {
+ db *bolt.DB
+}
+
+// New create a new stats object
+func New() repository.Stats {
+ return &stat{}
+}
+
+func (s *stat) initDB() {
+ _ = s.db.Update(func(tx *bolt.Tx) error {
+ _, err := tx.CreateBucketIfNotExists([]byte("global"))
+ if err != nil {
+ return fmt.Errorf("create bucket: %s", err)
+ }
+ return nil
+ })
+}
+
+func (s *stat) Load() {
+ db, err := bolt.Open("stats.db", 0600, nil)
+ if err != nil {
+ fmt.Println(err)
+ }
+ s.db = db
+
+ s.initDB()
+}
+
+func (s *stat) Close() error {
+ return s.db.Close()
+}
+
+// Summary return the summary of all stats
+func (s *stat) Summary() (repository.StatsSummary, error) {
+ var visit uint64
+ var uniqueSwitch int
+ var consoles map[string]interface{}
+ var download uint64
+ var downloadDetails map[string]interface{}
+
+ err := s.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("global"))
+ visit = utils.ByteToUint64(b.Get([]byte("visit")))
+
+ var errConsoles error
+ consoles, errConsoles = utils.ByteToMap(b.Get([]byte("switch")))
+ if errConsoles != nil {
+ return errConsoles
+ }
+ uniqueSwitch = len(consoles)
+
+ download = utils.ByteToUint64(b.Get([]byte("download")))
+
+ var errDownloadDetails error
+ downloadDetails, errDownloadDetails = utils.ByteToMap(b.Get([]byte("downloadDetails")))
+ if errDownloadDetails != nil {
+ return errDownloadDetails
+ }
+
+ return nil
+ })
+ if err != nil {
+ return repository.StatsSummary{}, err
+ }
+
+ return repository.StatsSummary{
+ Visit: visit,
+ UniqueSwitch: uint64(uniqueSwitch),
+ VisitPerSwitch: consoles,
+ DownloadAsked: download,
+ DownloadDetails: downloadDetails,
+ }, nil
+}
+
+// DownloadAsked compute stats when we download a game
+func (s *stat) DownloadAsked(IP string, gameID string) error {
+ fmt.Println("[Stats] DownloadAsked", IP, gameID)
+ // TODO: Add in global IP download stats
+
+ return s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("global"))
+
+ // Handle download
+ download := utils.ByteToUint64(b.Get([]byte("download")))
+ errDownload := b.Put([]byte("download"), utils.Itob(download+1))
+ if errDownload != nil {
+ return errDownload
+ }
+
+ // Handle download per IP
+ allDownloads, err := utils.ByteToMap(b.Get([]byte("downloadDetails")))
+ if err != nil {
+ return err
+ }
+ if allDownloads[IP] == nil {
+ allDownloads[IP] = make([]interface{}, 0)
+ }
+ allDownloads[IP] = append(allDownloads[IP].([]interface{}), gameID)
+ buf, err := json.Marshal(allDownloads)
+ if err != nil {
+ return err
+ }
+ return b.Put([]byte("downloadDetails"), buf)
+ })
+}
+
+// ListVisit count every visit to the listing page (either root or filter)
+func (s *stat) ListVisit(console *repository.Switch) error {
+ return s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("global"))
+
+ // Handle visit
+ visit := utils.ByteToUint64(b.Get([]byte("visit")))
+ errVisit := b.Put([]byte("visit"), utils.Itob(visit+1))
+ if errVisit != nil {
+ return errVisit
+ }
+
+ // Handle visit per switch
+ consoles, err := utils.ByteToMap(b.Get([]byte("switch")))
+ if err != nil {
+ return err
+ }
+ currentID := console.UID
+ if currentID == "" {
+ currentID = "Unknown-" + console.IP
+ }
+
+ if consoles[currentID] == nil {
+ consoles[currentID] = float64(0)
+ }
+ consoles[currentID] = uint64(consoles[currentID].(float64)) + 1
+ buf, err := json.Marshal(consoles)
+ if err != nil {
+ return err
+ }
+ return b.Put([]byte("switch"), buf)
+ })
+}
diff --git a/update_mocks.sh b/update_mocks.sh
index 1c6655f..2bb3ec3 100755
--- a/update_mocks.sh
+++ b/update_mocks.sh
@@ -4,4 +4,6 @@ mkdir -p mock_repository
mockgen github.com/DblK/tinshop/repository Config > mock_repository/mock_config.go
mockgen github.com/DblK/tinshop/repository Source > mock_repository/mock_source.go
mockgen github.com/DblK/tinshop/repository Collection > mock_repository/mock_collection.go
-mockgen github.com/DblK/tinshop/repository Sources > mock_repository/mock_sources.go
\ No newline at end of file
+mockgen github.com/DblK/tinshop/repository Sources > mock_repository/mock_sources.go
+mockgen github.com/DblK/tinshop/repository Stats > mock_repository/mock_stats.go
+mockgen github.com/DblK/tinshop/repository API > mock_repository/mock_api.go
\ No newline at end of file
diff --git a/utils/bytes.go b/utils/bytes.go
new file mode 100644
index 0000000..cbc54ad
--- /dev/null
+++ b/utils/bytes.go
@@ -0,0 +1,35 @@
+// Package utils provides some cross used information
+package utils
+
+import (
+ "encoding/binary"
+ "encoding/json"
+)
+
+// ByteToMap returns a map from bytes
+func ByteToMap(bytes []byte) (map[string]interface{}, error) {
+ val := make(map[string]interface{})
+ if len(bytes) > 0 {
+ err := json.Unmarshal(bytes, &val)
+ if err != nil {
+ return make(map[string]interface{}), err
+ }
+ }
+ return val, nil
+}
+
+// ByteToUint64 return an uint64 from bytes
+func ByteToUint64(bytes []byte) uint64 {
+ num := uint64(0)
+ if len(bytes) > 0 {
+ num = binary.BigEndian.Uint64(bytes)
+ }
+ return num
+}
+
+// Itob returns an 8-byte big endian representation of v.
+func Itob(v uint64) []byte {
+ b := make([]byte, 8)
+ binary.BigEndian.PutUint64(b, v)
+ return b
+}
diff --git a/utils/bytes_test.go b/utils/bytes_test.go
new file mode 100644
index 0000000..421aa8f
--- /dev/null
+++ b/utils/bytes_test.go
@@ -0,0 +1,47 @@
+package utils_test
+
+import (
+ "encoding/json"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/DblK/tinshop/utils"
+)
+
+var _ = Describe("Bytes", func() {
+ Describe("Itob", func() {
+ It("Test with 0", func() {
+ Expect(utils.Itob(0)).To(Equal([]uint8{0, 0, 0, 0, 0, 0, 0, 0}))
+ })
+ It("Test with 42", func() {
+ Expect(utils.Itob(42)).To(Equal([]uint8{0, 0, 0, 0, 0, 0, 0, 42}))
+ })
+ })
+ Describe("ByteToUint64", func() {
+ It("Test with empty byte", func() {
+ Expect(utils.ByteToUint64([]byte{})).To(Equal(uint64(0)))
+ })
+ It("Test with 42 in byte", func() {
+ Expect(utils.ByteToUint64([]byte{0, 0, 0, 0, 0, 0, 0, 42})).To(Equal(uint64(42)))
+ })
+ })
+ Describe("ByteToMap", func() {
+ It("Test with empty byte", func() {
+ res, err := utils.ByteToMap([]byte{})
+ Expect(err).To(BeNil())
+ Expect(res).To(HaveLen(0))
+ })
+ It("Test with 42 in byte", func() {
+ type test struct {
+ Value int `json:"visit,omitempty"`
+ }
+ newTest := &test{Value: 42}
+ buf, _ := json.Marshal(newTest)
+ res, err := utils.ByteToMap(buf)
+ Expect(err).To(BeNil())
+ Expect(res).To(HaveLen(1))
+ Expect(res["visit"]).To(Equal(float64(42)))
+ })
+ })
+})
diff --git a/utils/utils.go b/utils/utils.go
index 9c12a43..29e941d 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -6,6 +6,7 @@
package utils
import (
+ "net/http"
"reflect"
"regexp"
"strings"
@@ -88,3 +89,12 @@ func Contains(list interface{}, elem interface{}) bool {
}
return false
}
+
+// GetIPFromRequest returns ip from the request
+func GetIPFromRequest(r *http.Request) string {
+ ip := strings.Split(r.RemoteAddr, ":")[0]
+ if r.Header.Get("X-Forwarded-For") != "" {
+ ip = r.Header.Get("X-Forwarded-For")
+ }
+ return ip
+}
diff --git a/utils/utils_test.go b/utils/utils_test.go
index 08ba1fb..ee8c3a9 100644
--- a/utils/utils_test.go
+++ b/utils/utils_test.go
@@ -1,6 +1,9 @@
package utils_test
import (
+ "net/http"
+ "net/http/httptest"
+
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -225,4 +228,36 @@ var _ = Describe("Utils", func() {
Expect(utils.IsValidFilter("superpath")).To(BeFalse())
})
})
+ Describe("GetIPFromRequest", func() {
+ It("Test with ip", func() {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.RemoteAddr = "10.0.0.10"
+ Expect(utils.GetIPFromRequest(req)).To(Equal("10.0.0.10"))
+ })
+ It("Test with ip and X-Forwarded-For", func() {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.RemoteAddr = "10.0.0.10"
+ req.Header.Set("X-Forwarded-For", "1.1.1.1")
+ Expect(utils.GetIPFromRequest(req)).To(Equal("1.1.1.1"))
+ })
+ })
+ Describe("Search", func() {
+ It("Test with not found value", func() {
+ myTab := make([]string, 0)
+ myTab = append(myTab, "test")
+ idxMyTab := utils.Search(len(myTab), func(index int) bool {
+ return myTab[index] == "dblk"
+ })
+ Expect(idxMyTab).To(Equal(-1))
+ })
+ It("Test with found value", func() {
+ myTab := make([]string, 0)
+ myTab = append(myTab, "dblk")
+ myTab = append(myTab, "test")
+ idxMyTab := utils.Search(len(myTab), func(index int) bool {
+ return myTab[index] == "test"
+ })
+ Expect(idxMyTab).To(Equal(1))
+ })
+ })
})