diff --git a/cmd/api.go b/cmd/api.go index 7297270..ad702dc 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -25,6 +25,7 @@ var apiCmd = &cobra.Command{ Short: "Démarrer le serveur API", Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { + // TODO migrer à viper.Unmarshal(&models.Config) apiKey = viper.GetString("api.key") apiPort = viper.GetInt("api.port") @@ -93,60 +94,4 @@ var apiCmd = &cobra.Command{ func init() { rootCmd.AddCommand(apiCmd) - - // api.key - apiCmd.Flags().String( - "api-key", "bottin-agenda", - "API server key. Leave empty for no key auth. (config: 'api.key')") - viper.BindPFlag("api.key", apiCmd.Flags().Lookup("api-key")) - - // api.port - apiCmd.Flags().Int( - "api-port", 1313, - "API server port (config:'api.port')") - viper.BindPFlag("api.port", apiCmd.Flags().Lookup("api-port")) - - // bottin.api.host - apiCmd.Flags().String( - "bottin-api-host", "api", - "Remote bottin API server host (config:'bottin.api.host')") - viper.BindPFlag("bottin.api.host", apiCmd.Flags().Lookup("bottin-api-host")) - - // bottin.api.key - apiCmd.Flags().String( - "bottin-api-key", "bottin", - "Remote bottin API server key (config:'bottin.api.key')") - viper.BindPFlag("bottin.api.key", apiCmd.Flags().Lookup("bottin-api-key")) - - // bottin.api.protocol - apiCmd.Flags().String( - "bottin-api-protocol", "http", - "Remote bottin API server protocol (config:'bottin.api.protocol')") - viper.BindPFlag("bottin.api.protocol", apiCmd.Flags().Lookup("bottin-api-protocol")) - - // bottin.api.port - apiCmd.Flags().Int( - "bottin-api-port", 1312, - "Remote bottin API server port (config:'bottin.api.port')") - viper.BindPFlag("bottin.api.port", apiCmd.Flags().Lookup("bottin-api-port")) - - // db.database - apiCmd.Flags().String("db-database", "bottin-agenda", "Postgres database (config:'db.database')") - viper.BindPFlag("db.database", apiCmd.Flags().Lookup("db-database")) - - // db.host - apiCmd.Flags().String("db-host", "db", "Postgres host (config:'db.host')") - viper.BindPFlag("db.host", apiCmd.Flags().Lookup("db-host")) - - // db.password - apiCmd.Flags().String("db-password", "bottin-agenda", "Postgres password (config:'db.password')") - viper.BindPFlag("db.password", apiCmd.Flags().Lookup("db-password")) - - // db.port - apiCmd.Flags().Int("db-port", 5432, "Postgres port (config:'db.port')") - viper.BindPFlag("db.port", apiCmd.Flags().Lookup("db-port")) - - // db.user - apiCmd.Flags().String("db-user", "bottin-agenda", "Postgres user (config:'db.user')") - viper.BindPFlag("db.user", apiCmd.Flags().Lookup("db-user")) } diff --git a/cmd/root.go b/cmd/root.go index 7fa76ca..b75fdbc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,104 @@ func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bottin-agenda.yaml)") + + // web.api.host + rootCmd.PersistentFlags().String( + "web-api-host", "api", + "Remote API server host (config:'web.api.host')") + viper.BindPFlag("web.api.host", rootCmd.PersistentFlags().Lookup("web-api-host")) + + // web.api.key + rootCmd.PersistentFlags().String( + "web-api-key", "bottin-agenda", + "Remote API server key (config:'web.api.key')") + viper.BindPFlag("web.api.key", rootCmd.PersistentFlags().Lookup("web-api-key")) + + // web.api.protocol + rootCmd.PersistentFlags().String( + "web-api-protocol", "http", + "Remote API server protocol (config:'web.api.protocol')") + viper.BindPFlag("web.api.protocol", rootCmd.PersistentFlags().Lookup("web-api-protocol")) + + // web.api.port + rootCmd.PersistentFlags().Int( + "web-api-port", 1313, + "Remote API server port (config:'web.api.port')") + viper.BindPFlag("web.api.port", rootCmd.PersistentFlags().Lookup("web-api-port")) + + // web.password + rootCmd.PersistentFlags().String( + "web-password", "bottin-agenda", + "Web client password (config:'web.password')") + viper.BindPFlag("web.password", rootCmd.PersistentFlags().Lookup("web-password")) + + // web.port + rootCmd.PersistentFlags().Int( + "web-port", 2313, + "Web client port (config:'web.port')") + viper.BindPFlag("web.port", rootCmd.PersistentFlags().Lookup("web-port")) + + // web.user + rootCmd.PersistentFlags().String( + "web-user", "bottin-agenda", + "Web client user (config:'web.user')") + viper.BindPFlag("web.user", rootCmd.PersistentFlags().Lookup("web-user")) + + // api.key + rootCmd.PersistentFlags().String( + "api-key", "bottin-agenda", + "API server key. Leave empty for no key auth. (config: 'api.key')") + viper.BindPFlag("api.key", rootCmd.PersistentFlags().Lookup("api-key")) + + // api.port + rootCmd.PersistentFlags().Int( + "api-port", 1313, + "API server port (config:'api.port')") + viper.BindPFlag("api.port", rootCmd.PersistentFlags().Lookup("api-port")) + + // bottin.api.host + rootCmd.PersistentFlags().String( + "bottin-api-host", "api", + "Remote bottin API server host (config:'bottin.api.host')") + viper.BindPFlag("bottin.api.host", rootCmd.PersistentFlags().Lookup("bottin-api-host")) + + // bottin.api.key + rootCmd.PersistentFlags().String( + "bottin-api-key", "bottin", + "Remote bottin API server key (config:'bottin.api.key')") + viper.BindPFlag("bottin.api.key", rootCmd.PersistentFlags().Lookup("bottin-api-key")) + + // bottin.api.protocol + rootCmd.PersistentFlags().String( + "bottin-api-protocol", "http", + "Remote bottin API server protocol (config:'bottin.api.protocol')") + viper.BindPFlag("bottin.api.protocol", rootCmd.PersistentFlags().Lookup("bottin-api-protocol")) + + // bottin.api.port + rootCmd.PersistentFlags().Int( + "bottin-api-port", 1312, + "Remote bottin API server port (config:'bottin.api.port')") + viper.BindPFlag("bottin.api.port", rootCmd.PersistentFlags().Lookup("bottin-api-port")) + + // db.database + rootCmd.PersistentFlags().String("db-database", "bottin-agenda", "Postgres database (config:'db.database')") + viper.BindPFlag("db.database", rootCmd.PersistentFlags().Lookup("db-database")) + + // db.host + rootCmd.PersistentFlags().String("db-host", "db", "Postgres host (config:'db.host')") + viper.BindPFlag("db.host", rootCmd.PersistentFlags().Lookup("db-host")) + + // db.password + rootCmd.PersistentFlags().String("db-password", "bottin-agenda", "Postgres password (config:'db.password')") + viper.BindPFlag("db.password", rootCmd.PersistentFlags().Lookup("db-password")) + + // db.port + rootCmd.PersistentFlags().Int("db-port", 5432, "Postgres port (config:'db.port')") + viper.BindPFlag("db.port", rootCmd.PersistentFlags().Lookup("db-port")) + + // db.user + rootCmd.PersistentFlags().String("db-user", "bottin-agenda", "Postgres user (config:'db.user')") + viper.BindPFlag("db.user", rootCmd.PersistentFlags().Lookup("db-user")) } // initConfig reads in config file and ENV variables if set. diff --git a/cmd/web.go b/cmd/web.go new file mode 100644 index 0000000..ed1d7b3 --- /dev/null +++ b/cmd/web.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "crypto/subtle" + "embed" + "fmt" + "html/template" + "io" + "log" + + "git.agecem.com/agecem/bottin-agenda/data" + "git.agecem.com/agecem/bottin-agenda/models" + "git.agecem.com/agecem/bottin-agenda/web" + "git.agecem.com/agecem/bottin-agenda/web/webhandlers" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var templatesFS embed.FS + +type Template struct { + templates *template.Template +} + +func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { + return t.templates.ExecuteTemplate(w, name, data) +} + +// webCmd represents the web command +var webCmd = &cobra.Command{ + Use: "web", + Short: "Démarrer le client web", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + var config models.Config + + err := viper.Unmarshal(&config) + if err != nil { + log.Fatalf("Error during webCmd#viper.Unmarshal(): %s", err) + } + + // Ping API server + + apiClient := data.NewApiClient(config.Web.Api.Key, config.Web.Api.Host, config.Web.Api.Protocol, config.Web.Api.Port) + + healthResult, err := apiClient.GetHealth() + if err != nil { + log.Fatalf("Error during webCmd#apiClient.GetHealth(): %s", err) + } + + log.Println(healthResult) + + e := echo.New() + + // Middlewares + + e.Pre(middleware.AddTrailingSlash()) + + e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) { + usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(config.Web.User)) == 1 + passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(config.Web.Password)) == 1 + return usersMatch && passwordsMatch, nil + })) + + // Template + + t := &Template{ + templates: template.Must(template.ParseFS(templatesFS, "templates/*.html")), + } + + e.Renderer = t + + // Routes + + e.GET("/", webhandlers.GetIndex) + e.POST("/transaction/", webhandlers.PostTransaction) + + // Execution + + e.Logger.Fatal(e.Start( + fmt.Sprintf(":%d", config.Web.Port))) + }, +} + +func init() { + rootCmd.AddCommand(webCmd) + templatesFS = web.GetTemplates() +} diff --git a/data/apiclient.go b/data/apiclient.go index 6132a12..a1f6552 100644 --- a/data/apiclient.go +++ b/data/apiclient.go @@ -1,9 +1,16 @@ package data import ( + "bytes" + "encoding/json" + "errors" "fmt" "io" + "io/ioutil" "net/http" + + "git.agecem.com/agecem/bottin-agenda/models" + "git.agecem.com/agecem/bottin-agenda/responses" ) type ApiClient struct { @@ -40,7 +47,7 @@ func (a *ApiClient) Call(method, route string, requestBody io.Reader, useKey boo if useKey { if a.Key == "" { - return response, fmt.Errorf("Call to API required a key but none was provided. See --help for instructions on providing an API key.") + return response, fmt.Errorf("L'appel au serveur API requiert une clé, mais aucune n'était donnée. Voir --help pour les instructions sur comment fournir une clé API.") } request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Key)) @@ -58,3 +65,58 @@ func (a *ApiClient) Call(method, route string, requestBody io.Reader, useKey boo return response, nil } + +// GetHealth allows checking for API server health +func (a *ApiClient) GetHealth() (string, error) { + var response responses.GetHealthResponse + + getHealthResponse, err := a.Call(http.MethodGet, "/v3/health", nil, true) + if err != nil { + return response.Message, err + } + + defer getHealthResponse.Body.Close() + + body, err := ioutil.ReadAll(getHealthResponse.Body) + if err != nil { + return response.Message, err + } + + if err := json.Unmarshal(body, &response); err != nil { + return response.Message, err + } + + if response.Message == "" { + return response.Message, errors.New("Impossible de confirmer l'état du serveur API.") + } + + return response.Message, nil +} + +func (a *ApiClient) InsertTransactions(transactions []models.Transaction) ([]models.Transaction, error) { + var response responses.PostTransactionsResponse + + var buf bytes.Buffer + err := json.NewEncoder(&buf).Encode(transactions) + if err != nil { + return response.Data.Transactions, err + } + + postHealthResponse, err := a.Call(http.MethodPost, "/v3/transactions", &buf, true) + defer postHealthResponse.Body.Close() + + body, err := ioutil.ReadAll(postHealthResponse.Body) + if err != nil { + return response.Data.Transactions, err + } + + if err := json.Unmarshal(body, &response); err != nil { + return response.Data.Transactions, err + } + + if len(response.Data.Transactions) == 0 { + return response.Data.Transactions, fmt.Errorf(response.Message) + } + + return response.Data.Transactions, nil +} diff --git a/data/dataclient.go b/data/dataclient.go index 2398bd0..8715e89 100644 --- a/data/dataclient.go +++ b/data/dataclient.go @@ -87,7 +87,7 @@ func (d *DataClient) InsertTransactions(transactions []models.Transaction) ([]mo // Check values if transaction.MembreID == "" { tx.Rollback() - return rowsInserted, errors.New("Cannot insert transaction with no membre_id") + return rowsInserted, errors.New("Impossible d'insérer une transaction sans membre_id") } rows, err := tx.NamedQuery("INSERT INTO transactions (membre_id, given_at, is_perpetual) VALUES (:membre_id, current_timestamp, :is_perpetual) RETURNING id, membre_id, is_perpetual;", &transaction) @@ -126,7 +126,7 @@ func (d *DataClient) GetTransaction(membreID string, is_perpetual bool) (models. } if transaction.ID == "" { - return transaction, fmt.Errorf("No transaction found") + return transaction, fmt.Errorf("Aucune transaction trouvée") } return transaction, nil diff --git a/handlers/transaction.go b/handlers/transaction.go index ecd20d9..c6c6a15 100644 --- a/handlers/transaction.go +++ b/handlers/transaction.go @@ -19,7 +19,7 @@ func GetTransactions(c echo.Context) error { client, err := data.NewDataClientFromViper() if err != nil { - response.Message = fmt.Sprintf("Error during data.NewDataClientFromViper(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant data.NewDataClientFromViper(): %s", err) return c.JSON(statusCode, response) } @@ -28,19 +28,19 @@ func GetTransactions(c echo.Context) error { transactions, err := client.ListTransactions() response.Data.Transactions = transactions if err != nil { - response.Message = fmt.Sprintf("Error during client.ListTransactions(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant client.ListTransactions(): %s", err) return c.JSON(statusCode, response) } if len(transactions) == 0 { - response.Message = "No transactions found" + response.Message = "Aucune transaction trouvée" statusCode = http.StatusNotFound return c.JSON(statusCode, response) } - response.Message = "List successful" + response.Message = "Liste complétée" statusCode = http.StatusOK return c.JSON(statusCode, response) @@ -55,21 +55,21 @@ func PostTransactions(c echo.Context) error { if err := c.Bind(&transactions); err != nil { statusCode = http.StatusBadRequest - response.Message = fmt.Sprintf("Error during c.Bind(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant c.Bind(): %s", err) return c.JSON(statusCode, response) } client, err := data.NewDataClientFromViper() if err != nil { - response.Message = fmt.Sprintf("Error during data.NewDataClientFromViper(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant data.NewDataClientFromViper(): %s", err) return c.JSON(statusCode, response) } defer client.DB.Close() if len(transactions) == 0 { - response.Message = fmt.Sprintf("Nothing to do") + response.Message = fmt.Sprintf("Rien à faire") statusCode = http.StatusOK return c.JSON(statusCode, response) @@ -91,20 +91,20 @@ func PostTransactions(c echo.Context) error { // Check if membre_id exists according to bottin for _, transaction := range transactions { if transaction.MembreID == "" { - response.Message = fmt.Sprintf("Cannot insert transaction without a membre_id") + response.Message = fmt.Sprintf("Impossible d'insérer une transaction sans membre_id (numéro étudiant)") statusCode = http.StatusBadRequest return c.JSON(statusCode, response) } membre, err := bottinApiClient.GetMembre(transaction.MembreID) if err != nil { - response.Message = fmt.Sprintf("Error during bottinApiClient.GetMembre(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant bottinApiClient.GetMembre(): %s", err) return c.JSON(statusCode, response) } if membre.ID == "" { - response.Message = fmt.Sprintf("Cannot insert transaction for non-existent membre %s", membre.ID) + response.Message = fmt.Sprintf("Aucun membre avec numéro étudiant '%s' dans le bottin de l'AGECEM", membre.ID) statusCode = http.StatusNotFound return c.JSON(statusCode, response) @@ -118,17 +118,17 @@ func PostTransactions(c echo.Context) error { transaction, err := client.GetTransaction(transaction.MembreID, transaction.IsPerpetual) if err != nil { if err.Error() != "sql: no rows in result set" { - response.Message = fmt.Sprintf("Error during client.GetTransaction(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant client.GetTransaction(): %s", err) } } if transaction.ID != "" { - agendaType := "non-perpetual" + agendaType := "non-perpétuel" if transaction.IsPerpetual { - agendaType = "perpetual" + agendaType = "perpétuel" } - response.Message = fmt.Sprintf("Membre %s already received %s", transaction.MembreID, agendaType) + response.Message = fmt.Sprintf("Membre %s a déjà son agenda %s", transaction.MembreID, agendaType) statusCode = http.StatusBadRequest @@ -139,13 +139,13 @@ func PostTransactions(c echo.Context) error { insertedTransactions, err := client.InsertTransactions(transactions) response.Data.Transactions = insertedTransactions if err != nil { - response.Message = fmt.Sprintf("Error during client.InsertTransactions(): %s", err) + response.Message = fmt.Sprintf("Erreur pendant client.InsertTransactions(): %s", err) return c.JSON(statusCode, response) } statusCode = http.StatusCreated - response.Message = "Insert successful" + response.Message = "Insertion complétée" return c.JSON(statusCode, response) } diff --git a/models/models.go b/models/models.go index ac30776..691afc0 100644 --- a/models/models.go +++ b/models/models.go @@ -17,3 +17,44 @@ type Transaction struct { GivenAt *time.Time `db:"given_at" json:"given_at"` IsPerpetual bool `db:"is_perpetual" json:"is_perpetual"` } + +type Config struct { + Web WebConfig `json:"web"` + Api ApiConfig `json:"api"` + Bottin BottinConfig `json:"bottin"` + Db DbConfig `json:"db"` +} + +type ApiConfig struct { + Key string `json:"key"` + Port int `json:"port"` +} + +type BottinConfig struct { + Api struct { + Host string `json:"host"` + Key string `json:"key"` + Protocol string `json:"protocol"` + Port int `json:"port"` + } `json:"api"` +} + +type DbConfig struct { + Database string `json:"database"` + Host string `json:"host"` + Password string `json:"password"` + Port int `json:"port"` + User string `json:"user"` +} + +type WebConfig struct { + Api struct { + Host string `json:"host"` + Key string `json:"key"` + Port int `json:"port"` + Protocol string `json:"protocol"` + } `json:"api"` + Password string `json:"password"` + Port int `json:"port"` + User string `json:"user"` +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..465e5ec --- /dev/null +++ b/web/embed.go @@ -0,0 +1,10 @@ +package web + +import "embed" + +//go:embed templates/* +var templatesFS embed.FS + +func GetTemplates() embed.FS { + return templatesFS +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..9750a38 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,119 @@ +{{ define "index-html" }} + + + + + + AGECEM | Agenda + + + + + + +

+ Distribution d'agendas aux membres de l'AGECEM +

+ +

+ 1) Si l'agenda demandé est perpétuel, cochez 'Perpétuel?:'
+
+ 2) Sélectionnez le champs 'Numéro étudiant:'
+
+ 3) Scannez la carte étudiante d'unE membre
+ -ou-
+ Entrez manuellement le code à 7 chiffres
+
+ 4) Si aucune erreur ne survient, la personne est libre de partir avec son agenda +

+ +
+ +
+ +
+ +

{{ .Result }}

+ + + +{{ end }} diff --git a/web/webhandlers/handlers.go b/web/webhandlers/handlers.go new file mode 100644 index 0000000..2db3120 --- /dev/null +++ b/web/webhandlers/handlers.go @@ -0,0 +1,76 @@ +package webhandlers + +import ( + "fmt" + "net/http" + + "git.agecem.com/agecem/bottin-agenda/data" + "git.agecem.com/agecem/bottin-agenda/models" + "github.com/labstack/echo/v4" + "github.com/spf13/viper" +) + +type PostTransactionResult struct { + Result string +} + +func GetIndex(c echo.Context) error { + return c.Render(http.StatusOK, "index-html", nil) +} + +func PostTransaction(c echo.Context) error { + var config models.Config + var result PostTransactionResult + + err := viper.Unmarshal(&config) + if err != nil { + return c.Render(http.StatusInternalServerError, "index-html", PostTransactionResult{ + Result: fmt.Sprintln("👎", err.Error()), + }) + } + + // apiClient := data.NewApiClientkey host protocol port + apiClient := data.NewApiClient( + config.Web.Api.Key, + config.Web.Api.Host, + config.Web.Api.Protocol, + config.Web.Api.Port, + ) + + apiHealth, err := apiClient.GetHealth() + if err != nil { + result.Result = fmt.Sprintf("👎 %s | %s", apiHealth, err) + + return c.Render(http.StatusInternalServerError, "index-html", result) + } + + isPerpetual := c.FormValue("is_perpetual") == "on" + + membreId := c.FormValue("membre_id") + + if membreId == "" { + result.Result = "👎 Aucun numéro étudiant sélectionné. Assurez-vous de cliquer sur la case 'Numéro étudiant:' avant de scanner." + + return c.Render(http.StatusBadRequest, "index-html", result) + } + + transactions, err := apiClient.InsertTransactions( + []models.Transaction{{ + MembreID: membreId, + IsPerpetual: isPerpetual, + }}) + + if err != nil { + result.Result = fmt.Sprintf("👎 Erreur lors de l'insertion: %s", err) + + return c.Render(http.StatusInternalServerError, "index-html", result) + } + + agenda := "non-perpétuel" + if transactions[0].IsPerpetual { + agenda = "perpétuel" + } + result.Result = fmt.Sprintf("👍 Membre %s peut recevoir son agenda %s", transactions[0].MembreID, agenda) + + return c.Render(http.StatusOK, "index-html", result) +}