package handler import ( "context" "embed" "encoding/json" "fmt" "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/gin-gonic/gin" "github.com/google/go-github/v54/github" "github.com/sirupsen/logrus" "golang.org/x/exp/slices" "io/fs" "net/http" "os" "path" "path/filepath" "strings" ) //go:embed dashboard/*.json var dashboardFS embed.FS func GetDashboard(c *gin.Context) { logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) var dirEntries []fs.DirEntry var err error //load settings from dashboard logger.Infof("Loading User Settings..") userSettings, err := databaseRepo.LoadUserSettings(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } logger.Debugf("User Settings: %v", userSettings) //get current user currentUser, err := databaseRepo.GetCurrentUser(c) if err != nil { logger.Errorf("Error getting current user: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } var dashboards []map[string]interface{} if userSettings.DashboardLocations != nil && len(userSettings.DashboardLocations) > 0 { logger.Infof("Loading dashboard(s) from %v", userSettings.DashboardLocations) // initialize the cache directory cacheDir, err := getCacheDir(appConfig, currentUser.ID.String()) if err != nil { logger.Errorf("Error creating cache directory: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } dirEntries, err = os.ReadDir(cacheDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } dashboards, err = getDashboardFromDir(cacheDir, dirEntries, os.ReadFile) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } } else { dirEntries, err = dashboardFS.ReadDir("dashboard") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } dashboards, err = getDashboardFromDir("dashboard", dirEntries, dashboardFS.ReadFile) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } } c.JSON(http.StatusOK, gin.H{"success": true, "data": dashboards}) } func AddDashboardLocation(c *gin.Context) { logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) var dashboardLocation map[string]string if err := c.ShouldBindJSON(&dashboardLocation); err != nil { logger.Errorln("An error occurred while parsing new dashboard location", err) c.JSON(http.StatusBadRequest, gin.H{"success": false}) return } //load settings from database logger.Infof("Loading User Settings..") userSettings, err := databaseRepo.LoadUserSettings(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } //override locations with new locations userSettings.DashboardLocations = append(userSettings.DashboardLocations, dashboardLocation["location"]) logger.Debugf("User Settings: %v", userSettings) //get current user currentUser, err := databaseRepo.GetCurrentUser(c) if err != nil { logger.Errorf("Error getting current user: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } cacheDir, err := getCacheDir(appConfig, currentUser.ID.String()) if err != nil { logger.Errorf("Error creating cache directory: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } // initialize the github client (Anonymous access) githubClient := github.NewClient(nil) cacheErrors := map[string]error{} cacheDashboards := []string{} for _, remoteDashboardLocation := range userSettings.DashboardLocations { cacheDashboardLocation, err := cacheCustomDashboard(logger, githubClient, cacheDir, remoteDashboardLocation) if err != nil { cacheErrors[remoteDashboardLocation] = err } cacheDashboards = append(cacheDashboards, filepath.Base(cacheDashboardLocation)) } if len(cacheErrors) > 0 { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Errorf("error caching dashboards: %v", cacheErrors)}) return } //cleanup any files in the cache that are no longer in the dashboard locations logger.Infof("Cleaning cache dir: %v", cacheDir) dirEntries, err := os.ReadDir(cacheDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Errorf("error before cleaning cache dir: %v", err)}) return } for _, dirEntry := range dirEntries { if !slices.Contains(cacheDashboards, dirEntry.Name()) { logger.Debugf("Removing %v from cache", dirEntry.Name()) err = os.RemoveAll(filepath.Join(cacheDir, dirEntry.Name())) if err != nil { logger.Errorf("Error removing %v from cache: %v", dirEntry.Name(), err) } } } err = databaseRepo.SaveUserSettings(c, userSettings) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Errorf("error saving user settings: %v", err)}) return } c.JSON(http.StatusOK, gin.H{"success": true}) return } // private functions func getCacheDir(appConfig config.Interface, currentUserId string) (string, error) { // initialize the cache directory cacheDir := filepath.Join(appConfig.GetString("cache.location"), currentUserId, "dashboard") err := os.MkdirAll(cacheDir, 0755) if err != nil { return "", fmt.Errorf("error creating cache directory: %v", err) } return cacheDir, nil } func cacheCustomDashboard(logger *logrus.Entry, githubClient *github.Client, cacheDir string, remoteDashboardLocation string) (string, error) { //- validate that the url is to a github gist, no other locations are supported //- download the gist metadata //- if more than 1 file found, look for a dashboard.json //- check if the file sha exists on the file system (content-addressible file system) //- if its not present, download it //- if its not json, throw an error //- if it doesnt match the dashboard config schema, throw an error. if !strings.HasPrefix(remoteDashboardLocation, "https://gist.github.com") { return "", fmt.Errorf("remote dashboard location is not a github gist: %v", remoteDashboardLocation) } logger.Infof("Processing custom dashboard from %v", remoteDashboardLocation) gistSlug := strings.TrimPrefix(remoteDashboardLocation, "https://gist.github.com/") gistSlugParts := strings.Split(gistSlug, "/") if len(gistSlugParts) != 2 { return "", fmt.Errorf("invalid gist slug: %v", gistSlug) } gist, _, err := githubClient.Gists.Get(context.Background(), gistSlugParts[1]) if err != nil { return "", fmt.Errorf("error retrieving remote gist: %v", err) } logger.Debugf("Got Gist %v, files: %d", gist.GetID(), len(gist.GetFiles())) //check if gist has more than 1 file var dashboardJsonFile github.GistFile if len(gist.Files) == 0 { return "", fmt.Errorf("gist has no files: %v", remoteDashboardLocation) } if len(gist.Files) > 1 { //find the dashboard.json file if gistFile, ok := gist.Files["dashboard.json"]; ok { dashboardJsonFile = gistFile } else { return "", fmt.Errorf("dashboard location gist has more than 1 file and no dashboard.json: %v", remoteDashboardLocation) } } else { //only 1 file, use it for _, gistFile := range gist.Files { dashboardJsonFile = gistFile if contentType := gistFile.GetType(); contentType != "application/json" { logger.Warnf("ContentType is not detected as JSON: %v", remoteDashboardLocation) } } } //ensure that the file is valid json logger.Debugf("validating dashboard gist json") var dashboardJson map[string]interface{} err = json.Unmarshal([]byte(dashboardJsonFile.GetContent()), &dashboardJson) if err != nil { return "", fmt.Errorf("error unmarshalling dashboard configuration (invalid JSON?): %v", err) } dashboardJson["source"] = remoteDashboardLocation dashboardJson["id"] = gist.GetID() //TODO: validate against DashboardConfigSchema absCacheFileLocation := filepath.Join(cacheDir, fmt.Sprintf("%s.json", gist.GetID())) //write it to filesystem logger.Infof("Writing new dashboard configuration to filesystem: %v", remoteDashboardLocation) //write file to cache dashboardJsonBytes, err := json.MarshalIndent(dashboardJson, "", " ") if err != nil { return "", fmt.Errorf("error marshalling dashboard configuration: %v", err) } err = os.WriteFile(absCacheFileLocation, dashboardJsonBytes, 0644) if err != nil { return "", fmt.Errorf("error writing dashboard configuration to cache: %v", err) } return absCacheFileLocation, nil } func getDashboardFromDir(parentDir string, dirEntries []fs.DirEntry, fsReadFile func(name string) ([]byte, error)) ([]map[string]interface{}, error) { dashboards := []map[string]interface{}{} for _, file := range dirEntries { if file.IsDir() { continue } //unmarshal file into map //have to use path (not filepath.Join) because of https://github.com/golang/go/issues/45230 embeddedFile, err := fsReadFile(path.Join(parentDir, file.Name())) if err != nil { return nil, err } var dashboardJson map[string]interface{} err = json.Unmarshal(embeddedFile, &dashboardJson) if err != nil { return nil, err } dashboards = append(dashboards, dashboardJson) } return dashboards, nil }