diff --git a/api/api.go b/api/api.go index 70334f9..c7d270d 100644 --- a/api/api.go +++ b/api/api.go @@ -1,9 +1,13 @@ package api import ( + "bytes" + "encoding/json" "errors" "fmt" + "io" "io/ioutil" + "mime/multipart" "net/http" ) @@ -15,8 +19,22 @@ type API struct { } type APIOptions struct { - KeyAuth bool - Key string + KeyAuth bool + Key string + BasicAuth bool + Username string + Password string +} + +type UploadDocumentResponse struct { + Info UploadDocumentResponseInfo `json:"info"` + Message string `json:"message"` +} + +type UploadDocumentResponseInfo struct { + Bucket string `json:"bucket"` + Object string `json:"key"` + Size float64 `json:"size"` } func New(protocol, host string, port int, opts APIOptions) (*API, error) { @@ -97,5 +115,138 @@ func (a *API) Call(method, route string) ([]byte, error) { } return respBody, nil } + + //return nil, errors.New(fmt.Sprintf("method must be 'GET' or 'DELETE', got '%s'", method)) return nil, errors.New(fmt.Sprintf("method must be 'GET' or 'DELETE', got '%s'", method)) } + +func (a *API) UploadDocument(bucket string, file_header *multipart.FileHeader) (UploadDocumentResponse, error) { + endpoint := fmt.Sprintf("%s://%s:%d", + a.Protocol, + a.Host, + a.Port, + ) + + url := fmt.Sprintf("%s/v1/bucket/%s", endpoint, bucket) + + // Create a new multipart writer + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add the file to the request + file, err := file_header.Open() + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#file_header.Open: %s", err) + } + defer file.Close() + + part, err := writer.CreateFormFile("document", file_header.Filename) + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#writer.CreateFormFile: %s", err) + } + + _, err = io.Copy(part, file) + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#io.Copy: %s", err) + } + + err = writer.Close() + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#writer.Close: %s", err) + } + + // Create a new HTTP request with the multipart body + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#http.NewRequest: %s", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + if a.Opts.KeyAuth { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.Opts.Key)) + } + + // Send the HTTP request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return UploadDocumentResponse{}, fmt.Errorf("UploadDocument#client.Do: %s", err) + } + defer resp.Body.Close() + + // Handle the response + var res UploadDocumentResponse + + json.NewDecoder(resp.Body).Decode(&res) + return res, nil +} + +// CallWithData takes data and returns a string representing a response body. +// Can be used for POST or PUT methods +func (a *API) CallWithData(method, route string, data []byte) (string, error) { + endpoint := fmt.Sprintf("%s://%s:%d", + a.Protocol, + a.Host, + a.Port, + ) + request := fmt.Sprintf("%s%s", endpoint, route) + + switch method { + case http.MethodPost: + // initialize http client + client := &http.Client{} + + // set the HTTP method, url, and request body + req, err := http.NewRequest(http.MethodPost, request, bytes.NewBuffer(data)) + if err != nil { + return "", err + } + + if a.Opts.KeyAuth { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Opts.Key)) + } + + // set the request header Content-Type for json + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return "", err + } + + var res map[string]interface{} + + json.NewDecoder(resp.Body).Decode(&res) + return fmt.Sprintf("%s\n", res["message"]), nil + /* + case http.MethodPut: + // initialize http client + client := &http.Client{} + + // set the HTTP method, url, and request body + req, err := http.NewRequest(http.MethodPut, request, bytes.NewBuffer(data)) + if err != nil { + return "", err + } + + if a.Opts.KeyAuth { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Opts.Key)) + } + + // set the request header Content-Type for json + //req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return "", err + } + + var res map[string]interface{} + + json.NewDecoder(resp.Body).Decode(&res) + return fmt.Sprintf("%s\n", res["message"]), nil + */ + } + + //return "", errors.New(fmt.Sprintf("method must be 'POST' or 'PUT', got '%s'", method)) + return "", errors.New(fmt.Sprintf("method must be 'POST', got '%s'", method)) +} diff --git a/cmd/server.go b/cmd/server.go index 71f5a54..761d343 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "log" + "regexp" "embed" "html/template" @@ -81,6 +82,18 @@ func init() { // server.api.key - --server-api-key serverCmd.Flags().String("server-api-key", "", "Key to use for authenticating to /v1 routes") viper.BindPFlag("server.api.key", serverCmd.Flags().Lookup("server-api-key")) + + // server.admin.auth - --server-admin-auth + serverCmd.Flags().Bool("server-admin-auth", false, "Enable to allow basic authentication for /admin routes (config: server.admin.auth)") + viper.BindPFlag("server.admin.auth", serverCmd.Flags().Lookup("server-admin-auth")) + + // server.admin.username - --server-frontend-username + serverCmd.Flags().String("server-admin-username", "", "Username for basic authentication for /admin routes (config: server.admin.username)") + viper.BindPFlag("server.admin.username", serverCmd.Flags().Lookup("server-admin-username")) + + // server.admin.password - --server-frontend-password + serverCmd.Flags().String("server-admin-password", "", "Password for basic authentication for /admin routes (config: server.admin.password)") + viper.BindPFlag("server.admin.password", serverCmd.Flags().Lookup("server-admin-password")) } func RunServer() { @@ -106,6 +119,35 @@ func RunServer() { 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 })) + + log.Println("Key auth for /v1 activated") + } + + groupAdmin := e.Group("/admin") + + 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 { + log.Fatal("server.admin.auth is enabled, but server.admin.username is too small (needs at least 5 characters)") + } + + if len(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 { + return true, nil + } + return false, nil + })) + + log.Println("Basic auth for /admin activated") } // API Routes @@ -154,6 +196,14 @@ func RunServer() { e.GET("/public/documentation/:bucket/:document", handlePublicDocumentation) + // Admin Routes + + groupAdmin.GET("", handleAdmin) + + groupAdmin.GET("/documents/upload", handleAdminDocumentsUpload) + + groupAdmin.POST("/documents/upload", handleAdminDocumentsUploadPOST) + e.Logger.Fatal(e.Start( fmt.Sprintf(":%d", viper.GetInt("server.port")))) } @@ -337,9 +387,12 @@ func handleV1DocumentCreate(c echo.Context) error { bucket := c.Param("bucket") - form_file, err := c.FormFile("file") + form_file, err := c.FormFile("document") if err != nil { - return err + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "message": "Error during handleV1DocumentCreate's echo#Context.FormFile", + "error": err, + }) } allowed := false @@ -350,11 +403,6 @@ func handleV1DocumentCreate(c echo.Context) error { } 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"}) } @@ -379,7 +427,14 @@ func handleV1DocumentCreate(c echo.Context) error { } defer src.Close() - info, err := client.PutObject(ctx, bucket, form_file.Filename, src, form_file.Size, minio.PutObjectOptions{ + 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 := client.PutObject(ctx, bucket, filename_processed, src, form_file.Size, minio.PutObjectOptions{ ContentType: form_file.Header.Get("Content-Type"), }) if err != nil { @@ -618,21 +673,39 @@ func handleDocumentation(c echo.Context) error { var data []Bucket for _, bucket := range buckets { - result, err := client.Call(http.MethodGet, fmt.Sprintf("/v1/bucket/%s", bucket)) + content, err := client.Call(http.MethodGet, fmt.Sprintf("/v1/bucket/%s", bucket)) if err != nil { return c.Render(http.StatusInternalServerError, "documentation-html", nil) } var documents []string - err = json.Unmarshal(result, &documents) + err = json.Unmarshal(content, &documents) if err != nil { return c.Render(http.StatusInternalServerError, "documentation-html", nil) } + // Ce bloc retire tous les caractères spéciaux d'une string + // N'est pas présentement activé, car les fichiers sont processed + // à la création de toute façon. + /* + reg, err := regexp.Compile("[^.a-zA-Z0-9_-]+") + if err != nil { + return c.Render(http.StatusInternalServerError, "documentation-html", nil) + } + + var documents_processed []string + + for _, document := range documents { + document_processed := reg.ReplaceAllString(document, "") + documents_processed = append(documents_processed, document_processed) + } + */ + documents_processed := documents + data = append(data, Bucket{ Name: bucket, - Documents: documents, + Documents: documents_processed, }) } @@ -671,6 +744,50 @@ func handlePublicDocumentation(c echo.Context) error { return c.Blob(http.StatusOK, "application/octet-stream", result) } +func handleAdmin(c echo.Context) error { + return c.Render(http.StatusOK, "admin-html", nil) +} + +func handleAdminDocumentsUpload(c echo.Context) error { + return c.Render(http.StatusOK, "admin-upload-html", nil) +} + +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"), + }) + if err != nil { + return c.Render(http.StatusInternalServerError, "admin-upload-html", struct{ Message string }{Message: fmt.Sprintf("handleAdminDocumentsUploadPOST#api.New: %s", err)}) + } + + bucket := c.FormValue("bucket") + + document, err := c.FormFile("document") + if err != nil { + return c.Render(http.StatusBadRequest, "admin-upload-html", struct{ Message string }{Message: fmt.Sprintf("handleAdminDocumentsUploadPOST#c.FormFile: %s", err)}) + } + + response, err := client.UploadDocument(bucket, document) + if err != nil { + return c.Render(http.StatusInternalServerError, "admin-upload-html", struct{ Message string }{Message: fmt.Sprintf("handleAdminDocumentsUploadPOST#client.UploadDocument: %s", err)}) + } + + // Format response + var message, info, status string + + info = fmt.Sprintf("[%.0f] /public/documentation/%s/%s", response.Info.Size, response.Info.Bucket, response.Info.Object) + + status = response.Message + + message = fmt.Sprintf("%s - %s", status, info) + + return c.Render(http.StatusOK, "admin-upload-html", struct{ Message string }{Message: message}) +} + // CSS Handlers func handleStaticCSSIndex(c echo.Context) error { diff --git a/public/html/admin-upload.gohtml b/public/html/admin-upload.gohtml new file mode 100644 index 0000000..b7ff836 --- /dev/null +++ b/public/html/admin-upload.gohtml @@ -0,0 +1,28 @@ +{{ define "admin-upload-html" }} + + +
+ +{{ .Message }}
+ + +{{ end }} diff --git a/public/html/admin.gohtml b/public/html/admin.gohtml new file mode 100644 index 0000000..d7e0038 --- /dev/null +++ b/public/html/admin.gohtml @@ -0,0 +1,17 @@ +{{ define "admin-html" }} + + + + +