From a8c411aba553e720e28e2d360dbec9a2b7afb82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Boulanouar?= Date: Thu, 6 Jan 2022 14:26:34 +0100 Subject: [PATCH] feat: Collect statistics and serve over API (#13) * Add back linter on push * Add bbolt to project * Make it work with the new structure * Add new helper for ip * Add middleware for collecting stats * Add function to collect download asked * doc: update documentation * Add skeleton for API * Review structure a little bit * Add a proto for CORS custom middleware * Add visit statistic * Add unique switch stat * Add details per switch * Add ip for unkown switch * Trigger stats for download game only on existing game * Add stats about downloads * Begin to add test for StatsMiddleware * Add tests for StatsMiddleware * doc: Add more info * Add new github Action for testing * Add tests for API * Move bytes functions to utils * Add more tests * Fix ginkgo workflow * Another fix for ginkgo * Install dependencies in CI * Fix ginkgo workflow * Fix randomize tests * New badges * Another fix for GithubAction * doc: Cleaner readme * doc: More spaces --- .github/workflows/ginkgo.yml | 29 ++++ .github/workflows/golangci-lint.yml | 2 +- .gitignore | 4 +- .vscode/settings.json | 2 + README.md | 23 ++-- api/api.go | 30 +++++ api/api_suite_test.go | 13 ++ api/api_test.go | 42 ++++++ config/config_test.go | 76 +++++++++-- go.mod | 1 + go.sum | 3 + main.go | 51 ++++++- main_test.go | 198 ++++++++++++++++++++++++++++ mock_repository/mock_api.go | 48 +++++++ mock_repository/mock_sources.go | 14 ++ mock_repository/mock_stats.go | 104 +++++++++++++++ repository/interfaces.go | 35 +++++ security.go | 16 +++ security_test.go | 3 + sources/sources.go | 7 + stats/stats.go | 151 +++++++++++++++++++++ update_mocks.sh | 4 +- utils/bytes.go | 35 +++++ utils/bytes_test.go | 47 +++++++ utils/utils.go | 10 ++ utils/utils_test.go | 35 +++++ 26 files changed, 957 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ginkgo.yml create mode 100644 api/api.go create mode 100644 api/api_suite_test.go create mode 100644 api/api_test.go create mode 100644 mock_repository/mock_api.go create mode 100644 mock_repository/mock_stats.go create mode 100644 stats/stats.go create mode 100644 utils/bytes.go create mode 100644 utils/bytes_test.go 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 @@ -

-

- TinShop -

-

- Your own personal shop right into tinfoil! -

- -[![golangci-lint](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml/badge.svg?event=release)](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml) +
+TinShop

+Your own personal shop right into tinfoil!

+ +[![golangci-lint](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml/badge.svg?branch=master&event=release)](https://github.com/DblK/tinshop/actions/workflows/golangci-lint.yml) +[![test](https://github.com/DblK/tinshop/actions/workflows/ginkgo.yml/badge.svg?branch=master&event=release)](https://github.com/DblK/tinshop/actions/workflows/ginkgo.yml) [![GitHub go.mod Go version of a Go module](https://img.shields.io/github/go-mod/go-version/DblK/tinshop.svg)](https://github.com/DblK/tinshop) [![GoDoc reference example](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/DblK/tinshop) [![GoReportCard](https://goreportcard.com/badge/github.com/DblK/tinshop)](https://goreportcard.com/report/github.com/DblK/tinshop) [![GitHub release](https://img.shields.io/github/release/DblK/tinshop.svg)](https://GitHub.com/DblK/tinshop/releases/) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](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)) + }) + }) })