From 264381b340ed9a918dfdc395c85f9116342c5f15 Mon Sep 17 00:00:00 2001 From: Victor Lacasse-Beaudoin Date: Mon, 24 Jul 2023 14:28:55 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Ajouter=20handlers=20API=20=C3=A0=20serverh?= =?UTF-8?q?andlers/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 + serverhandlers/serverhandlers.go | 381 +++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 serverhandlers/serverhandlers.go diff --git a/Dockerfile b/Dockerfile index a062281..c5d663e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,8 @@ ADD media/ media/ ADD templates/ templates/ +ADD serverhandlers/ serverhandlers/ + RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o agecem-org . # Alpine diff --git a/serverhandlers/serverhandlers.go b/serverhandlers/serverhandlers.go new file mode 100644 index 0000000..eb688b5 --- /dev/null +++ b/serverhandlers/serverhandlers.go @@ -0,0 +1,381 @@ +package serverhandlers + +import ( + "context" + "net/http" + "sort" + + "git.agecem.com/agecem/agecem-org/config" + "git.agecem.com/agecem/agecem-org/media" + "github.com/labstack/echo/v4" + "github.com/minio/minio-go/v7" + "github.com/spf13/viper" +) + +// API Handlers + +// HandleV1 affiche les routes accessibles. +// Les routes sont triées selon .Path, pour les rendre plus facilement navigables. +func HandleV1(c echo.Context) error { + routes := c.Echo().Routes() + sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) + return c.JSON(http.StatusOK, routes) +} + +// 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 { + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + new_buckets, err := mediaClient.Seed() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during mediaClient.Seed()", + "error": err.Error(), + }) + } + + var message string + if len(new_buckets) == 0 { + message = "All buckets already exist" + + } else { + message = "Buckets successfully created" + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": message, + "buckets": new_buckets, + }) +} + +// HandleV1BucketList affiche les buckets permis par server.documents.buckets, qui existent. +func HandleV1BucketList(c echo.Context) error { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + var buckets []string + + 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") + } + + if exists { + buckets = append(buckets, bucket_name) + } + } + + return c.JSON(http.StatusOK, buckets) +} + +func HandleV1BucketRead(c echo.Context) error { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + + bucket := c.Param("bucket") + + allowed := false + for _, bucket_allowed := range cfg.Server.Documents.Buckets { + if bucket == bucket_allowed { + allowed = true + } + } + + if !allowed { + /* + return c.JSON(http.StatusBadRequest, map[string]string{ + "message": "Bucket is not allowed in server.documents.buckets", + }) + */ + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) + if err != nil { + return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") + } + + if !exists { + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + var keys []string + + objectCh := mediaClient.MinioClient.ListObjects(ctx, bucket, minio.ListObjectsOptions{}) + for object := range objectCh { + if object.Err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during minio#ListObjects", + }) + } + + keys = append(keys, object.Key) + } + + return c.JSON(http.StatusOK, keys) +} + +// HandleV1DocumentCreate permet d'ajouter un object dans un bucket, par multipart/form-data +func HandleV1DocumentCreate(c echo.Context) error { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + + bucket := c.Param("bucket") + + form_file, err := c.FormFile("document") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "message": "Error during HandleV1DocumentCreate's echo#Context.FormFile", + "error": err, + }) + } + + allowed := false + for _, bucket_allowed := range cfg.Server.Documents.Buckets { + if bucket == bucket_allowed { + allowed = true + } + } + + if !allowed { + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + src, err := form_file.Open() + if err != nil { + return err + } + defer src.Close() + + /* + reg, err := regexp.Compile("[^.a-zA-Z0-9_-]+") + if err != nil { + return c.Render(http.StatusInternalServerError, "documentation-html", nil) + } + + filename_processed := reg.ReplaceAllString(form_file.Filename, "") + */ + + info, err := mediaClient.MinioClient.PutObject(ctx, bucket, form_file.Filename, src, form_file.Size, minio.PutObjectOptions{ + ContentType: form_file.Header.Get("Content-Type"), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during minio#PutObject", + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "ok", + "info": map[string]interface{}{ + "bucket": info.Bucket, + "key": info.Key, + "size": info.Size, + }, + }) +} + +// HandleV1DocumentRead permet de lire le contenu d'un fichier et protentiellement de le télécharger +func HandleV1DocumentRead(c echo.Context) error { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + + bucket := c.Param("bucket") + document := c.Param("document") + + allowed := false + for _, bucket_allowed := range cfg.Server.Documents.Buckets { + if bucket == bucket_allowed { + allowed = true + } + } + + if !allowed { + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) + if err != nil { + return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") + } + + if !bucket_exists { + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) + + if err != nil { + if err.Error() == "The specified key does not exist." { + + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "Error during minio#StatObject", + }) + } + + _ = document_info + + 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", + }) + } + + defer document_object.Close() + + return c.Stream(http.StatusOK, document_info.ContentType, document_object) +} + +// HandleV1DocumentUpdate permet de mettre à jour certains champs d'un object, comme le Content-Type ou le Filename +func HandleV1DocumentUpdate(c echo.Context) error { + return c.JSON(http.StatusNotImplemented, map[string]string{ + "message": "Not Implemented", + }) +} + +// HandleV1DocumentDelete permet de supprimer un object +func HandleV1DocumentDelete(c echo.Context) error { + var cfg config.Config + if err := viper.Unmarshal(&cfg); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + bucket := c.Param("bucket") + document := c.Param("document") + + allowed := false + for _, bucket_allowed := range cfg.Server.Documents.Buckets { + if bucket == bucket_allowed { + allowed = true + } + } + + if !allowed { + /* + return c.JSON(http.StatusBadRequest, map[string]string{ + "message": "Bucket is not allowed in server.documents.buckets", + }) + */ + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + mediaClient, err := media.NewMediaClientFromViper() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "message": "Error during media.NewMediaClientFromViper()", + "error": err.Error(), + }) + } + + bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) + if err != nil { + return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") + } + + if !bucket_exists { + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) + if err != nil { + if err.Error() == "The specified key does not exist." { + + return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) + } + + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "Error during minio#StatObject", + }) + } + + //TODO Add error validation + _ = document_info + + 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", + }) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "Document deleted", + }) +} From 2c9817414484af08617c18cff464146892ebcd3d Mon Sep 17 00:00:00 2001 From: Victor Lacasse-Beaudoin Date: Mon, 24 Jul 2023 14:29:19 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Migrer=20utilisation=20d'API=20handlers=20?= =?UTF-8?q?=C3=A0=20serverhandlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server.go | 354 ++------------------------------------------------ 1 file changed, 9 insertions(+), 345 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index cef05fc..9c30316 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -4,7 +4,6 @@ Copyright © 2023 AGECEM package cmd import ( - "context" "crypto/subtle" "encoding/json" "fmt" @@ -14,9 +13,7 @@ import ( "html/template" "io" "net/http" - "sort" - "github.com/minio/minio-go/v7" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -24,6 +21,7 @@ import ( "git.agecem.com/agecem/agecem-org/config" "git.agecem.com/agecem/agecem-org/media" "git.agecem.com/agecem/agecem-org/public" + "git.agecem.com/agecem/agecem-org/serverhandlers" "git.agecem.com/agecem/agecem-org/templates" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -196,21 +194,21 @@ func RunServer() { // API Routes - groupV1.GET("", handleV1) + groupV1.GET("", serverhandlers.HandleV1) - groupV1.POST("/seed", handleV1Seed) + groupV1.POST("/seed", serverhandlers.HandleV1Seed) - groupV1.GET("/bucket", handleV1BucketList) + groupV1.GET("/bucket", serverhandlers.HandleV1BucketList) - groupV1.GET("/bucket/:bucket", handleV1BucketRead) + groupV1.GET("/bucket/:bucket", serverhandlers.HandleV1BucketRead) - groupV1.POST("/bucket/:bucket", handleV1DocumentCreate) + groupV1.POST("/bucket/:bucket", serverhandlers.HandleV1DocumentCreate) - groupV1.GET("/bucket/:bucket/:document", handleV1DocumentRead) + groupV1.GET("/bucket/:bucket/:document", serverhandlers.HandleV1DocumentRead) - groupV1.PUT("/bucket/:bucket/:document", handleV1DocumentUpdate) + groupV1.PUT("/bucket/:bucket/:document", serverhandlers.HandleV1DocumentUpdate) - groupV1.DELETE("/bucket/:bucket/:document", handleV1DocumentDelete) + groupV1.DELETE("/bucket/:bucket/:document", serverhandlers.HandleV1DocumentDelete) // HTML Routes @@ -250,340 +248,6 @@ func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Con return t.templates.ExecuteTemplate(w, name, data) } -// API Handlers - -// handleV1 affiche les routes accessibles. -// Les routes sont triées selon .Path, pour les rendre plus facilement navigables. -func handleV1(c echo.Context) error { - routes := c.Echo().Routes() - sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) - return c.JSON(http.StatusOK, routes) -} - -// 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 { - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - new_buckets, err := mediaClient.Seed() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during mediaClient.Seed()", - "error": err.Error(), - }) - } - - var message string - if len(new_buckets) == 0 { - message = "All buckets already exist" - - } else { - message = "Buckets successfully created" - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "message": message, - "buckets": new_buckets, - }) -} - -// handleV1BucketList affiche les buckets permis par server.documents.buckets, qui existent. -func handleV1BucketList(c echo.Context) error { - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - var buckets []string - - 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") - } - - if exists { - buckets = append(buckets, bucket_name) - } - } - - return c.JSON(http.StatusOK, buckets) -} - -func handleV1BucketRead(c echo.Context) error { - bucket := c.Param("bucket") - - allowed := false - for _, bucket_allowed := range cfg.Server.Documents.Buckets { - if bucket == bucket_allowed { - allowed = true - } - } - - if !allowed { - /* - return c.JSON(http.StatusBadRequest, map[string]string{ - "message": "Bucket is not allowed in server.documents.buckets", - }) - */ - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - ctx, cancel := context.WithCancel(context.Background()) - - defer cancel() - - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) - if err != nil { - return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") - } - - if !exists { - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - var keys []string - - objectCh := mediaClient.MinioClient.ListObjects(ctx, bucket, minio.ListObjectsOptions{}) - for object := range objectCh { - if object.Err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#ListObjects", - }) - } - - keys = append(keys, object.Key) - } - - return c.JSON(http.StatusOK, keys) -} - -// handleV1DocumentCreate permet d'ajouter un object dans un bucket, par multipart/form-data -func handleV1DocumentCreate(c echo.Context) error { - bucket := c.Param("bucket") - - form_file, err := c.FormFile("document") - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]interface{}{ - "message": "Error during handleV1DocumentCreate's echo#Context.FormFile", - "error": err, - }) - } - - allowed := false - for _, bucket_allowed := range cfg.Server.Documents.Buckets { - if bucket == bucket_allowed { - allowed = true - } - } - - if !allowed { - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - ctx, cancel := context.WithCancel(context.Background()) - - defer cancel() - - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - src, err := form_file.Open() - if err != nil { - return err - } - defer src.Close() - - /* - reg, err := regexp.Compile("[^.a-zA-Z0-9_-]+") - if err != nil { - return c.Render(http.StatusInternalServerError, "documentation-html", nil) - } - - filename_processed := reg.ReplaceAllString(form_file.Filename, "") - */ - - info, err := mediaClient.MinioClient.PutObject(ctx, bucket, form_file.Filename, src, form_file.Size, minio.PutObjectOptions{ - ContentType: form_file.Header.Get("Content-Type"), - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during minio#PutObject", - }) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "message": "ok", - "info": map[string]interface{}{ - "bucket": info.Bucket, - "key": info.Key, - "size": info.Size, - }, - }) -} - -// handleV1DocumentRead permet de lire le contenu d'un fichier et protentiellement de le télécharger -func handleV1DocumentRead(c echo.Context) error { - bucket := c.Param("bucket") - document := c.Param("document") - - allowed := false - for _, bucket_allowed := range cfg.Server.Documents.Buckets { - if bucket == bucket_allowed { - allowed = true - } - } - - if !allowed { - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - ctx, cancel := context.WithCancel(context.Background()) - - defer cancel() - - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) - if err != nil { - return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") - } - - if !bucket_exists { - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) - - if err != nil { - if err.Error() == "The specified key does not exist." { - - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "message": "Error during minio#StatObject", - }) - } - - _ = document_info - - 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", - }) - } - - defer document_object.Close() - - return c.Stream(http.StatusOK, document_info.ContentType, document_object) -} - -// handleV1DocumentUpdate permet de mettre à jour certains champs d'un object, comme le Content-Type ou le Filename -func handleV1DocumentUpdate(c echo.Context) error { - return c.JSON(http.StatusNotImplemented, map[string]string{ - "message": "Not Implemented", - }) -} - -// handleV1DocumentDelete permet de supprimer un object -func handleV1DocumentDelete(c echo.Context) error { - bucket := c.Param("bucket") - document := c.Param("document") - - allowed := false - for _, bucket_allowed := range cfg.Server.Documents.Buckets { - if bucket == bucket_allowed { - allowed = true - } - } - - if !allowed { - /* - return c.JSON(http.StatusBadRequest, map[string]string{ - "message": "Bucket is not allowed in server.documents.buckets", - }) - */ - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - ctx, cancel := context.WithCancel(context.Background()) - - defer cancel() - - mediaClient, err := media.NewMediaClientFromViper() - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "message": "Error during media.NewMediaClientFromViper()", - "error": err.Error(), - }) - } - - bucket_exists, err := mediaClient.MinioClient.BucketExists(ctx, bucket) - if err != nil { - return c.JSON(http.StatusInternalServerError, "Error during minio#BucketExists") - } - - if !bucket_exists { - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - document_info, err := mediaClient.MinioClient.StatObject(ctx, bucket, document, minio.StatObjectOptions{}) - if err != nil { - if err.Error() == "The specified key does not exist." { - - return c.JSON(http.StatusNotFound, map[string]string{"message": "Not Found"}) - } - - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "message": "Error during minio#StatObject", - }) - } - - //TODO Add error validation - _ = document_info - - 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", - }) - } - - return c.JSON(http.StatusOK, map[string]string{ - "message": "Document deleted", - }) -} - // HTML Handlers func handleIndex(c echo.Context) error { From b7536dd6a328b87e3fe33b3b6448d8e69244b0ff Mon Sep 17 00:00:00 2001 From: Victor Lacasse-Beaudoin Date: Tue, 25 Jul 2023 13:05:02 -0400 Subject: [PATCH 3/3] Ajouter dossier public/fonts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permettra d'embed les polices d'écritures dans le fichier binaire --- public/fonts/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/fonts/.gitkeep diff --git a/public/fonts/.gitkeep b/public/fonts/.gitkeep new file mode 100644 index 0000000..e69de29