diff --git a/.env b/.env index 88771c9..d45c67a 100644 --- a/.env +++ b/.env @@ -48,6 +48,10 @@ EMAIL_FROM=test@ma-al.com EMAIL_FROM_NAME=Gitea Manager EMAIL_ADMIN=goc_marek@ma-al.pl +# STORAGE +STORAGE_ROOT=./storage + + I18N_LANGS=en,pl,cs PDF_SERVER_URL=http://localhost:8000 diff --git a/app/config/config.go b/app/config/config.go index 586a182..1963d38 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -2,8 +2,10 @@ package config import ( "fmt" + "log" "log/slog" "os" + "path/filepath" "reflect" "strconv" "strings" @@ -24,7 +26,8 @@ type Config struct { GoogleTranslate GoogleTranslateConfig Image ImageConfig Cors CorsConfig - MailiSearch MeiliSearchConfig + MeiliSearch MeiliSearchConfig + Storage StorageConfig } type I18n struct { @@ -95,6 +98,10 @@ type EmailConfig struct { Enabled bool `env:"EMAIL_ENABLED,false"` } +type StorageConfig struct { + RootFolder string `env:"STORAGE_ROOT"` +} + type PdfPrinter struct { ServerUrl string `env:"PDF_SERVER_URL,http://localhost:8000"` } @@ -155,7 +162,7 @@ func load() *Config { err = loadEnv(&cfg.OAuth.Google) if err != nil { - slog.Error("not possible to load env variables for outh google : ", err.Error(), "") + slog.Error("not possible to load env variables for oauth google : ", err.Error(), "") } err = loadEnv(&cfg.App) @@ -170,12 +177,12 @@ func load() *Config { err = loadEnv(&cfg.I18n) if err != nil { - slog.Error("not possible to load env variables for email : ", err.Error(), "") + slog.Error("not possible to load env variables for i18n : ", err.Error(), "") } err = loadEnv(&cfg.Pdf) if err != nil { - slog.Error("not possible to load env variables for email : ", err.Error(), "") + slog.Error("not possible to load env variables for pdf : ", err.Error(), "") } err = loadEnv(&cfg.GoogleTranslate) @@ -185,19 +192,25 @@ func load() *Config { err = loadEnv(&cfg.Image) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for image : ", err.Error(), "") } err = loadEnv(&cfg.Cors) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for cors : ", err.Error(), "") } - err = loadEnv(&cfg.MailiSearch) + err = loadEnv(&cfg.MeiliSearch) if err != nil { - slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + slog.Error("not possible to load env variables for meili search : ", err.Error(), "") } + err = loadEnv(&cfg.Storage) + if err != nil { + slog.Error("not possible to load env variables for storage : ", err.Error(), "") + } + cfg.Storage.RootFolder = ResolveRelativePath(cfg.Storage.RootFolder) + return cfg } @@ -308,6 +321,22 @@ func setValue(field reflect.Value, val string, key string) error { return nil } +func ResolveRelativePath(relativePath string) string { + // get working directory (where program was started) + wd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + // convert to absolute path + absPath := relativePath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(wd, absPath) + } + + return filepath.Clean(absPath) +} + func parseEnvTag(tag string) (key string, def *string) { if tag == "" { return "", nil diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go new file mode 100644 index 0000000..bca3da6 --- /dev/null +++ b/app/delivery/web/api/restricted/storage.go @@ -0,0 +1,88 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/service/storageService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type StorageHandler struct { + storageService *storageService.StorageService + config *config.Config +} + +func NewStorageHandler() *StorageHandler { + return &StorageHandler{ + storageService: storageService.New(), + config: config.Get(), + } +} + +func StorageHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewStorageHandler() + + r.Get("/list-content", handler.ListContent) + r.Get("/download-file", handler.DownloadFile) + r.Get("/create-folder", handler.CreateFolder) + + return r +} + +// accepted path looks like e.g. "/folder1/" or "folder1" +func (h *StorageHandler) ListContent(c fiber.Ctx) error { + // relative path defaults to root directory + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + entries_in_list, err := h.storageService.ListContent(absPath) + + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(entries_in_list, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + f, filename, filesize, err := h.storageService.DownloadFilePrep(absPath) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + c.Attachment(filename) + c.Set("Content-Length", strconv.FormatInt(filesize, 10)) + return c.SendStream(f, int(filesize)) +} + +func (h *StorageHandler) CreateFolder(c fiber.Ctx) error { + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + err = h.storageService.CreateFolder(absPath, c.Query("name")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index eaf41d9..c48a778 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -115,6 +115,10 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + // storage (restricted) + storage := s.restricted.Group("/storage") + restricted.StorageHandlerRoutes(storage) + s.api.All("*", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNotFound) }) diff --git a/app/model/entry.go b/app/model/entry.go new file mode 100644 index 0000000..ae63646 --- /dev/null +++ b/app/model/entry.go @@ -0,0 +1,6 @@ +package model + +type EntryInList struct { + Name string + IsFolder bool +} diff --git a/app/repos/searchRepo/searchRepo.go b/app/repos/searchRepo/searchRepo.go index 05afd3a..de4d5ea 100644 --- a/app/repos/searchRepo/searchRepo.go +++ b/app/repos/searchRepo/searchRepo.go @@ -32,12 +32,12 @@ func New() UISearchRepo { } func (r *SearchRepo) Search(index string, body []byte) (*SearchProxyResponse, error) { - url := fmt.Sprintf("%s/indexes/%s/search", r.cfg.MailiSearch.ServerURL, index) + url := fmt.Sprintf("%s/indexes/%s/search", r.cfg.MeiliSearch.ServerURL, index) return r.doRequest(http.MethodPost, url, body) } func (r *SearchRepo) GetIndexSettings(index string) (*SearchProxyResponse, error) { - url := fmt.Sprintf("%s/indexes/%s/settings", r.cfg.MailiSearch.ServerURL, index) + url := fmt.Sprintf("%s/indexes/%s/settings", r.cfg.MeiliSearch.ServerURL, index) return r.doRequest(http.MethodGet, url, nil) } @@ -55,8 +55,8 @@ func (r *SearchRepo) doRequest(method, url string, body []byte) (*SearchProxyRes } req.Header.Set("Content-Type", "application/json") - if r.cfg.MailiSearch.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.cfg.MailiSearch.ApiKey)) + if r.cfg.MeiliSearch.ApiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.cfg.MeiliSearch.ApiKey)) } client := &http.Client{} diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go new file mode 100644 index 0000000..aa60c09 --- /dev/null +++ b/app/repos/storageRepo/storageRepo.go @@ -0,0 +1,51 @@ +package storageRepo + +import ( + "os" + + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIStorageRepo interface { + EntryInfo(absPath string) (os.FileInfo, error) + ListContent(absPath string) (*[]model.EntryInList, error) + OpenFile(absPath string) (*os.File, error) + CreateFolder(absPath string, name string) error +} + +type StorageRepo struct{} + +func New() UIStorageRepo { + return &StorageRepo{} +} + +func (r *StorageRepo) EntryInfo(absPath string) (os.FileInfo, error) { + return os.Stat(absPath) +} + +func (r *StorageRepo) ListContent(absPath string) (*[]model.EntryInList, error) { + entries, err := os.ReadDir(absPath) + if err != nil { + return nil, err + } + + var entries_in_list []model.EntryInList + + for _, entry := range entries { + var next_entry_in_list model.EntryInList + next_entry_in_list.Name = entry.Name() + next_entry_in_list.IsFolder = entry.IsDir() + + entries_in_list = append(entries_in_list, next_entry_in_list) + } + + return &entries_in_list, nil +} + +func (r *StorageRepo) OpenFile(absPath string) (*os.File, error) { + return os.Open(absPath) +} + +func (r *StorageRepo) CreateFolder(absPath string, name string) error { + os.(absPath) +} diff --git a/app/service/meiliService/meiliService.go b/app/service/meiliService/meiliService.go index 87b196b..6d9120a 100644 --- a/app/service/meiliService/meiliService.go +++ b/app/service/meiliService/meiliService.go @@ -27,8 +27,8 @@ type MeiliService struct { func New() *MeiliService { client := meilisearch.New( - config.Get().MailiSearch.ServerURL, - meilisearch.WithAPIKey(config.Get().MailiSearch.ApiKey), + config.Get().MeiliSearch.ServerURL, + meilisearch.WithAPIKey(config.Get().MeiliSearch.ApiKey), ) return &MeiliService{ diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go new file mode 100644 index 0000000..963f934 --- /dev/null +++ b/app/service/storageService/storageService.go @@ -0,0 +1,75 @@ +package storageService + +import ( + "os" + "path/filepath" + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/storageRepo" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" +) + +type StorageService struct { + storageRepo storageRepo.UIStorageRepo +} + +func New() *StorageService { + return &StorageService{ + storageRepo: storageRepo.New(), + } +} + +func (s *StorageService) ListContent(absPath string) (*[]model.EntryInList, error) { + info, err := s.storageRepo.EntryInfo(absPath) + if err != nil || !info.IsDir() { + return nil, responseErrors.ErrFolderDoesNotExist + } + + entries_in_list, err := s.storageRepo.ListContent(absPath) + return entries_in_list, err +} + +func (s *StorageService) DownloadFilePrep(absPath string) (*os.File, string, int64, error) { + info, err := s.storageRepo.EntryInfo(absPath) + if err != nil || info.IsDir() { + return nil, "", 0, responseErrors.ErrFileDoesNotExist + } + + f, err := s.storageRepo.OpenFile(absPath) + if err != nil { + return nil, "", 0, err + } + + return f, filepath.Base(absPath), info.Size(), nil +} + +func (s *StorageService) CreateFolder(absPath string, name string) error { + info, err := s.storageRepo.EntryInfo(absPath) + if err != nil || !info.IsDir() { + return responseErrors.ErrFolderDoesNotExist + } + + if name == "" || name == "." filepath.Base(name) != name { + return responseErrors.ErrBadAttribute + } + + absPath2, err := s.AbsPath(absPath, name) + if err != nil { + return err + } + + return s.storageRepo.CreateFolder(absPath, name) +} + +// AbsPath extracts an absolute path and validates it +func (s *StorageService) AbsPath(root string, relativePath string) (string, error) { + cleanName := filepath.Clean(relativePath) + fullPath := filepath.Join(root, cleanName) + + if fullPath != root && !strings.HasPrefix(fullPath, root+string(os.PathSeparator)) { + return "", responseErrors.ErrAccessDenied + } + + return fullPath, nil +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index c4247ea..65db40a 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -59,6 +59,11 @@ var ( ErrMaxAmtOfCartsReached = errors.New("maximal amount of carts reached") ErrUserHasNoSuchCart = errors.New("user does not have cart with given id") ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist") + + // Typed errors for storage + ErrAccessDenied = errors.New("access denied!") + ErrFolderDoesNotExist = errors.New("folder does not exist") + ErrFileDoesNotExist = errors.New("file does not exist") ) // Error represents an error with HTTP status code @@ -162,6 +167,13 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrProductOrItsVariationDoesNotExist): return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + case errors.Is(err, ErrAccessDenied): + return i18n.T_(c, "error.access_denied") + case errors.Is(err, ErrFolderDoesNotExist): + return i18n.T_(c, "error.folder_does_not_exist") + case errors.Is(err, ErrFileDoesNotExist): + return i18n.T_(c, "error.file_does_not_exist") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -203,7 +215,10 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrRootNeverReached), errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), - errors.Is(err, ErrProductOrItsVariationDoesNotExist): + errors.Is(err, ErrProductOrItsVariationDoesNotExist), + errors.Is(err, ErrAccessDenied), + errors.Is(err, ErrFolderDoesNotExist), + errors.Is(err, ErrFileDoesNotExist): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/bruno/b2b-daniel/download-file.yml b/bruno/b2b-daniel/download-file.yml new file mode 100644 index 0000000..468be8d --- /dev/null +++ b/bruno/b2b-daniel/download-file.yml @@ -0,0 +1,19 @@ +info: + name: download-file + type: http + seq: 20 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/download-file?path=/folder1/test.txt + params: + - name: path + value: /folder1/test.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/list-content.yml b/bruno/b2b-daniel/list-content.yml new file mode 100644 index 0000000..8a9d600 --- /dev/null +++ b/bruno/b2b-daniel/list-content.yml @@ -0,0 +1,19 @@ +info: + name: list-content + type: http + seq: 19 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/list-content?path=/folder1 + params: + - name: path + value: /folder1 + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/storage/folder1/test b/storage/folder1/test new file mode 100644 index 0000000..e69de29 diff --git a/storage/folder1/test.txt b/storage/folder1/test.txt new file mode 100644 index 0000000..273c1a9 --- /dev/null +++ b/storage/folder1/test.txt @@ -0,0 +1 @@ +This is a test. \ No newline at end of file