diff --git a/embedded/embedded.go b/embedded/embedded.go index 068bc8a..193aec4 100644 --- a/embedded/embedded.go +++ b/embedded/embedded.go @@ -36,7 +36,6 @@ func CopyEmbeddedFile(path string, to string) error { } embeddedHash, err := utils.FileHash(bytes.NewReader(content)) - if err != nil { return err } diff --git a/handlers/auth.go b/handlers/auth.go index af7187f..8410a07 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "git.difuse.io/Difuse/kalmia/config" "git.difuse.io/Difuse/kalmia/services" @@ -177,6 +178,54 @@ func UploadFile(db *gorm.DB, w http.ResponseWriter, r *http.Request, cfg *config SendJSONResponse(http.StatusOK, w, map[string]string{"status": "success", "message": "file_uploaded", "file": fileURL}) } +func UploadAssetsFile(db *gorm.DB, w http.ResponseWriter, r *http.Request, cfg *config.Config) { + // Capped at MaxFileSize set by the user + err := r.ParseMultipartForm(cfg.MaxFileSize << 20) + if err != nil { + SendJSONResponse(http.StatusBadRequest, w, map[string]string{"status": "error", "message": "failed_to_parse_form"}) + return + } + + uploadTagName := r.FormValue("upload_tag_name") + + file, header, err := r.FormFile(uploadTagName) + if err != nil { + SendJSONResponse(http.StatusBadRequest, w, map[string]string{"status": "error", "message": "failed_to_get_file"}) + return + } + defer file.Close() + + if header.Size > cfg.MaxFileSize<<20 { + SendJSONResponse(http.StatusBadRequest, w, map[string]string{"status": "error", "message": "file_too_large"}) + return + } + + fileBytes, err := io.ReadAll(file) + if err != nil { + SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": "failed_to_read_file"}) + return + } + + contentType := http.DetectContentType(fileBytes) + + fmt.Println("Request URI: ", r.RequestURI) + + fileURL, err := services.UploadToS3Storage(bytes.NewReader(fileBytes), header.Filename, contentType, cfg) + if err != nil { + + fmt.Println(fmt.Errorf("ERROR uploading: %v", err)) + SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": "failed_to_upload_file"}) + return + } + + // strip file name from the bucket url and we will only need that here + filePathSlices := strings.Split(fileURL, "/") + + bucketFileName := filePathSlices[len(filePathSlices)-1] + + SendJSONResponse(http.StatusOK, w, map[string]string{"status": "success", "message": "file_uploaded", "file": bucketFileName}) +} + func CreateJWT(authService *services.AuthService, w http.ResponseWriter, r *http.Request) { type Request struct { Username string `json:"username"` diff --git a/handlers/docs.go b/handlers/docs.go index 84cc60f..374c47f 100644 --- a/handlers/docs.go +++ b/handlers/docs.go @@ -15,7 +15,6 @@ import ( func GetDocumentations(service *services.DocService, w http.ResponseWriter, r *http.Request) { docs, err := service.GetDocumentations() - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{ "status": "error", @@ -33,13 +32,11 @@ func GetDocumentation(service *services.DocService, w http.ResponseWriter, r *ht } req, err := ValidateRequest[Request](w, r) - if err != nil { return } doc, err := service.GetDocumentation(req.ID) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{ "status": "error", @@ -51,16 +48,14 @@ func GetDocumentation(service *services.DocService, w http.ResponseWriter, r *ht SendJSONResponse(http.StatusOK, w, doc) } -func CreateDocumentation(services *services.ServiceRegistry, w http.ResponseWriter, r *http.Request) { +func CreateDocumentation(service *services.ServiceRegistry, w http.ResponseWriter, r *http.Request) { token, err := GetTokenFromHeader(r) - if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_token"}) return } - user, err := services.AuthService.GetUserFromToken(token) - + user, err := service.AuthService.GetUserFromToken(token) if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_token"}) return @@ -89,10 +84,14 @@ func CreateDocumentation(services *services.ServiceRegistry, w http.ResponseWrit GitUser string `json:"gitUser"` GitPassword string `json:"gitPassword"` GitEmail string `json:"gitEmail"` + + BucketFavicon string `json:"bucketFavicon"` + BucketMetaImage string `json:"bucketMetaImage"` + BucketNavImage string `json:"bucketNavImage"` + BucketNavImageDark string `json:"bucketNavImageDark"` } req, err := ValidateRequest[Request](w, r) - if err != nil { return } @@ -126,8 +125,12 @@ func CreateDocumentation(services *services.ServiceRegistry, w http.ResponseWrit GitEmail: req.GitEmail, } - err = services.DocService.CreateDocumentation(documentation, user) - + err = service.DocService.CreateDocumentation(documentation, user, map[string]string{ + "favicon": req.BucketFavicon, + "metaImage": req.BucketMetaImage, + "navImage": req.BucketNavImage, + "navImageDark": req.BucketNavImageDark, + }) if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -161,10 +164,14 @@ func EditDocumentation(services *services.ServiceRegistry, w http.ResponseWriter GitEmail string `json:"gitEmail"` GitUser string `json:"gitUser"` GitPassword string `json:"gitPassword"` + + BucketFavicon string `json:"bucketFavicon"` + BucketMetaImage string `json:"bucketMetaImage"` + BucketNavImage string `json:"bucketNavImage"` + BucketNavImageDark string `json:"bucketNavImageDark"` } req, err := ValidateRequest[Request](w, r) - if err != nil { return } @@ -181,11 +188,37 @@ func EditDocumentation(services *services.ServiceRegistry, w http.ResponseWriter return } - err = services.DocService.EditDocumentation(user, req.ID, req.Name, req.Description, req.Version, req.Favicon, req.MetaImage, - req.NavImage, req.NavImageDark, req.CustomCSS, req.FooterLabelLinks, req.MoreLabelLinks, req.CopyrightText, - req.URL, req.OrganizationName, req.ProjectName, req.BaseURL, req.LanderDetails, req.RequireAuth, req.GitRepo, - req.GitBranch, req.GitUser, req.GitPassword, req.GitEmail) - + err = services.DocService.EditDocumentation(user, + req.ID, + req.Name, + req.Description, + req.Version, + req.Favicon, + req.MetaImage, + req.NavImage, + req.NavImageDark, + req.CustomCSS, + req.FooterLabelLinks, + req.MoreLabelLinks, + req.CopyrightText, + req.URL, + req.OrganizationName, + req.ProjectName, + req.BaseURL, + req.LanderDetails, + req.RequireAuth, + req.GitRepo, + req.GitBranch, + req.GitUser, + req.GitPassword, + req.GitEmail, + map[string]string{ + "favicon": req.BucketFavicon, + "metaImage": req.BucketMetaImage, + "navImage": req.BucketNavImage, + "navImageDark": req.BucketNavImageDark, + }, + ) if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -200,13 +233,11 @@ func DeleteDocumentation(service *services.DocService, w http.ResponseWriter, r } req, err := ValidateRequest[Request](w, r) - if err != nil { return } err = service.DeleteDocumentation(req.ID) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -222,13 +253,11 @@ func CreateDocumentationVersion(service *services.DocService, w http.ResponseWri } req, err := ValidateRequest[Request](w, r) - if err != nil { return } err = service.CreateDocumentationVersion(req.OriginalDocID, req.NewVersion) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -239,7 +268,6 @@ func CreateDocumentationVersion(service *services.DocService, w http.ResponseWri func GetPages(service *services.DocService, w http.ResponseWriter, r *http.Request) { pages, err := service.GetPages() - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -254,13 +282,11 @@ func GetPage(service *services.DocService, w http.ResponseWriter, r *http.Reques } req, err := ValidateRequest[Request](w, r) - if err != nil { return } page, err := service.GetPage(req.ID) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -280,20 +306,17 @@ func CreatePage(services *services.ServiceRegistry, w http.ResponseWriter, r *ht } req, err := ValidateRequest[Request](w, r) - if err != nil { return } token, err := GetTokenFromHeader(r) - if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_request"}) return } user, err := services.AuthService.GetUserFromToken(token) - if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_request"}) return @@ -319,7 +342,6 @@ func CreatePage(services *services.ServiceRegistry, w http.ResponseWriter, r *ht } err = services.DocService.CreatePage(&page) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return @@ -434,20 +456,17 @@ func CreatePageGroup(services *services.ServiceRegistry, w http.ResponseWriter, } req, err := ValidateRequest[Request](w, r) - if err != nil { return } token, err := GetTokenFromHeader(r) - if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_request"}) return } user, err := services.AuthService.GetUserFromToken(token) - if err != nil { SendJSONResponse(http.StatusUnauthorized, w, map[string]string{"status": "error", "message": "invalid_request"}) return @@ -471,7 +490,6 @@ func CreatePageGroup(services *services.ServiceRegistry, w http.ResponseWriter, } _, err = services.DocService.CreatePageGroup(&pageGroup) - if err != nil { SendJSONResponse(http.StatusInternalServerError, w, map[string]string{"status": "error", "message": err.Error()}) return diff --git a/main.go b/main.go index 74089e9..bc3a739 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,7 @@ func main() { authRouter.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { handlers.GetUsers(aS, w, r) }).Methods("GET") authRouter.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { handlers.GetUser(aS, w, r) }).Methods("POST") authRouter.HandleFunc("/user/upload-file", func(w http.ResponseWriter, r *http.Request) { handlers.UploadFile(d, w, r, config.ParsedConfig) }).Methods("POST") + authRouter.HandleFunc("/user/assets/upload-file", func(w http.ResponseWriter, r *http.Request) { handlers.UploadAssetsFile(d, w, r, config.ParsedConfig) }).Methods("POST") authRouter.HandleFunc("/jwt/create", func(w http.ResponseWriter, r *http.Request) { handlers.CreateJWT(aS, w, r) }).Methods("POST") authRouter.HandleFunc("/jwt/refresh", func(w http.ResponseWriter, r *http.Request) { handlers.RefreshJWT(aS, w, r) }).Methods("POST") diff --git a/services/docs_docs.go b/services/docs_docs.go index 3f02da0..c79dc4b 100644 --- a/services/docs_docs.go +++ b/services/docs_docs.go @@ -3,11 +3,20 @@ package services import ( "errors" "fmt" + "os" + "path/filepath" "sort" + "strings" + "git.difuse.io/Difuse/kalmia/config" "git.difuse.io/Difuse/kalmia/db/models" "git.difuse.io/Difuse/kalmia/logger" "git.difuse.io/Difuse/kalmia/utils" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -125,7 +134,6 @@ func (service *DocService) GetDocIdFromBaseURL(baseUrl string) (uint, error) { func (service *DocService) GetChildrenOfDocumentation(id uint) ([]uint, error) { docs, err := service.GetDocumentations() - if err != nil { return nil, err } @@ -199,7 +207,14 @@ func (service *DocService) GetAllVersions(id uint) (string, []string, error) { return latestVersion, versions, nil } -func (service *DocService) CreateDocumentation(documentation *models.Documentation, user models.User) error { +type BucketUploadMetadataFiles struct { + BucketFavicon string `json:"bucketFavicon"` + BucketMetaImage string `json:"bucketMetaImage"` + BucketNavImage string `json:"bucketNavImage"` + BucketNavImageDark string `json:"bucketNavImageDark"` +} + +func (service *DocService) CreateDocumentation(documentation *models.Documentation, user models.User, bucketUploadedFiles map[string]string) error { db := service.DB var count int64 @@ -240,7 +255,6 @@ func (service *DocService) CreateDocumentation(documentation *models.Documentati } err := service.InitRsPress(documentation.ID) - if err != nil { logger.Error("failed_to_init_rspress", zap.Error(err)) db.Delete(&documentation) @@ -249,19 +263,116 @@ func (service *DocService) CreateDocumentation(documentation *models.Documentati return fmt.Errorf("failed_to_init_rspress") } - err = service.AddBuildTrigger(documentation.ID, false) + sess, err := session.NewSession(&aws.Config{ + Endpoint: aws.String(config.ParsedConfig.S3.Endpoint), + Region: aws.String(config.ParsedConfig.S3.Region), + Credentials: credentials.NewStaticCredentials(config.ParsedConfig.S3.AccessKeyId, config.ParsedConfig.S3.SecretAccessKey, ""), + S3ForcePathStyle: aws.Bool(config.ParsedConfig.S3.UsePathStyle), + }) + if err != nil { + return fmt.Errorf("error creating AWS session: %v", err) + } + + docPath := utils.GetDocPathByID(documentation.ID, config.ParsedConfig) + + publicAssetsDocPath := filepath.Join(docPath, "public") + + if !utils.PathExists(publicAssetsDocPath) { + if err := utils.MakeDir(publicAssetsDocPath); err != nil { + logger.Error("error creating public folder in: " + publicAssetsDocPath) + logger.Error(err.Error()) + return err + } + } + + downloader := s3manager.NewDownloader(sess) + + // get the uploaded files from s3/minio bucket + // map the favicon/metaImage, etc to the uploaded id + for key, bucketFileName := range bucketUploadedFiles { + // ignore empty ones to use default or previous values/files + if len(bucketFileName) == 0 { + continue + } + bucketFileExtension := utils.GetFileExtension(bucketFileName) + + assetFilePath := filepath.Join(publicAssetsDocPath, key+"."+bucketFileExtension) + + file, err := os.Create(assetFilePath) + if err != nil { + logger.Error(fmt.Sprintf("failed_to_create_file_at_path: %s", assetFilePath)) + return fmt.Errorf("failed_to_create_file_at_path") + } + + numBytes, err := downloader.Download(file, &s3.GetObjectInput{ + Bucket: aws.String("uploads"), + Key: aws.String(bucketFileName), + }) + if err != nil { + logger.Error(fmt.Sprintf("failed_to_download_object_file: %s, %s\nERROR: %v", key, bucketFileName, err)) + return fmt.Errorf("failed_to_set_uploaded_file") + } + + logger.Debug(fmt.Sprintf("Wrote %d bytes into %s file", numBytes, assetFilePath)) + + switch key { + case "favicon": + documentation.Favicon = fmt.Sprintf("/favicon.%s", bucketFileExtension) + case "metaImage": + documentation.MetaImage = fmt.Sprintf("/metaImage.%s", bucketFileExtension) + case "navImage": + documentation.NavImage = fmt.Sprintf("/navImage.%s", bucketFileExtension) + case "navImageDark": + documentation.NavImageDark = fmt.Sprintf("/navImageDark.%s", bucketFileExtension) + default: + return fmt.Errorf("invalid_key_value_for_asset") + } + } + + err = db.Save(documentation).Error + if err != nil { + logger.Error(fmt.Sprintf("failed_to_update_documentation of doc_%d: %s", documentation.ID, err.Error())) + return err + } + + err = service.AddBuildTrigger(documentation.ID, false) if err != nil { + logger.Error("failed_to_add_build_trigger: " + err.Error()) return fmt.Errorf("failed_to_add_build_trigger") } return nil } -func (service *DocService) EditDocumentation(user models.User, id uint, name, description, version, favicon, metaImage, navImage, - navImageDark, customCSS, footerLabelLinks, moreLabelLinks, copyrightText, url, - organizationName, projectName, baseURL, landerDetails string, requireAuth bool, - gitRepo string, gitBranch string, gitUser string, gitPassword string, gitEmail string) error { +func (service *DocService) EditDocumentation( + user models.User, + id uint, + name, + description, + version, + favicon, + metaImage, + navImage, + navImageDark, + customCSS, + footerLabelLinks, + moreLabelLinks, + copyrightText, + url, + organizationName, + projectName, + baseURL, + landerDetails string, + requireAuth bool, + gitRepo string, + gitBranch string, + gitUser string, + gitPassword string, + gitEmail string, + + bucketUploadedFiles map[string]string, +) error { tx := service.DB.Begin() if !utils.IsBaseURLValid(baseURL) { return fmt.Errorf("invalid_base_url") @@ -311,6 +422,85 @@ func (service *DocService) EditDocumentation(user models.User, id uint, name, de return nil } + docPath := utils.GetDocPathByID(id, config.ParsedConfig) + docPublicAssetPath := filepath.Join(docPath, "public") + + sess, err := session.NewSession(&aws.Config{ + Endpoint: aws.String(config.ParsedConfig.S3.Endpoint), + Region: aws.String(config.ParsedConfig.S3.Region), + Credentials: credentials.NewStaticCredentials(config.ParsedConfig.S3.AccessKeyId, config.ParsedConfig.S3.SecretAccessKey, ""), + S3ForcePathStyle: aws.Bool(config.ParsedConfig.S3.UsePathStyle), + }) + if err != nil { + return fmt.Errorf("error creating AWS session: %v", err) + } + + downloader := s3manager.NewDownloader(sess) + + for key, bucketFileName := range bucketUploadedFiles { + if len(bucketFileName) == 0 { + continue + } + + publicAssetFs, err := os.ReadDir(docPublicAssetPath) + if err != nil { + return err + } + + checkFileExists := func() (string, bool) { + for _, dirEntry := range publicAssetFs { + if strings.Contains(dirEntry.Name(), key) { + return dirEntry.Name(), true + } + } + return "", false + } + + if filename, exists := checkFileExists(); exists { + // remove that + err = os.Remove(filepath.Join(docPublicAssetPath, filename)) + if err != nil { + return err + } + } + // download the file into the new directory + // place the new file inside this + bucketFileExtension := utils.GetFileExtension(bucketFileName) + + assetFilePath := filepath.Join(docPublicAssetPath, key+"."+bucketFileExtension) + + file, err := os.Create(assetFilePath) + if err != nil { + logger.Error(fmt.Sprintf("failed_to_create_file_at_path: %s", assetFilePath)) + return fmt.Errorf("failed_to_create_file_at_path") + } + + numBytes, err := downloader.Download(file, &s3.GetObjectInput{ + Bucket: aws.String("uploads"), + Key: aws.String(bucketFileName), + }) + if err != nil { + logger.Error(fmt.Sprintf("failed_to_download_object_file: %s, %s\nERROR: %v", key, bucketFileName, err)) + return fmt.Errorf("failed_to_set_uploaded_file") + } + + logger.Debug(fmt.Sprintf("Wrote %d bytes into %s file", numBytes, assetFilePath)) + + switch key { + case "favicon": + favicon = fmt.Sprintf("/favicon.%s", bucketFileExtension) + case "metaImage": + metaImage = fmt.Sprintf("/metaImage.%s", bucketFileExtension) + case "navImage": + navImage = fmt.Sprintf("/navImage.%s", bucketFileExtension) + case "navImageDark": + navImageDark = fmt.Sprintf("/navImageDark.%s", bucketFileExtension) + default: + return fmt.Errorf("invalid_key_value_for_asset") + } + + } + var targetDoc models.Documentation if err := tx.Preload("Editors").First(&targetDoc, id).Error; err != nil { tx.Rollback() @@ -651,13 +841,11 @@ func (service *DocService) CreateDocumentationVersion(originalDocId uint, newVer return nil }) - if err != nil { return err } err = service.AddBuildTrigger(originalDocId, false) - if err != nil { return fmt.Errorf("failed_to_add_build_trigger") } @@ -740,7 +928,8 @@ func (service *DocService) BulkReorderPageOrPageGroup(pageOrder []struct { ParentID *uint `json:"parentId"` PageGroupID *uint `json:"pageGroupId"` IsPageGroup bool `json:"isPageGroup"` -}) error { +}, +) error { var docId uint var pageGroupUpdates []models.PageGroup var pageUpdates []models.Page @@ -798,7 +987,6 @@ func (service *DocService) BulkReorderPageOrPageGroup(pageOrder []struct { return nil }) - if err != nil { return err } diff --git a/services/docs_rspress.go b/services/docs_rspress.go index f47835f..2c0d15a 100644 --- a/services/docs_rspress.go +++ b/services/docs_rspress.go @@ -73,13 +73,11 @@ type MetaData struct { func (service *DocService) GenerateHead(docID uint, pageId uint, pageType string) (string, error) { var buffer bytes.Buffer doc, err := service.GetDocumentation(docID) - if err != nil { return "", err } latest, _, err := service.GetAllVersions(docID) - if err != nil { return "", err } @@ -115,7 +113,6 @@ func (service *DocService) GenerateHead(docID uint, pageId uint, pageType string } metaJSON, err := json.Marshal(meta) - if err != nil { return "", err } @@ -190,7 +187,6 @@ func (service *DocService) GenerateHead(docID uint, pageId uint, pageType string } metaJSON, err := json.Marshal(meta) - if err != nil { return "", err } @@ -254,7 +250,6 @@ func (service *DocService) UpdateWriteBuild(docId uint) error { } configHash, err := service.StartUpdate(docId, rootParentId) - if err != nil { return err } @@ -308,7 +303,6 @@ func (service *DocService) StartupCheck() error { } err = service.InitRsPressPackageCache() - if err != nil { logger.Fatal("Initializing Package Cache Failed...", zap.Error(err)) } @@ -329,7 +323,6 @@ func (service *DocService) StartupCheck() error { } err = service.AddBuildTrigger(doc.ID, false) - if err != nil { logger.Error("Failed to add build trigger", zap.Uint("doc_id", doc.ID), zap.Error(err)) } @@ -358,7 +351,6 @@ func (service *DocService) InitRsPressPackageCache() error { installStart := time.Now() err = utils.RunNpmCommand(packageCachePath, "install") - if err != nil { return err } @@ -382,7 +374,6 @@ func (service *DocService) InitRsPress(docId uint) error { } err := embedded.CopyInitFiles(docPath) - if err != nil { return err } @@ -394,7 +385,6 @@ func (service *DocService) InitRsPress(docId uint) error { } err = utils.RunNpmCommand(docPath, "install") - if err != nil { return err } @@ -409,7 +399,6 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e } docConfigTemplate, err := embedded.ReadEmbeddedFile("rspress.config.ts") - if err != nil { return "", err } @@ -471,7 +460,6 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e var socialLinksRsPress []SocialLinkRsPress err := json.Unmarshal([]byte(doc.FooterLabelLinks), &socialLinks) - if err != nil { replacements["__SOCIAL_LINKS__"] = "[]" } @@ -481,7 +469,6 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e } socialLinksJSON, err := json.Marshal(socialLinksRsPress) - if err != nil { replacements["__SOCIAL_LINKS__"] = "[]" } @@ -522,14 +509,12 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e } latest, versions, err := service.GetAllVersions(docId) - if err != nil { return "", err } multiVersions := Versions{Default: latest, Versions: versions} multiVersionsJSON, err := json.Marshal(multiVersions) - if err != nil { return "", err } @@ -537,7 +522,6 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e replacements["__MULTI_VERSIONS__"] = "multiVersion: " + string(multiVersionsJSON) err = utils.WriteToFile(docConfig, utils.ReplaceMany(string(docConfigTemplate), replacements)) - if err != nil { return "", err } @@ -546,13 +530,11 @@ func (service *DocService) StartUpdate(docId uint, rootParentId uint) (string, e replacements["__OUT_DIR__"] = "gitbuild" err = utils.WriteToFile(gitDocConfig, utils.ReplaceMany(string(docConfigTemplate), replacements)) - if err != nil { return "", err } configHash, err := utils.FileHash(docConfig) - if err != nil { return "", err } @@ -807,7 +789,6 @@ func (service *DocService) buildVersionTree(docId uint) ([]VersionInfo, error) { func (service *DocService) WriteContents(docId uint, rootParentId uint, preHash string) error { docIdPath := filepath.Join(config.ParsedConfig.DataPath, "rspress_data", "doc_"+strconv.Itoa(int(rootParentId))) _, err := service.GetDocumentation(docId) - if err != nil { if err.Error() == "documentation_not_found" { if err := utils.RemovePath(docIdPath); err != nil { @@ -826,14 +807,12 @@ func (service *DocService) WriteContents(docId uint, rootParentId uint, preHash } versionInfos, err := service.buildVersionTree(rootParentId) - if err != nil { return err } for _, versionInfo := range versionInfos { versionDoc, err := service.GetDocumentation(versionInfo.DocId) - if err != nil { return err } @@ -954,20 +933,17 @@ func (service *DocService) WriteContents(docId uint, rootParentId uint, preHash } newDocsHash, err := utils.DirHash(docsPath) - if err != nil { return err } newConfigHash, err := utils.FileHash(filepath.Join(docIdPath, "rspress.config.ts")) - if err != nil { return err } newHash := utils.HashStrings([]string{newDocsHash, newConfigHash}) deletionsOccurred, err := service.PreBuildCleanup(rootParentId) - if err != nil { return err } @@ -1003,7 +979,6 @@ func (service *DocService) WriteHomePage(documentation models.Documentation, con } err := json.Unmarshal([]byte(documentation.LanderDetails), &landerDetails) - if err != nil { return fmt.Errorf("error unmarshaling LanderDetails: %w", err) } @@ -1058,7 +1033,6 @@ func (service *DocService) WriteHomePage(documentation models.Documentation, con } metaJSON, err := json.Marshal(meta) - if err != nil { return err } @@ -1069,7 +1043,6 @@ func (service *DocService) WriteHomePage(documentation models.Documentation, con homePagePath = filepath.Join(contentPath, "../", "index.mdx") } else { head, err := service.GenerateHead(documentation.ID, math.MaxUint32, "doc") - if err != nil { logger.Error("Failed to generate head for home page", zap.Error(err)) } @@ -1155,6 +1128,11 @@ func (service *DocService) RsPressBuild(docId uint, rebuild bool) error { return err } + err = utils.CopyPublicAssetsToDocsIfEmpty(docPath) + if err != nil { + return err + } + err = utils.RunNpmCommand(docPath, "run", "build") if err != nil { return err @@ -1212,13 +1190,11 @@ func (service *DocService) GetRsPress(urlPath string) (uint, string, string, boo split := strings.Split(cacheKey, "|") docID := strings.TrimPrefix("doc_", split[1]) reqAuth, err := strconv.ParseBool(split[2]) - if err != nil { continue } id, err := strconv.Atoi(docID) - if err != nil { continue } @@ -1266,7 +1242,6 @@ func (service *DocService) GetRsPress(urlPath string) (uint, string, string, boo } files, err := os.ReadDir(docPath) - if err != nil { return 0, "", "", false, fmt.Errorf("error_reading_rspress_directory") } @@ -1315,7 +1290,6 @@ func (service *DocService) DeleteJob() { if utils.PathExists(docPath) { logger.Info("Deleting doc folder", zap.Uint("doc_id", trigger.DocumentationID)) err := service.RemoveDocFolder(trigger.DocumentationID) - if err != nil { logger.Error("(DeleteJob) Failed to remove doc folder", zap.Error(err)) skipSave = true @@ -1391,6 +1365,18 @@ func (service *DocService) BuildJob() { } else { logger.Info("Git Deploy completed", zap.Uint("doc_id", docID), zap.Duration("elapsed", gitElapsed), zap.Int("trigger_count", len(groupTriggers))) } + logger.Info(fmt.Sprintf("moving static assets to docs in doc_%d", docID)) + + docPath := utils.GetDocPathByID(docID, config.ParsedConfig) + docPublicAssetPath := filepath.Join(docPath, "public") + docsInternalPublicAssetPath := filepath.Join(docPath, "docs", "public") + err = utils.CopyOrOveriteDir(docPublicAssetPath, docsInternalPublicAssetPath) + + if err != nil { + logger.Error(fmt.Sprintf("error copying files from %s to %s", docPublicAssetPath, docsInternalPublicAssetPath), zap.Error(err)) + } else { + logger.Info("successfully copied files to target", zap.Uint("doc_id", docID)) + } } for i := range groupTriggers { diff --git a/utils/docs.go b/utils/docs.go new file mode 100644 index 0000000..ed55951 --- /dev/null +++ b/utils/docs.go @@ -0,0 +1,69 @@ +package utils + +import ( + "os" + "path/filepath" + + "git.difuse.io/Difuse/kalmia/logger" +) + +func CopyPublicAssetsToDocsIfEmpty(docPath string) error { + publicAssetsDirPath := filepath.Join(docPath, "public") + innerDocPath := filepath.Join(docPath, "docs") + + innerDocPublicPath := filepath.Join(innerDocPath, "public") + if !PathExists(innerDocPublicPath) { + if err := MakeDir(innerDocPublicPath); err != nil { + logger.Error("error creating public folder in: " + innerDocPublicPath) + logger.Error(err.Error()) + return err + } + } + + isEmptyDir, err := IsEmptyDir(innerDocPublicPath) + if err != nil { + return err + } + + if isEmptyDir { + err = os.CopyFS(innerDocPublicPath, os.DirFS(publicAssetsDirPath)) + if err != nil { + return err + } + } + return nil +} + +func CopyOrOveriteDir(sourceDir, destDir string) error { + sourceDirEntry, err := os.ReadDir(sourceDir) + if err != nil { + return err + } + for _, file := range sourceDirEntry { + exists, err := IsFileExistsInDir(file.Name(), destDir) + if err != nil { + return err + } + + if exists { + err = os.RemoveAll(filepath.Join(destDir, file.Name())) + if err != nil { + return err + } + } + } + + return os.CopyFS(destDir, os.DirFS(sourceDir)) +} + +func IsFileExistsInDir(filename string, dirname string) (bool, error) { + _, err := os.Stat(filepath.Join(dirname, filename)) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + + return false, err +} diff --git a/utils/fs.go b/utils/fs.go index 7d76c05..7330011 100644 --- a/utils/fs.go +++ b/utils/fs.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "git.difuse.io/Difuse/kalmia/config" "golang.org/x/mod/sumdb/dirhash" ) @@ -19,7 +20,6 @@ func PathExists(path string) bool { func CopyFile(src, dst string) error { sourceFile, err := os.Open(src) - if err != nil { return err } @@ -48,7 +48,6 @@ func TouchFile(path string) error { } _, err = file.WriteString("1") - if err != nil { return err } @@ -172,3 +171,32 @@ func DirHash(dir string) (string, error) { } return hash, nil } + +func GetFileExtension(filename string) string { + tmpStringSlice := strings.Split(filename, ".") + + extension := tmpStringSlice[len(tmpStringSlice)-1] + + return extension +} + +func GetDocPathByID(docID uint, cfg *config.Config) string { + return filepath.Join(cfg.DataPath, "rspress_data", fmt.Sprintf("doc_%d", docID)) +} + +func IsEmptyDir(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + + defer f.Close() + + _, err = f.Readdirnames(1) + + if err == io.EOF { + return true, nil + } + + return false, err +} diff --git a/web/src/api/Requests.ts b/web/src/api/Requests.ts index 6bf9c8f..bc23ea3 100644 --- a/web/src/api/Requests.ts +++ b/web/src/api/Requests.ts @@ -45,6 +45,12 @@ export interface DocumentationPayload { gitEmail?: string; gitPassword?: string; gitBranch?: string; + + // stored in bucket named + bucketFavicon: string; + bucketMetaImage: string; + bucketNavImage: string; + bucketNavImageDark: string; } interface CreateVersionPayload { @@ -273,6 +279,9 @@ export const updateUser = (data: UpdateUserPayload) => export const uploadFile = (data: FormData) => makeRequest("/kal-api/auth/user/upload-file", "post", data); +export const uploadAssetsFile = (data: FormData) => + makeRequest("/kal-api/auth/user/assets/upload-file", "post", data); + export const deleteUser = (username: string) => makeRequest("/kal-api/auth/user/delete", "post", { username }); diff --git a/web/src/components/CreateDocumentModal/CreateDocModal.tsx b/web/src/components/CreateDocumentModal/CreateDocModal.tsx index 878445d..6e18633 100644 --- a/web/src/components/CreateDocumentModal/CreateDocModal.tsx +++ b/web/src/components/CreateDocumentModal/CreateDocModal.tsx @@ -15,10 +15,12 @@ import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { + ApiResponse, createDocumentation, DocumentationPayload, getDocumentation, updateDocumentation, + uploadAssetsFile, } from "../../api/Requests"; import { ModalContext } from "../../context/ModalContext"; import { ThemeContext, ThemeContextType } from "../../context/ThemeContext"; @@ -45,6 +47,8 @@ import { import { toastMessage } from "../../utils/Toast"; import { customCSSInitial, SocialLinkIcon } from "../../utils/Utils"; import Breadcrumb from "../Breadcrumb/Breadcrumb"; +import { UploadFormField } from "./UploadFormField"; +import { AxiosError } from "axios"; const FormField: React.FC = ({ label, @@ -339,6 +343,71 @@ export default function CreateDocModal() { } }, []); + const [uploadedFiles, setUploadedFiles] = useState<{ [name: string]: { name?: string, uploaded: boolean } }>({ + favicon: { + name: "", + uploaded: false + }, + navImageDark: { + name: "", + uploaded: false + }, + navImage: { + name: "", + uploaded: false + }, + metaImage: { + name: "", + uploaded: false + } + }) + + const handleUploadAssetFile = async ( + e: React.ChangeEvent + ) => { + const { name } = e.target + + // check for files + + const { files } = e.target + + if (!files) { + toastMessage("no_files_selected", "warning") + return + } + + const file = files[0] + + const formData = new globalThis.FormData() + + formData.append("upload_tag_name", name) + formData.append(name, file) + + let res: ApiResponse<{ status: "error" | "success", message: string, file: string }> + + try { + res = await uploadAssetsFile(formData) + } catch (error) { + + const err = error as AxiosError + toastMessage(err.message, "error") + return + } + if (!res.data) { + toastMessage("no_response_from_server", "error") + return + } + + setUploadedFiles((prevData) => ({ + ...prevData, + [name]: { name: res.data?.file, uploaded: true } + })) + + toastMessage("updated_file", "success") + // DEBUG: remove after debug session + console.log("Uploaded files: ", uploadedFiles) + } + const handleChange = ( e: | React.ChangeEvent @@ -412,6 +481,10 @@ export default function CreateDocModal() { moreLabelLinks: moreField ? JSON.stringify(moreField) : [{ label: "", link: "" }], + bucketFavicon: uploadedFiles.favicon.name || "", + bucketMetaImage: uploadedFiles.metaImage.name || "", + bucketNavImage: uploadedFiles.navImage.name || "", + bucketNavImageDark: uploadedFiles.navImageDark.name || "" }; let result; @@ -614,31 +687,55 @@ export default function CreateDocModal() {
- */} + {/* */} + {/* */} + + - - +
@@ -650,13 +747,13 @@ export default function CreateDocModal() { name="copyrightText" required={true} /> -
diff --git a/web/src/components/CreateDocumentModal/UploadFormField.tsx b/web/src/components/CreateDocumentModal/UploadFormField.tsx new file mode 100644 index 0000000..029df63 --- /dev/null +++ b/web/src/components/CreateDocumentModal/UploadFormField.tsx @@ -0,0 +1,35 @@ +import { UploadFormFieldData } from "../../types/doc"; +import { Icon } from "@iconify/react" + +export const UploadFormField: React.FC = ({ + label, + placeholder, + onChange, + name, + required = false, + ref, + uploaded +}) => { + + return ( +
+ + {label} {required && *} + +
+ + + {uploaded && } +
+
+ ); +}; diff --git a/web/src/components/GitBookModal/GitBookModal.tsx b/web/src/components/GitBookModal/GitBookModal.tsx index 8c386ae..64d5f2e 100644 --- a/web/src/components/GitBookModal/GitBookModal.tsx +++ b/web/src/components/GitBookModal/GitBookModal.tsx @@ -161,6 +161,11 @@ export default function GitBookModal() { "https://downloads-bucket.difuse.io/kalmia-sideways-white-final.png", customCSS: customCSSInitial(), copyrightText: "N/A", + + bucketFavicon: "", + bucketMetaImage: "", + bucketNavImage: "", + bucketNavImageDark: "" }; const createResponse = await createDocumentation(payload); diff --git a/web/src/types/doc.ts b/web/src/types/doc.ts index 8b6dcec..d9b0cfd 100644 --- a/web/src/types/doc.ts +++ b/web/src/types/doc.ts @@ -132,6 +132,27 @@ export interface FormFieldData { ref?: React.Ref; } +export interface UploadFormField { + label: string; + placeholder: string; + value: string; + onChange: () => Promise; + name: string; + type: string; + required: boolean; + ref: React.RefObject; +} +export interface UploadFormFieldData { + label: string; + placeholder: string; + value?: string; + onChange: (e: React.ChangeEvent) => void; + name: string; + required?: boolean; + uploaded?: boolean; + ref?: React.Ref; +} + export interface FormData { name: string; description: string; diff --git a/web/src/utils/Common.ts b/web/src/utils/Common.ts index 2e788e7..5bfd513 100644 --- a/web/src/utils/Common.ts +++ b/web/src/utils/Common.ts @@ -337,18 +337,18 @@ export const validateFormData = ( } } - const urlFields: (keyof CreateDocumentFormData)[] = [ - "favicon", - "navImageDark", - "navImage", - "metaImage", - ]; - - for (const field of urlFields) { - if (formData[field] && !isValidURL(formData[field])) { - return { status: true, message: `valid_${field}_url_required` }; - } - } + // const urlFields: (keyof CreateDocumentFormData)[] = [ + // "favicon", + // "navImageDark", + // "navImage", + // "metaImage", + // ]; + // + // for (const field of urlFields) { + // if (formData[field] && !isValidURL(formData[field])) { + // return { status: true, message: `valid_${field}_url_required` }; + // } + // } if (!isValidBaseURL(formData.baseURL)) { return { status: true, message: "valid_base_url_required" }; @@ -601,3 +601,7 @@ export const setCookie = (name: string, value: string, days: number) => { export const b64ToString = (base64: string) => { return window.atob(base64); }; + +export const capitalizeFirstLetter = (word: string) => { + return word.charAt(0).toUpperCase() + word.slice(1); +}