diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3231f73 --- /dev/null +++ b/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Port uint16 + DataService DataService +} + +type DataService struct { + IP string + Port uint16 + Username string + Password string + Database string +} + +func New() (*Config, error) { + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.SetConfigName("config") + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + setDefaults() + + var config Config + err := viper.Unmarshal(&config) + + return &config, err +} + +func setDefaults() { + viper.SetDefault("Port", "8080") + viper.SetDefault("DataService.IP", "127.0.0.1") + viper.SetDefault("DataService.Port", "27017") +} diff --git a/features/api/channel/channel.go b/features/api/channel/channel.go new file mode 100755 index 0000000..67e0f30 --- /dev/null +++ b/features/api/channel/channel.go @@ -0,0 +1,14 @@ +package channel + +type Service interface { + Channel(id int) (*Channel, error) + Channels() ([]*Channel, error) +} + +type Channel struct { + ID int `json:"id"` + Name string `json:"name"` + Subchannels []Channel `json:"subchannels,omitempty"` + TotalClients int `json:"totalClients"` + NeededSubscribePower int `json:"neededSubscribePower"` +} diff --git a/features/api/channel/handler.go b/features/api/channel/handler.go new file mode 100755 index 0000000..a0ec7cd --- /dev/null +++ b/features/api/channel/handler.go @@ -0,0 +1,40 @@ +package channel + +import ( + "net/http" + "strconv" + + "git.cliffbreak.de/Cliffbreak/tsviewer/response" + "github.com/go-chi/chi" +) + +func ChannelAPIHandler(s Service) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.Handler(w, r, response.HandlerFunc(func() (int, error) { + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return http.StatusBadRequest, err + } + + c, err := s.Channel(int(id)) + if err != nil { + return http.StatusNotFound, err + } + + return response.New(c, r).Send(w, http.StatusOK) + })) + }) +} + +func ChannelsAPIHandler(s Service) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.Handler(w, r, response.HandlerFunc(func() (int, error) { + cc, err := s.Channels() + if err != nil { + return http.StatusBadRequest, err + } + + return response.New(cc, r).Send(w, http.StatusOK) + })) + }) +} diff --git a/features/api/channel/routes.go b/features/api/channel/routes.go new file mode 100755 index 0000000..02532d8 --- /dev/null +++ b/features/api/channel/routes.go @@ -0,0 +1,14 @@ +package channel + +import ( + "github.com/go-chi/chi" +) + +func APIRoutes(s Service) *chi.Mux { + router := chi.NewRouter() + + router.Get("/{id}", ChannelAPIHandler(s)) + router.Get("/", ChannelsAPIHandler(s)) + + return router +} diff --git a/features/api/client/client.go b/features/api/client/client.go new file mode 100755 index 0000000..e6ef949 --- /dev/null +++ b/features/api/client/client.go @@ -0,0 +1,15 @@ +package client + +type Service interface { + Client(id int) (*Client, error) + Clients() ([]*Client, error) +} + +type Client struct { + DatabaseID int `json:"databaseId"` + ChannelID int `json:"channelId"` + Nickname string `json:"nickname"` + Type int `json:"type"` + Away bool `json:"away"` + AwayMessage string `json:"awayMessage"` +} diff --git a/features/api/client/handler.go b/features/api/client/handler.go new file mode 100755 index 0000000..7dbfbe1 --- /dev/null +++ b/features/api/client/handler.go @@ -0,0 +1,40 @@ +package client + +import ( + "net/http" + "strconv" + + "git.cliffbreak.de/Cliffbreak/tsviewer/response" + "github.com/go-chi/chi" +) + +func ClientAPIHandler(s Service) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.Handler(w, r, response.HandlerFunc(func() (int, error) { + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return http.StatusBadRequest, err + } + + c, err := s.Client(int(id)) + if err != nil { + return http.StatusNotFound, err + } + + return response.New(c, r).Send(w, http.StatusOK) + })) + }) +} + +func ClientsAPIHandler(s Service) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.Handler(w, r, response.HandlerFunc(func() (int, error) { + cc, err := s.Clients() + if err != nil { + return http.StatusBadRequest, err + } + + return response.New(cc, r).Send(w, http.StatusOK) + })) + }) +} diff --git a/features/api/client/routes.go b/features/api/client/routes.go new file mode 100755 index 0000000..9e20d55 --- /dev/null +++ b/features/api/client/routes.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/go-chi/chi" +) + +func APIRoutes(s Service) *chi.Mux { + router := chi.NewRouter() + + router.Get("/{id}", ClientAPIHandler(s)) + router.Get("/", ClientsAPIHandler(s)) + + return router +} diff --git a/features/api/server/handler.go b/features/api/server/handler.go new file mode 100755 index 0000000..916e3a6 --- /dev/null +++ b/features/api/server/handler.go @@ -0,0 +1,20 @@ +package server + +import ( + "net/http" + + "git.cliffbreak.de/Cliffbreak/tsviewer/response" +) + +func InfoAPIHandler(s Service) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response.Handler(w, r, response.HandlerFunc(func() (int, error) { + s, err := s.ServerInfo() + if err != nil { + return http.StatusNotFound, err + } + + return response.New(s, r).Send(w, http.StatusOK) + })) + }) +} diff --git a/features/api/server/routes.go b/features/api/server/routes.go new file mode 100755 index 0000000..22342f3 --- /dev/null +++ b/features/api/server/routes.go @@ -0,0 +1,13 @@ +package server + +import ( + "github.com/go-chi/chi" +) + +func APIRoutes(s Service) *chi.Mux { + router := chi.NewRouter() + + router.Get("/info", InfoAPIHandler(s)) + + return router +} diff --git a/features/api/server/server.go b/features/api/server/server.go new file mode 100755 index 0000000..0cbdb34 --- /dev/null +++ b/features/api/server/server.go @@ -0,0 +1,22 @@ +package server + +import "time" + +type Service interface { + ServerInfo() (*Server, error) +} + +type Server struct { + Name string `json:"name"` + Status string `json:"status"` + Version string `json:"version"` + WelcomeMessage string `json:"welcomeMessage"` + MaxClients int `json:"maxClients"` + ClientsOnline int `json:"clientsOnline"` + ReservedSlots int `json:"reservedSlots"` + Uptime time.Duration `json:"uptime"` + TotalPing float32 `json:"totalPing"` + MinAndroidVersion int `json:"minAndroidVersion"` + MinClientVersion int `json:"minClientVersion"` + MiniOSVersion int `json:"miniOSVersion"` +} diff --git a/features/web/index/handler.go b/features/web/index/handler.go new file mode 100755 index 0000000..ca77456 --- /dev/null +++ b/features/web/index/handler.go @@ -0,0 +1,32 @@ +package index + +import ( + "html/template" + "net/http" + + "git.cliffbreak.de/Cliffbreak/tsviewer/features/web/weberror" +) + +func IndexGUIHandler(s Service, t template.Template) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server, err := s.ServerInfo() + if err != nil { + weberror.NewPage(err, http.StatusNotFound).Send(w, t) + return + } + + channels, err := s.ChannelsRaw() + if err != nil { + weberror.NewPage(err, http.StatusNotFound).Send(w, t) + return + } + + clients, err := s.Clients() + if err != nil { + weberror.NewPage(err, http.StatusNotFound).Send(w, t) + return + } + + NewPage(*server, channels, clients).Send(w, t) + }) +} diff --git a/features/web/index/page.go b/features/web/index/page.go new file mode 100755 index 0000000..c706649 --- /dev/null +++ b/features/web/index/page.go @@ -0,0 +1,35 @@ +package index + +import ( + "html/template" + "io" + + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/channel" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/client" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/server" +) + +type Service interface { + ServerInfo() (*server.Server, error) + Clients() ([]*client.Client, error) + Channels() ([]*channel.Channel, error) + ChannelsRaw() ([]*channel.Channel, error) +} + +type IndexPage struct { + Server server.Server + Channels []*channel.Channel + Clients []*client.Client +} + +func NewPage(server server.Server, channels []*channel.Channel, clients []*client.Client) *IndexPage { + return &IndexPage{ + Server: server, + Channels: channels, + Clients: clients, + } +} + +func (page IndexPage) Send(w io.Writer, t template.Template) { + t.Lookup("index.html").Execute(w, page) +} diff --git a/features/web/index/routes.go b/features/web/index/routes.go new file mode 100755 index 0000000..d14aec2 --- /dev/null +++ b/features/web/index/routes.go @@ -0,0 +1,15 @@ +package index + +import ( + "html/template" + + "github.com/go-chi/chi" +) + +func GUIRoutes(s Service, t template.Template) *chi.Mux { + router := chi.NewRouter() + + router.Get("/", IndexGUIHandler(s, t)) + + return router +} diff --git a/features/web/weberror/page.go b/features/web/weberror/page.go new file mode 100755 index 0000000..5bfa55a --- /dev/null +++ b/features/web/weberror/page.go @@ -0,0 +1,22 @@ +package weberror + +import ( + "html/template" + "io" +) + +type ErrorPage struct { + Error error + StatusCode int +} + +func NewPage(err error, statusCode int) *ErrorPage { + return &ErrorPage{ + Error: err, + StatusCode: statusCode, + } +} + +func (page ErrorPage) Send(w io.Writer, t template.Template) { + t.Lookup("error.html").Execute(w, page) +} diff --git a/gui/template.go b/gui/template.go new file mode 100755 index 0000000..17f6cd5 --- /dev/null +++ b/gui/template.go @@ -0,0 +1,35 @@ +package gui + +import ( + "fmt" + "html/template" + "io/ioutil" + "strings" +) + +func LoadTemplates() (*template.Template, error) { + return loadTemplates("templates/") +} + +func loadTemplates(path string) (*template.Template, error) { + dir, err := ioutil.ReadDir(path) + if err != nil { + return nil, err + } + + var ff []string + + for _, file := range dir { + filename := file.Name() + if strings.HasSuffix(filename, ".html") { + ff = append(ff, fmt.Sprintf("%s%s", path, filename)) + } + } + + templates, err := template.ParseFiles(ff...) + if err != nil { + return nil, err + } + + return templates, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1480e3f --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + "time" + + "git.cliffbreak.de/Cliffbreak/tsviewer/config" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/channel" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/client" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/api/server" + "git.cliffbreak.de/Cliffbreak/tsviewer/features/web/index" + "git.cliffbreak.de/Cliffbreak/tsviewer/gui" + "git.cliffbreak.de/Cliffbreak/tsviewer/service" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" +) + +func Routes(s service.Service, t template.Template) *chi.Mux { + router := chi.NewRouter() + cors := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"Get"}, + }) + router.Use( + middleware.Logger, + middleware.Timeout(5*time.Second), + middleware.DefaultCompress, + middleware.RedirectSlashes, + middleware.Recoverer, + + cors.Handler, + ) + + router.Route("/v1", func(r chi.Router) { + r.Mount("/channels", channel.APIRoutes(s)) + r.Mount("/clients", client.APIRoutes(s)) + r.Mount("/server", server.APIRoutes(s)) + }) + + router.Route("/", func(r chi.Router) { + r.Mount("/", index.GUIRoutes(s, t)) + }) + + return router +} + +func main() { + log.Println("Loading configurations...") + config, err := config.New() + if err != nil { + log.Fatal(err) + } + log.Println("Configurations loaded!") + + log.Println("Starting query service...") + service, err := service.New(*config) + if err != nil { + log.Fatal(err) + } + defer service.TSClient.Close() + log.Println("Query service connected to", fmt.Sprintf("%s:%d!", config.ServerTS.IP, config.ServerTS.PortQuery)) + + log.Println("Loading templates...") + templates, err := gui.LoadTemplates() + if err != nil { + log.Fatal(err) + } + log.Println("All templates loaded!") + + router := Routes(*service, *templates) + router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { + http.StripPrefix("/static", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r) + }) + + log.Println("Starting the web server locally on port", config.ServerWeb.Port) + log.Fatal("Handler: ", http.ListenAndServe(fmt.Sprintf(":%d", config.ServerWeb.Port), router)) +} diff --git a/request/meta.go b/request/meta.go new file mode 100755 index 0000000..2cf8b75 --- /dev/null +++ b/request/meta.go @@ -0,0 +1,36 @@ +package meta + +import ( + "net/http" + "strconv" +) + +type Meta struct { + Pretty bool `json:"pretty"` + Envelope bool `json:"envelope"` +} + +func ParseFromRequest(r *http.Request) *Meta { + meta := Meta{ + Pretty: false, + Envelope: false, + } + + pretty, ok := r.URL.Query()["pretty"] + if ok && len(pretty) > 0 { + prettyBool, err := strconv.ParseBool(pretty[0]) + if err == nil { + meta.Pretty = prettyBool + } + } + + envelope, ok := r.URL.Query()["envelope"] + if ok && len(envelope) > 0 { + envelopeBool, err := strconv.ParseBool(envelope[0]) + if err == nil { + meta.Envelope = envelopeBool + } + } + + return &meta +} diff --git a/response/error.go b/response/error.go new file mode 100755 index 0000000..1d495bb --- /dev/null +++ b/response/error.go @@ -0,0 +1,19 @@ +package response + +import "time" + +// Error data type +type Error struct { + Status int `json:"status"` + Error string `json:"error"` + Timestamp string `json:"timestamp"` +} + +// NewError creates a new Response fill with an error +func NewError(status int, err error) *Error { + return &Error{ + Status: status, + Error: err.Error(), + Timestamp: time.Now().Format("2006-01-02T15:04:05Z"), //.Format("02 Jan 2006, 15:04:05 MST"), + } +} diff --git a/response/response.go b/response/response.go new file mode 100755 index 0000000..a0a37d8 --- /dev/null +++ b/response/response.go @@ -0,0 +1,87 @@ +package response + +import ( + "encoding/json" + "log" + "net/http" + + "git.cliffbreak.de/Cliffbreak/tsviewer/request/meta" +) + +type HandlerFunc func() (int, error) + +// Responder is a service for responses +type Responder interface { + Send(http.ResponseWriter, int) +} + +// Response data type +type Response struct { + Content interface{} + Pretty bool +} + +type Envelope struct { + Data interface{} `json:"data,omitempty"` +} + +// New creates a new Response +func New(content interface{}, r *http.Request) *Response { + var resp Response + + meta := meta.NewFromRequest(r) + + if meta.Envelope { + resp = Response{ + Content: Envelope{ + Data: content, + }, + } + } else { + resp = Response{ + Content: content, + } + } + + resp.Pretty = meta.Pretty + + return &resp +} + +// Send sends the response +func (resp Response) Send(w http.ResponseWriter, status int) (int, error) { + var ( + rawJSON []byte + err error + ) + + if resp.Pretty { + rawJSON, err = json.MarshalIndent(resp.Content, "", " ") + } else { + rawJSON, err = json.Marshal(resp.Content) + } + + if err != nil { + return http.StatusInternalServerError, err + } else if string(rawJSON) == "null" { + rawJSON = make([]byte, 0) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(rawJSON) + + return status, nil +} + +func Handler(w http.ResponseWriter, r *http.Request, hf HandlerFunc) { + status, err := hf() + if err == nil { + return + } + + log.Printf("HTTP %d: %q", status, err) + if status, err = New(NewError(status, err), r).Send(w, status); err != nil { + http.Error(w, http.StatusText(status), status) + } +} diff --git a/service/crud.go b/service/crud.go new file mode 100644 index 0000000..5723ab7 --- /dev/null +++ b/service/crud.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "reflect" + "time" + + "github.com/haveachin/sawtexapi/service/mongodb/filter" + "go.mongodb.org/mongo-driver/bson" +) + +func (s DataService) Create(v interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result, err := s.collection(reflect.TypeOf(v)).InsertOne(ctx, v) + if err != nil { + return err + } + + if reflect.ValueOf(v).Kind() == reflect.Ptr { + reflect.ValueOf(v).Elem().FieldByName("ID").Set(reflect.ValueOf(result.InsertedID)) + } + + return nil +} + +func (s DataService) Query(v interface{}, filter *filter.Filter, multiQuery bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if !multiQuery { + if err := s.collection(reflect.TypeOf(v)).FindOne(ctx, filter).Decode(v); err != nil { + return err + } + + return nil + } + + cursor, err := s.collection(reflect.TypeOf(v)).Find(ctx, filter) + if err != nil { + return err + } + + return cursor.All(ctx, v) +} + +func (s DataService) Update(v interface{}, filter *filter.Filter) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := s.collection(reflect.TypeOf(v)).UpdateOne(ctx, filter, bson.M{"$set": v}) + if err != nil { + return err + } + + return nil +} + +func (s DataService) Delete(v interface{}, filter *filter.Filter) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := s.collection(reflect.TypeOf(v)).DeleteOne(ctx, filter); err != nil { + return err + } + + return nil +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..ee198aa --- /dev/null +++ b/service/service.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "reflect" + "time" + + "git.cliffbreak.de/haveachin/scoreboard/config" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/readpref" +) + +type DataService struct { + client *mongo.Client + db *mongo.Database + collections map[reflect.Type]*mongo.Collection +} + +func New(ds config.DataService) (*DataService, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + connectionString := fmt.Sprintf("mongodb://%s:%s@%s:%d/?authSource=admin", url.QueryEscape(ds.Username), url.QueryEscape(ds.Password), ds.IP, ds.Port) + client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString)) + if err != nil { + return nil, err + } + + if err = client.Ping(ctx, readpref.Primary()); err != nil { + return nil, err + } + + return &DataService{ + client: client, + db: client.Database(ds.Database), + }, nil +} + +func (s DataService) collection(t reflect.Type) *mongo.Collection { + for t.Kind() != reflect.Struct { + t = t.Elem() + } + + collection, ok := s.collections[t] + if !ok { + panic(fmt.Sprintf("No collection registered for %T", t)) + } + + return collection +} + +func (s DataService) RegisterCollection(t reflect.Type, collection string) { + for t.Kind() != reflect.Struct { + t = t.Elem() + } + + s.collections[t] = s.db.Collection(collection) +} diff --git a/static/styles.css b/static/styles.css new file mode 100755 index 0000000..70a16db --- /dev/null +++ b/static/styles.css @@ -0,0 +1,22 @@ +*{ + padding: 0; + margin: 0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + +} +.content { + max-width: 500px; + margin: auto; +} + +h2{ + padding-top: 20px; +} + +small{ + margin-left: 10px; +} + +p.client{ + margin-left: 10px; +} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100755 index 0000000..5ff2593 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,11 @@ + + + + Error + + +

Oops, an error occurred!

+ Error {{.StatusCode}} + {{.Error}} + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..73c31b4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,33 @@ + + + + + TS3 Viewer + + + + + +
+

Online Members

+ +

{{.Server.Name}}

+ + {{$clients := .Clients}} + {{range .Channels}} +

{{.Name}}

+ {{$channelId := .ID}} + {{range $clients}} + {{if eq $channelId .ChannelID}} +

{{.Nickname}}

+ {{end}} + {{end}} + {{end}} +
+ + + \ No newline at end of file