diff --git a/Dockerfile b/Dockerfile index 5281e65..d91ca09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ ADD cmd/ cmd/ ADD api/ api/ +ADD config/ config/ + +ADD media/ media/ + RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o agecem-org . # Alpine diff --git a/README.md b/README.md index 332a1a9..ef59413 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,18 @@ Le lifecycle du serveur est maintenu par containers, en utilisant `docker-compos ### Exemples d'utilisation de docker-compose +*L'exemple suivant assume que minio est déployé par docker-compose en même temps que le serveur web.* + +Remplir .env avec les credentials que minio utilisera pour sa configuration initiale. + +Remplacer `agecem-org` par quelque chose de sécurisé. + +``` +# .env +MINIO_ROOT_USER=agecem-org +MINIO_ROOT_PASSWORD=agecem-org +``` + Déployer le ou les containers en mode détaché, en s'assurant de rebâtir l'image. `$ docker-compose up -d --build` diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..a8d41f0 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,44 @@ +/* +Copyright © 2023 AGECEM +*/ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + + "git.agecem.com/agecem/agecem-org/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Print the config to stdout in indented JSON format", + Run: func(cmd *cobra.Command, args []string) { + var cfg config.Config + + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal(err) + } + + printConfig(cfg) + }, +} + +func init() { + rootCmd.AddCommand(configCmd) +} + +func printConfig(config config.Config) error { + buf, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + fmt.Println(string(buf)) + + return nil +} diff --git a/cmd/server.go b/cmd/server.go index ff7d4d4..e5d07f6 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -18,11 +18,12 @@ import ( "sort" "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" "github.com/spf13/cobra" "github.com/spf13/viper" "git.agecem.com/agecem/agecem-org/api" + "git.agecem.com/agecem/agecem-org/config" + "git.agecem.com/agecem/agecem-org/media" "git.agecem.com/agecem/agecem-org/public" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -32,6 +33,8 @@ type Template struct { templates *template.Template } +var cfg config.Config + var embedFS embed.FS // serverCmd represents the server command @@ -39,6 +42,10 @@ var serverCmd = &cobra.Command{ Use: "server", Short: "Démarrer le serveur web", Run: func(cmd *cobra.Command, args []string) { + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatal(err) + } + RunServer() }, } @@ -64,7 +71,7 @@ func init() { // server.documents.access_key_id - --server-documents-access-key-id serverCmd.Flags().String("server-documents-access-key-id", "", "Storage server access key id (config: server.documents.access_key_id)") - viper.BindPFlag("documents.accessKeyID", serverCmd.Flags().Lookup("documents-access-key-id")) + viper.BindPFlag("server.documents.access_key_id", serverCmd.Flags().Lookup("server-documents-access-key-id")) // server.documents.secret_access_key - --server-documents-secret-access-key serverCmd.Flags().String("server-documents-secret-access-key", "", "Storage server secret access key (config: server.documents.secret_access_key)") @@ -114,13 +121,13 @@ func RunServer() { groupV1.Use(middleware.AddTrailingSlash()) - if viper.GetBool("server.api.auth") { - if len(viper.GetString("server.api.key")) < 10 { + if cfg.Server.Api.Auth { + if len(cfg.Server.Api.Key) < 10 { log.Fatal("server.api.auth is enabled, but server.api.key is too small (needs at least 10 characters)") } groupV1.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { - return subtle.ConstantTimeCompare([]byte(key), []byte(viper.GetString("server.api.key"))) == 1, nil + return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.Api.Key)) == 1, nil })) log.Println("Key auth for /v1 activated") @@ -130,21 +137,19 @@ func RunServer() { groupAdmin.Use(middleware.AddTrailingSlash()) - if viper.GetBool("server.admin.auth") { - username := viper.GetString("server.admin.username") - password := viper.GetString("server.admin.password") - if len(username) < 5 { + if cfg.Server.Admin.Auth { + if len(cfg.Server.Admin.Username) < 5 { log.Fatal("server.admin.auth is enabled, but server.admin.username is too small (needs at least 5 characters)") } - if len(password) < 10 { + if len(cfg.Server.Admin.Password) < 10 { log.Fatal("server.admin.auth is enabled, but server.admin.password is too small (needs at least 10 characters)") } groupAdmin.Use(middleware.BasicAuth(func(username_entered, password_entered string, c echo.Context) (bool, error) { // Be careful to use constant time comparison to prevent timing attacks - if subtle.ConstantTimeCompare([]byte(username_entered), []byte(username)) == 1 && - subtle.ConstantTimeCompare([]byte(password_entered), []byte(password)) == 1 { + if subtle.ConstantTimeCompare([]byte(username_entered), []byte(cfg.Server.Admin.Username)) == 1 && + subtle.ConstantTimeCompare([]byte(password_entered), []byte(cfg.Server.Admin.Password)) == 1 { return true, nil } return false, nil @@ -208,7 +213,7 @@ func RunServer() { groupAdmin.POST("/documents/upload", handleAdminDocumentsUploadPOST) e.Logger.Fatal(e.Start( - fmt.Sprintf(":%d", viper.GetInt("server.port")))) + fmt.Sprintf(":%d", cfg.Server.Port))) } func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { @@ -228,30 +233,22 @@ func handleV1(c echo.Context) error { // handleV1Seed créé des buckets dans minio selon la liste de buckets dans server.documents.buckets // Les buckets sont créés avec paramètres par défaut, et sont ensuite visible dans /v1/bucket. func handleV1Seed(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } var new_buckets []string - for _, bucket := range documents_buckets { - exists, err := client.BucketExists(context.Background(), bucket) + for _, bucket := range cfg.Server.Documents.Buckets { + exists, err := mediaClient.MinioClient.BucketExists(context.Background(), bucket) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "message": "Error during minio#BucketExists", + "error": err.Error(), }) } @@ -259,9 +256,10 @@ func handleV1Seed(c echo.Context) error { continue } - if err = client.MakeBucket(context.Background(), bucket, minio.MakeBucketOptions{}); err != nil { + if err = mediaClient.MinioClient.MakeBucket(context.Background(), bucket, minio.MakeBucketOptions{}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "message": "Error during minio#MakeBucket", + "error": err.Error(), }) } new_buckets = append(new_buckets, bucket) @@ -282,27 +280,18 @@ func handleV1Seed(c echo.Context) error { // handleV1BucketList affiche les buckets permis par server.documents.buckets, qui existent. func handleV1BucketList(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } var buckets []string - for _, bucket_name := range documents_buckets { - exists, err := client.BucketExists(context.Background(), bucket_name) + for _, bucket_name := range cfg.Server.Documents.Buckets { + exists, err := mediaClient.MinioClient.BucketExists(context.Background(), bucket_name) if err != nil { return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") } @@ -316,16 +305,10 @@ func handleV1BucketList(c echo.Context) error { } func handleV1BucketRead(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - bucket := c.Param("bucket") allowed := false - for _, bucket_allowed := range documents_buckets { + for _, bucket_allowed := range cfg.Server.Documents.Buckets { if bucket == bucket_allowed { allowed = true } @@ -344,18 +327,15 @@ func handleV1BucketRead(c echo.Context) error { defer cancel() - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } - exists, err := client.BucketExists(ctx, bucket) + exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) if err != nil { return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") } @@ -366,7 +346,7 @@ func handleV1BucketRead(c echo.Context) error { var keys []string - objectCh := client.ListObjects(ctx, bucket, minio.ListObjectsOptions{}) + objectCh := mediaClient.MinioClient.ListObjects(ctx, bucket, minio.ListObjectsOptions{}) for object := range objectCh { if object.Err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ @@ -382,12 +362,6 @@ func handleV1BucketRead(c echo.Context) error { // handleV1DocumentCreate permet d'ajouter un object dans un bucket, par multipart/form-data func handleV1DocumentCreate(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - bucket := c.Param("bucket") form_file, err := c.FormFile("document") @@ -399,7 +373,7 @@ func handleV1DocumentCreate(c echo.Context) error { } allowed := false - for _, bucket_allowed := range documents_buckets { + for _, bucket_allowed := range cfg.Server.Documents.Buckets { if bucket == bucket_allowed { allowed = true } @@ -413,14 +387,11 @@ func handleV1DocumentCreate(c echo.Context) error { defer cancel() - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } @@ -437,7 +408,7 @@ func handleV1DocumentCreate(c echo.Context) error { filename_processed := reg.ReplaceAllString(form_file.Filename, "") - info, err := client.PutObject(ctx, bucket, filename_processed, src, form_file.Size, minio.PutObjectOptions{ + info, err := mediaClient.MinioClient.PutObject(ctx, bucket, filename_processed, src, form_file.Size, minio.PutObjectOptions{ ContentType: form_file.Header.Get("Content-Type"), }) if err != nil { @@ -458,17 +429,11 @@ func handleV1DocumentCreate(c echo.Context) error { // handleV1DocumentRead permet de lire le contenu d'un fichier et protentiellement de le télécharger func handleV1DocumentRead(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - bucket := c.Param("bucket") document := c.Param("document") allowed := false - for _, bucket_allowed := range documents_buckets { + for _, bucket_allowed := range cfg.Server.Documents.Buckets { if bucket == bucket_allowed { allowed = true } @@ -487,18 +452,15 @@ func handleV1DocumentRead(c echo.Context) error { defer cancel() - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } - bucket_exists, err := client.BucketExists(ctx, bucket) + bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) if err != nil { return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") } @@ -507,7 +469,7 @@ func handleV1DocumentRead(c echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) } - document_info, err := client.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) + document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) if err != nil { if err.Error() == "The specified key does not exist." { @@ -522,7 +484,7 @@ func handleV1DocumentRead(c echo.Context) error { _ = document_info - document_object, err := client.GetObject(ctx, bucket, document, minio.GetObjectOptions{}) + document_object, err := mediaClient.MinioClient.GetObject(ctx, bucket, document, minio.GetObjectOptions{}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "message": "Error during minio#GetObject", @@ -543,17 +505,11 @@ func handleV1DocumentUpdate(c echo.Context) error { // handleV1DocumentDelete permet de supprimer un object func handleV1DocumentDelete(c echo.Context) error { - documents_buckets := viper.GetStringSlice("server.documents.buckets") - documents_endpoint := viper.GetString("server.documents.endpoint") - documents_access_key_id := viper.GetString("server.documents.access_key_id") - documents_secret_access_key := viper.GetString("server.documents.secret_access_key") - documents_use_ssl := viper.GetBool("server.documents.use_ssl") - bucket := c.Param("bucket") document := c.Param("document") allowed := false - for _, bucket_allowed := range documents_buckets { + for _, bucket_allowed := range cfg.Server.Documents.Buckets { if bucket == bucket_allowed { allowed = true } @@ -572,18 +528,15 @@ func handleV1DocumentDelete(c echo.Context) error { defer cancel() - // Initialize minio client object - client, err := minio.New(documents_endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(documents_access_key_id, documents_secret_access_key, ""), - Secure: documents_use_ssl, - }) + mediaClient, err := media.NewMediaClientFromViper() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#New", + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), }) } - bucket_exists, err := client.BucketExists(ctx, bucket) + bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) if err != nil { return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") } @@ -592,7 +545,7 @@ func handleV1DocumentDelete(c echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) } - document_info, err := client.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) + document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) if err != nil { if err.Error() == "The specified key does not exist." { @@ -607,7 +560,7 @@ func handleV1DocumentDelete(c echo.Context) error { //TODO Add error validation _ = document_info - err = client.RemoveObject(ctx, bucket, document, minio.RemoveObjectOptions{}) + err = mediaClient.MinioClient.RemoveObject(ctx, bucket, document, minio.RemoveObjectOptions{}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "message": "Error during minio#RemoveObject", @@ -648,9 +601,9 @@ func handleVieEtudianteOrganisme(c echo.Context) error { } func handleDocumentation(c echo.Context) error { - client, err := api.New("http", "localhost", viper.GetInt("server.port"), api.APIOptions{ - KeyAuth: viper.GetBool("server.api.auth"), - Key: viper.GetString("server.api.key"), + client, err := api.New("http", "localhost", cfg.Server.Port, api.APIOptions{ + KeyAuth: cfg.Server.Api.Auth, + Key: cfg.Server.Api.Key, }) if err != nil { return c.Render(http.StatusInternalServerError, "documentation-html", nil) @@ -720,9 +673,9 @@ func handleFormulaires(c echo.Context) error { } func handlePublicDocumentation(c echo.Context) error { - client, err := api.New("http", "localhost", viper.GetInt("server.port"), api.APIOptions{ - KeyAuth: viper.GetBool("server.api.auth"), - Key: viper.GetString("server.api.key"), + client, err := api.New("http", "localhost", cfg.Server.Port, api.APIOptions{ + KeyAuth: cfg.Server.Api.Auth, + Key: cfg.Server.Api.Key, }) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) @@ -756,12 +709,12 @@ func handleAdminDocumentsUpload(c echo.Context) error { } func handleAdminDocumentsUploadPOST(c echo.Context) error { - client, err := api.New("http", "localhost", viper.GetInt("server.port"), api.APIOptions{ - KeyAuth: viper.GetBool("server.api.auth"), - Key: viper.GetString("server.api.key"), - BasicAuth: viper.GetBool("server.admin.auth"), - Username: viper.GetString("server.admin.username"), - Password: viper.GetString("server.admin.password"), + client, err := api.New("http", "localhost", cfg.Server.Port, api.APIOptions{ + KeyAuth: cfg.Server.Api.Auth, + Key: cfg.Server.Api.Key, + BasicAuth: cfg.Server.Admin.Auth, + Username: cfg.Server.Admin.Username, + Password: cfg.Server.Admin.Password, }) if err != nil { return c.Render(http.StatusInternalServerError, "admin-upload-html", struct{ Message string }{Message: fmt.Sprintf("handleAdminDocumentsUploadPOST#api.New: %s", err)}) diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..decfeca --- /dev/null +++ b/config/config.go @@ -0,0 +1,38 @@ +package config + +/* +Permet de contenir la configuration obtenue par cobra/viper + +Example d'utilisation sans error handling: + +``` +var cfg config.Config +viper.Unmarshal(&cfg) +``` + +`cfg` devrait alors contenir la configuration, et les données peuvent être +obtenues simplement en utilisant la dot (.) notation +*/ +type Config struct { + Server struct { + Admin struct { + Auth bool `mapstructure:"auth"` + Password string `mapstructure:"password"` + Username string `mapstructure:"username"` + } `mapstructure:"admin"` + Api struct { + Auth bool `mapstructure:"auth"` + Key string `mapstructure:"key"` + } `mapstructure:"api"` + Documents struct { + AccessKeyId string `mapstructure:"access_key_id"` + Buckets []string `mapstructure:"buckets"` + Endpoint string `mapstructure:"endpoint"` + SecretAccessKey string `mapstructure:"secret_access_key"` + UseSSL bool `mapstructure:"use_ssl"` + KeyId string `mapstructure:"keyid"` + KeyValue string `mapstructure:"keyvalue"` + } `mapstructure:"documents"` + Port int `mapstructure:"port"` + } `mapstructure:"server"` +} diff --git a/media/media.go b/media/media.go new file mode 100644 index 0000000..2d044f0 --- /dev/null +++ b/media/media.go @@ -0,0 +1,54 @@ +package media + +import ( + "errors" + + "git.agecem.com/agecem/agecem-org/config" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/spf13/viper" +) + +func NewMediaClient(endpoint, accessKeyId, secretAccessKey string, useSSL bool) (*MediaClient, error) { + if accessKeyId == "" { + return nil, errors.New("accessKeyId was found empty, but cannot be") + } + + if secretAccessKey == "" { + return nil, errors.New("secretAccessKey was found empty, but cannot be") + } + + var mediaClient MediaClient + minioClient, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKeyId, secretAccessKey, ""), + Secure: useSSL, + }) + if err != nil { + return &mediaClient, err + } + + mediaClient.MinioClient = *minioClient + return &mediaClient, nil +} + +func NewMediaClientFromViper() (*MediaClient, error) { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + mediaClient, err := NewMediaClient(cfg.Server.Documents.Endpoint, cfg.Server.Documents.AccessKeyId, cfg.Server.Documents.SecretAccessKey, cfg.Server.Documents.UseSSL) + if err != nil { + return mediaClient, err + } + + return mediaClient, nil +} + +type MediaClient struct { + MinioClient minio.Client +} + +func (m *MediaClient) foo() string { + return "bar" +}