From b2acb8c922c53ce69f8dd02c00ceef65df536b8c Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Wed, 1 Apr 2026 13:30:54 +0200 Subject: [PATCH 01/18] storage --- .env | 4 + app/config/config.go | 45 ++++++++-- app/delivery/web/api/restricted/storage.go | 88 ++++++++++++++++++++ app/delivery/web/init.go | 4 + app/model/entry.go | 6 ++ app/repos/searchRepo/searchRepo.go | 8 +- app/repos/storageRepo/storageRepo.go | 51 ++++++++++++ app/service/meiliService/meiliService.go | 4 +- app/service/storageService/storageService.go | 75 +++++++++++++++++ app/utils/responseErrors/responseErrors.go | 17 +++- bruno/b2b-daniel/download-file.yml | 19 +++++ bruno/b2b-daniel/list-content.yml | 19 +++++ storage/folder1/test | 0 storage/folder1/test.txt | 1 + 14 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 app/delivery/web/api/restricted/storage.go create mode 100644 app/model/entry.go create mode 100644 app/repos/storageRepo/storageRepo.go create mode 100644 app/service/storageService/storageService.go create mode 100644 bruno/b2b-daniel/download-file.yml create mode 100644 bruno/b2b-daniel/list-content.yml create mode 100644 storage/folder1/test create mode 100644 storage/folder1/test.txt 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 From b9bc121d43e7d5205d42da501dfa4a08a3a3d988 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 2 Apr 2026 10:27:14 +0200 Subject: [PATCH 02/18] getting to upload --- app/delivery/web/api/restricted/storage.go | 73 +++++++++++++-- app/repos/storageRepo/storageRepo.go | 40 ++++++--- app/service/storageService/storageService.go | 89 +++++++++++++++---- app/utils/responseErrors/responseErrors.go | 16 +++- bruno/b2b-daniel/create-folder.yml | 22 +++++ bruno/b2b-daniel/delete-entry.yml | 19 ++++ bruno/b2b-daniel/get-description.yml | 22 +++++ bruno/b2b-daniel/save-product-description.yml | 28 ++++++ .../translate-product-description.yml | 28 ++++++ 9 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 bruno/b2b-daniel/create-folder.yml create mode 100644 bruno/b2b-daniel/delete-entry.yml create mode 100644 bruno/b2b-daniel/get-description.yml create mode 100644 bruno/b2b-daniel/save-product-description.yml create mode 100644 bruno/b2b-daniel/translate-product-description.yml diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index bca3da6..6722a9b 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -29,7 +29,11 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { r.Get("/list-content", handler.ListContent) r.Get("/download-file", handler.DownloadFile) + + r.Post("/upload-file", handler.CreateFolder) r.Get("/create-folder", handler.CreateFolder) + r.Get("/delete-file", handler.DeleteFile) + r.Get("/delete-folder", handler.DeleteFolder) return r } @@ -37,13 +41,13 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { // 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")) + abs_path, 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) + entries_in_list, err := h.storageService.ListContent(abs_path) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). @@ -54,13 +58,13 @@ func (h *StorageHandler) ListContent(c fiber.Ctx) error { } func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { - absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, 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) + f, filename, filesize, err := h.storageService.DownloadFilePrep(abs_path) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -71,14 +75,69 @@ func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { 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")) +func (h *StorageHandler) UploadFile(c fiber.Ctx) error { + abs_path, 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")) + f, err := c.FormFile("document") + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrMissingFileFieldDocument)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrMissingFileFieldDocument))) + } + + err = h.storageService.UploadFile(abs_path, c.Query("name"), f) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + err = c.SaveFile(f, abs_path) + + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) +} + +func (h *StorageHandler) CreateFolder(c fiber.Ctx) error { + abs_path, 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(abs_path, 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))) +} + +func (h *StorageHandler) DeleteFile(c fiber.Ctx) error { + abs_path, 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.DeleteFile(abs_path) + 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))) +} + +func (h *StorageHandler) DeleteFolder(c fiber.Ctx) error { + abs_path, 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.DeleteFolder(abs_path) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go index aa60c09..ac838ff 100644 --- a/app/repos/storageRepo/storageRepo.go +++ b/app/repos/storageRepo/storageRepo.go @@ -1,16 +1,20 @@ package storageRepo import ( + "mime/multipart" "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 + EntryInfo(abs_path string) (os.FileInfo, error) + ListContent(abs_path string) (*[]model.EntryInList, error) + OpenFile(abs_path string) (*os.File, error) + UploadFile(abs_path string, f *multipart.FileHeader) error + CreateFolder(abs_path string) error + DeleteFile(abs_path string) error + DeleteFolder(abs_path string) error } type StorageRepo struct{} @@ -19,12 +23,12 @@ func New() UIStorageRepo { return &StorageRepo{} } -func (r *StorageRepo) EntryInfo(absPath string) (os.FileInfo, error) { - return os.Stat(absPath) +func (r *StorageRepo) EntryInfo(abs_path string) (os.FileInfo, error) { + return os.Stat(abs_path) } -func (r *StorageRepo) ListContent(absPath string) (*[]model.EntryInList, error) { - entries, err := os.ReadDir(absPath) +func (r *StorageRepo) ListContent(abs_path string) (*[]model.EntryInList, error) { + entries, err := os.ReadDir(abs_path) if err != nil { return nil, err } @@ -42,10 +46,22 @@ func (r *StorageRepo) ListContent(absPath string) (*[]model.EntryInList, error) return &entries_in_list, nil } -func (r *StorageRepo) OpenFile(absPath string) (*os.File, error) { - return os.Open(absPath) +func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { + return os.Open(abs_path) } -func (r *StorageRepo) CreateFolder(absPath string, name string) error { - os.(absPath) +func (r *StorageRepo) UploadFile(abs_path string, f *multipart.FileHeader) error { + return nil +} + +func (r *StorageRepo) CreateFolder(abs_path string) error { + return os.Mkdir(abs_path, 0755) +} + +func (r *StorageRepo) DeleteFile(abs_path string) error { + return os.Remove(abs_path) +} + +func (r *StorageRepo) DeleteFolder(abs_path string) error { + return os.RemoveAll(abs_path) } diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go index 963f934..f60f14b 100644 --- a/app/service/storageService/storageService.go +++ b/app/service/storageService/storageService.go @@ -1,6 +1,7 @@ package storageService import ( + "mime/multipart" "os" "path/filepath" "strings" @@ -20,56 +21,110 @@ func New() *StorageService { } } -func (s *StorageService) ListContent(absPath string) (*[]model.EntryInList, error) { - info, err := s.storageRepo.EntryInfo(absPath) +func (s *StorageService) ListContent(abs_path string) (*[]model.EntryInList, error) { + info, err := s.storageRepo.EntryInfo(abs_path) if err != nil || !info.IsDir() { return nil, responseErrors.ErrFolderDoesNotExist } - entries_in_list, err := s.storageRepo.ListContent(absPath) + entries_in_list, err := s.storageRepo.ListContent(abs_path) return entries_in_list, err } -func (s *StorageService) DownloadFilePrep(absPath string) (*os.File, string, int64, error) { - info, err := s.storageRepo.EntryInfo(absPath) +func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, int64, error) { + info, err := s.storageRepo.EntryInfo(abs_path) if err != nil || info.IsDir() { return nil, "", 0, responseErrors.ErrFileDoesNotExist } - f, err := s.storageRepo.OpenFile(absPath) + f, err := s.storageRepo.OpenFile(abs_path) if err != nil { return nil, "", 0, err } - return f, filepath.Base(absPath), info.Size(), nil + return f, filepath.Base(abs_path), info.Size(), nil } -func (s *StorageService) CreateFolder(absPath string, name string) error { - info, err := s.storageRepo.EntryInfo(absPath) +func (s *StorageService) UploadFile(abs_path string, name string, f *multipart.FileHeader) error { + info, err := s.storageRepo.EntryInfo(abs_path) if err != nil || !info.IsDir() { return responseErrors.ErrFolderDoesNotExist } - if name == "" || name == "." filepath.Base(name) != name { + if name == "" || name == "." || name == ".." || filepath.Base(name) != name { return responseErrors.ErrBadAttribute } - - absPath2, err := s.AbsPath(absPath, name) + abs_file_path, err := s.AbsPath(abs_path, name) if err != nil { return err } + if abs_file_path == abs_path { + return responseErrors.ErrBadAttribute + } - return s.storageRepo.CreateFolder(absPath, name) + info, err = s.storageRepo.EntryInfo(abs_file_path) + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.UploadFile(abs_file_path, f) + } else { + return err + } +} + +func (s *StorageService) CreateFolder(abs_path string, name string) error { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || !info.IsDir() { + return responseErrors.ErrFolderDoesNotExist + } + + if name == "" || name == "." || name == ".." || filepath.Base(name) != name { + return responseErrors.ErrBadAttribute + } + abs_folder_path, err := s.AbsPath(abs_path, name) + if err != nil { + return err + } + if abs_folder_path == abs_path { + return responseErrors.ErrBadAttribute + } + + info, err = s.storageRepo.EntryInfo(abs_folder_path) + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.CreateFolder(abs_folder_path) + } else { + return err + } +} + +func (s *StorageService) DeleteFile(abs_path string) error { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || info.IsDir() { + return responseErrors.ErrFileDoesNotExist + } + + return s.storageRepo.DeleteFile(abs_path) +} + +func (s *StorageService) DeleteFolder(abs_path string) error { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || !info.IsDir() { + return responseErrors.ErrFolderDoesNotExist + } + + return s.storageRepo.DeleteFolder(abs_path) } // 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) + clean_name := filepath.Clean(relativePath) + full_path := filepath.Join(root, clean_name) - if fullPath != root && !strings.HasPrefix(fullPath, root+string(os.PathSeparator)) { + if full_path != root && !strings.HasPrefix(full_path, root+string(os.PathSeparator)) { return "", responseErrors.ErrAccessDenied } - return fullPath, nil + return full_path, nil } diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 65db40a..2ea09e5 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -61,9 +61,11 @@ var ( 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") + ErrAccessDenied = errors.New("access denied!") + ErrFolderDoesNotExist = errors.New("folder does not exist") + ErrFileDoesNotExist = errors.New("file does not exist") + ErrNameTaken = errors.New("name taken") + ErrMissingFileFieldDocument = errors.New("missing file field 'document'") ) // Error represents an error with HTTP status code @@ -173,6 +175,10 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.folder_does_not_exist") case errors.Is(err, ErrFileDoesNotExist): return i18n.T_(c, "error.file_does_not_exist") + case errors.Is(err, ErrNameTaken): + return i18n.T_(c, "error.name_taken") + case errors.Is(err, ErrMissingFileFieldDocument): + return i18n.T_(c, "error.missing_file_field_document") default: return i18n.T_(c, "error.err_internal_server_error") @@ -218,7 +224,9 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrProductOrItsVariationDoesNotExist), errors.Is(err, ErrAccessDenied), errors.Is(err, ErrFolderDoesNotExist), - errors.Is(err, ErrFileDoesNotExist): + errors.Is(err, ErrFileDoesNotExist), + errors.Is(err, ErrNameTaken), + errors.Is(err, ErrMissingFileFieldDocument): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/bruno/b2b-daniel/create-folder.yml b/bruno/b2b-daniel/create-folder.yml new file mode 100644 index 0000000..fb04ec1 --- /dev/null +++ b/bruno/b2b-daniel/create-folder.yml @@ -0,0 +1,22 @@ +info: + name: create-folder + type: http + seq: 22 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/create-folder?path&name=../k + params: + - name: path + value: "" + type: query + - name: name + value: ../k + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/delete-entry.yml b/bruno/b2b-daniel/delete-entry.yml new file mode 100644 index 0000000..baf63f2 --- /dev/null +++ b/bruno/b2b-daniel/delete-entry.yml @@ -0,0 +1,19 @@ +info: + name: delete-entry + type: http + seq: 23 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/delete-entry?path=folder2 + params: + - name: path + value: folder2 + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/get-description.yml b/bruno/b2b-daniel/get-description.yml new file mode 100644 index 0000000..bd2137d --- /dev/null +++ b/bruno/b2b-daniel/get-description.yml @@ -0,0 +1,22 @@ +info: + name: get-description + type: http + seq: 24 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/get-product-description?productID=51&productLangID=2 + params: + - name: productID + value: "51" + type: query + - name: productLangID + value: "2" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml new file mode 100644 index 0000000..201f4f8 --- /dev/null +++ b/bruno/b2b-daniel/save-product-description.yml @@ -0,0 +1,28 @@ +info: + name: save-product-description + type: http + seq: 25 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=2 + params: + - name: productID + value: "1" + type: query + - name: productLangID + value: "2" + type: query + body: + type: json + data: |- + { + "description": "

Rehabilitation rollers are essential, multi-functional tools used across various exercises and treatments. Their application positively influences the alleviation of injuries and significantly enhances a patient's chances of returning to full physical fitness.

\n

These versatile medical rollers are a staple in movement rehabilitation, corrective gymnastics, and both traditional and sports massages, as their design is perfect for precise limb elevation and separation. They provide critical support, making them ideal for comfortably cushioning the patient's knees, feet, arms, and shoulders.

\n

Support for Children's Development: Rehabilitation bolsters are also highly recommended for children. Incorporating them into play activities offers an engaging way to substantially support the development of gross motor skills.

\n

Customize Your Therapy Space: Thanks to our wide range of colours and various sizes, you can easily assemble a comprehensive exercise kit necessary for every physiotherapy clinic, massage parlour, school, or kindergarten.

\n

\n

Certified Medical Device:

\n

\n

This Rehabilitation Roller is a certified medical device compliant with the essential requirements for medical products and the Medical Devices Act. It has been officially registered with the Office for Registration of Medicinal Products, Medical Devices and Biocidal Products (URPL), is equipped with the manufacturer's Declaration of Conformity, and bears the CE mark.

\n

\"\"

\n
\n

\n

Recommended use:

\n\n
\n

\n

Material Specification & Certification

\n

\n

Cover Material: PVC-coated material specifically designated for medical devices, making it extremely easy to clean and disinfect:

\n\n

\"REACH\"\"Certyfikat\"Nie\"Ognioodporny\"\"Odporny\"Odporny\"Przeznaczony\"Odporny\"Olejoodporny\"

\n

Filling: Medium-firm polyurethane foam with enhanced resistance to deformation.

\n\n

\"Certyfikat\"Atest\"Atest

" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml new file mode 100644 index 0000000..f08dc01 --- /dev/null +++ b/bruno/b2b-daniel/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 19 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/translate-product-description?productID=51&productFromLangID=2&productToLangID=3&model=Google + params: + - name: productID + value: "51" + type: query + - name: productFromLangID + value: "2" + type: query + - name: productToLangID + value: "3" + type: query + - name: model + value: Google + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 From 833f4a5a0743436c92273014cdf496a3f1c455e6 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 2 Apr 2026 11:26:58 +0200 Subject: [PATCH 03/18] deleting and uploading files --- app/delivery/web/api/restricted/storage.go | 9 +++---- app/repos/storageRepo/storageRepo.go | 7 ++--- app/service/storageService/storageService.go | 6 +++-- bruno/b2b-daniel/add-new-cart.yml | 2 +- bruno/b2b-daniel/add-product-to-cart (1).yml | 2 +- bruno/b2b-daniel/add-product-to-cart.yml | 2 +- bruno/b2b-daniel/change-cart-name.yml | 2 +- bruno/b2b-daniel/create-folder.yml | 6 ++--- bruno/b2b-daniel/create-index.yml | 2 +- bruno/b2b-daniel/delete-file.yml | 19 ++++++++++++++ .../{delete-entry.yml => delete-folder.yml} | 10 +++---- bruno/b2b-daniel/download-file.yml | 2 +- bruno/b2b-daniel/get-breadcrumb.yml | 2 +- bruno/b2b-daniel/get-category-tree.yml | 2 +- bruno/b2b-daniel/get-description.yml | 22 ---------------- bruno/b2b-daniel/get-indexes.yml | 2 +- bruno/b2b-daniel/get-product-description.yml | 2 +- bruno/b2b-daniel/get_countries.yml | 2 +- bruno/b2b-daniel/list-content.yml | 2 +- bruno/b2b-daniel/list-products.yml | 2 +- bruno/b2b-daniel/list-users.yml | 2 +- bruno/b2b-daniel/remove-index.yml | 2 +- bruno/b2b-daniel/retrieve-cart.yml | 2 +- bruno/b2b-daniel/retrieve-carts-info.yml | 2 +- bruno/b2b-daniel/save-product-description.yml | 2 +- bruno/b2b-daniel/search.yml | 2 +- bruno/b2b-daniel/test.yml | 2 +- .../translate-product-description.yml | 2 +- bruno/b2b-daniel/update-choice.yml | 2 +- bruno/b2b-daniel/upload-file.yml | 26 +++++++++++++++++++ storage/test.txt | 1 + 31 files changed, 88 insertions(+), 62 deletions(-) create mode 100644 bruno/b2b-daniel/delete-file.yml rename bruno/b2b-daniel/{delete-entry.yml => delete-folder.yml} (52%) delete mode 100644 bruno/b2b-daniel/get-description.yml create mode 100644 bruno/b2b-daniel/upload-file.yml create mode 100644 storage/test.txt diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index 6722a9b..3760547 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -30,10 +30,10 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { r.Get("/list-content", handler.ListContent) r.Get("/download-file", handler.DownloadFile) - r.Post("/upload-file", handler.CreateFolder) + r.Post("/upload-file", handler.UploadFile) r.Get("/create-folder", handler.CreateFolder) - r.Get("/delete-file", handler.DeleteFile) - r.Get("/delete-folder", handler.DeleteFolder) + r.Delete("/delete-file", handler.DeleteFile) + r.Delete("/delete-folder", handler.DeleteFolder) return r } @@ -88,12 +88,11 @@ func (h *StorageHandler) UploadFile(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrMissingFileFieldDocument))) } - err = h.storageService.UploadFile(abs_path, c.Query("name"), f) + err = h.storageService.UploadFile(c, abs_path, f) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - err = c.SaveFile(f, abs_path) return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) } diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go index ac838ff..e6c1461 100644 --- a/app/repos/storageRepo/storageRepo.go +++ b/app/repos/storageRepo/storageRepo.go @@ -5,13 +5,14 @@ import ( "os" "git.ma-al.com/goc_daniel/b2b/app/model" + "github.com/gofiber/fiber/v3" ) type UIStorageRepo interface { EntryInfo(abs_path string) (os.FileInfo, error) ListContent(abs_path string) (*[]model.EntryInList, error) OpenFile(abs_path string) (*os.File, error) - UploadFile(abs_path string, f *multipart.FileHeader) error + UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error CreateFolder(abs_path string) error DeleteFile(abs_path string) error DeleteFolder(abs_path string) error @@ -50,8 +51,8 @@ func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { return os.Open(abs_path) } -func (r *StorageRepo) UploadFile(abs_path string, f *multipart.FileHeader) error { - return nil +func (r *StorageRepo) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error { + return c.SaveFile(f, abs_path) } func (r *StorageRepo) CreateFolder(abs_path string) error { diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go index f60f14b..3e01c61 100644 --- a/app/service/storageService/storageService.go +++ b/app/service/storageService/storageService.go @@ -9,6 +9,7 @@ import ( "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" + "github.com/gofiber/fiber/v3" ) type StorageService struct { @@ -45,12 +46,13 @@ func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, in return f, filepath.Base(abs_path), info.Size(), nil } -func (s *StorageService) UploadFile(abs_path string, name string, f *multipart.FileHeader) error { +func (s *StorageService) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error { info, err := s.storageRepo.EntryInfo(abs_path) if err != nil || !info.IsDir() { return responseErrors.ErrFolderDoesNotExist } + name := f.Filename if name == "" || name == "." || name == ".." || filepath.Base(name) != name { return responseErrors.ErrBadAttribute } @@ -66,7 +68,7 @@ func (s *StorageService) UploadFile(abs_path string, name string, f *multipart.F if err == nil { return responseErrors.ErrNameTaken } else if os.IsNotExist(err) { - return s.storageRepo.UploadFile(abs_file_path, f) + return s.storageRepo.UploadFile(c, abs_file_path, f) } else { return err } diff --git a/bruno/b2b-daniel/add-new-cart.yml b/bruno/b2b-daniel/add-new-cart.yml index a6beb62..1b6cbde 100644 --- a/bruno/b2b-daniel/add-new-cart.yml +++ b/bruno/b2b-daniel/add-new-cart.yml @@ -1,7 +1,7 @@ info: name: add-new-cart type: http - seq: 11 + seq: 14 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart (1).yml b/bruno/b2b-daniel/add-product-to-cart (1).yml index 7441656..bf5252b 100644 --- a/bruno/b2b-daniel/add-product-to-cart (1).yml +++ b/bruno/b2b-daniel/add-product-to-cart (1).yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart (1) type: http - seq: 16 + seq: 19 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart.yml b/bruno/b2b-daniel/add-product-to-cart.yml index 95e978b..045c7b0 100644 --- a/bruno/b2b-daniel/add-product-to-cart.yml +++ b/bruno/b2b-daniel/add-product-to-cart.yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart type: http - seq: 15 + seq: 18 http: method: GET diff --git a/bruno/b2b-daniel/change-cart-name.yml b/bruno/b2b-daniel/change-cart-name.yml index 5dd32ee..ced76f1 100644 --- a/bruno/b2b-daniel/change-cart-name.yml +++ b/bruno/b2b-daniel/change-cart-name.yml @@ -1,7 +1,7 @@ info: name: change-cart-name type: http - seq: 12 + seq: 15 http: method: GET diff --git a/bruno/b2b-daniel/create-folder.yml b/bruno/b2b-daniel/create-folder.yml index fb04ec1..0f9fabb 100644 --- a/bruno/b2b-daniel/create-folder.yml +++ b/bruno/b2b-daniel/create-folder.yml @@ -1,17 +1,17 @@ info: name: create-folder type: http - seq: 22 + seq: 24 http: method: GET - url: http://localhost:3000/api/v1/restricted/storage/create-folder?path&name=../k + url: http://localhost:3000/api/v1/restricted/storage/create-folder?path=&name=folder params: - name: path value: "" type: query - name: name - value: ../k + value: folder type: query auth: inherit diff --git a/bruno/b2b-daniel/create-index.yml b/bruno/b2b-daniel/create-index.yml index 79eb62e..6f00a56 100644 --- a/bruno/b2b-daniel/create-index.yml +++ b/bruno/b2b-daniel/create-index.yml @@ -1,7 +1,7 @@ info: name: create-index type: http - seq: 7 + seq: 10 http: method: GET diff --git a/bruno/b2b-daniel/delete-file.yml b/bruno/b2b-daniel/delete-file.yml new file mode 100644 index 0000000..32f7104 --- /dev/null +++ b/bruno/b2b-daniel/delete-file.yml @@ -0,0 +1,19 @@ +info: + name: delete-file + type: http + seq: 25 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-file?path=/folder/test.txt + params: + - name: path + value: /folder/test.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/delete-entry.yml b/bruno/b2b-daniel/delete-folder.yml similarity index 52% rename from bruno/b2b-daniel/delete-entry.yml rename to bruno/b2b-daniel/delete-folder.yml index baf63f2..49dacb7 100644 --- a/bruno/b2b-daniel/delete-entry.yml +++ b/bruno/b2b-daniel/delete-folder.yml @@ -1,14 +1,14 @@ info: - name: delete-entry + name: delete-folder type: http - seq: 23 + seq: 26 http: - method: GET - url: http://localhost:3000/api/v1/restricted/storage/delete-entry?path=folder2 + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-folder?path=/folder/ params: - name: path - value: folder2 + value: /folder/ type: query auth: inherit diff --git a/bruno/b2b-daniel/download-file.yml b/bruno/b2b-daniel/download-file.yml index 468be8d..d400ef4 100644 --- a/bruno/b2b-daniel/download-file.yml +++ b/bruno/b2b-daniel/download-file.yml @@ -1,7 +1,7 @@ info: name: download-file type: http - seq: 20 + seq: 22 http: method: GET diff --git a/bruno/b2b-daniel/get-breadcrumb.yml b/bruno/b2b-daniel/get-breadcrumb.yml index 8b10c00..9a49428 100644 --- a/bruno/b2b-daniel/get-breadcrumb.yml +++ b/bruno/b2b-daniel/get-breadcrumb.yml @@ -1,7 +1,7 @@ info: name: get-breadcrumb type: http - seq: 18 + seq: 20 http: method: GET diff --git a/bruno/b2b-daniel/get-category-tree.yml b/bruno/b2b-daniel/get-category-tree.yml index c6b436e..b81d6d1 100644 --- a/bruno/b2b-daniel/get-category-tree.yml +++ b/bruno/b2b-daniel/get-category-tree.yml @@ -1,7 +1,7 @@ info: name: get-category-tree type: http - seq: 5 + seq: 8 http: method: GET diff --git a/bruno/b2b-daniel/get-description.yml b/bruno/b2b-daniel/get-description.yml deleted file mode 100644 index bd2137d..0000000 --- a/bruno/b2b-daniel/get-description.yml +++ /dev/null @@ -1,22 +0,0 @@ -info: - name: get-description - type: http - seq: 24 - -http: - method: GET - url: http://localhost:3000/api/v1/restricted/product-translation/get-product-description?productID=51&productLangID=2 - params: - - name: productID - value: "51" - type: query - - name: productLangID - value: "2" - type: query - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/bruno/b2b-daniel/get-indexes.yml b/bruno/b2b-daniel/get-indexes.yml index 850f7bc..ebca4aa 100644 --- a/bruno/b2b-daniel/get-indexes.yml +++ b/bruno/b2b-daniel/get-indexes.yml @@ -1,7 +1,7 @@ info: name: get-indexes type: http - seq: 9 + seq: 12 http: method: GET diff --git a/bruno/b2b-daniel/get-product-description.yml b/bruno/b2b-daniel/get-product-description.yml index 63a7447..4b6086d 100644 --- a/bruno/b2b-daniel/get-product-description.yml +++ b/bruno/b2b-daniel/get-product-description.yml @@ -1,7 +1,7 @@ info: name: get-product-description type: http - seq: 17 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/get_countries.yml b/bruno/b2b-daniel/get_countries.yml index e7077fd..07fed01 100644 --- a/bruno/b2b-daniel/get_countries.yml +++ b/bruno/b2b-daniel/get_countries.yml @@ -1,7 +1,7 @@ info: name: get_countries type: http - seq: 4 + seq: 7 http: method: GET diff --git a/bruno/b2b-daniel/list-content.yml b/bruno/b2b-daniel/list-content.yml index 8a9d600..972779f 100644 --- a/bruno/b2b-daniel/list-content.yml +++ b/bruno/b2b-daniel/list-content.yml @@ -1,7 +1,7 @@ info: name: list-content type: http - seq: 19 + seq: 21 http: method: GET diff --git a/bruno/b2b-daniel/list-products.yml b/bruno/b2b-daniel/list-products.yml index adc88a7..8fed3db 100644 --- a/bruno/b2b-daniel/list-products.yml +++ b/bruno/b2b-daniel/list-products.yml @@ -1,7 +1,7 @@ info: name: list-products type: http - seq: 1 + seq: 4 http: method: GET diff --git a/bruno/b2b-daniel/list-users.yml b/bruno/b2b-daniel/list-users.yml index 288afbc..4e435aa 100644 --- a/bruno/b2b-daniel/list-users.yml +++ b/bruno/b2b-daniel/list-users.yml @@ -1,7 +1,7 @@ info: name: list-users type: http - seq: 2 + seq: 5 http: method: GET diff --git a/bruno/b2b-daniel/remove-index.yml b/bruno/b2b-daniel/remove-index.yml index aecc977..6ee9ebb 100644 --- a/bruno/b2b-daniel/remove-index.yml +++ b/bruno/b2b-daniel/remove-index.yml @@ -1,7 +1,7 @@ info: name: remove-index type: http - seq: 8 + seq: 11 http: method: DELETE diff --git a/bruno/b2b-daniel/retrieve-cart.yml b/bruno/b2b-daniel/retrieve-cart.yml index 114116c..8316965 100644 --- a/bruno/b2b-daniel/retrieve-cart.yml +++ b/bruno/b2b-daniel/retrieve-cart.yml @@ -1,7 +1,7 @@ info: name: retrieve-cart type: http - seq: 14 + seq: 17 http: method: GET diff --git a/bruno/b2b-daniel/retrieve-carts-info.yml b/bruno/b2b-daniel/retrieve-carts-info.yml index f15ce51..8d76d52 100644 --- a/bruno/b2b-daniel/retrieve-carts-info.yml +++ b/bruno/b2b-daniel/retrieve-carts-info.yml @@ -1,7 +1,7 @@ info: name: retrieve-carts-info type: http - seq: 13 + seq: 16 http: method: GET diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml index 201f4f8..eb3fb26 100644 --- a/bruno/b2b-daniel/save-product-description.yml +++ b/bruno/b2b-daniel/save-product-description.yml @@ -1,7 +1,7 @@ info: name: save-product-description type: http - seq: 25 + seq: 3 http: method: POST diff --git a/bruno/b2b-daniel/search.yml b/bruno/b2b-daniel/search.yml index 39d3f04..16cb913 100644 --- a/bruno/b2b-daniel/search.yml +++ b/bruno/b2b-daniel/search.yml @@ -1,7 +1,7 @@ info: name: search type: http - seq: 10 + seq: 13 http: method: GET diff --git a/bruno/b2b-daniel/test.yml b/bruno/b2b-daniel/test.yml index e63fe60..0b73d2f 100644 --- a/bruno/b2b-daniel/test.yml +++ b/bruno/b2b-daniel/test.yml @@ -1,7 +1,7 @@ info: name: test type: http - seq: 6 + seq: 9 http: method: GET diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml index f08dc01..2e8a7b4 100644 --- a/bruno/b2b-daniel/translate-product-description.yml +++ b/bruno/b2b-daniel/translate-product-description.yml @@ -1,7 +1,7 @@ info: name: translate-product-description type: http - seq: 19 + seq: 2 http: method: GET diff --git a/bruno/b2b-daniel/update-choice.yml b/bruno/b2b-daniel/update-choice.yml index 53a469b..3cd6ece 100644 --- a/bruno/b2b-daniel/update-choice.yml +++ b/bruno/b2b-daniel/update-choice.yml @@ -1,7 +1,7 @@ info: name: update-choice type: http - seq: 3 + seq: 6 http: method: POST diff --git a/bruno/b2b-daniel/upload-file.yml b/bruno/b2b-daniel/upload-file.yml new file mode 100644 index 0000000..41ab47a --- /dev/null +++ b/bruno/b2b-daniel/upload-file.yml @@ -0,0 +1,26 @@ +info: + name: upload-file + type: http + seq: 23 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/storage/upload-file?path=folder/ + params: + - name: path + value: folder/ + type: query + body: + type: multipart-form + data: + - name: document + type: file + value: + - /home/daniel/coding/work/b2b/storage/folder1/test.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/storage/test.txt b/storage/test.txt new file mode 100644 index 0000000..273c1a9 --- /dev/null +++ b/storage/test.txt @@ -0,0 +1 @@ +This is a test. \ No newline at end of file From 7d4242abb191a012edef10e7a3822084f69d0277 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 2 Apr 2026 13:52:50 +0200 Subject: [PATCH 04/18] move path to params --- app/delivery/web/api/restricted/storage.go | 25 ++++++++++--------- bruno/{b2b-daniel => b2b_daniel}/.gitignore | 0 bruno/b2b_daniel/auth/folder.yml | 7 ++++++ .../auth}/update-choice.yml | 2 +- .../carts}/add-new-cart.yml | 2 +- .../carts}/add-product-to-cart (1).yml | 2 +- .../carts}/add-product-to-cart.yml | 2 +- .../carts}/change-cart-name.yml | 2 +- bruno/b2b_daniel/carts/folder.yml | 7 ++++++ .../carts}/retrieve-cart.yml | 2 +- .../carts}/retrieve-carts-info.yml | 2 +- .../b2b_daniel/langs-and-countries/folder.yml | 7 ++++++ .../langs-and-countries}/get_countries.yml | 2 +- bruno/b2b_daniel/list/folder.yml | 7 ++++++ .../list}/list-products.yml | 2 +- .../list}/list-users.yml | 2 +- bruno/b2b_daniel/menu/folder.yml | 7 ++++++ .../menu}/get-breadcrumb.yml | 2 +- .../menu}/get-category-tree.yml | 2 +- .../opencollection.yml | 0 .../b2b_daniel/product-translation/folder.yml | 7 ++++++ .../get-product-description.yml | 0 .../save-product-description.yml | 2 +- .../translate-product-description.yml | 2 +- .../search}/create-index.yml | 4 +-- bruno/b2b_daniel/search/folder.yml | 7 ++++++ .../search}/get-indexes.yml | 2 +- .../search}/remove-index.yml | 2 +- .../search}/search.yml | 4 +-- .../search}/test.yml | 4 +-- .../storage}/create-folder.yml | 7 ++---- .../storage}/delete-file.yml | 8 ++---- .../storage}/delete-folder.yml | 8 ++---- .../storage}/download-file.yml | 8 ++---- bruno/b2b_daniel/storage/folder.yml | 7 ++++++ .../storage}/list-content.yml | 8 ++---- .../storage}/upload-file.yml | 10 +++----- 37 files changed, 104 insertions(+), 70 deletions(-) rename bruno/{b2b-daniel => b2b_daniel}/.gitignore (100%) create mode 100644 bruno/b2b_daniel/auth/folder.yml rename bruno/{b2b-daniel => b2b_daniel/auth}/update-choice.yml (97%) rename bruno/{b2b-daniel => b2b_daniel/carts}/add-new-cart.yml (95%) rename bruno/{b2b-daniel => b2b_daniel/carts}/add-product-to-cart (1).yml (97%) rename bruno/{b2b-daniel => b2b_daniel/carts}/add-product-to-cart.yml (98%) rename bruno/{b2b-daniel => b2b_daniel/carts}/change-cart-name.yml (97%) create mode 100644 bruno/b2b_daniel/carts/folder.yml rename bruno/{b2b-daniel => b2b_daniel/carts}/retrieve-cart.yml (96%) rename bruno/{b2b-daniel => b2b_daniel/carts}/retrieve-carts-info.yml (96%) create mode 100644 bruno/b2b_daniel/langs-and-countries/folder.yml rename bruno/{b2b-daniel => b2b_daniel/langs-and-countries}/get_countries.yml (96%) create mode 100644 bruno/b2b_daniel/list/folder.yml rename bruno/{b2b-daniel => b2b_daniel/list}/list-products.yml (97%) rename bruno/{b2b-daniel => b2b_daniel/list}/list-users.yml (97%) create mode 100644 bruno/b2b_daniel/menu/folder.yml rename bruno/{b2b-daniel => b2b_daniel/menu}/get-breadcrumb.yml (97%) rename bruno/{b2b-daniel => b2b_daniel/menu}/get-category-tree.yml (97%) rename bruno/{b2b-daniel => b2b_daniel}/opencollection.yml (100%) create mode 100644 bruno/b2b_daniel/product-translation/folder.yml rename bruno/{b2b-daniel => b2b_daniel/product-translation}/get-product-description.yml (100%) rename bruno/{b2b-daniel => b2b_daniel/product-translation}/save-product-description.yml (99%) rename bruno/{b2b-daniel => b2b_daniel/product-translation}/translate-product-description.yml (98%) rename bruno/{b2b-daniel => b2b_daniel/search}/create-index.yml (65%) create mode 100644 bruno/b2b_daniel/search/folder.yml rename bruno/{b2b-daniel => b2b_daniel/search}/get-indexes.yml (96%) rename bruno/{b2b-daniel => b2b_daniel/search}/remove-index.yml (96%) rename bruno/{b2b-daniel => b2b_daniel/search}/search.yml (75%) rename bruno/{b2b-daniel => b2b_daniel/search}/test.yml (67%) rename bruno/{b2b-daniel => b2b_daniel/storage}/create-folder.yml (77%) rename bruno/{b2b-daniel => b2b_daniel/storage}/delete-file.yml (67%) rename bruno/{b2b-daniel => b2b_daniel/storage}/delete-folder.yml (70%) rename bruno/{b2b-daniel => b2b_daniel/storage}/download-file.yml (66%) create mode 100644 bruno/b2b_daniel/storage/folder.yml rename bruno/{b2b-daniel => b2b_daniel/storage}/list-content.yml (70%) rename bruno/{b2b-daniel => b2b_daniel/storage}/upload-file.yml (67%) diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index 3760547..b851f9a 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -27,13 +27,13 @@ func NewStorageHandler() *StorageHandler { func StorageHandlerRoutes(r fiber.Router) fiber.Router { handler := NewStorageHandler() - r.Get("/list-content", handler.ListContent) - r.Get("/download-file", handler.DownloadFile) + r.Get("/list-content/*", handler.ListContent) + r.Get("/download-file/*", handler.DownloadFile) - r.Post("/upload-file", handler.UploadFile) - r.Get("/create-folder", handler.CreateFolder) - r.Delete("/delete-file", handler.DeleteFile) - r.Delete("/delete-folder", handler.DeleteFolder) + r.Post("/upload-file/*", handler.UploadFile) + r.Get("/create-folder/*", handler.CreateFolder) + r.Delete("/delete-file/*", handler.DeleteFile) + r.Delete("/delete-folder/*", handler.DeleteFolder) return r } @@ -41,7 +41,7 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { // accepted path looks like e.g. "/folder1/" or "folder1" func (h *StorageHandler) ListContent(c fiber.Ctx) error { // relative path defaults to root directory - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -58,7 +58,7 @@ func (h *StorageHandler) ListContent(c fiber.Ctx) error { } func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -72,11 +72,12 @@ func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { c.Attachment(filename) c.Set("Content-Length", strconv.FormatInt(filesize, 10)) + c.Set("Content-Type", "application/octet-stream") return c.SendStream(f, int(filesize)) } func (h *StorageHandler) UploadFile(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -98,7 +99,7 @@ func (h *StorageHandler) UploadFile(c fiber.Ctx) error { } func (h *StorageHandler) CreateFolder(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -114,7 +115,7 @@ func (h *StorageHandler) CreateFolder(c fiber.Ctx) error { } func (h *StorageHandler) DeleteFile(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -130,7 +131,7 @@ func (h *StorageHandler) DeleteFile(c fiber.Ctx) error { } func (h *StorageHandler) DeleteFolder(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("path")) + abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/bruno/b2b-daniel/.gitignore b/bruno/b2b_daniel/.gitignore similarity index 100% rename from bruno/b2b-daniel/.gitignore rename to bruno/b2b_daniel/.gitignore diff --git a/bruno/b2b_daniel/auth/folder.yml b/bruno/b2b_daniel/auth/folder.yml new file mode 100644 index 0000000..120ac3e --- /dev/null +++ b/bruno/b2b_daniel/auth/folder.yml @@ -0,0 +1,7 @@ +info: + name: auth + type: folder + seq: 1 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/update-choice.yml b/bruno/b2b_daniel/auth/update-choice.yml similarity index 97% rename from bruno/b2b-daniel/update-choice.yml rename to bruno/b2b_daniel/auth/update-choice.yml index 3cd6ece..0a511b0 100644 --- a/bruno/b2b-daniel/update-choice.yml +++ b/bruno/b2b_daniel/auth/update-choice.yml @@ -1,7 +1,7 @@ info: name: update-choice type: http - seq: 6 + seq: 1 http: method: POST diff --git a/bruno/b2b-daniel/add-new-cart.yml b/bruno/b2b_daniel/carts/add-new-cart.yml similarity index 95% rename from bruno/b2b-daniel/add-new-cart.yml rename to bruno/b2b_daniel/carts/add-new-cart.yml index 1b6cbde..20199cf 100644 --- a/bruno/b2b-daniel/add-new-cart.yml +++ b/bruno/b2b_daniel/carts/add-new-cart.yml @@ -1,7 +1,7 @@ info: name: add-new-cart type: http - seq: 14 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart (1).yml b/bruno/b2b_daniel/carts/add-product-to-cart (1).yml similarity index 97% rename from bruno/b2b-daniel/add-product-to-cart (1).yml rename to bruno/b2b_daniel/carts/add-product-to-cart (1).yml index bf5252b..eb7a5a1 100644 --- a/bruno/b2b-daniel/add-product-to-cart (1).yml +++ b/bruno/b2b_daniel/carts/add-product-to-cart (1).yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart (1) type: http - seq: 19 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/add-product-to-cart.yml b/bruno/b2b_daniel/carts/add-product-to-cart.yml similarity index 98% rename from bruno/b2b-daniel/add-product-to-cart.yml rename to bruno/b2b_daniel/carts/add-product-to-cart.yml index 045c7b0..ff780c1 100644 --- a/bruno/b2b-daniel/add-product-to-cart.yml +++ b/bruno/b2b_daniel/carts/add-product-to-cart.yml @@ -1,7 +1,7 @@ info: name: add-product-to-cart type: http - seq: 18 + seq: 14 http: method: GET diff --git a/bruno/b2b-daniel/change-cart-name.yml b/bruno/b2b_daniel/carts/change-cart-name.yml similarity index 97% rename from bruno/b2b-daniel/change-cart-name.yml rename to bruno/b2b_daniel/carts/change-cart-name.yml index ced76f1..08838dc 100644 --- a/bruno/b2b-daniel/change-cart-name.yml +++ b/bruno/b2b_daniel/carts/change-cart-name.yml @@ -1,7 +1,7 @@ info: name: change-cart-name type: http - seq: 15 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/carts/folder.yml b/bruno/b2b_daniel/carts/folder.yml new file mode 100644 index 0000000..4f51dfd --- /dev/null +++ b/bruno/b2b_daniel/carts/folder.yml @@ -0,0 +1,7 @@ +info: + name: carts + type: folder + seq: 7 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/retrieve-cart.yml b/bruno/b2b_daniel/carts/retrieve-cart.yml similarity index 96% rename from bruno/b2b-daniel/retrieve-cart.yml rename to bruno/b2b_daniel/carts/retrieve-cart.yml index 8316965..69c5e2e 100644 --- a/bruno/b2b-daniel/retrieve-cart.yml +++ b/bruno/b2b_daniel/carts/retrieve-cart.yml @@ -1,7 +1,7 @@ info: name: retrieve-cart type: http - seq: 17 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/retrieve-carts-info.yml b/bruno/b2b_daniel/carts/retrieve-carts-info.yml similarity index 96% rename from bruno/b2b-daniel/retrieve-carts-info.yml rename to bruno/b2b_daniel/carts/retrieve-carts-info.yml index 8d76d52..479be4e 100644 --- a/bruno/b2b-daniel/retrieve-carts-info.yml +++ b/bruno/b2b_daniel/carts/retrieve-carts-info.yml @@ -1,7 +1,7 @@ info: name: retrieve-carts-info type: http - seq: 16 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/langs-and-countries/folder.yml b/bruno/b2b_daniel/langs-and-countries/folder.yml new file mode 100644 index 0000000..b895323 --- /dev/null +++ b/bruno/b2b_daniel/langs-and-countries/folder.yml @@ -0,0 +1,7 @@ +info: + name: langs-and-countries + type: folder + seq: 4 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get_countries.yml b/bruno/b2b_daniel/langs-and-countries/get_countries.yml similarity index 96% rename from bruno/b2b-daniel/get_countries.yml rename to bruno/b2b_daniel/langs-and-countries/get_countries.yml index 07fed01..b7204b4 100644 --- a/bruno/b2b-daniel/get_countries.yml +++ b/bruno/b2b_daniel/langs-and-countries/get_countries.yml @@ -1,7 +1,7 @@ info: name: get_countries type: http - seq: 7 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/list/folder.yml b/bruno/b2b_daniel/list/folder.yml new file mode 100644 index 0000000..52fa517 --- /dev/null +++ b/bruno/b2b_daniel/list/folder.yml @@ -0,0 +1,7 @@ +info: + name: list + type: folder + seq: 3 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/list-products.yml b/bruno/b2b_daniel/list/list-products.yml similarity index 97% rename from bruno/b2b-daniel/list-products.yml rename to bruno/b2b_daniel/list/list-products.yml index 8fed3db..adc88a7 100644 --- a/bruno/b2b-daniel/list-products.yml +++ b/bruno/b2b_daniel/list/list-products.yml @@ -1,7 +1,7 @@ info: name: list-products type: http - seq: 4 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/list-users.yml b/bruno/b2b_daniel/list/list-users.yml similarity index 97% rename from bruno/b2b-daniel/list-users.yml rename to bruno/b2b_daniel/list/list-users.yml index 4e435aa..85d70fa 100644 --- a/bruno/b2b-daniel/list-users.yml +++ b/bruno/b2b_daniel/list/list-users.yml @@ -1,7 +1,7 @@ info: name: list-users type: http - seq: 5 + seq: 1 http: method: GET diff --git a/bruno/b2b_daniel/menu/folder.yml b/bruno/b2b_daniel/menu/folder.yml new file mode 100644 index 0000000..32bc162 --- /dev/null +++ b/bruno/b2b_daniel/menu/folder.yml @@ -0,0 +1,7 @@ +info: + name: menu + type: folder + seq: 5 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-breadcrumb.yml b/bruno/b2b_daniel/menu/get-breadcrumb.yml similarity index 97% rename from bruno/b2b-daniel/get-breadcrumb.yml rename to bruno/b2b_daniel/menu/get-breadcrumb.yml index 9a49428..a805790 100644 --- a/bruno/b2b-daniel/get-breadcrumb.yml +++ b/bruno/b2b_daniel/menu/get-breadcrumb.yml @@ -1,7 +1,7 @@ info: name: get-breadcrumb type: http - seq: 20 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/get-category-tree.yml b/bruno/b2b_daniel/menu/get-category-tree.yml similarity index 97% rename from bruno/b2b-daniel/get-category-tree.yml rename to bruno/b2b_daniel/menu/get-category-tree.yml index b81d6d1..6e9d875 100644 --- a/bruno/b2b-daniel/get-category-tree.yml +++ b/bruno/b2b_daniel/menu/get-category-tree.yml @@ -1,7 +1,7 @@ info: name: get-category-tree type: http - seq: 8 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/opencollection.yml b/bruno/b2b_daniel/opencollection.yml similarity index 100% rename from bruno/b2b-daniel/opencollection.yml rename to bruno/b2b_daniel/opencollection.yml diff --git a/bruno/b2b_daniel/product-translation/folder.yml b/bruno/b2b_daniel/product-translation/folder.yml new file mode 100644 index 0000000..cda7116 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/folder.yml @@ -0,0 +1,7 @@ +info: + name: product-translation + type: folder + seq: 2 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-product-description.yml b/bruno/b2b_daniel/product-translation/get-product-description.yml similarity index 100% rename from bruno/b2b-daniel/get-product-description.yml rename to bruno/b2b_daniel/product-translation/get-product-description.yml diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b_daniel/product-translation/save-product-description.yml similarity index 99% rename from bruno/b2b-daniel/save-product-description.yml rename to bruno/b2b_daniel/product-translation/save-product-description.yml index eb3fb26..201f4f8 100644 --- a/bruno/b2b-daniel/save-product-description.yml +++ b/bruno/b2b_daniel/product-translation/save-product-description.yml @@ -1,7 +1,7 @@ info: name: save-product-description type: http - seq: 3 + seq: 25 http: method: POST diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b_daniel/product-translation/translate-product-description.yml similarity index 98% rename from bruno/b2b-daniel/translate-product-description.yml rename to bruno/b2b_daniel/product-translation/translate-product-description.yml index 2e8a7b4..12c65b4 100644 --- a/bruno/b2b-daniel/translate-product-description.yml +++ b/bruno/b2b_daniel/product-translation/translate-product-description.yml @@ -1,7 +1,7 @@ info: name: translate-product-description type: http - seq: 2 + seq: 24 http: method: GET diff --git a/bruno/b2b-daniel/create-index.yml b/bruno/b2b_daniel/search/create-index.yml similarity index 65% rename from bruno/b2b-daniel/create-index.yml rename to bruno/b2b_daniel/search/create-index.yml index 6f00a56..1469dc4 100644 --- a/bruno/b2b-daniel/create-index.yml +++ b/bruno/b2b_daniel/search/create-index.yml @@ -1,11 +1,11 @@ info: name: create-index type: http - seq: 10 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/create-index + url: http://localhost:3000/api/v1/restricted/search/create-index auth: inherit settings: diff --git a/bruno/b2b_daniel/search/folder.yml b/bruno/b2b_daniel/search/folder.yml new file mode 100644 index 0000000..7b92aae --- /dev/null +++ b/bruno/b2b_daniel/search/folder.yml @@ -0,0 +1,7 @@ +info: + name: search + type: folder + seq: 6 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/get-indexes.yml b/bruno/b2b_daniel/search/get-indexes.yml similarity index 96% rename from bruno/b2b-daniel/get-indexes.yml rename to bruno/b2b_daniel/search/get-indexes.yml index ebca4aa..0b85acf 100644 --- a/bruno/b2b-daniel/get-indexes.yml +++ b/bruno/b2b_daniel/search/get-indexes.yml @@ -1,7 +1,7 @@ info: name: get-indexes type: http - seq: 12 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/remove-index.yml b/bruno/b2b_daniel/search/remove-index.yml similarity index 96% rename from bruno/b2b-daniel/remove-index.yml rename to bruno/b2b_daniel/search/remove-index.yml index 6ee9ebb..c1c8856 100644 --- a/bruno/b2b-daniel/remove-index.yml +++ b/bruno/b2b_daniel/search/remove-index.yml @@ -1,7 +1,7 @@ info: name: remove-index type: http - seq: 11 + seq: 1 http: method: DELETE diff --git a/bruno/b2b-daniel/search.yml b/bruno/b2b_daniel/search/search.yml similarity index 75% rename from bruno/b2b-daniel/search.yml rename to bruno/b2b_daniel/search/search.yml index 16cb913..5200d85 100644 --- a/bruno/b2b-daniel/search.yml +++ b/bruno/b2b_daniel/search/search.yml @@ -1,11 +1,11 @@ info: name: search type: http - seq: 13 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/search?query=w&limit=4&id_category=0&price_lower_bound=60.0&price_upper_bound=70.0 + url: http://localhost:3000/api/v1/restricted/search/search?query=w&limit=4&id_category=0&price_lower_bound=60.0&price_upper_bound=70.0 params: - name: query value: w diff --git a/bruno/b2b-daniel/test.yml b/bruno/b2b_daniel/search/test.yml similarity index 67% rename from bruno/b2b-daniel/test.yml rename to bruno/b2b_daniel/search/test.yml index 0b73d2f..60fe55a 100644 --- a/bruno/b2b-daniel/test.yml +++ b/bruno/b2b_daniel/search/test.yml @@ -1,11 +1,11 @@ info: name: test type: http - seq: 9 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/meili-search/test + url: http://localhost:3000/api/v1/restricted/search/test auth: inherit settings: diff --git a/bruno/b2b-daniel/create-folder.yml b/bruno/b2b_daniel/storage/create-folder.yml similarity index 77% rename from bruno/b2b-daniel/create-folder.yml rename to bruno/b2b_daniel/storage/create-folder.yml index 0f9fabb..1250965 100644 --- a/bruno/b2b-daniel/create-folder.yml +++ b/bruno/b2b_daniel/storage/create-folder.yml @@ -1,15 +1,12 @@ info: name: create-folder type: http - seq: 24 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/storage/create-folder?path=&name=folder + url: http://localhost:3000/api/v1/restricted/storage/create-folder?name=folder params: - - name: path - value: "" - type: query - name: name value: folder type: query diff --git a/bruno/b2b-daniel/delete-file.yml b/bruno/b2b_daniel/storage/delete-file.yml similarity index 67% rename from bruno/b2b-daniel/delete-file.yml rename to bruno/b2b_daniel/storage/delete-file.yml index 32f7104..01b1744 100644 --- a/bruno/b2b-daniel/delete-file.yml +++ b/bruno/b2b_daniel/storage/delete-file.yml @@ -1,15 +1,11 @@ info: name: delete-file type: http - seq: 25 + seq: 1 http: method: DELETE - url: http://localhost:3000/api/v1/restricted/storage/delete-file?path=/folder/test.txt - params: - - name: path - value: /folder/test.txt - type: query + url: http://localhost:3000/api/v1/restricted/storage/delete-file/folder1/TODO.txt auth: inherit settings: diff --git a/bruno/b2b-daniel/delete-folder.yml b/bruno/b2b_daniel/storage/delete-folder.yml similarity index 70% rename from bruno/b2b-daniel/delete-folder.yml rename to bruno/b2b_daniel/storage/delete-folder.yml index 49dacb7..3c578ce 100644 --- a/bruno/b2b-daniel/delete-folder.yml +++ b/bruno/b2b_daniel/storage/delete-folder.yml @@ -1,15 +1,11 @@ info: name: delete-folder type: http - seq: 26 + seq: 1 http: method: DELETE - url: http://localhost:3000/api/v1/restricted/storage/delete-folder?path=/folder/ - params: - - name: path - value: /folder/ - type: query + url: http://localhost:3000/api/v1/restricted/storage/delete-folder/folder/ auth: inherit settings: diff --git a/bruno/b2b-daniel/download-file.yml b/bruno/b2b_daniel/storage/download-file.yml similarity index 66% rename from bruno/b2b-daniel/download-file.yml rename to bruno/b2b_daniel/storage/download-file.yml index d400ef4..d6c65a1 100644 --- a/bruno/b2b-daniel/download-file.yml +++ b/bruno/b2b_daniel/storage/download-file.yml @@ -1,15 +1,11 @@ info: name: download-file type: http - seq: 22 + seq: 1 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 + url: http://localhost:3000/api/v1/restricted/storage/download-file/folder1/test.xlsx auth: inherit settings: diff --git a/bruno/b2b_daniel/storage/folder.yml b/bruno/b2b_daniel/storage/folder.yml new file mode 100644 index 0000000..70062a4 --- /dev/null +++ b/bruno/b2b_daniel/storage/folder.yml @@ -0,0 +1,7 @@ +info: + name: storage + type: folder + seq: 1 + +request: + auth: inherit diff --git a/bruno/b2b-daniel/list-content.yml b/bruno/b2b_daniel/storage/list-content.yml similarity index 70% rename from bruno/b2b-daniel/list-content.yml rename to bruno/b2b_daniel/storage/list-content.yml index 972779f..ed67b6d 100644 --- a/bruno/b2b-daniel/list-content.yml +++ b/bruno/b2b_daniel/storage/list-content.yml @@ -1,15 +1,11 @@ info: name: list-content type: http - seq: 21 + seq: 1 http: method: GET - url: http://localhost:3000/api/v1/restricted/storage/list-content?path=/folder1 - params: - - name: path - value: /folder1 - type: query + url: http://localhost:3000/api/v1/restricted/storage/list-content/folder1 auth: inherit settings: diff --git a/bruno/b2b-daniel/upload-file.yml b/bruno/b2b_daniel/storage/upload-file.yml similarity index 67% rename from bruno/b2b-daniel/upload-file.yml rename to bruno/b2b_daniel/storage/upload-file.yml index 41ab47a..aa8d740 100644 --- a/bruno/b2b-daniel/upload-file.yml +++ b/bruno/b2b_daniel/storage/upload-file.yml @@ -1,22 +1,18 @@ info: name: upload-file type: http - seq: 23 + seq: 1 http: method: POST - url: http://localhost:3000/api/v1/restricted/storage/upload-file?path=folder/ - params: - - name: path - value: folder/ - type: query + url: http://localhost:3000/api/v1/restricted/storage/upload-file/folder1/ body: type: multipart-form data: - name: document type: file value: - - /home/daniel/coding/work/b2b/storage/folder1/test.txt + - /home/daniel/TODO.txt auth: inherit settings: From 395d67029836a64fc9edd11ba84f2208ad607e86 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 2 Apr 2026 14:00:58 +0200 Subject: [PATCH 05/18] add storage to .gitignore --- .gitignore | 4 +++- storage/.gitkeep | 0 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 storage/.gitkeep diff --git a/.gitignore b/.gitignore index 0408331..d9058fb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ bin/ i18n/*.json *_templ.go tmp/main -test.go \ No newline at end of file +test.go +storage/* +!storage/.gitkeep \ No newline at end of file diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 From a988bbbc33640417bda1a7652c7fa66e3f01c5d5 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 3 Apr 2026 11:25:16 +0200 Subject: [PATCH 06/18] added copying and moving --- app/delivery/web/api/restricted/storage.go | 46 +++++++++++++++++++ app/repos/storageRepo/storageRepo.go | 32 +++++++++++++ app/service/storageService/storageService.go | 29 ++++++++++++ bruno/b2b-daniel/save-product-description.yml | 28 +++++++++++ .../translate-product-description.yml | 28 +++++++++++ bruno/b2b_daniel/storage/copy.yml | 19 ++++++++ bruno/b2b_daniel/storage/move.yml | 19 ++++++++ 7 files changed, 201 insertions(+) create mode 100644 bruno/b2b-daniel/save-product-description.yml create mode 100644 bruno/b2b-daniel/translate-product-description.yml create mode 100644 bruno/b2b_daniel/storage/copy.yml create mode 100644 bruno/b2b_daniel/storage/move.yml diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index b851f9a..f337547 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -30,6 +30,8 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { r.Get("/list-content/*", handler.ListContent) r.Get("/download-file/*", handler.DownloadFile) + r.Get("/move/*", handler.Move) + r.Get("/copy/*", handler.Copy) r.Post("/upload-file/*", handler.UploadFile) r.Get("/create-folder/*", handler.CreateFolder) r.Delete("/delete-file/*", handler.DeleteFile) @@ -38,6 +40,50 @@ func StorageHandlerRoutes(r fiber.Router) fiber.Router { return r } +func (h *StorageHandler) Move(c fiber.Ctx) error { + src_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + dest_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("dest_path")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + err = h.storageService.Move(src_abs_path, dest_abs_path) + 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))) +} + +func (h *StorageHandler) Copy(c fiber.Ctx) error { + src_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + dest_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("dest_path")) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + err = h.storageService.Copy(src_abs_path, dest_abs_path) + 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))) +} + // accepted path looks like e.g. "/folder1/" or "folder1" func (h *StorageHandler) ListContent(c fiber.Ctx) error { // relative path defaults to root directory diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go index e6c1461..08441a6 100644 --- a/app/repos/storageRepo/storageRepo.go +++ b/app/repos/storageRepo/storageRepo.go @@ -1,6 +1,7 @@ package storageRepo import ( + "io" "mime/multipart" "os" @@ -11,6 +12,8 @@ import ( type UIStorageRepo interface { EntryInfo(abs_path string) (os.FileInfo, error) ListContent(abs_path string) (*[]model.EntryInList, error) + Move(src_abs_path string, dest_abs_path string) error + Copy(src_abs_path string, dest_abs_path string) error OpenFile(abs_path string) (*os.File, error) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error CreateFolder(abs_path string) error @@ -47,6 +50,35 @@ func (r *StorageRepo) ListContent(abs_path string) (*[]model.EntryInList, error) return &entries_in_list, nil } +func (r *StorageRepo) Move(src_abs_path string, dest_abs_path string) error { + return os.Rename(src_abs_path, dest_abs_path) +} + +func (r *StorageRepo) Copy(src_abs_path string, dest_abs_path string) error { + in, err := os.Open(src_abs_path) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(dest_abs_path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + return out.Sync() +} + func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { return os.Open(abs_path) } diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go index 3e01c61..28b78ed 100644 --- a/app/service/storageService/storageService.go +++ b/app/service/storageService/storageService.go @@ -46,6 +46,35 @@ func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, in return f, filepath.Base(abs_path), info.Size(), nil } +func (s *StorageService) Move(src_abs_path string, dest_abs_path string) error { + _, err := s.storageRepo.EntryInfo(src_abs_path) + if err != nil { + return responseErrors.ErrFileDoesNotExist + } + + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.Move(src_abs_path, dest_abs_path) + } else { + return err + } +} +func (s *StorageService) Copy(src_abs_path string, dest_abs_path string) error { + _, err := s.storageRepo.EntryInfo(src_abs_path) + if err != nil { + return responseErrors.ErrFileDoesNotExist + } + + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.Copy(src_abs_path, dest_abs_path) + } else { + return err + } +} + func (s *StorageService) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error { info, err := s.storageRepo.EntryInfo(abs_path) if err != nil || !info.IsDir() { diff --git a/bruno/b2b-daniel/save-product-description.yml b/bruno/b2b-daniel/save-product-description.yml new file mode 100644 index 0000000..3ea103c --- /dev/null +++ b/bruno/b2b-daniel/save-product-description.yml @@ -0,0 +1,28 @@ +info: + name: save-product-description + type: http + seq: 19 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/product-translation/save-product-description?productID=1&productLangID=3 + params: + - name: productID + value: "1" + type: query + - name: productLangID + value: "3" + type: query + body: + type: json + data: |- + { + "description": "

Der Einsatz von Rehabilitationsrollen in verschiedenen Übungen und Behandlungen wirkt sich positiv auf die Reduzierung von Verletzungen und die Genesungschancen aus. Sie werden in der Rehabilitation, bei Korrekturgymnastik sowie in der traditionellen und Sportmassage eingesetzt, da sie ideal zum Anheben und Spreizen von Gliedmaßen geeignet sind. Zudem können sie zur Unterstützung von Knien, Füßen, Armen und Schultern verwendet werden. Auch für Kinder sind Rehabilitationsrollen empfehlenswert; ihre spielerische Anwendung fördert die Entwicklung der Grobmotorik.

Dank der großen Auswahl an Farben und Größen lässt sich ein Übungsset zusammenstellen, das in jeder Physiotherapiepraxis, jedem Massageraum, jeder Schule oder jedem Kindergarten benötigt wird.

Die Rehabilitationsrolle ist ein Medizinprodukt, das den grundlegenden Anforderungen an Medizinprodukte und den Bestimmungen des Medizinproduktegesetzes entspricht, im Register für Medizinprodukte des Amtes für die Registrierung von Arzneimitteln, Medizinprodukten und Biozidprodukten eingetragen ist, mit der Konformitätserklärung des Herstellers versehen ist und das CE-Zeichen trägt.

\"Medizinprodukt\"

Empfohlene Verwendung:

  • in der Rehabilitation
  • während Massagen (traditionell, Sport)
  • in der Korrekturgymnastik (insbesondere für Kinder)
  • zur Linderung von Verletzungen einzelner Körperteile
  • Zur Unterstützung von: Knien, Knöcheln, Kopf des Patienten
  • bei Übungen zur Entwicklung der motorischen Fähigkeiten von Kindern
  • in Schönheitssalons
  • in Kinderspielzimmern

Materialspezifikationen:

Abdeckung: PVC-beschichtetes Material, das für medizinische Geräte vorgesehen ist und daher sehr leicht zu reinigen und zu desinfizieren ist:

  • Material gemäß REACH-Verordnung, zertifiziert mit dem STANDARD 100 Zertifikat von OEKO-TEX®.
  • Enthält keine Phthalate
  • feuerfest
  • resistent gegenüber physiologischen Flüssigkeiten (Blut, Urin, Schweiß) und Alkohol
  • UV-beständig, daher auch für den Einsatz im Freien geeignet.
  • kratzfest
  • ölbeständig

\"ERREICHEN\"\"Öko-Tex\"Enthält\"Feuerfest\"\"Alkoholbeständig\"\"UV-beständig\"\"Für\"Kratzfest\"\"Ölbeständig\"

Füllung: mittelharter Polyurethanschaum mit erhöhter Verformungsbeständigkeit:

  • besitzt ein Hygienezertifikat, ausgestellt vom Institut für Maritime und Tropenmedizin in Gdynia
  • zertifiziert mit dem STANDARD 100 by OEKO-TEX® Zertifikat – Produktklasse I, ausgestellt vom Textilforschungsinstitut in Łódź
  • Hergestellt aus hochwertigen Rohstoffen, die die Ozonschicht nicht schädigen.

\"Öko-Tex\"Hygienezertifikat\"\"Hygienezertifikat\"

" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml new file mode 100644 index 0000000..5f3a787 --- /dev/null +++ b/bruno/b2b-daniel/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 21 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/product-translation/translate-product-description?productID=51&productFromLangID=1&productToLangID=3&model=Google + params: + - name: productID + value: "51" + type: query + - name: productFromLangID + value: "1" + type: query + - name: productToLangID + value: "3" + type: query + - name: model + value: Google + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage/copy.yml b/bruno/b2b_daniel/storage/copy.yml new file mode 100644 index 0000000..8161fc0 --- /dev/null +++ b/bruno/b2b_daniel/storage/copy.yml @@ -0,0 +1,19 @@ +info: + name: copy + type: http + seq: 7 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/copy/folder1/test.txt?dest_path=/folder/a.txt + params: + - name: dest_path + value: /folder/a.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage/move.yml b/bruno/b2b_daniel/storage/move.yml new file mode 100644 index 0000000..7fb51e5 --- /dev/null +++ b/bruno/b2b_daniel/storage/move.yml @@ -0,0 +1,19 @@ +info: + name: move + type: http + seq: 8 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/move/folder?dest_path=/folder1/test.txt + params: + - name: dest_path + value: /folder1/test.txt + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 From 1bab7f642f809f48e030361dc4f45b7ce30b0998 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 3 Apr 2026 11:44:15 +0200 Subject: [PATCH 07/18] typo --- app/service/storageService/storageService.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go index 28b78ed..6fc9d41 100644 --- a/app/service/storageService/storageService.go +++ b/app/service/storageService/storageService.go @@ -52,6 +52,7 @@ func (s *StorageService) Move(src_abs_path string, dest_abs_path string) error { return responseErrors.ErrFileDoesNotExist } + _, err = s.storageRepo.EntryInfo(dest_abs_path) if err == nil { return responseErrors.ErrNameTaken } else if os.IsNotExist(err) { @@ -66,6 +67,7 @@ func (s *StorageService) Copy(src_abs_path string, dest_abs_path string) error { return responseErrors.ErrFileDoesNotExist } + _, err = s.storageRepo.EntryInfo(dest_abs_path) if err == nil { return responseErrors.ErrNameTaken } else if os.IsNotExist(err) { From f6b321b602b4fc4fb8268ca6db52a532267997e7 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 3 Apr 2026 13:55:57 +0200 Subject: [PATCH 08/18] a few fixes for user teleportation --- app/delivery/middleware/auth.go | 14 ++++---------- app/service/emailService/email.go | 3 ++- app/utils/const_data/consts.go | 1 + app/utils/i18n/i18n.go | 3 ++- app/utils/localeExtractor/localeExtractor.go | 8 ++++++++ 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index c5a87cc..14fc0df 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -8,6 +8,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/authService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "github.com/gofiber/fiber/v3" ) @@ -115,21 +116,14 @@ func AuthMiddleware() fiber.Handler { // RequireAdmin creates admin-only middleware func RequireAdmin() fiber.Handler { return func(c fiber.Ctx) error { - user := c.Locals("user") - if user == nil { + originalUserRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": "not authenticated", }) } - userSession, ok := user.(*model.UserSession) - if !ok { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "invalid user session", - }) - } - - if userSession.Role != model.RoleAdmin { + if originalUserRole != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) diff --git a/app/service/emailService/email.go b/app/service/emailService/email.go index 6b1e082..29cc9bb 100644 --- a/app/service/emailService/email.go +++ b/app/service/emailService/email.go @@ -10,6 +10,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/templ/emails" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/view" ) @@ -133,6 +134,6 @@ func (s *EmailService) passwordResetEmailTemplate(name, resetURL string, langID // newUserAdminNotificationTemplate returns the HTML template for admin notification func (s *EmailService) newUserAdminNotificationTemplate(userEmail, userName, baseURL string) string { buf := bytes.Buffer{} - emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: 2, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf) + emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: constdata.ADMIN_NOTIFICATION_LANGUAGE, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf) return buf.String() } diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index b3790c8..05f23e8 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -4,6 +4,7 @@ package constdata const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$` const SHOP_ID = 1 const SHOP_DEFAULT_LANGUAGE = 1 +const ADMIN_NOTIFICATION_LANGUAGE = 2 // CATEGORY_TREE_ROOT_ID corresponds to id_category in ps_category which has is_root_category=1 const CATEGORY_TREE_ROOT_ID = 2 diff --git a/app/utils/i18n/i18n.go b/app/utils/i18n/i18n.go index 3dfec66..5f3b6a0 100644 --- a/app/utils/i18n/i18n.go +++ b/app/utils/i18n/i18n.go @@ -8,6 +8,7 @@ import ( "sync" "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "github.com/gofiber/fiber/v3" ) @@ -177,7 +178,7 @@ func (s *TranslationsStore) ReloadTranslations(translations []model.Translation) // T_ is meant to be used to translate error messages and other system communicates. func T_[T ~string](c fiber.Ctx, key T, params ...interface{}) string { - if langID, ok := c.Locals("langID").(uint); ok { + if langID, ok := localeExtractor.GetLangID(c); ok { parts := strings.Split(string(key), ".") if len(parts) >= 2 { diff --git a/app/utils/localeExtractor/localeExtractor.go b/app/utils/localeExtractor/localeExtractor.go index 735397c..4b641d9 100644 --- a/app/utils/localeExtractor/localeExtractor.go +++ b/app/utils/localeExtractor/localeExtractor.go @@ -21,3 +21,11 @@ func GetUserID(c fiber.Ctx) (uint, bool) { } return user_locale.User.ID, true } + +func GetOriginalUserRole(c fiber.Ctx) (model.CustomerRole, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.OriginalUser == nil { + return "", false + } + return user_locale.OriginalUser.Role, true +} From 7eee0bd03229e511f563098fbcd0d1e9524afe75 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Wed, 8 Apr 2026 13:09:19 +0200 Subject: [PATCH 09/18] rebuilt storage --- app/delivery/middleware/auth.go | 68 ++++ .../web/api/restricted/productTranslation.go | 13 + app/delivery/web/api/restricted/search.go | 7 + app/delivery/web/api/restricted/storage.go | 133 ++------ app/delivery/web/api/webdav/storage.go | 198 ++++++++++++ app/delivery/web/init.go | 35 +- app/model/customer.go | 2 + app/repos/storageRepo/storageRepo.go | 144 +++++++-- app/service/authService/auth.go | 13 + app/service/storageService/storageService.go | 306 ++++++++++++------ app/utils/const_data/consts.go | 5 + app/utils/responseErrors/responseErrors.go | 18 +- .../{storage => storage-old}/copy.yml | 0 .../create-folder.yml | 0 .../{storage => storage-old}/delete-file.yml | 0 .../delete-folder.yml | 0 .../download-file.yml | 0 .../{storage => storage-old}/folder.yml | 2 +- .../{storage => storage-old}/list-content.yml | 0 .../{storage => storage-old}/move.yml | 0 .../{storage => storage-old}/upload-file.yml | 0 .../create-new-webdav-token.yml | 15 + .../b2b_daniel/storage-restricted/folder.yml | 7 + go.mod | 4 +- go.sum | 6 + .../20260302163122_create_tables.sql | 4 + storage/folder/a.txt | 1 - storage/folder1/test | 0 storage/folder1/test.txt | 1 - storage/test.txt | 1 - 30 files changed, 723 insertions(+), 260 deletions(-) create mode 100644 app/delivery/web/api/webdav/storage.go rename bruno/b2b_daniel/{storage => storage-old}/copy.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/create-folder.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/delete-file.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/delete-folder.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/download-file.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/folder.yml (73%) rename bruno/b2b_daniel/{storage => storage-old}/list-content.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/move.yml (100%) rename bruno/b2b_daniel/{storage => storage-old}/upload-file.yml (100%) create mode 100644 bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml create mode 100644 bruno/b2b_daniel/storage-restricted/folder.yml delete mode 100644 storage/folder/a.txt delete mode 100644 storage/folder1/test delete mode 100644 storage/folder1/test.txt delete mode 100644 storage/test.txt diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index 14fc0df..8d5a906 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -1,8 +1,10 @@ package middleware import ( + "encoding/base64" "strconv" "strings" + "time" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/model" @@ -133,6 +135,72 @@ func RequireAdmin() fiber.Handler { } } +// Webdav +func Webdav() fiber.Handler { + authService := authService.NewAuthService() + + return func(c fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "authorization token required", + }) + } + + if !strings.HasPrefix(authHeader, "Basic ") { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + encoded := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + credentials := strings.SplitN(string(decoded), ":", 2) + rawToken := "" + if len(credentials) == 1 { + rawToken = credentials[0] + } else if len(credentials) == 2 { + rawToken = credentials[1] + } + if len(rawToken) != constdata.NBYTES_IN_WEBDAV_TOKEN*2 { + c.Set("WWW-Authenticate", `Basic realm="webdav"`) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization token", + }) + } + + // we identify user based on this token. + user, err := authService.GetUserByWebdavToken(rawToken) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "user not found", + }) + } + + if user.WebdavExpires != nil && user.WebdavExpires.Before(time.Now()) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid or expired token", + }) + } + + var userLocale model.UserLocale + userLocale.OriginalUser = user + userLocale.User = user + c.Locals(constdata.USER_LOCALE, &userLocale) + + return c.Next() + } +} + // GetConfig returns the app config func GetConfig() *config.Config { return config.Get() diff --git a/app/delivery/web/api/restricted/productTranslation.go b/app/delivery/web/api/restricted/productTranslation.go index ea6f906..58b378b 100644 --- a/app/delivery/web/api/restricted/productTranslation.go +++ b/app/delivery/web/api/restricted/productTranslation.go @@ -4,6 +4,7 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/productTranslationService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" @@ -79,6 +80,12 @@ func (h *ProductTranslationHandler) SaveProductDescription(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || userRole != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + productID_attribute := c.Query("productID") productID, err := strconv.Atoi(productID_attribute) if err != nil { @@ -116,6 +123,12 @@ func (h *ProductTranslationHandler) TranslateProductDescription(c fiber.Ctx) err JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || userRole != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + productID_attribute := c.Query("productID") productID, err := strconv.Atoi(productID_attribute) if err != nil { diff --git a/app/delivery/web/api/restricted/search.go b/app/delivery/web/api/restricted/search.go index 8881853..0a7bef3 100644 --- a/app/delivery/web/api/restricted/search.go +++ b/app/delivery/web/api/restricted/search.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/meiliService" searchservice "git.ma-al.com/goc_daniel/b2b/app/service/searchService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" @@ -43,6 +44,12 @@ func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || userRole != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + err := h.meiliService.CreateIndex(id_lang) if err != nil { fmt.Printf("CreateIndex error: %v\n", err) diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index f337547..a8a09b7 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -4,8 +4,10 @@ import ( "strconv" "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" "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/localeExtractor" "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" @@ -27,63 +29,16 @@ func NewStorageHandler() *StorageHandler { func StorageHandlerRoutes(r fiber.Router) fiber.Router { handler := NewStorageHandler() + // for all users r.Get("/list-content/*", handler.ListContent) r.Get("/download-file/*", handler.DownloadFile) - r.Get("/move/*", handler.Move) - r.Get("/copy/*", handler.Copy) - r.Post("/upload-file/*", handler.UploadFile) - r.Get("/create-folder/*", handler.CreateFolder) - r.Delete("/delete-file/*", handler.DeleteFile) - r.Delete("/delete-folder/*", handler.DeleteFolder) + // for admins only + r.Get("/create-new-webdav-token", handler.CreateNewWebdavToken) return r } -func (h *StorageHandler) Move(c fiber.Ctx) error { - src_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - dest_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("dest_path")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - err = h.storageService.Move(src_abs_path, dest_abs_path) - 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))) -} - -func (h *StorageHandler) Copy(c fiber.Ctx) error { - src_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - dest_abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Query("dest_path")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - err = h.storageService.Copy(src_abs_path, dest_abs_path) - 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))) -} - // accepted path looks like e.g. "/folder1/" or "folder1" func (h *StorageHandler) ListContent(c fiber.Ctx) error { // relative path defaults to root directory @@ -122,72 +77,24 @@ func (h *StorageHandler) DownloadFile(c fiber.Ctx) error { return c.SendStream(f, int(filesize)) } -func (h *StorageHandler) UploadFile(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) +func (h *StorageHandler) CreateNewWebdavToken(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + userRole, ok := localeExtractor.GetOriginalUserRole(c) + if !ok || userRole != model.RoleAdmin { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) + } + + new_token, err := h.storageService.NewWebdavToken(userID) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - f, err := c.FormFile("document") - if err != nil { - return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrMissingFileFieldDocument)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrMissingFileFieldDocument))) - } - - err = h.storageService.UploadFile(c, abs_path, f) - 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))) -} - -func (h *StorageHandler) CreateFolder(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - err = h.storageService.CreateFolder(abs_path, 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))) -} - -func (h *StorageHandler) DeleteFile(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - err = h.storageService.DeleteFile(abs_path) - 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))) -} - -func (h *StorageHandler) DeleteFolder(c fiber.Ctx) error { - abs_path, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) - if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)). - JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) - } - - err = h.storageService.DeleteFolder(abs_path) - 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))) + return c.JSON(response.Make(&new_token, 0, i18n.T_(c, response.Message_OK))) } diff --git a/app/delivery/web/api/webdav/storage.go b/app/delivery/web/api/webdav/storage.go new file mode 100644 index 0000000..8a01d0d --- /dev/null +++ b/app/delivery/web/api/webdav/storage.go @@ -0,0 +1,198 @@ +package webdav + +import ( + "bytes" + "io" + "net/http" + "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/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() + + // for webdav use only + r.Get("/*", handler.Get) + r.Head("/*", handler.Get) + r.Put("/*", handler.Put) + r.Delete("/*", handler.Delete) + r.Add([]string{"MKCOL"}, "/*", handler.Mkcol) + r.Add([]string{"PROPFIND"}, "/*", handler.Propfind) + r.Add([]string{"PROPPATCH"}, "/*", handler.Proppatch) + r.Add([]string{"MOVE"}, "/*", handler.Move) + r.Add([]string{"COPY"}, "/*", handler.Copy) + + return r +} + +func (h *StorageHandler) Get(c fiber.Ctx) error { + // fmt.Println("GET") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + info, err := h.storageService.EntryInfo(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + if info.IsDir() { + xml, err := h.storageService.Propfind(h.config.Storage.RootFolder, absPath, "1") + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Set("Content-Type", `application/xml; charset="utf-8"`) + return c.Status(http.StatusMultiStatus).SendString(xml) + + } else { + f, filename, filesize, err := h.storageService.DownloadFilePrep(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Attachment(filename) + c.Set("Content-Length", strconv.FormatInt(filesize, 10)) + c.Set("Content-Type", "application/octet-stream") + return c.SendStream(f, int(filesize)) + } +} + +func (h *StorageHandler) Put(c fiber.Ctx) error { + // fmt.Println("PUT") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + var src io.Reader + if bodyStream := c.Request().BodyStream(); bodyStream != nil { + defer c.Request().CloseBodyStream() + src = bodyStream + } else { + src = bytes.NewReader(c.Body()) + } + + err = h.storageService.Put(absPath, src) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Delete(c fiber.Ctx) error { + // fmt.Println("DELETE") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + if absPath == h.config.Storage.RootFolder { + return c.SendStatus(responseErrors.GetErrorStatus(responseErrors.ErrAccessDenied)) + } + + err = h.storageService.Delete(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusNoContent) +} + +func (h *StorageHandler) Mkcol(c fiber.Ctx) error { + // fmt.Println("Mkcol") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Mkcol(absPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Propfind(c fiber.Ctx) error { + // fmt.Println("PROPFIND") + absPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + xml, err := h.storageService.Propfind(h.config.Storage.RootFolder, absPath, "1") + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + c.Set("Content-Type", `application/xml; charset="utf-8"`) + return c.Status(http.StatusMultiStatus).SendString(xml) +} + +func (h *StorageHandler) Proppatch(c fiber.Ctx) error { + return c.SendStatus(http.StatusNotImplemented) // 501 +} + +func (h *StorageHandler) Move(c fiber.Ctx) error { + // fmt.Println("MOVE") + srcAbsPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + dest := c.Get("Destination") + if dest == "" { + return c.SendStatus(http.StatusBadRequest) + } + destAbsPath, err := h.storageService.ObtainDestPath(h.config.Storage.RootFolder, dest) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Move(srcAbsPath, destAbsPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + return c.SendStatus(http.StatusCreated) +} + +func (h *StorageHandler) Copy(c fiber.Ctx) error { + // fmt.Println("COPY") + srcAbsPath, err := h.storageService.AbsPath(h.config.Storage.RootFolder, c.Params("*")) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + dest := c.Get("Destination") + if dest == "" { + return c.SendStatus(http.StatusBadRequest) + } + destAbsPath, err := h.storageService.ObtainDestPath(h.config.Storage.RootFolder, dest) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + + err = h.storageService.Copy(srcAbsPath, destAbsPath) + if err != nil { + return c.SendStatus(responseErrors.GetErrorStatus(err)) + } + return c.SendStatus(http.StatusCreated) +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index c48a778..2139073 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -14,6 +14,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/public" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/restricted" + "git.ma-al.com/goc_daniel/b2b/app/delivery/web/api/webdav" "git.ma-al.com/goc_daniel/b2b/app/delivery/web/general" "github.com/gofiber/fiber/v3" @@ -25,6 +26,7 @@ import ( type Server struct { app *fiber.App cfg *config.Config + webdav fiber.Router api fiber.Router public fiber.Router restricted fiber.Router @@ -42,12 +44,23 @@ func (s *Server) Cfg() *config.Config { // New creates a new server instance func New() *Server { - return &Server{ - app: fiber.New(fiber.Config{ - ErrorHandler: customErrorHandler, - }), - cfg: config.Get(), - } + var s Server + + app := + fiber.New(fiber.Config{ + ErrorHandler: customErrorHandler, + BodyLimit: 50 * 1024 * 1024, // 50 MB + StreamRequestBody: true, + RequestMethods: []string{ + fiber.MethodGet, fiber.MethodHead, fiber.MethodPost, fiber.MethodPut, + fiber.MethodDelete, fiber.MethodConnect, fiber.MethodOptions, + fiber.MethodTrace, fiber.MethodPatch, "MKCOL", "PROPFIND", "PROPPATCH", "MOVE", "COPY", + }, + }) + + s.app = app + s.cfg = config.Get() + return &s } // Setup configures the server with routes and middleware @@ -76,6 +89,8 @@ func (s *Server) Setup() error { s.public = s.api.Group("/public") s.restricted = s.api.Group("/restricted") s.restricted.Use(middleware.AuthMiddleware()) + s.webdav = s.api.Group("/webdav") + s.webdav.Use(middleware.Webdav()) // initialize language endpoints (general) api.NewLangHandler().InitLanguage(s.api, s.cfg) @@ -115,9 +130,11 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) - // storage (restricted) - storage := s.restricted.Group("/storage") - restricted.StorageHandlerRoutes(storage) + // storage (uses various authorization means) + restrictedStorage := s.restricted.Group("/storage") + webdavStorage := s.webdav.Group("/storage") + restricted.StorageHandlerRoutes(restrictedStorage) + webdav.StorageHandlerRoutes(webdavStorage) s.api.All("*", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNotFound) diff --git a/app/model/customer.go b/app/model/customer.go index 3934dcd..60164ae 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -23,6 +23,8 @@ type Customer struct { EmailVerificationExpires *time.Time `json:"-"` PasswordResetToken string `gorm:"size:255" json:"-"` PasswordResetExpires *time.Time `json:"-"` + WebdavToken string `gorm:"size:255" json:"-"` + WebdavExpires *time.Time `json:"-"` LastPasswordResetRequest *time.Time `json:"-"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language diff --git a/app/repos/storageRepo/storageRepo.go b/app/repos/storageRepo/storageRepo.go index 08441a6..69dc906 100644 --- a/app/repos/storageRepo/storageRepo.go +++ b/app/repos/storageRepo/storageRepo.go @@ -2,23 +2,24 @@ package storageRepo import ( "io" - "mime/multipart" "os" + "path/filepath" + "time" + "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" - "github.com/gofiber/fiber/v3" ) type UIStorageRepo interface { + SaveWebdavToken(user_id uint, hash_token string, expires_at *time.Time) error EntryInfo(abs_path string) (os.FileInfo, error) ListContent(abs_path string) (*[]model.EntryInList, error) + OpenFile(abs_path string) (*os.File, error) + Put(abs_path string, src io.Reader) error + Delete(abs_path string) error + Mkcol(abs_path string) error Move(src_abs_path string, dest_abs_path string) error Copy(src_abs_path string, dest_abs_path string) error - OpenFile(abs_path string) (*os.File, error) - UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error - CreateFolder(abs_path string) error - DeleteFile(abs_path string) error - DeleteFolder(abs_path string) error } type StorageRepo struct{} @@ -27,6 +28,17 @@ func New() UIStorageRepo { return &StorageRepo{} } +func (r *StorageRepo) SaveWebdavToken(user_id uint, hash_token string, expires_at *time.Time) error { + return db.DB. + Table("b2b_customers"). + Where("id = ?", user_id). + Updates(map[string]interface{}{ + "webdav_token": hash_token, + "webdav_expires": expires_at, + }). + Error +} + func (r *StorageRepo) EntryInfo(abs_path string) (os.FileInfo, error) { return os.Stat(abs_path) } @@ -50,51 +62,117 @@ func (r *StorageRepo) ListContent(abs_path string) (*[]model.EntryInList, error) return &entries_in_list, nil } +func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { + return os.Open(abs_path) +} + +func (r *StorageRepo) Put(abs_path string, src io.Reader) error { + // Write to a temp file in the same directory, then atomically rename. + tmp, err := os.CreateTemp(filepath.Dir(abs_path), ".put-*") + if err != nil { + return err + } + tmp_name := tmp.Name() + cleanup_tmp := true + defer func() { + _ = tmp.Close() + if cleanup_tmp { + _ = os.Remove(tmp_name) + } + }() + + _, err = io.Copy(tmp, src) + if err != nil { + return err + } + + err = tmp.Sync() + if err != nil { + return err + } + err = tmp.Close() + if err != nil { + return err + } + + err = os.Chmod(tmp_name, 0o644) + if err != nil { + return err + } + + err = os.Rename(tmp_name, abs_path) + if err != nil { + return err + } + + cleanup_tmp = false + return nil +} + +func (r *StorageRepo) Delete(abs_path string) error { + return os.RemoveAll(abs_path) +} + +func (r *StorageRepo) Mkcol(abs_path string) error { + return os.Mkdir(abs_path, 0755) +} + func (r *StorageRepo) Move(src_abs_path string, dest_abs_path string) error { return os.Rename(src_abs_path, dest_abs_path) } func (r *StorageRepo) Copy(src_abs_path string, dest_abs_path string) error { - in, err := os.Open(src_abs_path) - if err != nil { - return err - } - defer in.Close() - - info, err := in.Stat() + info, err := os.Stat(src_abs_path) if err != nil { return err } - out, err := os.OpenFile(dest_abs_path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if info.IsDir() { + return r.copyDir(src_abs_path, dest_abs_path) + } else { + return r.copyFile(src_abs_path, dest_abs_path) + } +} + +func (r *StorageRepo) copyFile(src_abs_path string, dest_abs_path string) error { + f, err := os.Open(src_abs_path) if err != nil { return err } - defer out.Close() + defer f.Close() - if _, err := io.Copy(out, in); err != nil { + err = r.Put(dest_abs_path, f) + return err +} + +func (r *StorageRepo) copyDir(src_abs_path string, dest_abs_path string) error { + if err := os.Mkdir(dest_abs_path, 0755); err != nil { return err } - return out.Sync() -} + entries, err := os.ReadDir(src_abs_path) + if err != nil { + return err + } -func (r *StorageRepo) OpenFile(abs_path string) (*os.File, error) { - return os.Open(abs_path) -} + for _, entry := range entries { -func (r *StorageRepo) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error { - return c.SaveFile(f, abs_path) -} + entity_src_path := filepath.Join(src_abs_path, entry.Name()) + entity_dst_Path := filepath.Join(dest_abs_path, entry.Name()) -func (r *StorageRepo) CreateFolder(abs_path string) error { - return os.Mkdir(abs_path, 0755) -} + if entry.IsDir() { + err = r.copyDir(entity_src_path, entity_dst_Path) + if err != nil { + return err + } -func (r *StorageRepo) DeleteFile(abs_path string) error { - return os.Remove(abs_path) -} + } else { + err = r.copyFile(entity_src_path, entity_dst_Path) + if err != nil { + return err + } + } + } -func (r *StorageRepo) DeleteFolder(abs_path string) error { - return os.RemoveAll(abs_path) + return nil } diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index c873ce0..4b19a13 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -452,6 +452,19 @@ func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) { return &user, nil } +func (s *AuthService) GetUserByWebdavToken(rawToken string) (*model.Customer, error) { + tokenHash := hashToken(rawToken) + + var user model.Customer + if err := s.db.Where("webdav_token = ?", tokenHash).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, responseErrors.ErrUserNotFound + } + return nil, fmt.Errorf("database error: %w", err) + } + return &user, nil +} + // createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token. func (s *AuthService) createRefreshToken(userID uint) (string, error) { // Generate 32 random bytes → 64-char hex string diff --git a/app/service/storageService/storageService.go b/app/service/storageService/storageService.go index 6fc9d41..f5ffba8 100644 --- a/app/service/storageService/storageService.go +++ b/app/service/storageService/storageService.go @@ -1,15 +1,24 @@ package storageService import ( - "mime/multipart" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "io" + "net/http" + "net/url" "os" + "path" "path/filepath" + "strconv" "strings" + "time" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/repos/storageRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" - "github.com/gofiber/fiber/v3" ) type StorageService struct { @@ -22,14 +31,24 @@ func New() *StorageService { } } -func (s *StorageService) ListContent(abs_path string) (*[]model.EntryInList, error) { - info, err := s.storageRepo.EntryInfo(abs_path) - if err != nil || !info.IsDir() { - return nil, responseErrors.ErrFolderDoesNotExist +func (s *StorageService) EntryInfo(abs_path string) (os.FileInfo, error) { + return s.storageRepo.EntryInfo(abs_path) +} + +func (s *StorageService) NewWebdavToken(user_id uint) (string, error) { + b := make([]byte, constdata.NBYTES_IN_WEBDAV_TOKEN) + + _, err := rand.Read(b) + if err != nil { + return "", err } - entries_in_list, err := s.storageRepo.ListContent(abs_path) - return entries_in_list, err + raw_token := hex.EncodeToString(b) + hash_token_bytes := sha256.Sum256([]byte(raw_token)) + hash_token := hex.EncodeToString(hash_token_bytes[:]) + expires_at := time.Now().Add(24 * time.Hour) + + return raw_token, s.storageRepo.SaveWebdavToken(user_id, hash_token, &expires_at) } func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, int64, error) { @@ -46,118 +65,219 @@ func (s *StorageService) DownloadFilePrep(abs_path string) (*os.File, string, in return f, filepath.Base(abs_path), info.Size(), nil } +func (s *StorageService) ListContent(abs_path string) (*[]model.EntryInList, error) { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || !info.IsDir() { + return nil, responseErrors.ErrFolderDoesNotExist + } + + entries_in_list, err := s.storageRepo.ListContent(abs_path) + return entries_in_list, err +} + +func (s *StorageService) Propfind(root string, abs_path string, depth string) (string, error) { + href := href(root, abs_path) + + max_depth := 0 + switch depth { + case "0": + max_depth = 0 + case "1": + max_depth = 1 + case "infinity": + max_depth = 32 + default: + max_depth = 0 + } + + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil { + return "", err + } + + xml := `` + + `` + + if info.IsDir() { + href = ensureTrailingSlash(href) + next_xml, err := buildDirPropResponse(abs_path, href, info, max_depth) + if err != nil { + return "", err + } + xml += next_xml + } else { + xml += buildFilePropResponse(href, info) + } + + xml += `` + + return xml, nil +} + +func (s *StorageService) Put(abs_path string, src io.Reader) error { + return s.storageRepo.Put(abs_path, src) +} + +func (s *StorageService) Delete(abs_path string) error { + return s.storageRepo.Delete(abs_path) +} + +func (s *StorageService) Mkcol(abs_path string) error { + _, err := s.storageRepo.EntryInfo(abs_path) + if err == nil { + return responseErrors.ErrNameTaken + } else if os.IsNotExist(err) { + return s.storageRepo.Mkcol(abs_path) + } else { + return err + } +} + func (s *StorageService) Move(src_abs_path string, dest_abs_path string) error { - _, err := s.storageRepo.EntryInfo(src_abs_path) - if err != nil { - return responseErrors.ErrFileDoesNotExist - } - - _, err = s.storageRepo.EntryInfo(dest_abs_path) - if err == nil { - return responseErrors.ErrNameTaken - } else if os.IsNotExist(err) { - return s.storageRepo.Move(src_abs_path, dest_abs_path) - } else { - return err - } + return s.storageRepo.Move(src_abs_path, dest_abs_path) } + func (s *StorageService) Copy(src_abs_path string, dest_abs_path string) error { - _, err := s.storageRepo.EntryInfo(src_abs_path) + return s.storageRepo.Copy(src_abs_path, dest_abs_path) +} + +func buildFilePropResponse(href string, info os.FileInfo) string { + name := info.Name() + return "" + + "" + + "" + xmlEscape(href) + "" + + "" + + "" + + "" + xmlEscape(name) + "" + + "" + strconv.FormatInt(info.Size(), 10) + "" + + "" + xmlEscape(info.ModTime().UTC().Format(http.TimeFormat)) + "" + + "" + + "" + + "HTTP/1.1 200 OK" + + "" + + "" +} + +func buildDirPropResponse(abs_path string, href string, info os.FileInfo, max_depth int) (string, error) { + name := info.Name() + + xml := "" + + "" + + "" + xmlEscape(ensureTrailingSlash(href)) + "" + + "" + + "" + + "" + xmlEscape(name) + "" + + "" + + "" + xmlEscape(info.ModTime().UTC().Format(http.TimeFormat)) + "" + + "" + + "HTTP/1.1 200 OK" + + "" + + "" + + if max_depth <= 0 { + return xml, nil + } + + entries, err := os.ReadDir(abs_path) if err != nil { - return responseErrors.ErrFileDoesNotExist + return "", err } - _, err = s.storageRepo.EntryInfo(dest_abs_path) - if err == nil { - return responseErrors.ErrNameTaken - } else if os.IsNotExist(err) { - return s.storageRepo.Copy(src_abs_path, dest_abs_path) - } else { - return err + for _, entry := range entries { + child_abs_path := filepath.Join(abs_path, entry.Name()) + child_href := path.Join(href, entry.Name()) + + child_info, err := entry.Info() + if err != nil { + return "", err + } + + var xml_next string + if entry.IsDir() { + xml_next, err = buildDirPropResponse(child_abs_path, ensureTrailingSlash(child_href), child_info, max_depth-1) + } else { + xml_next = buildFilePropResponse(child_href, child_info) + } + + if err != nil { + return "", err + } + xml += xml_next } + + return xml, nil } -func (s *StorageService) UploadFile(c fiber.Ctx, abs_path string, f *multipart.FileHeader) error { - info, err := s.storageRepo.EntryInfo(abs_path) - if err != nil || !info.IsDir() { - return responseErrors.ErrFolderDoesNotExist +func ensureTrailingSlash(s string) string { + if s == "/" { + return s } - - name := f.Filename - if name == "" || name == "." || name == ".." || filepath.Base(name) != name { - return responseErrors.ErrBadAttribute - } - abs_file_path, err := s.AbsPath(abs_path, name) - if err != nil { - return err - } - if abs_file_path == abs_path { - return responseErrors.ErrBadAttribute - } - - info, err = s.storageRepo.EntryInfo(abs_file_path) - if err == nil { - return responseErrors.ErrNameTaken - } else if os.IsNotExist(err) { - return s.storageRepo.UploadFile(c, abs_file_path, f) - } else { - return err + if !strings.HasSuffix(s, "/") { + return s + "/" } + return s } -func (s *StorageService) CreateFolder(abs_path string, name string) error { - info, err := s.storageRepo.EntryInfo(abs_path) - if err != nil || !info.IsDir() { - return responseErrors.ErrFolderDoesNotExist - } - - if name == "" || name == "." || name == ".." || filepath.Base(name) != name { - return responseErrors.ErrBadAttribute - } - abs_folder_path, err := s.AbsPath(abs_path, name) - if err != nil { - return err - } - if abs_folder_path == abs_path { - return responseErrors.ErrBadAttribute - } - - info, err = s.storageRepo.EntryInfo(abs_folder_path) - if err == nil { - return responseErrors.ErrNameTaken - } else if os.IsNotExist(err) { - return s.storageRepo.CreateFolder(abs_folder_path) - } else { - return err - } +func xmlEscape(s string) string { + var b strings.Builder + xml.EscapeText(&b, []byte(s)) + return b.String() } -func (s *StorageService) DeleteFile(abs_path string) error { - info, err := s.storageRepo.EntryInfo(abs_path) - if err != nil || info.IsDir() { - return responseErrors.ErrFileDoesNotExist +// Returns href based on file's absolute path. Doesn't validate abs_path +func href(root string, abs_path string) string { + rel, _ := filepath.Rel(root, abs_path) + + if rel == "." { + return constdata.WEBDAV_HREF_ROOT + "/" } - return s.storageRepo.DeleteFile(abs_path) -} + rel = filepath.ToSlash(rel) -func (s *StorageService) DeleteFolder(abs_path string) error { - info, err := s.storageRepo.EntryInfo(abs_path) - if err != nil || !info.IsDir() { - return responseErrors.ErrFolderDoesNotExist + parts := strings.Split(rel, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) } - return s.storageRepo.DeleteFolder(abs_path) + return strings.TrimRight(constdata.WEBDAV_HREF_ROOT, "/") + "/" + strings.Join(parts, "/") } // AbsPath extracts an absolute path and validates it -func (s *StorageService) AbsPath(root string, relativePath string) (string, error) { - clean_name := filepath.Clean(relativePath) +func (s *StorageService) AbsPath(root string, relative_path string) (string, error) { + decoded, err := url.PathUnescape(relative_path) + if err != nil { + return "", err + } + + clean_name := filepath.Clean(decoded) full_path := filepath.Join(root, clean_name) - if full_path != root && !strings.HasPrefix(full_path, root+string(os.PathSeparator)) { + if full_path != root && !strings.HasPrefix(full_path, root+"/") { return "", responseErrors.ErrAccessDenied } return full_path, nil } + +// ObtainDestPath extracts the absolute path based on URL absolute path +func (s *StorageService) ObtainDestPath(root string, dest_path string) (string, error) { + idx := strings.Index(dest_path, constdata.WEBDAV_TRIMMED_ROOT) + if idx == -1 { + return "", responseErrors.ErrAccessDenied + } + prefix_removed := dest_path[idx+len(constdata.WEBDAV_TRIMMED_ROOT):] + + decoded, err := url.PathUnescape(prefix_removed) + if err != nil { + return "", err + } + + clean_dest_path := filepath.Clean(decoded) + if clean_dest_path == "" { + return root, nil + } else if strings.HasPrefix(clean_dest_path, "/") { + return root + "/" + strings.TrimPrefix(clean_dest_path, "/"), nil + } else { + return "", responseErrors.ErrAccessDenied + } +} diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 05f23e8..f71ed51 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -13,3 +13,8 @@ const MAX_AMOUNT_OF_CARTS_PER_USER = 10 const DEFAULT_NEW_CART_NAME = "new cart" const USER_LOCALE = "user" + +// WEBDAV +const NBYTES_IN_WEBDAV_TOKEN = 32 +const WEBDAV_HREF_ROOT = "http://localhost:3000/api/v1/webdav/storage" +const WEBDAV_TRIMMED_ROOT = "localhost:3000/api/v1/webdav/storage" diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 2ea09e5..d81ecbb 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -9,13 +9,14 @@ import ( var ( // Typed errors for request validation and authentication - ErrInvalidBody = errors.New("invalid request body") - ErrNotAuthenticated = errors.New("not authenticated") - ErrUserNotFound = errors.New("user not found") - ErrUserInactive = errors.New("user account is inactive") - ErrInvalidToken = errors.New("invalid token") - ErrTokenExpired = errors.New("token has expired") - ErrTokenRequired = errors.New("token is required") + ErrInvalidBody = errors.New("invalid request body") + ErrNotAuthenticated = errors.New("not authenticated") + ErrUserNotFound = errors.New("user not found") + ErrUserInactive = errors.New("user account is inactive") + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token has expired") + ErrTokenRequired = errors.New("token is required") + ErrAdminAccessRequired = errors.New("admin access is required") // Typed errors for logging in and registering ErrInvalidCredentials = errors.New("invalid email or password") @@ -118,6 +119,8 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.err_token_required") case errors.Is(err, ErrRefreshTokenRequired): return i18n.T_(c, "error.err_refresh_token_required") + case errors.Is(err, ErrAdminAccessRequired): + return i18n.T_(c, "error.err_admin_access_required") case errors.Is(err, ErrBadLangID): return i18n.T_(c, "error.err_bad_lang_id") case errors.Is(err, ErrBadCountryID): @@ -202,6 +205,7 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrEmailPasswordRequired), errors.Is(err, ErrTokenRequired), errors.Is(err, ErrRefreshTokenRequired), + errors.Is(err, ErrAdminAccessRequired), errors.Is(err, ErrBadLangID), errors.Is(err, ErrBadCountryID), errors.Is(err, ErrPasswordsDoNotMatch), diff --git a/bruno/b2b_daniel/storage/copy.yml b/bruno/b2b_daniel/storage-old/copy.yml similarity index 100% rename from bruno/b2b_daniel/storage/copy.yml rename to bruno/b2b_daniel/storage-old/copy.yml diff --git a/bruno/b2b_daniel/storage/create-folder.yml b/bruno/b2b_daniel/storage-old/create-folder.yml similarity index 100% rename from bruno/b2b_daniel/storage/create-folder.yml rename to bruno/b2b_daniel/storage-old/create-folder.yml diff --git a/bruno/b2b_daniel/storage/delete-file.yml b/bruno/b2b_daniel/storage-old/delete-file.yml similarity index 100% rename from bruno/b2b_daniel/storage/delete-file.yml rename to bruno/b2b_daniel/storage-old/delete-file.yml diff --git a/bruno/b2b_daniel/storage/delete-folder.yml b/bruno/b2b_daniel/storage-old/delete-folder.yml similarity index 100% rename from bruno/b2b_daniel/storage/delete-folder.yml rename to bruno/b2b_daniel/storage-old/delete-folder.yml diff --git a/bruno/b2b_daniel/storage/download-file.yml b/bruno/b2b_daniel/storage-old/download-file.yml similarity index 100% rename from bruno/b2b_daniel/storage/download-file.yml rename to bruno/b2b_daniel/storage-old/download-file.yml diff --git a/bruno/b2b_daniel/storage/folder.yml b/bruno/b2b_daniel/storage-old/folder.yml similarity index 73% rename from bruno/b2b_daniel/storage/folder.yml rename to bruno/b2b_daniel/storage-old/folder.yml index 70062a4..852efec 100644 --- a/bruno/b2b_daniel/storage/folder.yml +++ b/bruno/b2b_daniel/storage-old/folder.yml @@ -1,5 +1,5 @@ info: - name: storage + name: storage-old type: folder seq: 1 diff --git a/bruno/b2b_daniel/storage/list-content.yml b/bruno/b2b_daniel/storage-old/list-content.yml similarity index 100% rename from bruno/b2b_daniel/storage/list-content.yml rename to bruno/b2b_daniel/storage-old/list-content.yml diff --git a/bruno/b2b_daniel/storage/move.yml b/bruno/b2b_daniel/storage-old/move.yml similarity index 100% rename from bruno/b2b_daniel/storage/move.yml rename to bruno/b2b_daniel/storage-old/move.yml diff --git a/bruno/b2b_daniel/storage/upload-file.yml b/bruno/b2b_daniel/storage-old/upload-file.yml similarity index 100% rename from bruno/b2b_daniel/storage/upload-file.yml rename to bruno/b2b_daniel/storage-old/upload-file.yml diff --git a/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml b/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml new file mode 100644 index 0000000..4340fda --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/create-new-webdav-token.yml @@ -0,0 +1,15 @@ +info: + name: create-new-webdav-token + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/create-new-webdav-token + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/folder.yml b/bruno/b2b_daniel/storage-restricted/folder.yml new file mode 100644 index 0000000..ec9eca7 --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/folder.yml @@ -0,0 +1,7 @@ +info: + name: storage-restricted + type: folder + seq: 9 + +request: + auth: inherit diff --git a/go.mod b/go.mod index 62c8aad..1c184da 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,8 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -98,7 +100,7 @@ require ( github.com/valyala/fasthttp v1.69.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xyproto/randomstring v1.2.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/go.sum b/go.sum index d208fb1..81fe849 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= @@ -134,6 +136,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= @@ -154,6 +158,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index bfe5401..ae553dd 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -69,6 +69,8 @@ CREATE TABLE IF NOT EXISTS b2b_customers ( email_verification_expires DATETIME(6) NULL, password_reset_token VARCHAR(255) NULL, password_reset_expires DATETIME(6) NULL, + webdav_token VARCHAR(255) NULL, + webdav_expires DATETIME(6) NULL, last_password_reset_request DATETIME(6) NULL, last_login_at DATETIME(6) NULL, lang_id BIGINT NULL DEFAULT 2, @@ -84,6 +86,8 @@ ON b2b_customers (email); CREATE INDEX IF NOT EXISTS idx_customers_deleted_at ON b2b_customers (deleted_at); +CREATE INDEX IF NOT EXISTS idx_customers_webdav_token +ON b2b_customers (webdav_token); -- customer_carts CREATE TABLE IF NOT EXISTS b2b_customer_carts ( diff --git a/storage/folder/a.txt b/storage/folder/a.txt deleted file mode 100644 index 273c1a9..0000000 --- a/storage/folder/a.txt +++ /dev/null @@ -1 +0,0 @@ -This is a test. \ No newline at end of file diff --git a/storage/folder1/test b/storage/folder1/test deleted file mode 100644 index e69de29..0000000 diff --git a/storage/folder1/test.txt b/storage/folder1/test.txt deleted file mode 100644 index 273c1a9..0000000 --- a/storage/folder1/test.txt +++ /dev/null @@ -1 +0,0 @@ -This is a test. \ No newline at end of file diff --git a/storage/test.txt b/storage/test.txt deleted file mode 100644 index 273c1a9..0000000 --- a/storage/test.txt +++ /dev/null @@ -1 +0,0 @@ -This is a test. \ No newline at end of file From 578d8c6cac5ce646acd015881c6231ce6f814368 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Wed, 8 Apr 2026 13:20:07 +0200 Subject: [PATCH 10/18] merged with current main --- app/delivery/middleware/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/delivery/middleware/auth.go b/app/delivery/middleware/auth.go index b8837c4..756e79f 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -125,7 +125,7 @@ func RequireAdmin() fiber.Handler { }) } - if originalUserRole != model.RoleAdmin { + if model.CustomerRole(originalUserRole.Name) != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) From 569a805a133bbdc109edfc8645ab80bbc4ea260a Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Wed, 8 Apr 2026 13:23:05 +0200 Subject: [PATCH 11/18] small fix --- app/delivery/web/api/restricted/productTranslation.go | 4 ++-- app/delivery/web/api/restricted/search.go | 2 +- app/delivery/web/api/restricted/storage.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/delivery/web/api/restricted/productTranslation.go b/app/delivery/web/api/restricted/productTranslation.go index 58b378b..3dc16bd 100644 --- a/app/delivery/web/api/restricted/productTranslation.go +++ b/app/delivery/web/api/restricted/productTranslation.go @@ -81,7 +81,7 @@ func (h *ProductTranslationHandler) SaveProductDescription(c fiber.Ctx) error { } userRole, ok := localeExtractor.GetOriginalUserRole(c) - if !ok || userRole != model.RoleAdmin { + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) } @@ -124,7 +124,7 @@ func (h *ProductTranslationHandler) TranslateProductDescription(c fiber.Ctx) err } userRole, ok := localeExtractor.GetOriginalUserRole(c) - if !ok || userRole != model.RoleAdmin { + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) } diff --git a/app/delivery/web/api/restricted/search.go b/app/delivery/web/api/restricted/search.go index 0a7bef3..843c956 100644 --- a/app/delivery/web/api/restricted/search.go +++ b/app/delivery/web/api/restricted/search.go @@ -45,7 +45,7 @@ func (h *MeiliSearchHandler) CreateIndex(c fiber.Ctx) error { } userRole, ok := localeExtractor.GetOriginalUserRole(c) - if !ok || userRole != model.RoleAdmin { + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) } diff --git a/app/delivery/web/api/restricted/storage.go b/app/delivery/web/api/restricted/storage.go index a8a09b7..910aae1 100644 --- a/app/delivery/web/api/restricted/storage.go +++ b/app/delivery/web/api/restricted/storage.go @@ -85,7 +85,7 @@ func (h *StorageHandler) CreateNewWebdavToken(c fiber.Ctx) error { } userRole, ok := localeExtractor.GetOriginalUserRole(c) - if !ok || userRole != model.RoleAdmin { + if !ok || model.CustomerRole(userRole.Name) != model.RoleAdmin { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrAdminAccessRequired)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrAdminAccessRequired))) } From 1083ab7a61a6099b7d17e81b010e40eafd777b08 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 9 Apr 2026 12:21:56 +0200 Subject: [PATCH 12/18] added addresses endpoints --- app/delivery/web/api/restricted/addresses.go | 157 ++++++++++++++++++ app/delivery/web/init.go | 4 + app/model/address.go | 79 +++++++++ app/repos/addressesRepo/addressesRepo.go | 91 ++++++++++ .../addressesService/addressesService.go | 152 +++++++++++++++++ app/utils/const_data/consts.go | 2 + app/utils/responseErrors/responseErrors.go | 47 ++++-- bo/components.d.ts | 1 - .../b2b_daniel/addresses/add-new-address.yml | 31 ++++ bruno/b2b_daniel/addresses/delete-address.yml | 19 +++ bruno/b2b_daniel/addresses/folder.yml | 7 + bruno/b2b_daniel/addresses/get-template.yml | 19 +++ bruno/b2b_daniel/addresses/modify-address.yml | 33 ++++ .../addresses/retrieve-addresses.yml | 15 ++ .../storage-restricted/download-file.yml | 15 ++ .../storage-restricted/list-content.yml | 15 ++ .../20260302163122_create_tables.sql | 12 ++ 17 files changed, 684 insertions(+), 15 deletions(-) create mode 100644 app/delivery/web/api/restricted/addresses.go create mode 100644 app/model/address.go create mode 100644 app/repos/addressesRepo/addressesRepo.go create mode 100644 app/service/addressesService/addressesService.go create mode 100644 bruno/b2b_daniel/addresses/add-new-address.yml create mode 100644 bruno/b2b_daniel/addresses/delete-address.yml create mode 100644 bruno/b2b_daniel/addresses/folder.yml create mode 100644 bruno/b2b_daniel/addresses/get-template.yml create mode 100644 bruno/b2b_daniel/addresses/modify-address.yml create mode 100644 bruno/b2b_daniel/addresses/retrieve-addresses.yml create mode 100644 bruno/b2b_daniel/storage-restricted/download-file.yml create mode 100644 bruno/b2b_daniel/storage-restricted/list-content.yml diff --git a/app/delivery/web/api/restricted/addresses.go b/app/delivery/web/api/restricted/addresses.go new file mode 100644 index 0000000..903f011 --- /dev/null +++ b/app/delivery/web/api/restricted/addresses.go @@ -0,0 +1,157 @@ +package restricted + +import ( + "strconv" + + "git.ma-al.com/goc_daniel/b2b/app/service/addressesService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" + "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 AddressesHandler struct { + addressesService *addressesService.AddressesService +} + +func NewAddressesHandler() *AddressesHandler { + addressesService := addressesService.New() + return &AddressesHandler{ + addressesService: addressesService, + } +} + +func AddressesHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewAddressesHandler() + + r.Get("/get-template", handler.GetTemplate) + r.Post("/add-new-address", handler.AddNewAddress) + r.Post("/modify-address", handler.ModifyAddress) + r.Get("/retrieve-addresses", handler.RetrieveAddressesInfo) + r.Delete("/delete-address", handler.DeleteAddress) + + return r +} + +func (h *AddressesHandler) GetTemplate(c fiber.Ctx) error { + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + template, err := h.addressesService.GetTemplate(uint(country_id)) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&template, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) AddNewAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_info := string(c.Body()) + if address_info == "" { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.AddNewAddress(userID, address_info, uint(country_id)) + 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))) +} + +func (h *AddressesHandler) ModifyAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_id_attribute := c.Query("address_id") + address_id, err := strconv.Atoi(address_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + address_info := string(c.Body()) + if address_info == "" { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + country_id_attribute := c.Query("country_id") + country_id, err := strconv.Atoi(country_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.ModifyAddress(userID, uint(address_id), address_info, uint(country_id)) + 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))) +} + +func (h *AddressesHandler) RetrieveAddressesInfo(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + addresses_info, err := h.addressesService.RetrieveAddressesInfo(userID) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + return c.JSON(response.Make(&addresses_info, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *AddressesHandler) DeleteAddress(c fiber.Ctx) error { + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + address_id_attribute := c.Query("address_id") + address_id, err := strconv.Atoi(address_id_attribute) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) + } + + err = h.addressesService.DeleteAddress(userID, uint(address_id)) + 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 51d9f51..2162d66 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -132,6 +132,10 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + // addresses (restricted) + addresses := s.restricted.Group("/addresses") + restricted.AddressesHandlerRoutes(addresses) + // storage (uses various authorization means) restrictedStorage := s.restricted.Group("/storage") webdavStorage := s.webdav.Group("/storage") diff --git a/app/model/address.go b/app/model/address.go new file mode 100644 index 0000000..a84056a --- /dev/null +++ b/app/model/address.go @@ -0,0 +1,79 @@ +package model + +type Address struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CustomerID uint `gorm:"column:b2b_customer_id;not null;index" json:"customer_id"` + AddressInfo string `gorm:"column:address_info;not null" json:"address_info"` + CountryID uint `gorm:"column:b2b_country_id;not null" json:"country_id"` +} + +func (Address) TableName() string { + return "b2b_addresses" +} + +type AddressUnparsed struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CustomerID uint `gorm:"column:b2b_customer_id;not null;index" json:"customer_id"` + AddressInfo AddressField `gorm:"column:address_info;not null" json:"address_info"` + CountryID uint `gorm:"column:b2b_country_id;not null" json:"country_id"` +} + +type AddressField interface { +} + +// Address template in Poland +type AddressPL struct { + PostalCode string `json:"postal_code"` // format: 00-000 + City string `json:"city"` // e.g. Kraków + Voivodeship string `json:"voivodeship"` // e.g. małopolskie (optional but useful) + + Street string `json:"street"` // e.g. Marszałkowska + BuildingNo string `json:"building_no"` // e.g. 10, 221B, 12A + ApartmentNo string `json:"apartment_no"` // e.g. 5, 12B + + AddressLine2 string `json:"address_line2"` // optional extra info + + Recipient string `json:"recipient"` // name/company +} + +// Address template in Great Britain +type AddressGB struct { + PostalCode string `json:"postal_code"` // e.g. SW1A 1AA + PostTown string `json:"post_town"` // e.g. London + County string `json:"county"` // optional + + Thoroughfare string `json:"thoroughfare"` // street name, e.g. Baker Street + BuildingNo string `json:"building_no"` // e.g. 221B + BuildingName string `json:"building_name"` // e.g. Flatiron House + SubBuilding string `json:"sub_building"` // e.g. Flat 5, Apt 2 + + AddressLine2 string `json:"address_line2"` + Recipient string `json:"recipient"` +} + +// Address template in Czech Republic +type AddressCZ struct { + PostalCode string `json:"postal_code"` // usually 110 00 or 11000 + City string `json:"city"` // e.g. Praha + Region string `json:"region"` + + Street string `json:"street"` // may be omitted in some village-style addresses + HouseNumber string `json:"house_number"` // descriptive / conscription no. + OrientationNumber string `json:"orientation_number"` // optional, often after slash + + AddressLine2 string `json:"address_line2"` + Recipient string `json:"recipient"` +} + +// Address template in Germany +type AddressDE struct { + PostalCode string `json:"postal_code"` // e.g. 10115 + City string `json:"city"` // e.g. Berlin + State string `json:"state"` // Bundesland, optional + + Street string `json:"street"` // e.g. Unter den Linden + HouseNumber string `json:"house_number"` // e.g. 77, 12a + + AddressLine2 string `json:"address_line2"` // extra details + Recipient string `json:"recipient"` +} diff --git a/app/repos/addressesRepo/addressesRepo.go b/app/repos/addressesRepo/addressesRepo.go new file mode 100644 index 0000000..5f674a0 --- /dev/null +++ b/app/repos/addressesRepo/addressesRepo.go @@ -0,0 +1,91 @@ +package addressesRepo + +import ( + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +type UIAddressesRepo interface { + UserHasAddress(user_id uint, address_id uint) (uint, error) + UserAddressesAmt(user_id uint) (uint, error) + AddNewAddress(user_id uint, address_info string, country_id uint) error + UpdateAddress(user_id uint, address_id uint, address_info string, country_id uint) error + RetrieveAddresses(user_id uint) (*[]model.Address, error) + DeleteAddress(user_id uint, address_id uint) error +} + +type AddressesRepo struct{} + +func New() UIAddressesRepo { + return &AddressesRepo{} +} + +func (repo *AddressesRepo) UserHasAddress(user_id uint, address_id uint) (uint, error) { + var amt uint + + err := db.DB. + Table("b2b_addresses"). + Select("COUNT(*) AS amt"). + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Scan(&amt). + Error + + return amt, err +} + +func (repo *AddressesRepo) UserAddressesAmt(user_id uint) (uint, error) { + var amt uint + + err := db.DB. + Table("b2b_addresses"). + Select("COUNT(*) AS amt"). + Where("b2b_customer_id = ?", user_id). + Scan(&amt). + Error + + return amt, err +} + +func (repo *AddressesRepo) AddNewAddress(user_id uint, address_info string, country_id uint) error { + address := model.Address{ + CustomerID: user_id, + AddressInfo: address_info, + CountryID: country_id, + } + + return db.DB. + Create(&address). + Error +} + +func (repo *AddressesRepo) UpdateAddress(user_id uint, address_id uint, address_info string, country_id uint) error { + address := model.Address{ + ID: address_id, + CustomerID: user_id, + AddressInfo: address_info, + CountryID: country_id, + } + + return db.DB. + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Updates(&address). + Error +} + +func (repo *AddressesRepo) RetrieveAddresses(user_id uint) (*[]model.Address, error) { + var addresses []model.Address + + err := db.DB. + Where("b2b_customer_id = ?", user_id). + Find(&addresses). + Error + + return &addresses, err +} + +func (repo *AddressesRepo) DeleteAddress(user_id uint, address_id uint) error { + return db.DB. + Where("id = ? AND b2b_customer_id = ?", address_id, user_id). + Delete(&model.Address{}). + Error +} diff --git a/app/service/addressesService/addressesService.go b/app/service/addressesService/addressesService.go new file mode 100644 index 0000000..b077486 --- /dev/null +++ b/app/service/addressesService/addressesService.go @@ -0,0 +1,152 @@ +package addressesService + +import ( + "encoding/json" + "fmt" + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/addressesRepo" + constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" +) + +type AddressesService struct { + repo addressesRepo.UIAddressesRepo +} + +func New() *AddressesService { + return &AddressesService{ + repo: addressesRepo.New(), + } +} + +func (s *AddressesService) GetTemplate(country_id uint) (model.AddressField, error) { + switch country_id { + + case 1: // Poland + return model.AddressPL{}, nil + + case 2: // Great Britain + return model.AddressGB{}, nil + + case 3: // Czech Republic + return model.AddressCZ{}, nil + + case 4: // Germany + return model.AddressDE{}, nil + + default: + return nil, responseErrors.ErrInvalidCountryID + } +} + +func (s *AddressesService) AddNewAddress(user_id uint, address_info string, country_id uint) error { + amt, err := s.repo.UserAddressesAmt(user_id) + if err != nil { + return err + } else if amt >= constdata.MAX_AMOUNT_OF_ADDRESSES_PER_USER { + return responseErrors.ErrMaxAmtOfAddressesReached + } + + _, err = s.validateAddressJson(address_info, country_id) + if err != nil { + return err + } + + return s.repo.AddNewAddress(user_id, address_info, country_id) +} + +// country_id = 0 means that country_id remains unchanged +func (s *AddressesService) ModifyAddress(user_id uint, address_id uint, address_info string, country_id uint) error { + amt, err := s.repo.UserHasAddress(user_id, address_id) + if err != nil { + return err + } else if amt != 1 { + return responseErrors.ErrUserHasNoSuchAddress + } + + _, err = s.validateAddressJson(address_info, country_id) + if err != nil { + return err + } + + return s.repo.UpdateAddress(user_id, address_id, address_info, country_id) +} + +func (s *AddressesService) RetrieveAddressesInfo(user_id uint) (*[]model.AddressUnparsed, error) { + parsed_addresses, err := s.repo.RetrieveAddresses(user_id) + if err != nil { + return nil, err + } + + var unparsed_addresses []model.AddressUnparsed + + for i := 0; i < len(*parsed_addresses); i++ { + var next_address model.AddressUnparsed + next_address.ID = (*parsed_addresses)[i].ID + next_address.CustomerID = (*parsed_addresses)[i].CustomerID + next_address.CountryID = (*parsed_addresses)[i].CountryID + + next_address.AddressInfo, err = s.validateAddressJson((*parsed_addresses)[i].AddressInfo, next_address.CountryID) + // log such errors + if err != nil { + fmt.Printf("err: %v\n", err) + } + + unparsed_addresses = append(unparsed_addresses, next_address) + } + + return &unparsed_addresses, nil +} + +func (s *AddressesService) DeleteAddress(user_id uint, address_id uint) error { + amt, err := s.repo.UserHasAddress(user_id, address_id) + if err != nil { + return err + } else if amt != 1 { + return responseErrors.ErrUserHasNoSuchAddress + } + + return s.repo.DeleteAddress(user_id, address_id) +} + +// validateAddressJson makes sure that the info string represents a valid json of address in given country +func (s *AddressesService) validateAddressJson(info string, country_id uint) (model.AddressField, error) { + dec := json.NewDecoder(strings.NewReader(info)) + dec.DisallowUnknownFields() + + switch country_id { + + case 1: // Poland + var address model.AddressPL + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 2: // Great Britain + var address model.AddressGB + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 3: // Czech Republic + var address model.AddressCZ + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + case 4: // Germany + var address model.AddressDE + if err := dec.Decode(&address); err != nil { + return address, responseErrors.ErrInvalidAddressJSON + } + return address, nil + + default: + return nil, responseErrors.ErrInvalidCountryID + } +} diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index 1ed8a7c..aa62f27 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -12,6 +12,8 @@ const CATEGORY_TREE_ROOT_ID = 2 const MAX_AMOUNT_OF_CARTS_PER_USER = 10 const DEFAULT_NEW_CART_NAME = "new cart" +const MAX_AMOUNT_OF_ADDRESSES_PER_USER = 10 + const USER_LOCALE = "user" // WEBDAV diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index b3fe72f..28802e1 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -72,6 +72,12 @@ var ( // Typed errors for data parsing ErrJSONBody = errors.New("invalid JSON body") + + // Typed errors for addresses + ErrMaxAmtOfAddressesReached = errors.New("maximal amount of addresses per user reached") + ErrUserHasNoSuchAddress = errors.New("user has no such address") + ErrInvalidCountryID = errors.New("invalid country id") + ErrInvalidAddressJSON = errors.New("invalid address json") ) // Error represents an error with HTTP status code @@ -154,7 +160,7 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrBadField): return i18n.T_(c, "error.err_bad_field") case errors.Is(err, ErrInvalidURLSlug): - return i18n.T_(c, "error.invalid_url_slug") + return i18n.T_(c, "error.err_invalid_url_slug") case errors.Is(err, ErrInvalidXHTML): return i18n.T_(c, "error.err_invalid_html") case errors.Is(err, ErrAIResponseFail): @@ -166,35 +172,44 @@ func GetErrorCode(c fiber.Ctx, err error) string { return i18n.T_(c, "error.err_bad_paging") case errors.Is(err, ErrNoRootFound): - return i18n.T_(c, "error.no_root_found") + return i18n.T_(c, "error.err_no_root_found") case errors.Is(err, ErrCircularDependency): - return i18n.T_(c, "error.circular_dependency") + return i18n.T_(c, "error.err_circular_dependency") case errors.Is(err, ErrStartCategoryNotFound): - return i18n.T_(c, "error.start_category_not_found") + return i18n.T_(c, "error.err_start_category_not_found") case errors.Is(err, ErrRootNeverReached): - return i18n.T_(c, "error.root_never_reached") + return i18n.T_(c, "error.err_root_never_reached") case errors.Is(err, ErrMaxAmtOfCartsReached): - return i18n.T_(c, "error.max_amt_of_carts_reached") + return i18n.T_(c, "error.err_max_amt_of_carts_reached") case errors.Is(err, ErrUserHasNoSuchCart): - return i18n.T_(c, "error.user_has_no_such_cart") + return i18n.T_(c, "error.err_user_has_no_such_cart") case errors.Is(err, ErrProductOrItsVariationDoesNotExist): - return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + return i18n.T_(c, "error.err_product_or_its_variation_does_not_exist") case errors.Is(err, ErrAccessDenied): - return i18n.T_(c, "error.access_denied") + return i18n.T_(c, "error.err_access_denied") case errors.Is(err, ErrFolderDoesNotExist): - return i18n.T_(c, "error.folder_does_not_exist") + return i18n.T_(c, "error.err_folder_does_not_exist") case errors.Is(err, ErrFileDoesNotExist): - return i18n.T_(c, "error.file_does_not_exist") + return i18n.T_(c, "error.err_file_does_not_exist") case errors.Is(err, ErrNameTaken): - return i18n.T_(c, "error.name_taken") + return i18n.T_(c, "error.err_name_taken") case errors.Is(err, ErrMissingFileFieldDocument): - return i18n.T_(c, "error.missing_file_field_document") + return i18n.T_(c, "error.err_missing_file_field_document") case errors.Is(err, ErrJSONBody): return i18n.T_(c, "error.err_json_body") + case errors.Is(err, ErrMaxAmtOfAddressesReached): + return i18n.T_(c, "error.err_max_amt_of_addresses_reached") + case errors.Is(err, ErrUserHasNoSuchAddress): + return i18n.T_(c, "error.err_user_has_no_such_address") + case errors.Is(err, ErrInvalidCountryID): + return i18n.T_(c, "error.err_invalid_country_id") + case errors.Is(err, ErrInvalidAddressJSON): + return i18n.T_(c, "error.err_invalid_address_json") + default: return i18n.T_(c, "error.err_internal_server_error") } @@ -246,7 +261,11 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrFileDoesNotExist), errors.Is(err, ErrNameTaken), errors.Is(err, ErrMissingFileFieldDocument), - errors.Is(err, ErrJSONBody): + errors.Is(err, ErrJSONBody), + errors.Is(err, ErrMaxAmtOfAddressesReached), + errors.Is(err, ErrUserHasNoSuchAddress), + errors.Is(err, ErrInvalidCountryID), + errors.Is(err, ErrInvalidAddressJSON): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): return fiber.StatusConflict diff --git a/bo/components.d.ts b/bo/components.d.ts index 51b00ed..06b4ea1 100644 --- a/bo/components.d.ts +++ b/bo/components.d.ts @@ -13,7 +13,6 @@ declare module 'vue' { export interface GlobalComponents { CartDetails: typeof import('./src/components/customer/CartDetails.vue')['default'] CategoryMenu: typeof import('./src/components/inner/categoryMenu.vue')['default'] - CategoryMenuListing: typeof import('./src/components/inner/categoryMenuListing.vue')['default'] Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default'] Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default'] En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default'] diff --git a/bruno/b2b_daniel/addresses/add-new-address.yml b/bruno/b2b_daniel/addresses/add-new-address.yml new file mode 100644 index 0000000..9c1abc1 --- /dev/null +++ b/bruno/b2b_daniel/addresses/add-new-address.yml @@ -0,0 +1,31 @@ +info: + name: add-new-address + type: http + seq: 1 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/addresses/add-new-address?country_id=1 + params: + - name: country_id + value: "1" + type: query + body: + type: json + data: |- + { + "postal_code": "31-154", + "city": "Kraków", + "voivodeship": "małopolskie", + "street": "Długa", + "building_no": "5", + "apartment_no": "7", + "recipient": "Jan Kowalski" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/delete-address.yml b/bruno/b2b_daniel/addresses/delete-address.yml new file mode 100644 index 0000000..dc9d33c --- /dev/null +++ b/bruno/b2b_daniel/addresses/delete-address.yml @@ -0,0 +1,19 @@ +info: + name: delete-address + type: http + seq: 4 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/addresses/delete-address?address_id=1 + params: + - name: address_id + value: "1" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/folder.yml b/bruno/b2b_daniel/addresses/folder.yml new file mode 100644 index 0000000..aaa37e8 --- /dev/null +++ b/bruno/b2b_daniel/addresses/folder.yml @@ -0,0 +1,7 @@ +info: + name: addresses + type: folder + seq: 10 + +request: + auth: inherit diff --git a/bruno/b2b_daniel/addresses/get-template.yml b/bruno/b2b_daniel/addresses/get-template.yml new file mode 100644 index 0000000..4105fb8 --- /dev/null +++ b/bruno/b2b_daniel/addresses/get-template.yml @@ -0,0 +1,19 @@ +info: + name: get-template + type: http + seq: 5 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/addresses/get-template?country_id=3 + params: + - name: country_id + value: "3" + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/modify-address.yml b/bruno/b2b_daniel/addresses/modify-address.yml new file mode 100644 index 0000000..aadd02d --- /dev/null +++ b/bruno/b2b_daniel/addresses/modify-address.yml @@ -0,0 +1,33 @@ +info: + name: modify-address + type: http + seq: 2 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/addresses/modify-address?country_id=1&address_id=1 + params: + - name: country_id + value: "1" + type: query + - name: address_id + value: "1" + type: query + body: + type: json + data: |- + { + "postal_code": "31-154", + "city": "Kraków", + "voivodeship": "śląskie", + "street": "Długa", + "building_no": "5", + "recipient": "Adam Adamowicz" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/addresses/retrieve-addresses.yml b/bruno/b2b_daniel/addresses/retrieve-addresses.yml new file mode 100644 index 0000000..e490024 --- /dev/null +++ b/bruno/b2b_daniel/addresses/retrieve-addresses.yml @@ -0,0 +1,15 @@ +info: + name: retrieve-addresses + type: http + seq: 3 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/addresses/retrieve-addresses + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/download-file.yml b/bruno/b2b_daniel/storage-restricted/download-file.yml new file mode 100644 index 0000000..13553de --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/download-file.yml @@ -0,0 +1,15 @@ +info: + name: download-file + type: http + seq: 3 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/download-file/dest/src/cccc.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-restricted/list-content.yml b/bruno/b2b_daniel/storage-restricted/list-content.yml new file mode 100644 index 0000000..7c94250 --- /dev/null +++ b/bruno/b2b_daniel/storage-restricted/list-content.yml @@ -0,0 +1,15 @@ +info: + name: list-content + type: http + seq: 2 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/list-content/dest/src + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index d975294..a82cea5 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -210,6 +210,18 @@ ON `b2b_countries` ( `ps_id_country` ASC ); +-- addresses +CREATE TABLE IF NOT EXISTS b2b_addresses ( + id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, + b2b_customer_id BIGINT UNSIGNED NOT NULL, + address_info TEXT NOT NULL, + b2b_country_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_b2b_addresses_b2b_customers FOREIGN KEY (b2b_customer_id) REFERENCES b2b_customers (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_b2b_addresses_b2b_countries FOREIGN KEY (b2b_country_id) REFERENCES b2b_countries (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB; + + CREATE TABLE b2b_specific_price ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, From 393de36cb2b9fac750ad96dd9ae1dfb7839209b8 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 9 Apr 2026 14:49:50 +0200 Subject: [PATCH 13/18] favorites --- app/delivery/web/api/restricted/product.go | 8 ++- app/model/product.go | 1 + app/repos/productsRepo/productsRepo.go | 56 +++++++++++++------ app/service/productService/productService.go | 4 +- .../20260302163122_create_tables.sql | 10 ++++ i18n/migrations/20260319163200_procedures.sql | 6 ++ 6 files changed, 65 insertions(+), 20 deletions(-) diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index ddd8677..2c9d894 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -90,7 +90,13 @@ func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - list, err := h.productService.Find(id_lang, paging, filters) + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + list, err := h.productService.Find(id_lang, userID, paging, filters) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/model/product.go b/app/model/product.go index fa47790..06b599e 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -70,6 +70,7 @@ type ProductInList struct { Reference string `gorm:"column:reference" json:"reference"` VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` Quantity int64 `gorm:"column:quantity" json:"quantity"` + IsFavorite bool `gorm:"column:is_favorite" json:"is_favorite"` } type ProductFilters struct { diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 341b348..9e0ab1c 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -16,7 +16,7 @@ import ( type UIProductsRepo interface { GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) - Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) + Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) } type ProductsRepo struct{} @@ -37,7 +37,6 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo return nil, err } - // Optional: validate it's valid JSON if !json.Valid([]byte(productStr)) { return nil, fmt.Errorf("invalid json returned from stored procedure") } @@ -46,37 +45,60 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo return &raw, nil } -func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { +func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { var list []model.ProductInList var total int64 query := db.Get(). Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). Select(` - ps.id_product AS product_id, - pl.name AS name, - pl.link_rewrite AS link_rewrite, - CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, - cl.name AS category_name, - p.reference AS reference, - COALESCE(v.variants_number, 0) AS variants_number, - sa.quantity AS quantity - `, config.Get().Image.ImagePrefix). + ps.id_product AS product_id, + pl.name AS name, + pl.link_rewrite AS link_rewrite, + CONCAT(?, '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, + cl.name AS category_name, + p.reference AS reference, + COALESCE(v.variants_number, 0) AS variants_number, + sa.quantity AS quantity, + COALESCE(f.is_favorite, 0) AS is_favorite + `, config.Get().Image.ImagePrefix). Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). + Joins("LEFT JOIN favorites f ON f.id_product = ps.id_product"). Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). Where("ps.active = ?", 1). Group("ps.id_product"). - Clauses(exclause.With{CTEs: []exclause.CTE{ - { - Name: "variants", - Subquery: exclause.Subquery{DB: db.Get().Model(&dbmodel.PsProductAttributeShop{}).Select("id_product", "COUNT(*) AS variants_number").Group("id_product")}, + Clauses(exclause.With{ + CTEs: []exclause.CTE{ + { + Name: "variants", + Subquery: exclause.Subquery{ + DB: db.Get(). + Model(&dbmodel.PsProductAttributeShop{}). + Select("id_product", "COUNT(*) AS variants_number"). + Group("id_product"), + }, + }, + + { + Name: "favorites", + Subquery: exclause.Subquery{ + DB: db.Get(). + Table("b2b_favorites"). + Select(` + product_id AS id_product, + COUNT(*) > 0 AS is_favorite + `). + Where("user_id = ?", userID). + Group("product_id"), + }, + }, }, - }}). + }). Order("ps.id_product DESC") // Apply all filters diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 1a1620e..f5d7ca1 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -29,6 +29,6 @@ func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_ return products, nil } -func (s *ProductService) Find(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { - return s.productsRepo.Find(id_lang, p, filters) +func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { + return s.productsRepo.Find(id_lang, userID, p, filters) } diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index a82cea5..ba4469a 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -151,6 +151,16 @@ CREATE TABLE IF NOT EXISTS b2b_carts_products ( CREATE INDEX IF NOT EXISTS idx_carts_products_cart_id ON b2b_carts_products (cart_id); +-- favorites +CREATE TABLE IF NOT EXISTS b2b_favorites ( + user_id BIGINT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + PRIMARY KEY (user_id, product_id), + CONSTRAINT fk_favorites_customer FOREIGN KEY (user_id) REFERENCES b2b_customers(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_favorites_product FOREIGN KEY (product_id) REFERENCES ps_product(id_product) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + + -- refresh_tokens CREATE TABLE IF NOT EXISTS b2b_refresh_tokens ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index 8f7d5ab..743ca43 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -132,6 +132,12 @@ JSON_OBJECT( m.name, 'category', cl.name, + /* ================= FAVORITE ================= */ + 'is_favorite', + EXISTS( + SELECT 1 FROM b2b_favorites f + WHERE f.user_id = p_id_customer AND f.product_id = p_id_product + ), /* ================= IMAGE ================= */ 'cover_image', JSON_OBJECT( From f1f5daa82b3753606e17698eb38a28be652453ea Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Thu, 9 Apr 2026 14:53:56 +0200 Subject: [PATCH 14/18] and add filtering by is_favorite --- app/delivery/web/api/restricted/product.go | 1 + 1 file changed, 1 insertion(+) diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index 2c9d894..5ab5b36 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -112,4 +112,5 @@ var columnMappingListProducts map[string]string = map[string]string{ "category_name": "cl.name", "category_id": "cp.id_category", "quantity": "sa.quantity", + "is_favorite": "ps.is_favorite", } From 0a5ce5d9c2e32a1c3f0962d7428d9dee01c2d4a4 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 10 Apr 2026 09:13:13 +0200 Subject: [PATCH 15/18] ... --- app/delivery/web/api/restricted/product.go | 50 +++++++++++++++++++ app/model/product.go | 9 ++++ app/repos/productsRepo/productsRepo.go | 16 ++++++ app/service/productService/productService.go | 8 +++ app/utils/responseErrors/responseErrors.go | 16 +++++- bruno/api_v1/product/Add To Favorites.yml | 15 ++++++ .../api_v1/product/Remove Form Favorites.yml | 15 ++++++ go.mod | 6 +-- go.sum | 6 --- 9 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 bruno/api_v1/product/Add To Favorites.yml create mode 100644 bruno/api_v1/product/Remove Form Favorites.yml diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index 5ab5b36..096d5ec 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -34,6 +34,8 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router { r.Get("/:id/:country_id/:quantity", handler.GetProductJson) r.Get("/list", handler.ListProducts) + r.Post("/favorite/:product_id", handler.AddToFavorites) + r.Delete("/favorite/:product_id", handler.RemoveFromFavorites) return r } @@ -114,3 +116,51 @@ var columnMappingListProducts map[string]string = map[string]string{ "quantity": "sa.quantity", "is_favorite": "ps.is_favorite", } + +func (h *ProductsHandler) AddToFavorites(c fiber.Ctx) error { + productIDStr := c.Params("product_id") + + productID, err := strconv.Atoi(productIDStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + err = h.productService.AddToFavorites(userID, uint(productID)) + 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))) +} + +func (h *ProductsHandler) RemoveFromFavorites(c fiber.Ctx) error { + productIDStr := c.Params("product_id") + + productID, err := strconv.Atoi(productIDStr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) + } + + userID, ok := localeExtractor.GetUserID(c) + if !ok { + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) + } + + err = h.productService.RemoveFromFavorites(userID, uint(productID)) + 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/model/product.go b/app/model/product.go index 06b599e..e862259 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -86,3 +86,12 @@ type ProductFilters struct { } type FeatVal = map[uint][]uint + +type B2bFavorite struct { + UserID uint `gorm:"column:user_id;not null;primaryKey" json:"user_id"` + ProductID uint `gorm:"column:product_id;not null;primaryKey" json:"product_id"` +} + +func (*B2bFavorite) TableName() string { + return "b2b_favorites" +} diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 9e0ab1c..7699c10 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -17,6 +17,8 @@ import ( type UIProductsRepo interface { GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) + AddToFavorites(userID uint, productID uint) error + RemoveFromFavorites(userID uint, productID uint) error } type ProductsRepo struct{} @@ -125,3 +127,17 @@ func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filter Count: uint(total), }, nil } + +func (repo *ProductsRepo) AddToFavorites(userID uint, productID uint) error { + fav := model.B2bFavorite{ + UserID: userID, + ProductID: productID, + } + return db.Get().Create(&fav).Error +} + +func (repo *ProductsRepo) RemoveFromFavorites(userID uint, productID uint) error { + return db.Get(). + Where("user_id = ? AND product_id = ?", userID, productID). + Delete(&model.B2bFavorite{}).Error +} diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index f5d7ca1..03a7132 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -32,3 +32,11 @@ func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_ func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { return s.productsRepo.Find(id_lang, userID, p, filters) } + +func (s *ProductService) AddToFavorites(userID uint, productID uint) error { + return s.productsRepo.AddToFavorites(userID, productID) +} + +func (s *ProductService) RemoveFromFavorites(userID uint, productID uint) error { + return s.productsRepo.RemoveFromFavorites(userID, productID) +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 28802e1..6b3c548 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -49,8 +49,11 @@ var ( ErrAIResponseFail = errors.New("AI responded with failure") ErrAIBadOutput = errors.New("AI response does not obey the format") - // Typed errors for product list handler - ErrBadPaging = errors.New("bad or missing paging attribute value in header") + // Typed errors for product handler + ErrBadPaging = errors.New("bad or missing paging attribute value in header") + ErrProductNotFound = errors.New("product with provided id does not exist") + ErrAlreadyInFavorites = errors.New("the product already is in your favorites") + ErrNotInFavorites = errors.New("the product already is not in your favorites") // Typed errors for menu handler ErrNoRootFound = errors.New("no root found in categories table") @@ -170,6 +173,12 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrBadPaging): return i18n.T_(c, "error.err_bad_paging") + case errors.Is(err, ErrProductNotFound): + return i18n.T_(c, "error.err_product_not_found") + case errors.Is(err, ErrAlreadyInFavorites): + return i18n.T_(c, "error.err_already_in_favorites") + case errors.Is(err, ErrNotInFavorites): + return i18n.T_(c, "error.err_already_not_in_favorites") case errors.Is(err, ErrNoRootFound): return i18n.T_(c, "error.err_no_root_found") @@ -249,6 +258,9 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrInvalidURLSlug), errors.Is(err, ErrInvalidXHTML), errors.Is(err, ErrBadPaging), + errors.Is(err, ErrProductNotFound), + errors.Is(err, ErrAlreadyInFavorites), + errors.Is(err, ErrNotInFavorites), errors.Is(err, ErrNoRootFound), errors.Is(err, ErrCircularDependency), errors.Is(err, ErrStartCategoryNotFound), diff --git a/bruno/api_v1/product/Add To Favorites.yml b/bruno/api_v1/product/Add To Favorites.yml new file mode 100644 index 0000000..71b3d9a --- /dev/null +++ b/bruno/api_v1/product/Add To Favorites.yml @@ -0,0 +1,15 @@ +info: + name: Add To Favorites + type: http + seq: 3 + +http: + method: POST + url: "{{bas_url}}/restricted/product/favorite/51" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/product/Remove Form Favorites.yml b/bruno/api_v1/product/Remove Form Favorites.yml new file mode 100644 index 0000000..a76feb6 --- /dev/null +++ b/bruno/api_v1/product/Remove Form Favorites.yml @@ -0,0 +1,15 @@ +info: + name: Remove Form Favorites + type: http + seq: 4 + +http: + method: DELETE + url: "{{bas_url}}/restricted/product/favorite/1" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/go.mod b/go.mod index 1c184da..6141322 100644 --- a/go.mod +++ b/go.mod @@ -36,8 +36,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -100,10 +98,10 @@ require ( github.com/valyala/fasthttp v1.69.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xyproto/randomstring v1.2.0 // indirect - golang.org/x/net v0.52.0 + golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.35.0 gopkg.in/warnings.v0 v0.1.2 // indirect gorm.io/driver/mysql v1.6.0 ) diff --git a/go.sum b/go.sum index 81fe849..d208fb1 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= @@ -136,8 +134,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= @@ -158,8 +154,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= From f7f56c29284bb8d1c6344cdb6ec3631ba9fb5b77 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 10 Apr 2026 09:33:44 +0200 Subject: [PATCH 16/18] catching errors --- app/repos/productsRepo/productsRepo.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 7699c10..010fc2c 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -10,6 +10,7 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "git.ma-al.com/goc_marek/gormcol" "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" ) @@ -129,6 +130,29 @@ func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filter } func (repo *ProductsRepo) AddToFavorites(userID uint, productID uint) error { + var count int64 + err := db.Get(). + Table(dbmodel.TableNamePsProduct). + Where(dbmodel.PsProductCols.IDProduct.Col()+" = ?", productID). + Count(&count).Error + if err != nil { + return err + } + if count == 0 { + return responseErrors.ErrProductNotFound + } + + err = db.Get(). + Table("b2b_favorites"). + Where("user_id = ? AND product_id = ?", userID, productID). + Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return responseErrors.ErrAlreadyInFavorites + } + fav := model.B2bFavorite{ UserID: userID, ProductID: productID, From 61ccd32c4a822a19c936851b8e630a60841d2dfc Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 10 Apr 2026 09:43:49 +0200 Subject: [PATCH 17/18] catching errors again --- app/repos/productsRepo/productsRepo.go | 44 +++++++++---------- app/service/productService/productService.go | 33 ++++++++++++++ bruno/api_v1/product/Add To Favorites.yml | 2 +- .../api_v1/product/Remove Form Favorites.yml | 2 +- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 010fc2c..da6409b 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -10,7 +10,6 @@ import ( "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" - "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "git.ma-al.com/goc_marek/gormcol" "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" ) @@ -20,6 +19,8 @@ type UIProductsRepo interface { Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) AddToFavorites(userID uint, productID uint) error RemoveFromFavorites(userID uint, productID uint) error + ExistsInFavorites(userID uint, productID uint) (int64, error) + ProductInDatabase(productID uint) (int64, error) } type ProductsRepo struct{} @@ -130,29 +131,6 @@ func (repo *ProductsRepo) Find(id_lang, userID uint, p find.Paging, filt *filter } func (repo *ProductsRepo) AddToFavorites(userID uint, productID uint) error { - var count int64 - err := db.Get(). - Table(dbmodel.TableNamePsProduct). - Where(dbmodel.PsProductCols.IDProduct.Col()+" = ?", productID). - Count(&count).Error - if err != nil { - return err - } - if count == 0 { - return responseErrors.ErrProductNotFound - } - - err = db.Get(). - Table("b2b_favorites"). - Where("user_id = ? AND product_id = ?", userID, productID). - Count(&count).Error - if err != nil { - return err - } - if count > 0 { - return responseErrors.ErrAlreadyInFavorites - } - fav := model.B2bFavorite{ UserID: userID, ProductID: productID, @@ -165,3 +143,21 @@ func (repo *ProductsRepo) RemoveFromFavorites(userID uint, productID uint) error Where("user_id = ? AND product_id = ?", userID, productID). Delete(&model.B2bFavorite{}).Error } + +func (repo *ProductsRepo) ExistsInFavorites(userID uint, productID uint) (int64, error) { + var count int64 + err := db.Get(). + Table("b2b_favorites"). + Where("user_id = ? AND product_id = ?", userID, productID). + Count(&count).Error + return count, err +} + +func (repo *ProductsRepo) ProductInDatabase(productID uint) (int64, error) { + var count int64 + err := db.Get(). + Table(dbmodel.TableNamePsProduct). + Where(dbmodel.PsProductCols.IDProduct.Col()+" = ?", productID). + Count(&count).Error + return count, err +} diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 03a7132..ae3ddd6 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -8,6 +8,7 @@ import ( constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" ) type ProductService struct { @@ -34,9 +35,41 @@ func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filt } func (s *ProductService) AddToFavorites(userID uint, productID uint) error { + count, err := s.productsRepo.ProductInDatabase(productID) + if err != nil { + return err + } + if count <= 0 { + return responseErrors.ErrProductNotFound + } + + count, err = s.productsRepo.ExistsInFavorites(userID, productID) + if err != nil { + return err + } + if count >= 1 { + return responseErrors.ErrAlreadyInFavorites + } + return s.productsRepo.AddToFavorites(userID, productID) } func (s *ProductService) RemoveFromFavorites(userID uint, productID uint) error { + count, err := s.productsRepo.ProductInDatabase(productID) + if err != nil { + return err + } + if count <= 0 { + return responseErrors.ErrProductNotFound + } + + count, err = s.productsRepo.ExistsInFavorites(userID, productID) + if err != nil { + return err + } + if count <= 0 { + return responseErrors.ErrNotInFavorites + } + return s.productsRepo.RemoveFromFavorites(userID, productID) } diff --git a/bruno/api_v1/product/Add To Favorites.yml b/bruno/api_v1/product/Add To Favorites.yml index 71b3d9a..29a660d 100644 --- a/bruno/api_v1/product/Add To Favorites.yml +++ b/bruno/api_v1/product/Add To Favorites.yml @@ -5,7 +5,7 @@ info: http: method: POST - url: "{{bas_url}}/restricted/product/favorite/51" + url: "{{bas_url}}/restricted/product/favorite/53" auth: inherit settings: diff --git a/bruno/api_v1/product/Remove Form Favorites.yml b/bruno/api_v1/product/Remove Form Favorites.yml index a76feb6..2b388c2 100644 --- a/bruno/api_v1/product/Remove Form Favorites.yml +++ b/bruno/api_v1/product/Remove Form Favorites.yml @@ -5,7 +5,7 @@ info: http: method: DELETE - url: "{{bas_url}}/restricted/product/favorite/1" + url: "{{bas_url}}/restricted/product/favorite/51" auth: inherit settings: From c5832c0cf587addbcc4c6532cbd744509cefbe38 Mon Sep 17 00:00:00 2001 From: Daniel Goc Date: Fri, 10 Apr 2026 09:57:07 +0200 Subject: [PATCH 18/18] minor change --- app/repos/productsRepo/productsRepo.go | 12 ++++++------ app/service/productService/productService.go | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index da6409b..4450b52 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -19,8 +19,8 @@ type UIProductsRepo interface { Find(id_lang, userID uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) AddToFavorites(userID uint, productID uint) error RemoveFromFavorites(userID uint, productID uint) error - ExistsInFavorites(userID uint, productID uint) (int64, error) - ProductInDatabase(productID uint) (int64, error) + ExistsInFavorites(userID uint, productID uint) (bool, error) + ProductInDatabase(productID uint) (bool, error) } type ProductsRepo struct{} @@ -144,20 +144,20 @@ func (repo *ProductsRepo) RemoveFromFavorites(userID uint, productID uint) error Delete(&model.B2bFavorite{}).Error } -func (repo *ProductsRepo) ExistsInFavorites(userID uint, productID uint) (int64, error) { +func (repo *ProductsRepo) ExistsInFavorites(userID uint, productID uint) (bool, error) { var count int64 err := db.Get(). Table("b2b_favorites"). Where("user_id = ? AND product_id = ?", userID, productID). Count(&count).Error - return count, err + return count >= 1, err } -func (repo *ProductsRepo) ProductInDatabase(productID uint) (int64, error) { +func (repo *ProductsRepo) ProductInDatabase(productID uint) (bool, error) { var count int64 err := db.Get(). Table(dbmodel.TableNamePsProduct). Where(dbmodel.PsProductCols.IDProduct.Col()+" = ?", productID). Count(&count).Error - return count, err + return count >= 1, err } diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index ae3ddd6..de6d70e 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -35,19 +35,19 @@ func (s *ProductService) Find(id_lang, userID uint, p find.Paging, filters *filt } func (s *ProductService) AddToFavorites(userID uint, productID uint) error { - count, err := s.productsRepo.ProductInDatabase(productID) + exists, err := s.productsRepo.ProductInDatabase(productID) if err != nil { return err } - if count <= 0 { + if !exists { return responseErrors.ErrProductNotFound } - count, err = s.productsRepo.ExistsInFavorites(userID, productID) + exists, err = s.productsRepo.ExistsInFavorites(userID, productID) if err != nil { return err } - if count >= 1 { + if exists { return responseErrors.ErrAlreadyInFavorites } @@ -55,19 +55,19 @@ func (s *ProductService) AddToFavorites(userID uint, productID uint) error { } func (s *ProductService) RemoveFromFavorites(userID uint, productID uint) error { - count, err := s.productsRepo.ProductInDatabase(productID) + exists, err := s.productsRepo.ProductInDatabase(productID) if err != nil { return err } - if count <= 0 { + if !exists { return responseErrors.ErrProductNotFound } - count, err = s.productsRepo.ExistsInFavorites(userID, productID) + exists, err = s.productsRepo.ExistsInFavorites(userID, productID) if err != nil { return err } - if count <= 0 { + if !exists { return responseErrors.ErrNotInFavorites }