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/.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/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/middleware/auth.go b/app/delivery/middleware/auth.go index 312cbe5..756e79f 100644 --- a/app/delivery/middleware/auth.go +++ b/app/delivery/middleware/auth.go @@ -1,13 +1,16 @@ 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" "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 +118,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 model.CustomerRole(userSession.RoleName) != model.RoleAdmin { + if model.CustomerRole(originalUserRole.Name) != model.RoleAdmin { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "admin access required", }) @@ -139,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..3dc16bd 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 || model.CustomerRole(userRole.Name) != 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 || model.CustomerRole(userRole.Name) != 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..843c956 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 || model.CustomerRole(userRole.Name) != 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 new file mode 100644 index 0000000..910aae1 --- /dev/null +++ b/app/delivery/web/api/restricted/storage.go @@ -0,0 +1,100 @@ +package restricted + +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" + "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 all users + r.Get("/list-content/*", handler.ListContent) + r.Get("/download-file/*", handler.DownloadFile) + + // for admins only + r.Get("/create-new-webdav-token", handler.CreateNewWebdavToken) + + 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 + 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))) + } + + entries_in_list, err := h.storageService.ListContent(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(entries_in_list, 0, i18n.T_(c, response.Message_OK))) +} + +func (h *StorageHandler) DownloadFile(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))) + } + + 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))) + } + + 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) 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 || model.CustomerRole(userRole.Name) != 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))) + } + + 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 9d673f5..51d9f51 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) @@ -117,7 +132,14 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + // storage (uses various authorization means) + restrictedStorage := s.restricted.Group("/storage") + webdavStorage := s.webdav.Group("/storage") + restricted.StorageHandlerRoutes(restrictedStorage) + webdav.StorageHandlerRoutes(webdavStorage) + restricted.CurrencyHandlerRoutes(s.restricted) + 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 77102ad..f79d282 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -25,6 +25,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/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..69dc906 --- /dev/null +++ b/app/repos/storageRepo/storageRepo.go @@ -0,0 +1,178 @@ +package storageRepo + +import ( + "io" + "os" + "path/filepath" + "time" + + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" +) + +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 +} + +type StorageRepo struct{} + +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) +} + +func (r *StorageRepo) ListContent(abs_path string) (*[]model.EntryInList, error) { + entries, err := os.ReadDir(abs_path) + 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(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 { + info, err := os.Stat(src_abs_path) + if err != nil { + return err + } + + 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 f.Close() + + 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 + } + + entries, err := os.ReadDir(src_abs_path) + if err != nil { + return err + } + + for _, entry := range entries { + + entity_src_path := filepath.Join(src_abs_path, entry.Name()) + entity_dst_Path := filepath.Join(dest_abs_path, entry.Name()) + + if entry.IsDir() { + err = r.copyDir(entity_src_path, entity_dst_Path) + if err != nil { + return err + } + + } else { + err = r.copyFile(entity_src_path, entity_dst_Path) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index ebc9e32..83b6b2f 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -457,6 +457,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/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/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..f5ffba8 --- /dev/null +++ b/app/service/storageService/storageService.go @@ -0,0 +1,283 @@ +package storageService + +import ( + "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" +) + +type StorageService struct { + storageRepo storageRepo.UIStorageRepo +} + +func New() *StorageService { + return &StorageService{ + storageRepo: storageRepo.New(), + } +} + +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 + } + + 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) { + info, err := s.storageRepo.EntryInfo(abs_path) + if err != nil || info.IsDir() { + return nil, "", 0, responseErrors.ErrFileDoesNotExist + } + + f, err := s.storageRepo.OpenFile(abs_path) + if err != nil { + return nil, "", 0, err + } + + 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 { + return s.storageRepo.Move(src_abs_path, dest_abs_path) +} + +func (s *StorageService) Copy(src_abs_path string, dest_abs_path string) error { + 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 "", 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 ensureTrailingSlash(s string) string { + if s == "/" { + return s + } + if !strings.HasSuffix(s, "/") { + return s + "/" + } + return s +} + +func xmlEscape(s string) string { + var b strings.Builder + xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// 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 + "/" + } + + rel = filepath.ToSlash(rel) + + parts := strings.Split(rel, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + + return strings.TrimRight(constdata.WEBDAV_HREF_ROOT, "/") + "/" + strings.Join(parts, "/") +} + +// AbsPath extracts an absolute path and validates it +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+"/") { + 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 cbd5657..1ed8a7c 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 @@ -13,6 +14,11 @@ 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" + // Slug sanitization const NON_ALNUM_REGEX = `[^a-z0-9]+` const MULTI_DASH_REGEX = `-+` 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 7dcd0cc..37bdb0a 100644 --- a/app/utils/localeExtractor/localeExtractor.go +++ b/app/utils/localeExtractor/localeExtractor.go @@ -22,6 +22,14 @@ func GetUserID(c fiber.Ctx) (uint, bool) { return user_locale.User.ID, true } +func GetOriginalUserRole(c fiber.Ctx) (model.Role, bool) { + user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) + if !ok || user_locale.OriginalUser == nil || user_locale.OriginalUser.Role == nil { + return model.Role{}, false + } + return *user_locale.OriginalUser.Role, true +} + func GetCustomer(c fiber.Ctx) (*model.Customer, bool) { user_locale, ok := c.Locals(constdata.USER_LOCALE).(*model.UserLocale) if !ok || user_locale.User == nil { diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index dc54a4f..b3fe72f 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -9,14 +9,15 @@ import ( var ( // Typed errors for request validation and authentication - ErrForbidden = errors.New("forbidden") - 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") + ErrForbidden = errors.New("forbidden") + 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 required") // Typed errors for logging in and registering ErrInvalidCredentials = errors.New("invalid email or password") @@ -62,6 +63,13 @@ var ( 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") + ErrNameTaken = errors.New("name taken") + ErrMissingFileFieldDocument = errors.New("missing file field 'document'") + // Typed errors for data parsing ErrJSONBody = errors.New("invalid JSON body") ) @@ -118,6 +126,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): @@ -171,6 +181,17 @@ 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") + 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") + case errors.Is(err, ErrJSONBody): return i18n.T_(c, "error.err_json_body") @@ -198,6 +219,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), @@ -219,6 +241,11 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), errors.Is(err, ErrProductOrItsVariationDoesNotExist), + errors.Is(err, ErrAccessDenied), + errors.Is(err, ErrFolderDoesNotExist), + errors.Is(err, ErrFileDoesNotExist), + errors.Is(err, ErrNameTaken), + errors.Is(err, ErrMissingFileFieldDocument), errors.Is(err, ErrJSONBody): return fiber.StatusBadRequest case errors.Is(err, ErrEmailExists): diff --git a/bruno/b2b-daniel/translate-product-description.yml b/bruno/b2b-daniel/translate-product-description.yml index c914958..5f3a787 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: 20 + seq: 21 http: method: GET 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 53a469b..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: 3 + 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 a6beb62..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: 11 + 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 7441656..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: 16 + 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 95e978b..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: 15 + 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 5dd32ee..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: 12 + 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 114116c..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: 14 + 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 f15ce51..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: 13 + 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 e7077fd..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: 4 + 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 100% rename from bruno/b2b-daniel/list-products.yml rename to bruno/b2b_daniel/list/list-products.yml 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 288afbc..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: 2 + 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 8b10c00..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: 18 + 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 c6b436e..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: 5 + 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 97% rename from bruno/b2b-daniel/get-product-description.yml rename to bruno/b2b_daniel/product-translation/get-product-description.yml index 63a7447..4b6086d 100644 --- a/bruno/b2b-daniel/get-product-description.yml +++ b/bruno/b2b_daniel/product-translation/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/product-translation/save-product-description.yml b/bruno/b2b_daniel/product-translation/save-product-description.yml new file mode 100644 index 0000000..201f4f8 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/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/product-translation/translate-product-description.yml b/bruno/b2b_daniel/product-translation/translate-product-description.yml new file mode 100644 index 0000000..12c65b4 --- /dev/null +++ b/bruno/b2b_daniel/product-translation/translate-product-description.yml @@ -0,0 +1,28 @@ +info: + name: translate-product-description + type: http + seq: 24 + +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 diff --git a/bruno/b2b-daniel/create-index.yml b/bruno/b2b_daniel/search/create-index.yml similarity index 66% rename from bruno/b2b-daniel/create-index.yml rename to bruno/b2b_daniel/search/create-index.yml index 79eb62e..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: 7 + 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 850f7bc..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: 9 + seq: 1 http: method: GET diff --git a/bruno/b2b-daniel/remove-index.yml b/bruno/b2b_daniel/search/remove-index.yml similarity index 97% rename from bruno/b2b-daniel/remove-index.yml rename to bruno/b2b_daniel/search/remove-index.yml index aecc977..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: 8 + 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 39d3f04..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: 10 + 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 e63fe60..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: 6 + 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/storage-old/copy.yml b/bruno/b2b_daniel/storage-old/copy.yml new file mode 100644 index 0000000..8161fc0 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/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-old/create-folder.yml b/bruno/b2b_daniel/storage-old/create-folder.yml new file mode 100644 index 0000000..1250965 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/create-folder.yml @@ -0,0 +1,19 @@ +info: + name: create-folder + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/create-folder?name=folder + params: + - name: name + value: folder + type: query + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/delete-file.yml b/bruno/b2b_daniel/storage-old/delete-file.yml new file mode 100644 index 0000000..01b1744 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/delete-file.yml @@ -0,0 +1,15 @@ +info: + name: delete-file + type: http + seq: 1 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-file/folder1/TODO.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/delete-folder.yml b/bruno/b2b_daniel/storage-old/delete-folder.yml new file mode 100644 index 0000000..3c578ce --- /dev/null +++ b/bruno/b2b_daniel/storage-old/delete-folder.yml @@ -0,0 +1,15 @@ +info: + name: delete-folder + type: http + seq: 1 + +http: + method: DELETE + url: http://localhost:3000/api/v1/restricted/storage/delete-folder/folder/ + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/download-file.yml b/bruno/b2b_daniel/storage-old/download-file.yml new file mode 100644 index 0000000..d6c65a1 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/download-file.yml @@ -0,0 +1,15 @@ +info: + name: download-file + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/download-file/folder1/test.xlsx + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/folder.yml b/bruno/b2b_daniel/storage-old/folder.yml new file mode 100644 index 0000000..852efec --- /dev/null +++ b/bruno/b2b_daniel/storage-old/folder.yml @@ -0,0 +1,7 @@ +info: + name: storage-old + type: folder + seq: 1 + +request: + auth: inherit diff --git a/bruno/b2b_daniel/storage-old/list-content.yml b/bruno/b2b_daniel/storage-old/list-content.yml new file mode 100644 index 0000000..ed67b6d --- /dev/null +++ b/bruno/b2b_daniel/storage-old/list-content.yml @@ -0,0 +1,15 @@ +info: + name: list-content + type: http + seq: 1 + +http: + method: GET + url: http://localhost:3000/api/v1/restricted/storage/list-content/folder1 + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/b2b_daniel/storage-old/move.yml b/bruno/b2b_daniel/storage-old/move.yml new file mode 100644 index 0000000..7fb51e5 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/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 diff --git a/bruno/b2b_daniel/storage-old/upload-file.yml b/bruno/b2b_daniel/storage-old/upload-file.yml new file mode 100644 index 0000000..aa8d740 --- /dev/null +++ b/bruno/b2b_daniel/storage-old/upload-file.yml @@ -0,0 +1,22 @@ +info: + name: upload-file + type: http + seq: 1 + +http: + method: POST + url: http://localhost:3000/api/v1/restricted/storage/upload-file/folder1/ + body: + type: multipart-form + data: + - name: document + type: file + value: + - /home/daniel/TODO.txt + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 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 b88f14e..d975294 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -104,6 +104,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 INT NULL DEFAULT 2, @@ -119,6 +121,9 @@ 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); + ALTER TABLE b2b_customers ADD CONSTRAINT fk_customer_role FOREIGN KEY (role_id) REFERENCES b2b_roles(id); diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 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