major: séparer commande de librairie importable #67
25 changed files with 513 additions and 451 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -22,6 +22,8 @@
|
|||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# cert files
|
||||
*.pem
|
||||
|
||||
# env
|
||||
.env
|
||||
|
|
|
@ -4,12 +4,14 @@ LABEL author="vlbeaudoin"
|
|||
|
||||
WORKDIR /go/src/app
|
||||
|
||||
COPY go.mod go.sum client.go client_test.go cmd.go config.go db.go entity.go main.go request.go response.go routes.go template.go ./
|
||||
COPY go.mod go.sum LICENSE ./
|
||||
|
||||
ADD sql/ sql/
|
||||
ADD cmd/ cmd/
|
||||
ADD pkg/ pkg/
|
||||
ADD queries/ queries/
|
||||
ADD templates/ templates/
|
||||
|
||||
RUN CGO_ENABLED=0 go build -a -o bottin .
|
||||
RUN CGO_ENABLED=0 go build -a -o bottin ./cmd/bottin
|
||||
|
||||
# Alpine
|
||||
|
||||
|
|
12
Makefile
12
Makefile
|
@ -14,3 +14,15 @@ help: ## Show this help
|
|||
.PHONY: test-integration
|
||||
test-integration: ## run integration tests through API client. Config is read from `~/.bottin.yaml`. WARNING: affects data in the database, do not run on production server
|
||||
docker-compose down && docker-compose up -d --build && sleep 2 && go test
|
||||
|
||||
.PHONY: dev
|
||||
dev: generate-self-signed-x509 compose-inject-x509 ## deploy development environment on docker-compose
|
||||
docker-compose up -d
|
||||
|
||||
.PHONY: generate-self-signed-x509
|
||||
generate-self-signed-x509: ## Générer une paire de clés x509 self-signed pour utilisation avec un serveur de développement
|
||||
./scripts/generate-self-signed-x509.sh
|
||||
|
||||
.PHONY: compose-inject-x509
|
||||
compose-inject-x509: ## Copie la paire de clés x509 du current directory vers les containers orchestrés par docker-compose
|
||||
./scripts/compose-inject-x509.sh
|
||||
|
|
58
README.md
58
README.md
|
@ -1,57 +1 @@
|
|||
# agecem/bottin
|
||||
|
||||
Bottin de la masse étudiante, en Go
|
||||
|
||||
https://git.agecem.com/agecem/bottin
|
||||
|
||||
## fonctionalités
|
||||
|
||||
### Serveur API
|
||||
|
||||
- Insertion de membre et programme
|
||||
- Lecture de membre
|
||||
- Modification du nom d'usage de membre
|
||||
|
||||
### Client web
|
||||
|
||||
- Lecture de membre par requête au serveur API
|
||||
|
||||
## usage
|
||||
|
||||
Remplir .env avec les infos qui seront utilisées pour déployer le container
|
||||
|
||||
Au minimum, il faut ces 3 entrées:
|
||||
|
||||
*Remplacer `bottin` par quelque chose de plus sécuritaire*
|
||||
|
||||
```sh
|
||||
BOTTIN_SERVER_DB_DATABASE=bottin
|
||||
BOTTIN_SERVER_DB_PASSWORD=bottin
|
||||
BOTTIN_SERVER_DB_USER=bottin
|
||||
```
|
||||
|
||||
*D'autres entrées peuvent être ajoutées, voir `config.go` pour les options*
|
||||
|
||||
Déployer avec docker-compose
|
||||
|
||||
`$ docker-compose up -d`
|
||||
|
||||
### Optionnel: configuration par fichiers YAML
|
||||
|
||||
*seulement nécessaire si les fichiers `.env` et `docker-compose.yaml` ne contiennent pas toute l'information nécessaire*
|
||||
|
||||
Pour modifier la configuration du serveur API
|
||||
|
||||
`$ docker-compose exec -it api vi /etc/bottin/api.yaml`
|
||||
|
||||
*Y remplir au minimum le champs `server.api.key` (string)*
|
||||
|
||||
Pour modifier la configuration du client web
|
||||
|
||||
`$ docker-compose exec -it ui vi /etc/bottin/ui.yaml`
|
||||
|
||||
*Y remplir au minimum les champs `server.ui.api.key` (string), `server.ui.user` (string) et `server.ui.password` (string)*
|
||||
|
||||
Redémarrer les containers une fois la configuration modifiée
|
||||
|
||||
`$ docker-compose down && docker-compose up -d`
|
||||
Requiert un fichier .env ici pour un déploiement avec base de donnée
|
||||
|
|
285
cmd.go
285
cmd.go
|
@ -1,285 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"codeberg.org/vlbeaudoin/voki/v3"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "bottin",
|
||||
Short: "Bottin étudiant de l'AGECEM",
|
||||
}
|
||||
|
||||
// execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Démarrer serveurs (API ou Web UI)",
|
||||
}
|
||||
|
||||
// apiCmd represents the api command
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "Démarrer le serveur API",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
log.Fatal("parse config:", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Middlewares
|
||||
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
if cfg.Server.API.Key != "" {
|
||||
e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) {
|
||||
return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.API.Key)) == 1, nil
|
||||
}))
|
||||
} else {
|
||||
log.Println("Server started but no API key (server.api.key) was provided, using empty key (NOT RECOMMENDED FOR PRODUCTION)")
|
||||
}
|
||||
|
||||
// DataClient
|
||||
ctx := context.Background()
|
||||
|
||||
//prep
|
||||
pool, err := pgxpool.New(
|
||||
ctx,
|
||||
fmt.Sprintf(
|
||||
"user=%s password=%s database=%s host=%s port=%d sslmode=%s ",
|
||||
cfg.Server.API.DB.User,
|
||||
cfg.Server.API.DB.Password,
|
||||
cfg.Server.API.DB.Database,
|
||||
cfg.Server.API.DB.Host,
|
||||
cfg.Server.API.DB.Port,
|
||||
cfg.Server.API.DB.SSLMode,
|
||||
))
|
||||
if err != nil {
|
||||
log.Fatal("init pgx pool:", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
db := &PostgresClient{
|
||||
Ctx: ctx,
|
||||
Pool: pool,
|
||||
}
|
||||
if err := db.Pool.Ping(ctx); err != nil {
|
||||
log.Fatal("ping db:", err)
|
||||
}
|
||||
|
||||
if err := db.CreateOrReplaceSchema(); err != nil {
|
||||
log.Fatal("create or replace schema:", err)
|
||||
}
|
||||
|
||||
if err := db.CreateOrReplaceViews(); err != nil {
|
||||
log.Fatal("create or replace views:", err)
|
||||
}
|
||||
|
||||
// Routes
|
||||
if err := addRoutes(e, db, cfg); err != nil {
|
||||
log.Fatal("add routes:", err)
|
||||
}
|
||||
/*
|
||||
h := handlers.New(client)
|
||||
|
||||
e.GET("/v8/health/", h.GetHealth)
|
||||
|
||||
e.POST("/v8/membres/", h.PostMembres)
|
||||
|
||||
e.GET("/v8/membres/", h.ListMembres)
|
||||
|
||||
e.GET("/v8/membres/:membre_id/", h.ReadMembre)
|
||||
|
||||
e.PUT("/v8/membres/:membre_id/prefered_name/", h.PutMembrePreferedName)
|
||||
|
||||
e.POST("/v8/programmes/", h.PostProgrammes)
|
||||
|
||||
e.POST("/v8/seed/", h.PostSeed)
|
||||
*/
|
||||
|
||||
// Execution
|
||||
switch cfg.Server.API.TLS.Enabled {
|
||||
case false:
|
||||
e.Logger.Fatal(
|
||||
e.Start(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port),
|
||||
),
|
||||
)
|
||||
case true:
|
||||
if cfg.Server.API.TLS.Certfile == "" {
|
||||
log.Fatal("TLS enabled for API but no certificate file provided")
|
||||
}
|
||||
|
||||
if cfg.Server.API.TLS.Keyfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no private key file provided")
|
||||
}
|
||||
|
||||
e.Logger.Fatal(
|
||||
e.StartTLS(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port),
|
||||
cfg.Server.API.TLS.Certfile,
|
||||
cfg.Server.API.TLS.Keyfile,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// uiCmd represents the ui command
|
||||
var uiCmd = &cobra.Command{
|
||||
Use: "ui",
|
||||
Aliases: []string{"web", "interface"},
|
||||
Short: "Démarrer l'interface Web UI",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Parse config
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
log.Fatal("init config:", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Middlewares
|
||||
|
||||
// Trailing slash
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
// Auth
|
||||
e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) {
|
||||
usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Server.UI.User)) == 1
|
||||
passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Server.UI.Password)) == 1
|
||||
return usersMatch && passwordsMatch, nil
|
||||
}))
|
||||
|
||||
// Templating
|
||||
e.Renderer = &Template{
|
||||
templates: template.Must(template.ParseFS(templatesFS, "templates/*.html")),
|
||||
}
|
||||
|
||||
// API Client
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.Server.UI.API.TLS.SkipVerify,
|
||||
},
|
||||
},
|
||||
}
|
||||
apiClient := APIClient{voki.New(
|
||||
httpClient,
|
||||
cfg.Server.UI.API.Host,
|
||||
cfg.Server.UI.API.Key,
|
||||
cfg.Server.UI.API.Port,
|
||||
cfg.Server.UI.API.Protocol,
|
||||
)}
|
||||
defer apiClient.Voki.CloseIdleConnections()
|
||||
|
||||
// Routes
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
pingResult, err := apiClient.GetHealth()
|
||||
if err != nil {
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("impossible d'accéder au serveur API: %s", err)},
|
||||
)
|
||||
}
|
||||
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: pingResult},
|
||||
)
|
||||
})
|
||||
|
||||
e.GET("/membre/", func(c echo.Context) error {
|
||||
membreID := c.QueryParam("membre_id")
|
||||
switch {
|
||||
case membreID == "":
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: "❗Veuillez entrer un numéro étudiant à rechercher"},
|
||||
)
|
||||
case !IsMembreID(membreID):
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("❗Numéro étudiant '%s' invalide", membreID)},
|
||||
)
|
||||
}
|
||||
|
||||
membre, err := apiClient.GetMembreForDisplay(membreID)
|
||||
if err != nil {
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("❗erreur: %s", err)},
|
||||
)
|
||||
}
|
||||
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf(`
|
||||
Numéro étudiant: %s
|
||||
Nom d'usage: %s
|
||||
Programme: [%s] %s
|
||||
`,
|
||||
membre.ID,
|
||||
membre.Name,
|
||||
membre.ProgrammeID,
|
||||
membre.ProgrammeName,
|
||||
)},
|
||||
)
|
||||
})
|
||||
|
||||
// Execution
|
||||
switch cfg.Server.UI.TLS.Enabled {
|
||||
case false:
|
||||
e.Logger.Fatal(e.Start(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port)))
|
||||
case true:
|
||||
if cfg.Server.UI.TLS.Certfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no certificate file provided")
|
||||
}
|
||||
|
||||
if cfg.Server.UI.TLS.Keyfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no private key file provided")
|
||||
}
|
||||
|
||||
e.Logger.Fatal(
|
||||
e.StartTLS(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port),
|
||||
cfg.Server.UI.TLS.Certfile,
|
||||
cfg.Server.UI.TLS.Keyfile,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
}
|
|
@ -1,67 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/vlbeaudoin/voki/v3"
|
||||
"git.agecem.com/agecem/bottin/v9/pkg/bottin"
|
||||
"git.agecem.com/agecem/bottin/v9/templates"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Client struct {
|
||||
API struct {
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
Protocol string `yaml:"protocol"`
|
||||
} `yaml:"api"`
|
||||
} `yaml:"client"`
|
||||
|
||||
Server struct {
|
||||
API struct {
|
||||
DB struct {
|
||||
Database string `yaml:"database"`
|
||||
Host string `yaml:"host"`
|
||||
Password string `yaml:"password"`
|
||||
Port int `yaml:"port"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
User string `yaml:"user"`
|
||||
} `yaml:"db"`
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
TLS struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Certfile string `yaml:"certfile"`
|
||||
Keyfile string `yaml:"keyfile"`
|
||||
} `yaml:"tls"`
|
||||
} `yaml:"api"`
|
||||
UI struct {
|
||||
API struct {
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
Protocol string `yaml:"protocol"`
|
||||
TLS struct {
|
||||
SkipVerify bool `yaml:"skipverify"`
|
||||
} `yaml:"tls"`
|
||||
} `yaml:"api"`
|
||||
Host string `yaml:"host"`
|
||||
Password string `yaml:"password"`
|
||||
Port int `yaml:"port"`
|
||||
TLS struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Certfile string `yaml:"certfile"`
|
||||
Keyfile string `yaml:"keyfile"`
|
||||
} `yaml:"tls"`
|
||||
User string `yaml:"user"`
|
||||
} `yaml:"ui"`
|
||||
} `yaml:"server"`
|
||||
}
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
|
@ -90,6 +48,17 @@ func initConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
/* TODO
|
||||
if err := Run(context.Background(), Config{}, nil, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
*/
|
||||
|
||||
// Handle the command-line via cobra and viper
|
||||
execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// rootCmd
|
||||
|
||||
|
@ -475,3 +444,274 @@ func init() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO
|
||||
func Run(ctx context.Context, config Config, args []string, stdout io.Writer) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
*/
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "bottin",
|
||||
Short: "Bottin étudiant de l'AGECEM",
|
||||
}
|
||||
|
||||
// execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Démarrer serveurs (API ou Web UI)",
|
||||
}
|
||||
|
||||
// apiCmd represents the api command
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "Démarrer le serveur API",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var cfg bottin.Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
log.Fatal("parse config:", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Middlewares
|
||||
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
if cfg.Server.API.Key != "" {
|
||||
e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) {
|
||||
return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.API.Key)) == 1, nil
|
||||
}))
|
||||
} else {
|
||||
log.Println("Server started but no API key (server.api.key) was provided, using empty key (NOT RECOMMENDED FOR PRODUCTION)")
|
||||
}
|
||||
|
||||
// DataClient
|
||||
ctx := context.Background()
|
||||
|
||||
//prep
|
||||
pool, err := pgxpool.New(
|
||||
ctx,
|
||||
fmt.Sprintf(
|
||||
"user=%s password=%s database=%s host=%s port=%d sslmode=%s ",
|
||||
cfg.Server.API.DB.User,
|
||||
cfg.Server.API.DB.Password,
|
||||
cfg.Server.API.DB.Database,
|
||||
cfg.Server.API.DB.Host,
|
||||
cfg.Server.API.DB.Port,
|
||||
cfg.Server.API.DB.SSLMode,
|
||||
))
|
||||
if err != nil {
|
||||
log.Fatal("init pgx pool:", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
db := &bottin.PostgresClient{
|
||||
Ctx: ctx,
|
||||
Pool: pool,
|
||||
}
|
||||
if err := db.Pool.Ping(ctx); err != nil {
|
||||
log.Fatal("ping db:", err)
|
||||
}
|
||||
|
||||
if err := db.CreateOrReplaceSchema(); err != nil {
|
||||
log.Fatal("create or replace schema:", err)
|
||||
}
|
||||
|
||||
if err := db.CreateOrReplaceViews(); err != nil {
|
||||
log.Fatal("create or replace views:", err)
|
||||
}
|
||||
|
||||
// Routes
|
||||
if err := bottin.AddRoutes(e, db, cfg); err != nil {
|
||||
log.Fatal("add routes:", err)
|
||||
}
|
||||
/*
|
||||
h := handlers.New(client)
|
||||
|
||||
e.GET("/v9/health/", h.GetHealth)
|
||||
|
||||
e.POST("/v9/membres/", h.PostMembres)
|
||||
|
||||
e.GET("/v9/membres/", h.ListMembres)
|
||||
|
||||
e.GET("/v9/membres/:membre_id/", h.ReadMembre)
|
||||
|
||||
e.PUT("/v9/membres/:membre_id/prefered_name/", h.PutMembrePreferedName)
|
||||
|
||||
e.POST("/v9/programmes/", h.PostProgrammes)
|
||||
|
||||
e.POST("/v9/seed/", h.PostSeed)
|
||||
*/
|
||||
|
||||
// Execution
|
||||
switch cfg.Server.API.TLS.Enabled {
|
||||
case false:
|
||||
e.Logger.Fatal(
|
||||
e.Start(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port),
|
||||
),
|
||||
)
|
||||
case true:
|
||||
if cfg.Server.API.TLS.Certfile == "" {
|
||||
log.Fatal("TLS enabled for API but no certificate file provided")
|
||||
}
|
||||
|
||||
if cfg.Server.API.TLS.Keyfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no private key file provided")
|
||||
}
|
||||
|
||||
e.Logger.Fatal(
|
||||
e.StartTLS(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.API.Host, cfg.Server.API.Port),
|
||||
cfg.Server.API.TLS.Certfile,
|
||||
cfg.Server.API.TLS.Keyfile,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// uiCmd represents the ui command
|
||||
var uiCmd = &cobra.Command{
|
||||
Use: "ui",
|
||||
Aliases: []string{"web", "interface"},
|
||||
Short: "Démarrer l'interface Web UI",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Parse config
|
||||
var cfg bottin.Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
log.Fatal("init config:", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Middlewares
|
||||
|
||||
// Trailing slash
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
// Auth
|
||||
e.Use(middleware.BasicAuth(func(user, password string, c echo.Context) (bool, error) {
|
||||
usersMatch := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Server.UI.User)) == 1
|
||||
passwordsMatch := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Server.UI.Password)) == 1
|
||||
return usersMatch && passwordsMatch, nil
|
||||
}))
|
||||
|
||||
// Templating
|
||||
e.Renderer = templates.NewTemplate()
|
||||
|
||||
// API Client
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.Server.UI.API.TLS.SkipVerify,
|
||||
},
|
||||
},
|
||||
}
|
||||
apiClient := bottin.APIClient{
|
||||
Voki: voki.New(
|
||||
httpClient,
|
||||
cfg.Server.UI.API.Host,
|
||||
cfg.Server.UI.API.Key,
|
||||
cfg.Server.UI.API.Port,
|
||||
cfg.Server.UI.API.Protocol,
|
||||
)}
|
||||
defer apiClient.Voki.CloseIdleConnections()
|
||||
|
||||
// Routes
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
pingResult, err := apiClient.GetHealth()
|
||||
if err != nil {
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("impossible d'accéder au serveur API: %s", err)},
|
||||
)
|
||||
}
|
||||
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: pingResult},
|
||||
)
|
||||
})
|
||||
|
||||
e.GET("/membre/", func(c echo.Context) error {
|
||||
membreID := c.QueryParam("membre_id")
|
||||
switch {
|
||||
case membreID == "":
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: "❗Veuillez entrer un numéro étudiant à rechercher"},
|
||||
)
|
||||
case !bottin.IsMembreID(membreID):
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("❗Numéro étudiant '%s' invalide", membreID)},
|
||||
)
|
||||
}
|
||||
|
||||
membre, err := apiClient.GetMembreForDisplay(membreID)
|
||||
if err != nil {
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf("❗erreur: %s", err)},
|
||||
)
|
||||
}
|
||||
|
||||
return c.Render(
|
||||
http.StatusOK,
|
||||
"index-html",
|
||||
voki.MessageResponse{Message: fmt.Sprintf(`
|
||||
Numéro étudiant: %s
|
||||
Nom d'usage: %s
|
||||
Programme: [%s] %s
|
||||
`,
|
||||
membre.ID,
|
||||
membre.Name,
|
||||
membre.ProgrammeID,
|
||||
membre.ProgrammeName,
|
||||
)},
|
||||
)
|
||||
})
|
||||
|
||||
// Execution
|
||||
switch cfg.Server.UI.TLS.Enabled {
|
||||
case false:
|
||||
e.Logger.Fatal(e.Start(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port)))
|
||||
case true:
|
||||
if cfg.Server.UI.TLS.Certfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no certificate file provided")
|
||||
}
|
||||
|
||||
if cfg.Server.UI.TLS.Keyfile == "" {
|
||||
log.Fatal("TLS enabled for UI but no private key file provided")
|
||||
}
|
||||
|
||||
e.Logger.Fatal(
|
||||
e.StartTLS(
|
||||
fmt.Sprintf("%s:%d", cfg.Server.UI.Host, cfg.Server.UI.Port),
|
||||
cfg.Server.UI.TLS.Certfile,
|
||||
cfg.Server.UI.TLS.Keyfile,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
}
|
|
@ -6,6 +6,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"codeberg.org/vlbeaudoin/voki/v3"
|
||||
"git.agecem.com/agecem/bottin/v9/pkg/bottin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
|
@ -14,7 +15,7 @@ func init() {
|
|||
}
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
var cfg Config
|
||||
var cfg bottin.Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
|
@ -35,7 +36,7 @@ func TestAPI(t *testing.T) {
|
|||
defer httpClient.CloseIdleConnections()
|
||||
|
||||
vokiClient := voki.New(&httpClient, "localhost", cfg.Client.API.Key, cfg.Client.API.Port, cfg.Client.API.Protocol)
|
||||
apiClient := APIClient{vokiClient}
|
||||
apiClient := bottin.APIClient{Voki: vokiClient}
|
||||
|
||||
t.Run("get API health", func(t *testing.T) {
|
||||
health, err := apiClient.GetHealth()
|
||||
|
@ -53,9 +54,9 @@ func TestAPI(t *testing.T) {
|
|||
|
||||
t.Run("insert programmes",
|
||||
func(t *testing.T) {
|
||||
programmes := []Programme{
|
||||
{"404.42", "Cool programme"},
|
||||
{"200.10", "Autre programme"},
|
||||
programmes := []bottin.Programme{
|
||||
{ID: "404.42", Name: "Cool programme"},
|
||||
{ID: "200.10", Name: "Autre programme"},
|
||||
}
|
||||
t.Log("programmes:", programmes)
|
||||
_, err := apiClient.InsertProgrammes(programmes...)
|
||||
|
@ -64,7 +65,7 @@ func TestAPI(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
testMembres := []Membre{
|
||||
testMembres := []bottin.Membre{
|
||||
{
|
||||
ID: "0000000",
|
||||
FirstName: "Test",
|
|
@ -1,3 +1,4 @@
|
|||
name: 'bottin'
|
||||
services:
|
||||
|
||||
db:
|
||||
|
@ -13,7 +14,7 @@ services:
|
|||
api:
|
||||
depends_on:
|
||||
- db
|
||||
build: .
|
||||
build: ../..
|
||||
image: 'git.agecem.com/agecem/bottin:latest'
|
||||
env_file: '.env'
|
||||
ports:
|
||||
|
@ -26,7 +27,7 @@ services:
|
|||
ui:
|
||||
depends_on:
|
||||
- api
|
||||
build: .
|
||||
build: ../..
|
||||
image: 'git.agecem.com/agecem/bottin:latest'
|
||||
env_file: '.env'
|
||||
ports:
|
60
deployments/kubernetes/bottin-pod.yaml
Normal file
60
deployments/kubernetes/bottin-pod.yaml
Normal file
|
@ -0,0 +1,60 @@
|
|||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: bottin-pod
|
||||
spec:
|
||||
initContainers:
|
||||
- name: clone
|
||||
image: alpine:3.20
|
||||
command: ['sh', '-c']
|
||||
args:
|
||||
- apk add git &&
|
||||
git clone -- https://git.agecem.com/agecem/bottin /opt/bottin-src
|
||||
volumeMounts:
|
||||
- name: bottin-src
|
||||
mountPath: /opt/bottin-src
|
||||
- name: build
|
||||
image: golang:1.23
|
||||
env:
|
||||
- name: CGO_ENABLED
|
||||
value: '0'
|
||||
command: ['sh', '-c']
|
||||
args:
|
||||
- cd /opt/bottin-src &&
|
||||
go build -a -o /opt/bottin-executable/bottin
|
||||
volumeMounts:
|
||||
- name: bottin-src
|
||||
mountPath: /opt/bottin-src
|
||||
- name: bottin-executable
|
||||
mountPath: /opt/bottin-executable
|
||||
containers:
|
||||
- name: api
|
||||
image: alpine:3.20
|
||||
command: ['sh', '-c']
|
||||
args:
|
||||
- ln -s /opt/bottin-executable/bottin /usr/bin/bottin
|
||||
volumeMounts:
|
||||
- name: bottin-executable
|
||||
mountPath: /opt/bottin-executable
|
||||
- name: bottin-secret
|
||||
readOnly: true
|
||||
mountPath: '/etc/bottin'
|
||||
- name: ui
|
||||
image: alpine:3.20
|
||||
command: ['sh', '-c']
|
||||
args:
|
||||
- bottin --config /etc/bottin/ui.yaml server ui
|
||||
volumeMounts:
|
||||
- name: bottin-executable
|
||||
mountPath: /opt/bottin-executable
|
||||
- name: bottin-secret
|
||||
readOnly: true
|
||||
mountPath: '/etc/bottin'
|
||||
volumes:
|
||||
- name: bottin-src
|
||||
emptyDir: {}
|
||||
- name: bottin-executable
|
||||
emptyDir: {}
|
||||
- name: bottin-secret
|
||||
secret:
|
||||
secretName: bottin-secret
|
26
deployments/kubernetes/example-bottin-secret.yaml
Normal file
26
deployments/kubernetes/example-bottin-secret.yaml
Normal file
|
@ -0,0 +1,26 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bottin-secret
|
||||
stringData:
|
||||
api.yaml: |
|
||||
bottin:
|
||||
server:
|
||||
api:
|
||||
db:
|
||||
database: 'bottin'
|
||||
host: 'db.example.com'
|
||||
password: 'bottin'
|
||||
sslmode: 'require'
|
||||
user: 'bottin'
|
||||
key: 'bottin'
|
||||
ui.yaml: |
|
||||
bottin:
|
||||
server:
|
||||
ui:
|
||||
api:
|
||||
tls:
|
||||
skipverify: 'true'
|
||||
key: 'bottin'
|
||||
password: 'bottin'
|
||||
user: 'bottin'
|
2
go.mod
2
go.mod
|
@ -1,4 +1,4 @@
|
|||
module git.agecem.com/agecem/bottin/v8
|
||||
module git.agecem.com/agecem/bottin/v9
|
||||
|
||||
go 1.22.0
|
||||
|
||||
|
|
18
main.go
18
main.go
|
@ -1,18 +0,0 @@
|
|||
package main
|
||||
|
||||
func main() {
|
||||
/* TODO
|
||||
if err := Run(context.Background(), Config{}, nil, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
*/
|
||||
|
||||
// Handle the command-line via cobra and viper
|
||||
execute()
|
||||
}
|
||||
|
||||
/* TODO
|
||||
func Run(ctx context.Context, config Config, args []string, stdout io.Writer) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
*/
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import (
|
||||
"fmt"
|
53
pkg/bottin/config.go
Normal file
53
pkg/bottin/config.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
package bottin
|
||||
|
||||
type Config struct {
|
||||
Client struct {
|
||||
API struct {
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
Protocol string `yaml:"protocol"`
|
||||
} `yaml:"api"`
|
||||
} `yaml:"client"`
|
||||
|
||||
Server struct {
|
||||
API struct {
|
||||
DB struct {
|
||||
Database string `yaml:"database"`
|
||||
Host string `yaml:"host"`
|
||||
Password string `yaml:"password"`
|
||||
Port int `yaml:"port"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
User string `yaml:"user"`
|
||||
} `yaml:"db"`
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
TLS struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Certfile string `yaml:"certfile"`
|
||||
Keyfile string `yaml:"keyfile"`
|
||||
} `yaml:"tls"`
|
||||
} `yaml:"api"`
|
||||
UI struct {
|
||||
API struct {
|
||||
Host string `yaml:"host"`
|
||||
Key string `yaml:"key"`
|
||||
Port int `yaml:"port"`
|
||||
Protocol string `yaml:"protocol"`
|
||||
TLS struct {
|
||||
SkipVerify bool `yaml:"skipverify"`
|
||||
} `yaml:"tls"`
|
||||
} `yaml:"api"`
|
||||
Host string `yaml:"host"`
|
||||
Password string `yaml:"password"`
|
||||
Port int `yaml:"port"`
|
||||
TLS struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Certfile string `yaml:"certfile"`
|
||||
Keyfile string `yaml:"keyfile"`
|
||||
} `yaml:"tls"`
|
||||
User string `yaml:"user"`
|
||||
} `yaml:"ui"`
|
||||
} `yaml:"server"`
|
||||
}
|
|
@ -1,20 +1,15 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"git.agecem.com/agecem/bottin/v9/queries"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed sql/schema.sql
|
||||
var sqlSchema string
|
||||
|
||||
//go:embed sql/views.sql
|
||||
var sqlViews string
|
||||
|
||||
type PostgresClient struct {
|
||||
//TODO move context out of client
|
||||
Ctx context.Context
|
||||
|
@ -22,12 +17,12 @@ type PostgresClient struct {
|
|||
}
|
||||
|
||||
func (db *PostgresClient) CreateOrReplaceSchema() error {
|
||||
_, err := db.Pool.Exec(db.Ctx, sqlSchema)
|
||||
_, err := db.Pool.Exec(db.Ctx, queries.SQLSchema())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *PostgresClient) CreateOrReplaceViews() error {
|
||||
_, err := db.Pool.Exec(db.Ctx, sqlViews)
|
||||
_, err := db.Pool.Exec(db.Ctx, queries.SQLViews())
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import "unicode"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
@ -23,7 +23,7 @@ func (request HealthGETRequest) Request(v *voki.Voki) (response HealthGETRespons
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
"/api/v8/health/",
|
||||
"/api/v9/health/",
|
||||
nil,
|
||||
true,
|
||||
)
|
||||
|
@ -64,7 +64,7 @@ func (request ProgrammesPOSTRequest) Request(v *voki.Voki) (response ProgrammesP
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodPost,
|
||||
"/api/v8/programme/",
|
||||
"/api/v9/programme/",
|
||||
&buf,
|
||||
true,
|
||||
)
|
||||
|
@ -105,7 +105,7 @@ func (request MembresPOSTRequest) Request(v *voki.Voki) (response MembresPOSTRes
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodPost,
|
||||
"/api/v8/membre/",
|
||||
"/api/v9/membre/",
|
||||
&buf,
|
||||
true,
|
||||
)
|
||||
|
@ -146,7 +146,7 @@ func (request MembreGETRequest) Request(v *voki.Voki) (response MembreGETRespons
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v8/membre/%s/", request.Param.MembreID),
|
||||
fmt.Sprintf("/api/v9/membre/%s/", request.Param.MembreID),
|
||||
nil,
|
||||
true,
|
||||
)
|
||||
|
@ -180,7 +180,7 @@ func (request MembresGETRequest) Request(v *voki.Voki) (response MembresGETRespo
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v8/membre/?limit=%d", request.Query.Limit),
|
||||
fmt.Sprintf("/api/v9/membre/?limit=%d", request.Query.Limit),
|
||||
nil,
|
||||
true,
|
||||
)
|
||||
|
@ -224,7 +224,7 @@ func (request MembrePreferedNamePUTRequest) Request(v *voki.Voki) (response Memb
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/api/v8/membre/%s/prefered_name/", request.Param.MembreID),
|
||||
fmt.Sprintf("/api/v9/membre/%s/prefered_name/", request.Param.MembreID),
|
||||
&buf,
|
||||
true,
|
||||
)
|
||||
|
@ -258,7 +258,7 @@ func (request ProgrammesGETRequest) Request(v *voki.Voki) (response ProgrammesGE
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v8/programme/?limit=%d", request.Query.Limit),
|
||||
fmt.Sprintf("/api/v9/programme/?limit=%d", request.Query.Limit),
|
||||
nil,
|
||||
true,
|
||||
)
|
||||
|
@ -292,7 +292,7 @@ func (request MembresDisplayGETRequest) Request(v *voki.Voki) (response MembresD
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v8/membre/display/?limit=%d", request.Query.Limit),
|
||||
fmt.Sprintf("/api/v9/membre/display/?limit=%d", request.Query.Limit),
|
||||
nil,
|
||||
true,
|
||||
)
|
||||
|
@ -333,7 +333,7 @@ func (request MembreDisplayGETRequest) Request(v *voki.Voki) (response MembreDis
|
|||
|
||||
statusCode, body, err := v.CallAndParse(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v8/membre/%s/display/", request.Param.MembreID),
|
||||
fmt.Sprintf("/api/v9/membre/%s/display/", request.Param.MembreID),
|
||||
nil,
|
||||
true,
|
||||
)
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import (
|
||||
"fmt"
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package bottin
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
|
@ -13,11 +13,11 @@ import (
|
|||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func addRoutes(e *echo.Echo, db *PostgresClient, cfg Config) error {
|
||||
func AddRoutes(e *echo.Echo, db *PostgresClient, cfg Config) error {
|
||||
_ = db
|
||||
_ = cfg
|
||||
|
||||
apiPath := "/api/v8"
|
||||
apiPath := "/api/v9"
|
||||
apiGroup := e.Group(apiPath)
|
||||
p := pave.New()
|
||||
if err := pave.EchoRegister[HealthGETRequest](
|
14
queries/queries.go
Normal file
14
queries/queries.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var sqlSchema string
|
||||
|
||||
//go:embed views.sql
|
||||
var sqlViews string
|
||||
|
||||
func SQLSchema() string { return sqlSchema }
|
||||
func SQLViews() string { return sqlViews }
|
6
scripts/compose-inject-x509.sh
Executable file
6
scripts/compose-inject-x509.sh
Executable file
|
@ -0,0 +1,6 @@
|
|||
#!/bin/sh
|
||||
|
||||
docker-compose cp cert.pem api:/etc/bottin/cert.pem
|
||||
docker-compose cp key.pem api:/etc/bottin/key.pem
|
||||
docker-compose cp cert.pem ui:/etc/bottin/cert.pem
|
||||
docker-compose cp key.pem ui:/etc/bottin/key.pem
|
2
scripts/generate-self-signed-x509.sh
Executable file
2
scripts/generate-self-signed-x509.sh
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
//go:embed *.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
type Template struct {
|
||||
|
@ -18,3 +18,10 @@ type Template struct {
|
|||
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
// NewTemplate returns a new Template instance with templates embedded from *.html
|
||||
func NewTemplate() *Template {
|
||||
return &Template{
|
||||
templates: template.Must(template.ParseFS(templatesFS, "*.html")),
|
||||
}
|
||||
}
|
Reference in a new issue