From fabe432ac428a5f556eff4737f5c56f4f722f53f Mon Sep 17 00:00:00 2001 From: Cyberes Date: Mon, 17 Jul 2023 23:20:21 -0600 Subject: [PATCH] should be pretty good! --- .gitignore | 33 +++ README.md | 46 +++- chatgpt suggestions.md | 8 + config.yml.sample | 15 ++ src/api/AdminCacheInfo.go | 58 +++++ src/api/AdminRecache.go | 69 +++++ src/api/Health.go | 24 ++ src/api/Search.go | 103 ++++++++ src/api/download.go | 118 +++++++++ src/api/helpers/http.go | 29 +++ src/api/helpers/shared.go | 95 +++++++ src/api/list.go | 197 +++++++++++++++ src/api/routes.go | 129 ++++++++++ src/api/thumbnail.go | 257 +++++++++++++++++++ src/cache/crawl.go | 390 +++++++++++++++++++++++++++++ src/cache/crawler.go | 60 +++++ src/cache/file.go | 49 ++++ src/cache/initial.go | 76 ++++++ src/cache/recache.go | 64 +++++ src/cache/watcher.go | 105 ++++++++ src/config/config.go | 135 ++++++++++ src/crazyfs.go | 142 +++++++++++ src/data/struct.go | 16 ++ src/file/image.go | 82 ++++++ src/file/zipstream.go | 207 +++++++++++++++ src/go.mod | 37 +++ src/go.sum | 511 ++++++++++++++++++++++++++++++++++++++ src/logging/http.go | 27 ++ src/logging/logging.go | 27 ++ 29 files changed, 3107 insertions(+), 2 deletions(-) create mode 100644 chatgpt suggestions.md create mode 100644 config.yml.sample create mode 100644 src/api/AdminCacheInfo.go create mode 100644 src/api/AdminRecache.go create mode 100644 src/api/Health.go create mode 100644 src/api/Search.go create mode 100644 src/api/download.go create mode 100644 src/api/helpers/http.go create mode 100644 src/api/helpers/shared.go create mode 100644 src/api/list.go create mode 100644 src/api/routes.go create mode 100644 src/api/thumbnail.go create mode 100644 src/cache/crawl.go create mode 100644 src/cache/crawler.go create mode 100644 src/cache/file.go create mode 100644 src/cache/initial.go create mode 100644 src/cache/recache.go create mode 100644 src/cache/watcher.go create mode 100644 src/config/config.go create mode 100644 src/crazyfs.go create mode 100644 src/data/struct.go create mode 100644 src/file/image.go create mode 100644 src/file/zipstream.go create mode 100644 src/go.mod create mode 100644 src/go.sum create mode 100644 src/logging/http.go create mode 100644 src/logging/logging.go diff --git a/.gitignore b/.gitignore index adf8f72..f05a7dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.idea +config.yml +config.yaml + # ---> Go # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore @@ -21,3 +25,32 @@ # Go workspace file go.work +## NODEJS +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +#dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md index a582421..6b30c86 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ -# massive-fileserver +# crazy-file-server -A heavy-duty web file browser. \ No newline at end of file +_A heavy-duty web file browser for CRAZY files._ + + + +The whole schtick of this program is that it caches the directory and file structures so that the server doesn't have to re-read the disk on every request. By doing the processing upfront when the server starts along with some background scans to keep the cache fresh we can keep requests snappy and responsive. + + + +I needed to serve a very large dataset full of small files publicly over the internet in an easy to browse website. My data was mounted over NFS so I had to take into account network delays. The existing solutions were subpar and I found myself having to create confusing Openresty scripts and complex CDN caching to keep things responsive and server load low. I gave up and decided to create my own solution. + + + +**Features** + +- Automated cache management + - Optionally fill the cache on server start, or as requests come in. + - Watch for changes or scan interval. +- File browsing API. +- Download API. +- Restrict certain files and directories from the download API to prevent users from downloading your entire 100GB+ dataset. +- Frontend-agnostic design. You can have it serve a simple web interface or just act as a JSON API and serve files. +- Simple resources. The resources for the frontend aren't compiled into the binary which allows you to modify or even replace it. +- Basic searching. +- Elasticsearch integration (to do). + + +## Install + +1. Install Go. +2. Download the binary or do `cd src && go mod tidy && go build`. + + + +## Use + +1. Edit `config.yml`. It's well commented. +2. `./crazyfs --config /path/to/config.yml`. You can use `-d` for debug mode to see what it's doing. + +By default, it looks for your config in the same directory as the executable: `./config.yml` or `./config.yaml`. + +If you're using initial cache and have tons of files to scan you'll need at least 5GB of RAM and will have to wait 10 or so minutes for it to traverse the directory structure. CrazyFS is heavily threaded so you'll want at least an 8-core machine. + +The search endpoint searches through the cached files. If they aren't cached, they won't be found. Enable pre-cache at startup to cache everything. diff --git a/chatgpt suggestions.md b/chatgpt suggestions.md new file mode 100644 index 0000000..cced7d0 --- /dev/null +++ b/chatgpt suggestions.md @@ -0,0 +1,8 @@ +The code you've posted is already quite efficient, but there are a few things you could consider to improve its performance: + +1. **Use a more efficient file watcher:** The `github.com/radovskyb/watcher` package uses polling to detect file changes, which can be inefficient for large directories. If you're on Linux, consider using a package like `github.com/fsnotify/fsnotify` which uses inotify, a Linux kernel subsystem that provides more efficient file change notifications. +2. **Reduce the number of goroutines:** Each time a file change event is received, a new goroutine is created to handle it. This could potentially create a large number of goroutines if many file changes are happening at once. Consider using a worker pool pattern to limit the number of concurrent goroutines. +3. **Optimize your cache:** The LRU cache you're using is thread-safe, but it uses a mutex to achieve this. If you have a lot of contention (i.e., many goroutines trying to access the cache at once), this could slow things down. Consider using a sharded cache, which reduces contention by dividing the cache into several smaller caches, each with its own lock. +4. **Avoid unnecessary work:** If a file is created and then immediately modified, your code will crawl it twice. Consider adding a delay before crawling a file, and if another event for the same file is received during this delay, only crawl it once. +5. **Optimize your logging:** Writing to the log can be slow, especially if it's writing to a file or over the network. Consider using a buffered logger, which can improve performance by batching log messages together. +6. **Use a profiler:** The best way to find out where your code is spending its time is to use a profiler. The `net/http/pprof` package provides a simple way to add profiling endpoints to your application, which you can then view with the `go tool pprof` command. \ No newline at end of file diff --git a/config.yml.sample b/config.yml.sample new file mode 100644 index 0000000..11cc5aa --- /dev/null +++ b/config.yml.sample @@ -0,0 +1,15 @@ +root_dir: /home/username + +http_port: 8081 + +watch_mode: crawl +crawl_mode_crawl_interval: 3600 # seconds +watch_interval: 2 # seconds + +directory_crawlers: 10 +crawl_workers: 200 + +cache_size: 100000000 +cache_time: 30 # minutes +cache_print_new: false +cache_print_changes: true diff --git a/src/api/AdminCacheInfo.go b/src/api/AdminCacheInfo.go new file mode 100644 index 0000000..92274d2 --- /dev/null +++ b/src/api/AdminCacheInfo.go @@ -0,0 +1,58 @@ +package api + +import ( + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" +) + +func AdminCacheInfo(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + auth := r.URL.Query().Get("auth") + if auth == "" || auth != cfg.HttpAdminKey { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 403, + "error": "access denied", + }) + return + } + + cacheLen := sharedCache.Len() + keys := r.URL.Query().Get("keys") + var cacheKeys []string + if keys != "" { + cacheKeys = sharedCache.Keys() + } else { + cacheKeys = []string{} + } + + // Get the running scans and workers + //runningScans := cache.GetRunningScans() + runningWorkers := cache.GetRunningWorkers() + + // Get the active scans + activeScans := cache.GetActiveScans() + + // Convert the active scans map to a list + activeScanList := make([]string, 0, len(activeScans)) + for scan := range activeScans { + if activeScans[scan] { + activeScanList = append(activeScanList, scan) + } + } + + response := map[string]interface{}{ + "cache_size": cacheLen, + "cache_keys": cacheKeys, + "cache_max": cfg.CacheSize, + "running_workers": runningWorkers, + "running_scans": activeScanList, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/src/api/AdminRecache.go b/src/api/AdminRecache.go new file mode 100644 index 0000000..7a3687f --- /dev/null +++ b/src/api/AdminRecache.go @@ -0,0 +1,69 @@ +package api + +import ( + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" + "path/filepath" + "strings" +) + +func AdminReCache(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + log := logging.GetLogger() + + if r.Method != http.MethodPost { + helpers.Return400Msg("this is a POST endpoint", w) + return + } + + // Parse request body + var requestBody map[string]string + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + helpers.Return400Msg("invalid request body", w) + return + } + + auth := requestBody["auth"] + if auth == "" || auth != cfg.HttpAdminKey { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 403, + "error": "access denied", + }) + return + } + + pathArg := requestBody["path"] + + // Clean the path to prevent directory traversal + if strings.Contains(pathArg, "/../") || strings.HasPrefix(pathArg, "../") || strings.HasSuffix(pathArg, "/..") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": "invalid file path", + }) + return + } + + fullPath := filepath.Join(cfg.RootDir, filepath.Clean("/"+pathArg)) + //relPath := cache.StripRootDir(fullPath, cfg.RootDir) + + // Check and re-cache the directory + cache.Recache(fullPath, cfg, sharedCache) + + response := map[string]interface{}{ + "message": "Re-cache triggered for directory: " + fullPath, + } + log.Infof("Admin triggered recache for %s", fullPath) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/src/api/Health.go b/src/api/Health.go new file mode 100644 index 0000000..2c68bf5 --- /dev/null +++ b/src/api/Health.go @@ -0,0 +1,24 @@ +package api + +import ( + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" +) + +// TODO: show the time the initial crawl started + +func HealthCheck(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + //log := logging.GetLogger() + + response := map[string]interface{}{} + + response["scan_running"] = cache.GetRunningScans() > 0 + response["initial_scan_running"] = cache.InitialCrawlInProgress + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/src/api/Search.go b/src/api/Search.go new file mode 100644 index 0000000..9758139 --- /dev/null +++ b/src/api/Search.go @@ -0,0 +1,103 @@ +package api + +import ( + "bytes" + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "encoding/gob" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "log" + "net/http" + "strconv" + "strings" +) + +func SearchFile(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + if cache.InitialCrawlInProgress && !cfg.HttpAllowDuringInitialCrawl { + helpers.HandleRejectDuringInitialCrawl(w) + return + } + + queryString := r.URL.Query().Get("query") + if queryString == "" { + helpers.Return400Msg("query parameter is required", w) + return + } + + queryString = strings.ToLower(queryString) // convert to lowercase + //queryElements := strings.Split(queryString, " ") // split by spaces + + excludeString := r.URL.Query().Get("exclude") // get exclude parameter + var excludeElements []string + if excludeString != "" { + excludeElements = strings.Split(excludeString, ",") // split by comma + } + + limitResultsStr := r.URL.Query().Get("limit") + var limitResults int + if limitResultsStr != "" { + if !helpers.IsPositiveInt(limitResultsStr) { + helpers.Return400Msg("limit must be positive number", w) + return + } + limitResults, _ = strconv.Atoi(limitResultsStr) + } else { + limitResults = 0 + } + + var results []*data.Item +outer: + for _, key := range sharedCache.Keys() { + cacheItem, found := sharedCache.Get(key) + if found { + //for _, query := range queryElements { + if strings.Contains(strings.ToLower(key), queryString) { // query) { // convert key to lowercase + // check if key contains any of the exclude elements + shouldExclude := false + for _, exclude := range excludeElements { + if strings.Contains(strings.ToLower(key), exclude) { + shouldExclude = true + break + } + } + if shouldExclude { + continue + } + + // Create a deep copy of the item + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + dec := gob.NewDecoder(&buf) + err := enc.Encode(cacheItem) + if err != nil { + log.Printf("Error encoding item: %v", err) + return + } + var item data.Item + err = dec.Decode(&item) + if err != nil { + log.Printf("Error decoding item: %v", err) + return + } + if !cfg.ApiSearchShowChildren { + item.Children = make([]*data.Item, 0) // erase the children dict + } + results = append(results, &item) + if (limitResults > 0 && len(results) == limitResults) || len(results) >= cfg.ApiSearchMaxResults { + break outer + } + } + //} + } + + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "results": results, + }) +} diff --git a/src/api/download.go b/src/api/download.go new file mode 100644 index 0000000..0b0aabf --- /dev/null +++ b/src/api/download.go @@ -0,0 +1,118 @@ +package api + +import ( + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/file" + "crazyfs/logging" + "encoding/json" + "fmt" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" + "path/filepath" + "strings" +) + +func Download(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + if cache.InitialCrawlInProgress && !cfg.HttpAllowDuringInitialCrawl { + helpers.HandleRejectDuringInitialCrawl(w) + return + } + + log := logging.GetLogger() + + queryPath := r.URL.Query().Get("path") + if queryPath == "" { + helpers.Return400Msg("missing path", w) + return + } + + paths := strings.Split(queryPath, ",") + if len(paths) > 1 { + // Multiple files, zip them + file.ZipHandlerCompressMultiple(paths, w, r, cfg, sharedCache) + return + } + + // Single file or directory + relPath := cache.StripRootDir(filepath.Join(cfg.RootDir, paths[0]), cfg.RootDir) + relPath = strings.TrimSuffix(relPath, "/") + fullPath := filepath.Join(cfg.RootDir, relPath) + + // Check if the path is in the restricted download paths + for _, restrictedPath := range cfg.RestrictedDownloadPaths { + if relPath == restrictedPath { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusForbidden, + "error": "not allowed to download this path", + }) + return + } + } + + // Try to get the data from the cache + item, found := sharedCache.Get(relPath) + if !found { + item = helpers.HandleFileNotFound(relPath, fullPath, sharedCache, cfg, w) + } + if item == nil { + // The errors have already been handled in handleFileNotFound() so we're good to just exit + return + } + + if cfg.HttpAPIDlCacheControl > 0 { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, must-revalidate", cfg.HttpAPIDlCacheControl)) + } else { + w.Header().Set("Cache-Control", "no-store") + } + + if !item.IsDir { + // Get the MIME type of the file + var fileExists bool + var mimeType string + var err error + if item.Type == nil { + fileExists, mimeType, _, err = cache.GetMimeType(fullPath, true) + if !fileExists { + helpers.Return400Msg("file not found", w) + } + if err != nil { + log.Warnf("Error detecting MIME type: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "internal server error", + }) + return + } + // GetMimeType() returns an empty string if it was a directory + if mimeType != "" { + // Update the item's MIME in the sharedCache + item.Type = &mimeType + sharedCache.Add(relPath, item) + } + } + + // Only files can have inline disposition, zip archives cannot + contentDownload := r.URL.Query().Get("download") + var disposition string + if contentDownload != "" { + disposition = "attachment" + } else { + disposition = "inline" + } + w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, item.Name)) + + w.Header().Set("Content-Type", mimeType) // Set the content type to the MIME type of the file + http.ServeFile(w, r, fullPath) // Send the file to the client + } else { + // Stream archive of the directory here + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, item.Name)) + file.ZipHandlerCompress(fullPath, w, r) + } +} diff --git a/src/api/helpers/http.go b/src/api/helpers/http.go new file mode 100644 index 0000000..52dafc4 --- /dev/null +++ b/src/api/helpers/http.go @@ -0,0 +1,29 @@ +package helpers + +import ( + "crazyfs/logging" + "encoding/json" + "net/http" +) + +func Return400Msg(msg string, w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": msg, + }) +} + +func HandleRejectDuringInitialCrawl(w http.ResponseWriter) { + log := logging.GetLogger() + log.Warnln("Rejecting request during initial crawl") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusServiceUnavailable, + "error": "initial file system crawl in progress", + }) +} diff --git a/src/api/helpers/shared.go b/src/api/helpers/shared.go new file mode 100644 index 0000000..ace558f --- /dev/null +++ b/src/api/helpers/shared.go @@ -0,0 +1,95 @@ +package helpers + +import ( + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" + "os" + "strconv" +) + +func HandleFileNotFound(relPath string, fullPath string, sharedCache *lru.Cache[string, *data.Item], cfg *config.Config, w http.ResponseWriter) *data.Item { + log := logging.GetLogger() + // If the data is not in the cache, start a new crawler + log.Debugf("CRAWLER - %s not in cache, crawling", fullPath) + dc := cache.NewDirectoryCrawler(sharedCache) + // We don't want to traverse the entire directory tree since we'll only return the current directory anyways + err := dc.CrawlNoRecursion(fullPath, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Errorf("LIST - crawl failed: %s", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "internal server error", + }) + return nil + } + + // Try to get the data from the cache again + item, found := sharedCache.Get(relPath) + if !found { + // If the data is still not in the cache, check if the file or directory exists. + // We could check if the file exists before checking the cache but we want to limit disk reads. + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + log.Debugf("File not in cache: %s", fullPath) + // If the file or directory does not exist, return a 404 status code and a message + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 400, + "error": "file or directory not found", + }) + return nil + } else if err != nil { + // If there was an error checking if the file or directory exists, return a 500 status code and the error + log.Errorf("LIST - %s", err.Error()) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "internal server error", + }) + return nil + } + } + + // If item is still nil, error + if item == nil { + log.Errorf("LIST - crawler failed to find %s and did not return a 404", relPath) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "crawler failed to fetch file or directory", + }) + return nil + } + cache.CheckAndRecache(fullPath, cfg, sharedCache) + return item +} + +func IsPositiveInt(testStr string) bool { + if num, err := strconv.ParseInt(testStr, 10, 64); err == nil { + return num >= 0 + } + return false +} + +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +func Max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/src/api/list.go b/src/api/list.go new file mode 100644 index 0000000..c42f990 --- /dev/null +++ b/src/api/list.go @@ -0,0 +1,197 @@ +package api + +import ( + "bytes" + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + "encoding/gob" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + "math" + "net/http" + "path/filepath" + "strconv" + "strings" +) + +func ListDir(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + if cache.InitialCrawlInProgress && !cfg.HttpAllowDuringInitialCrawl { + helpers.HandleRejectDuringInitialCrawl(w) + return + } + + log := logging.GetLogger() + pathArg := r.URL.Query().Get("path") + + // Clean the path to prevent directory traversal + // filepath.Clean() below will do most of the work but these are just a few checks + // Also this will break the cache because it will create another entry for the relative path + if strings.Contains(pathArg, "/../") || strings.HasPrefix(pathArg, "../") || strings.HasSuffix(pathArg, "/..") || strings.HasPrefix(pathArg, "~") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": "invalid file path", + }) + return + } + + fullPath := filepath.Join(cfg.RootDir, filepath.Clean("/"+pathArg)) + relPath := cache.StripRootDir(fullPath, cfg.RootDir) + needToCrawlRecusive := false + + // Try to get the data from the cache + cacheItem, found := sharedCache.Get(relPath) + if !found { + needToCrawlRecusive = true + cacheItem = helpers.HandleFileNotFound(relPath, fullPath, sharedCache, cfg, w) + } + if cacheItem == nil { + // The errors have already been handled in handleFileNotFound() so we're good to just exit + return + } + + // Create a deep copy of the item + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + dec := gob.NewDecoder(&buf) + err := enc.Encode(cacheItem) + if err != nil { + log.Errorf("Error encoding item: %v", err) + return + } + var item data.Item + err = dec.Decode(&item) + if err != nil { + log.Errorf("Error decoding item: %v", err) + return + } + + // Get the MIME type of the file if the 'mime' argument is present + mime := r.URL.Query().Get("mime") + if mime != "" { + if item.IsDir && !cfg.HttpAllowDirMimeParse { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 403, + "error": "not allowed to analyze the mime of directories", + }) + return + } else { + // Only update it if it hasn't been set already. + // TODO: need to make sure that when a re-crawl is triggered, the Type is set back to nil + if item.Type == nil { + fileExists, mimeType, ext, err := cache.GetMimeType(fullPath, true) + if !fileExists { + helpers.Return400Msg("file not found", w) + } + if err != nil { + log.Warnf("Error detecting MIME type: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "internal server error", + }) + return + } + // Update the item's MIME in the sharedCache + item.Type = &mimeType + item.Extension = &ext + sharedCache.Add(relPath, &item) // take the address of item + } + } + } + + if needToCrawlRecusive { + go func() { + log.Debugf("Starting background recursive crawl for %s", fullPath) + dc := cache.NewDirectoryCrawler(sharedCache) + err := dc.Crawl(fullPath, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Errorf("LIST - background recursive crawl failed: %s", err) + } + }() + } + + response := map[string]interface{}{} + + // Pagination + var paginationLimit int + if r.URL.Query().Get("limit") != "" { + if !helpers.IsPositiveInt(r.URL.Query().Get("limit")) { + helpers.Return400Msg("limit must be positive number", w) + return + } + i, _ := strconv.ParseInt(r.URL.Query().Get("limit"), 10, 32) + paginationLimit = int(i) + } else { + paginationLimit = 100 + } + + totalPages := math.Ceil(float64(len(item.Children)) / float64(paginationLimit)) + if r.URL.Query().Get("page") != "" { + response["total_pages"] = int(totalPages) + } + + paginatedChildren := item.Children + pageParam := r.URL.Query().Get("page") + if pageParam != "" { + page, err := strconv.Atoi(pageParam) + if err != nil || page < 1 || page > int(totalPages) { + //w.Header().Set("Content-Type", "application/json") + //w.WriteHeader(http.StatusBadRequest) + //json.NewEncoder(w).Encode(map[string]interface{}{ + // "code": http.StatusBadRequest, + // "error": "invalid page number", + // "total_pages": int(totalPages), + //}) + //return + + // Don't return an error, just trunucate things + page = int(totalPages) + } + + start := (page - 1) * paginationLimit + end := start + paginationLimit + + if start >= 0 { // avoid segfaults + if start > len(item.Children) { + start = len(item.Children) + } + if end > len(item.Children) { + end = len(item.Children) + } + paginatedChildren = paginatedChildren[start:end] + } + } + + // TODO: don't use depriciated file read methods + + //if cfg.HttpAPIListCacheControl > 0 { + // w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, must-revalidate", cfg.HttpAPIListCacheControl)) + //} else { + w.Header().Set("Cache-Control", "no-store") + //} + + response["item"] = map[string]interface{}{ + "path": item.Path, + "name": item.Name, + "size": item.Size, + "extension": item.Extension, + "modified": item.Modified, + "mode": item.Mode, + "isDir": item.IsDir, + "isSymlink": item.IsSymlink, + "cached": item.Cached, + "children": paginatedChildren, + "type": item.Type, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/src/api/routes.go b/src/api/routes.go new file mode 100644 index 0000000..859dff8 --- /dev/null +++ b/src/api/routes.go @@ -0,0 +1,129 @@ +package api + +import ( + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + "encoding/json" + "fmt" + "github.com/gorilla/mux" + lru "github.com/hashicorp/golang-lru/v2" + "net/http" +) + +type Route struct { + Name string + Method string + Pattern string + HandlerFunc AppHandler +} + +type Routes []Route + +type AppHandler func(http.ResponseWriter, *http.Request, *config.Config, *lru.Cache[string, *data.Item]) + +var routes = Routes{ + Route{ + "ListDir", + "GET", + "/api/file/list", + ListDir, + }, + Route{ + "Download", + "GET", + "/api/file/download", + Download, + }, + Route{ + "Thumbnail", + "GET", + "/api/file/thumb", + Thumbnail, + }, + Route{ + "Search", + "GET", + "/api/search", + SearchFile, + }, + Route{ + "Cache Info", + "GET", + "/api/admin/cache/info", + AdminCacheInfo, + }, + Route{ + "Trigger Recache", + "POST", + "/api/admin/recache", + AdminReCache, + }, + Route{ + "Trigger Recache", + "GET", + "/api/admin/recache", + wrongMethod("POST", AdminReCache), + }, + Route{ + "Server Health", + "GET", + "/api/health", + HealthCheck, + }, +} + +func setHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Server", "crazy-file-server") + w.Header().Set("Access-Control-Allow-Origin", "*") + next.ServeHTTP(w, r) + }) +} + +func NewRouter(cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) *mux.Router { + r := mux.NewRouter().StrictSlash(true) + for _, route := range routes { + var handler http.Handler + + // Create a new variable to hold the current route + currentRoute := route + + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentRoute.HandlerFunc(w, r, cfg, sharedCache) + }) + handler = setHeaders(handler) + handler = logging.LogRequest(handler) + + r. + Methods(currentRoute.Method). + Path(currentRoute.Pattern). + Name(currentRoute.Name). + Handler(handler) + } + + // Add 404 route + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusNotFound, + "error": "not an endpoint", + }) + }) + + return r +} + +func wrongMethod(expectedMethod string, next AppHandler) AppHandler { + return func(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": fmt.Sprintf("Received a %s request on a %s endpoint", r.Method, expectedMethod), + }) + return + } +} diff --git a/src/api/thumbnail.go b/src/api/thumbnail.go new file mode 100644 index 0000000..078eb07 --- /dev/null +++ b/src/api/thumbnail.go @@ -0,0 +1,257 @@ +package api + +import ( + "bytes" + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/file" + "crazyfs/logging" + "encoding/json" + "github.com/disintegration/imaging" + lru "github.com/hashicorp/golang-lru/v2" + + "image" + "image/color" + "image/png" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/nfnt/resize" +) + +func Thumbnail(w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + if cache.InitialCrawlInProgress && !cfg.HttpAllowDuringInitialCrawl { + helpers.HandleRejectDuringInitialCrawl(w) + returnDummyPNG(w) + return + } + + log := logging.GetLogger() + relPath := cache.StripRootDir(filepath.Join(cfg.RootDir, r.URL.Query().Get("path")), cfg.RootDir) + relPath = strings.TrimSuffix(relPath, "/") + fullPath := filepath.Join(cfg.RootDir, relPath) + + // Validate args before doing any operations + widthStr := r.URL.Query().Get("width") + if widthStr != "" { + if !helpers.IsPositiveInt(widthStr) { + helpers.Return400Msg("height and width must both be positive numbers", w) + return + } + } + heightStr := r.URL.Query().Get("height") + if heightStr != "" { + if !helpers.IsPositiveInt(heightStr) { + helpers.Return400Msg("height and width must both be positive numbers", w) + return + } + } + + pngQualityStr := r.URL.Query().Get("quality") + var pngQuality int + if pngQualityStr != "" { + if !helpers.IsPositiveInt(pngQualityStr) { + helpers.Return400Msg("quality must be a positive number", w) + return + } + pngQuality64, _ := strconv.ParseInt(pngQualityStr, 10, 32) + pngQuality = int(pngQuality64) + } else { + pngQuality = 50 + } + + scaleStr := r.URL.Query().Get("auto") + var autoScale bool + if scaleStr != "" { + autoScale = true + } + + squareStr := r.URL.Query().Get("square") + var square bool + if squareStr != "" { + square = true + } + + // Try to get the data from the cache + item, found := sharedCache.Get(relPath) + if !found { + item = helpers.HandleFileNotFound(relPath, fullPath, sharedCache, cfg, w) + } + if item == nil { + return + } + + if item.IsDir { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": "that's a directory", + }) + return + } + + // Get the MIME type of the file + fileExists, mimeType, ext, err := cache.GetMimeType(fullPath, true) + if !fileExists { + helpers.Return400Msg("file not found", w) + } + if err != nil { + log.Errorf("THUMB = error detecting MIME type: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 500, + "error": "internal server error", + }) + return + } + // Update the item's MIME in the sharedCache + item.Type = &mimeType + item.Extension = &ext + sharedCache.Add(relPath, item) + + // Check if the file is an image + if !strings.HasPrefix(mimeType, "image/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusBadRequest, + "error": "file is not an image", + }) + return + } + + // Convert the image to a PNG + imageBytes, err := file.ConvertToPNG(fullPath, mimeType) + if err != nil { + log.Warnf("Error converting %s to PNG: %v", fullPath, err) + returnDummyPNG(w) + return + } + + // Decode the image + var img image.Image + img, err = png.Decode(bytes.NewReader(imageBytes)) + if err != nil { + log.Warnf("Error decoding %s image data: %v", fullPath, err) + returnDummyPNG(w) + return + } + + // Resize the image + var width, height uint + if widthStr != "" { + width64, _ := strconv.ParseUint(widthStr, 10, 32) + width = uint(width64) + } + if heightStr != "" { + height64, _ := strconv.ParseUint(heightStr, 10, 32) + height = uint(height64) + } + + if square { + var size int + if width == 0 && height == 0 { + size = 300 + } else if (width != 0 && height != 0) && (width != height) { + helpers.Return400Msg("width and height must be equal in square mode, or only one provided", w) + return + } else if width != 0 { + size = int(width) + } else { + size = int(height) + } + if size > img.Bounds().Dx() || size > img.Bounds().Dy() { + size = helpers.Max(img.Bounds().Dx(), img.Bounds().Dy()) + } + + // First, make the image square by scaling the smallest dimension to the larget size + if img.Bounds().Dx() > img.Bounds().Dy() { + width = 0 + height = uint(size) + } else { + width = uint(size) + height = 0 + } + resized := resize.Resize(width, height, img, resize.Lanczos3) + + // Then crop the image to the target size + img = imaging.CropCenter(resized, size, size) + } else { + if width == 0 && height == 0 { + if autoScale { + // If both width and height parameters are not provided, set + // the largest dimension to 300 and scale the other. + if img.Bounds().Dx() > img.Bounds().Dy() { + width = 300 + height = 0 + } else { + width = 0 + height = 300 + } + } else { + // Don't auto-resize because this endpoint can also be used for simply reducing the quality of an image + width = uint(img.Bounds().Dx()) + height = uint(img.Bounds().Dy()) + } + } else if width == 0 { + // If only width is provided, calculate the height based on the image's aspect ratio + width = uint(img.Bounds().Dx()) * height / uint(img.Bounds().Dy()) + } else if height == 0 { + height = uint(img.Bounds().Dy()) * width / uint(img.Bounds().Dx()) + } + // Scale the image. If the image is smaller than the provided height or width, it won't be resized. + img = resize.Resize(width, height, img, resize.Lanczos3) + } + + // Encode the image + //buf := new(bytes.Buffer) + //if err := png.Encode(buf, img); err != nil { + // log.Warnf("Error encoding %s to PNG: %v", fullPath, err) + // returnDummyPNG(w) + // //w.Header().Set("Content-Type", "application/json") + // //w.WriteHeader(http.StatusInternalServerError) + // //json.NewEncoder(w).Encode(map[string]interface{}{ + // // "code": 500, + // // "error": "500 internal server error", + // //}) + // return + //} + + buf, err := file.CompressPNGFile(img, pngQuality) + if err != nil { + log.Warnf("Error compressing %s to PNG: %v", fullPath, err) + returnDummyPNG(w) + } + + // Return the image + w.Header().Set("Content-Type", "image/png") + w.Write(buf.Bytes()) +} + +func returnDummyPNG(w http.ResponseWriter) { + img := image.NewRGBA(image.Rect(0, 0, 300, 300)) + blue := color.RGBA{255, 255, 255, 255} + + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + img.Set(x, y, blue) + } + } + + buffer := new(bytes.Buffer) + if err := png.Encode(buffer, img); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // TODO: set cache-control + + w.Header().Set("Content-Type", "image/png") + w.Write(buffer.Bytes()) +} diff --git a/src/cache/crawl.go b/src/cache/crawl.go new file mode 100644 index 0000000..e08ff65 --- /dev/null +++ b/src/cache/crawl.go @@ -0,0 +1,390 @@ +package cache + +import ( + "crazyfs/data" + "crazyfs/logging" + "github.com/gabriel-vasile/mimetype" + lru "github.com/hashicorp/golang-lru/v2" + "mime" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +var FollowSymlinks bool + +// Add global variables to keep track of running scans and workers +var runningScans int32 +var runningWorkers int32 + +// Add maps to keep track of the current directory each worker is on and which directory scans are currently active +var workerDirs map[int32]string +var activeScans map[string]bool +var mapMutex sync.Mutex + +func init() { + workerDirs = make(map[int32]string) + activeScans = make(map[string]bool) +} + +func NewItem(path string, info os.FileInfo, CachePrintNew bool, RootDir string, CrawlerParseMIME bool) *data.Item { + if CachePrintNew { + log = logging.GetLogger() + log.Debugf("CACHE - new: %s", path) + } + + var mimeType string + var ext string + if !info.IsDir() { + if CrawlerParseMIME { + if !info.IsDir() { + ext = filepath.Ext(path) + mimeObj, err := mimetype.DetectFile(path) + if err != nil { + log.Warnf("Error detecting MIME type: %v", err) + } else { + mimeType = mimeObj.String() + } + } + } else { + mimeType = mime.TypeByExtension(ext) + } + } + ext = filepath.Ext(path) + + if strings.Contains(mimeType, ";") { + mimeType = strings.Split(mimeType, ";")[0] + } + + // Create pointers for mimeType and ext + var mimeTypePtr, extPtr *string + if mimeType != "" { + mimeTypePtr = &mimeType + } + if ext != "" { + extPtr = &ext + } + + return &data.Item{ + Path: StripRootDir(path, RootDir), + Name: info.Name(), + Size: info.Size(), + Extension: extPtr, + Modified: info.ModTime().UTC().Format(time.RFC3339Nano), + Mode: uint32(info.Mode().Perm()), + IsDir: info.IsDir(), + IsSymlink: info.Mode()&os.ModeSymlink != 0, + Cached: time.Now().UnixNano() / int64(time.Millisecond), // Set the created time to now in milliseconds + Children: make([]*data.Item, 0), + Type: mimeTypePtr, + } +} + +type DirectoryCrawler struct { + cache *lru.Cache[string, *data.Item] +} + +func NewDirectoryCrawler(cache *lru.Cache[string, *data.Item]) *DirectoryCrawler { + return &DirectoryCrawler{cache: cache} +} + +type worker struct { + wg *sync.WaitGroup + addWg sync.WaitGroup + ch chan string + active bool + id int32 +} + +func newWorker() *worker { + // Increment running workers count + id := atomic.AddInt32(&runningWorkers, 1) + return &worker{ + wg: new(sync.WaitGroup), + ch: make(chan string), + active: false, + id: id, + } +} + +func (w *worker) start(dc *DirectoryCrawler, CachePrintNew bool, RootDir string, CrawlerParseMIME bool) { + w.active = true + w.wg.Add(1) + go func() { + defer w.wg.Done() + for path := range w.ch { + w.addWg.Add(1) + // Update the current directory of the worker + mapMutex.Lock() + workerDirs[w.id] = path + mapMutex.Unlock() + info, err := os.Stat(path) + if err != nil { + // handle error + w.addWg.Done() + continue + } + dc.cache.Add(StripRootDir(path, RootDir), NewItem(path, info, CachePrintNew, RootDir, CrawlerParseMIME)) + w.addWg.Done() + } + w.active = false + // Decrement running workers count + atomic.AddInt32(&runningWorkers, -1) + }() +} + +func (w *worker) add(path string) { + w.ch <- path +} + +func (w *worker) stop() { + close(w.ch) + w.addWg.Wait() + w.wg.Wait() +} + +func (dc *DirectoryCrawler) Crawl(path string, CachePrintNew bool, numWorkers int, RootDir string, CrawlerParseMIME bool) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + // If the path doesn't exist, just silently exit + return nil + } + + info, err := os.Stat(path) + if err != nil { + return err + } + + // Increment running scans count + atomic.AddInt32(&runningScans, 1) + // Mark the scan as active + mapMutex.Lock() + activeScans[path] = true + mapMutex.Unlock() + + // Remove all entries in the cache that belong to this directory so we can start fresh + for _, key := range dc.cache.Keys() { + if strings.HasPrefix(key, path) { + dc.cache.Remove(key) + } + } + + if info.IsDir() { + // If the path is a directory, start workers and walk the directory + workers := make([]*worker, numWorkers) + for i := 0; i < numWorkers; i++ { + workers[i] = newWorker() + workers[i].start(dc, CachePrintNew, RootDir, CrawlerParseMIME) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + err := dc.walkDir(path, &wg, workers, numWorkers, CachePrintNew, RootDir, CrawlerParseMIME) + if err != nil { + log.Errorf("CRAWLER - dc.walkDir() in Crawl() returned error: %s", err) + } + }() + wg.Wait() + for _, worker := range workers { + worker.stop() + } + } else { + // If the path is a file, add it to the cache directly + dc.cache.Add(StripRootDir(path, RootDir), NewItem(path, info, CachePrintNew, RootDir, CrawlerParseMIME)) + } + + // Mark the scan as inactive + mapMutex.Lock() + activeScans[path] = false + mapMutex.Unlock() + // Decrement running scans count + atomic.AddInt32(&runningScans, -1) + return nil +} + +func (dc *DirectoryCrawler) walkDir(dir string, n *sync.WaitGroup, workers []*worker, numWorkers int, CachePrintNew bool, RootDir string, CrawlerParseMIME bool) error { + defer n.Done() + + entries, err := os.ReadDir(dir) + if err != nil { + log.Errorf("CRAWLER - walkDir() failed to read directory %s: %s", dir, err) + return err + } + + // Create the directory item but don't add it to the cache yet + info, err := os.Stat(dir) + if err != nil { + log.Errorf("CRAWLER - walkDir() failed to stat %s: %s", dir, err) + return err + } + dirItem := NewItem(dir, info, CachePrintNew, RootDir, CrawlerParseMIME) + + i := 0 + for _, entry := range entries { + subpath := filepath.Join(dir, entry.Name()) + info, err = os.Lstat(subpath) + if err != nil { + log.Warnf("CRAWLER - walkDir() failed to stat subpath %s: %s", subpath, err) + continue + } + if FollowSymlinks && info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(subpath) + if err != nil { + log.Warnf("CRAWLER - walkDir() failed to read symlink %s: %s", subpath, err) + continue + } + linkInfo, err := os.Stat(link) + if err != nil { + log.Warnf("CRAWLER - walkDir() failed to stat link %s: %s", link, err) + continue + } + if linkInfo.IsDir() { + n.Add(1) + go func() { + err := dc.walkDir(link, n, workers, numWorkers, CachePrintNew, RootDir, CrawlerParseMIME) + if err != nil { + log.Errorf("CRAWLER - dc.walkDir() in walkDir() -> follow symlinks returned error: %s", err) + } + }() + } + } else if entry.IsDir() { + n.Add(1) + go func() { + err := dc.walkDir(subpath, n, workers, numWorkers, CachePrintNew, RootDir, CrawlerParseMIME) + if err != nil { + log.Errorf("CRAWLER - dc.walkDir() in walkDir() -> IsDir() returned error: %s", err) + } + }() + } else { + workers[i%numWorkers].add(subpath) + i++ + } + + // Add the entry to the directory's contents + entryItem := NewItem(subpath, info, CachePrintNew, RootDir, CrawlerParseMIME) + dirItem.Children = append(dirItem.Children, entryItem) + } + + // Add the directory to the cache after all of its children have been processed + dc.cache.Add(StripRootDir(dir, RootDir), dirItem) + return nil +} + +func (dc *DirectoryCrawler) Get(path string) (*data.Item, bool) { + return dc.cache.Get(path) +} + +func (dc *DirectoryCrawler) walkDirNoRecursion(dir string, n *sync.WaitGroup, workers []*worker, numWorkers int, CachePrintNew bool, RootDir string, CrawlerParseMIME bool) error { + defer n.Done() + + entries, err := os.ReadDir(dir) + if err != nil { + log.Errorf("CRAWLER - walkDir() failed to read directory %s: %s", dir, err) + return err + } + + // Add the directory itself to the cache + info, err := os.Stat(dir) + if err != nil { + log.Errorf("CRAWLER - walkDir() failed to stat %s: %s", dir, err) + return err + } + dirItem := NewItem(dir, info, CachePrintNew, RootDir, CrawlerParseMIME) + dc.cache.Add(StripRootDir(dir, RootDir), dirItem) + + i := 0 + for _, entry := range entries { + subpath := filepath.Join(dir, entry.Name()) + if !entry.IsDir() { + workers[i%numWorkers].add(subpath) + i++ + } + + // Add the entry to the directory's contents + info, err = os.Stat(subpath) + if err != nil { + log.Warnf("CRAWLER - walkDir() failed to stat subpath %s: %s", subpath, err) + continue + } + entryItem := NewItem(subpath, info, CachePrintNew, RootDir, CrawlerParseMIME) + dirItem.Children = append(dirItem.Children, entryItem) + } + return nil +} + +func (dc *DirectoryCrawler) CrawlNoRecursion(path string, CachePrintNew bool, numWorkers int, RootDir string, CrawlerParseMIME bool) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + // If the path doesn't exist, just silently exit + return nil + } + + info, err := os.Stat(path) + if err != nil { + return err + } + + // Increment running scans count + atomic.AddInt32(&runningScans, 1) + // Mark the scan as active + mapMutex.Lock() + activeScans[path] = true + mapMutex.Unlock() + + if info.IsDir() { + // If the path is a directory, start workers and walk the directory + workers := make([]*worker, numWorkers) + for i := 0; i < numWorkers; i++ { + workers[i] = newWorker() + workers[i].start(dc, CachePrintNew, RootDir, CrawlerParseMIME) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + err := dc.walkDirNoRecursion(path, &wg, workers, numWorkers, CachePrintNew, RootDir, CrawlerParseMIME) + if err != nil { + log.Errorf("CRAWLER - dc.walkDirNoRecursion() in CrawlNoRecursion() returned error: %s", err) + } + }() + wg.Wait() + for _, worker := range workers { + worker.stop() + } + } else { + // If the path is a file, add it to the cache directly + dc.cache.Add(StripRootDir(path, RootDir), NewItem(path, info, CachePrintNew, RootDir, CrawlerParseMIME)) + } + + // Mark the scan as inactive + mapMutex.Lock() + activeScans[path] = false + mapMutex.Unlock() + // Decrement running scans count + atomic.AddInt32(&runningScans, -1) + return nil +} + +// functions to get the number of running scans and workers + +func GetRunningScans() int32 { + return atomic.LoadInt32(&runningScans) +} + +func GetRunningWorkers() int32 { + return atomic.LoadInt32(&runningWorkers) +} + +// Functions to get the current directory of a worker and the active scans + +func GetWorkerDir(id int32) string { + mapMutex.Lock() + defer mapMutex.Unlock() + return workerDirs[id] +} + +func GetActiveScans() map[string]bool { + mapMutex.Lock() + defer mapMutex.Unlock() + return activeScans +} diff --git a/src/cache/crawler.go b/src/cache/crawler.go new file mode 100644 index 0000000..513c297 --- /dev/null +++ b/src/cache/crawler.go @@ -0,0 +1,60 @@ +package cache + +import ( + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + lru "github.com/hashicorp/golang-lru/v2" + "sync" + "time" +) + +func StartCrawler(basePath string, sharedCache *lru.Cache[string, *data.Item], cfg *config.Config) error { + log = logging.GetLogger() + var wg sync.WaitGroup + crawlerChan := make(chan struct{}, cfg.DirectoryCrawlers) + + // TODO: a crawl may take some time to complete so we need to adjust the wait time based on the duration it took + go func() { + ticker := time.NewTicker(time.Second * time.Duration(cfg.CrawlModeCrawlInterval)) + defer ticker.Stop() + + // delay before first crawl + time.Sleep(time.Second * time.Duration(cfg.CrawlModeCrawlInterval)) + + for { + select { + case <-ticker.C: + crawlerChan <- struct{}{} // block if there are already cfg.DirectoryCrawlers crawlers + wg.Add(1) + go func() { + defer wg.Done() + dc := NewDirectoryCrawler(sharedCache) + log.Infoln("CRAWLER - Starting a crawl...") + start := time.Now() + err := dc.Crawl(basePath, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + duration := time.Since(start).Round(time.Second) + if err != nil { + log.Warnf("CRAWLER - Crawl failed: %s", err) + } else { + log.Infof("CRAWLER - Crawl completed in %s", duration) + keys := sharedCache.Keys() + log.Debugf("%d/%d items in the cache.", cfg.CacheSize, len(keys)) + } + <-crawlerChan // release + }() + } + } + }() + + ticker := time.NewTicker(60 * time.Second) + go func(c *lru.Cache[string, *data.Item]) { + for range ticker.C { + keys := c.Keys() + log.Debugf("%d/%d items in the cache.", len(keys), cfg.CacheSize) + //fmt.Println(keys) // for debug when things are really messed up + } + }(sharedCache) + + return nil +} diff --git a/src/cache/file.go b/src/cache/file.go new file mode 100644 index 0000000..c97f7ca --- /dev/null +++ b/src/cache/file.go @@ -0,0 +1,49 @@ +package cache + +import ( + "github.com/gabriel-vasile/mimetype" + "mime" + "os" + "path/filepath" + "strings" +) + +func StripRootDir(path, RootDir string) string { + if path == "/" || path == RootDir { + // Avoid erasing our path + return "/" + } else { + return strings.TrimSuffix(strings.TrimPrefix(path, RootDir), "/") + } +} + +func GetMimeType(path string, analyze bool) (bool, string, string, error) { + var MIME *mimetype.MIME + var mimeType string + var ext string + var err error + info, err := os.Stat(path) + if err != nil { + // File does not exist + return false, "", "", err + } + if !info.IsDir() { + ext = filepath.Ext(path) + if analyze { + MIME, err = mimetype.DetectFile(path) + if err != nil { + log.Warnf("Error analyzing MIME type: %v", err) + return false, "", "", err + } + mimeType = MIME.String() + } else { + mimeType = mime.TypeByExtension(ext) + } + } else { + return true, "", ext, nil + } + if strings.Contains(mimeType, ";") { + mimeType = strings.Split(mimeType, ";")[0] + } + return true, mimeType, ext, nil +} diff --git a/src/cache/initial.go b/src/cache/initial.go new file mode 100644 index 0000000..730b516 --- /dev/null +++ b/src/cache/initial.go @@ -0,0 +1,76 @@ +package cache + +import ( + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + lru "github.com/hashicorp/golang-lru/v2" + "runtime" + "sync" + "time" +) + +var InitialCrawlInProgress bool + +func init() { + InitialCrawlInProgress = false +} + +func InitialCrawl(sharedCache *lru.Cache[string, *data.Item], cfg *config.Config) { + log = logging.GetLogger() + InitialCrawlInProgress = true + dirChan := make(chan string, 100000) // Buffered channel to hold directories to be crawled + var wg sync.WaitGroup + cacheFull := make(chan bool, 1) // Channel to signal when cache is full + + // Start worker goroutines + for i := 0; i < runtime.NumCPU()*6; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case dir, ok := <-dirChan: + if ok { + crawlDir(dir, sharedCache, cacheFull, cfg) + } else { + return + } + case <-cacheFull: + return + } + } + }() + } + + // Kick off the crawl + dirChan <- cfg.RootDir + close(dirChan) + + // Start a ticker to log the number of items in the cache every 2 seconds + ticker := time.NewTicker(2 * time.Second) + go func() { + for range ticker.C { + log.Debugf("INITIAL CRAWL - cache size: %d/%d", sharedCache.Len(), cfg.CacheSize) + } + }() + + // Wait for all goroutines to finish + wg.Wait() + ticker.Stop() + InitialCrawlInProgress = false +} + +func crawlDir(dir string, sharedCache *lru.Cache[string, *data.Item], cacheFull chan<- bool, cfg *config.Config) { + dc := NewDirectoryCrawler(sharedCache) + err := dc.Crawl(dir, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Fatalf("Crawl failed: %s", err) + return + } + + // Check if cache is full + if sharedCache.Len() >= cfg.CacheSize { + cacheFull <- true + } +} diff --git a/src/cache/recache.go b/src/cache/recache.go new file mode 100644 index 0000000..0328f55 --- /dev/null +++ b/src/cache/recache.go @@ -0,0 +1,64 @@ +package cache + +import ( + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + lru "github.com/hashicorp/golang-lru/v2" + "os" + "path/filepath" + "time" +) + +var sem chan struct{} + +func InitRecacheSemaphore(limit int) { + sem = make(chan struct{}, limit) +} + +func CheckAndRecache(path string, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + item, found := sharedCache.Get(path) + if found && time.Now().UnixNano()/int64(time.Millisecond)-item.Cached > int64(cfg.CacheTime)*60*1000 { + log := logging.GetLogger() + log.Debugf("Re-caching: %s", path) + sem <- struct{}{} // acquire a token + go func() { + defer func() { <-sem }() // release the token when done + dc := NewDirectoryCrawler(sharedCache) + err := dc.Crawl(path, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Errorf("RECACHE ERROR: %s", err.Error()) + } + }() + } +} + +func Recache(path string, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + log := logging.GetLogger() + log.Debugf("Re-caching: %s", path) + sem <- struct{}{} // acquire a token + go func() { + defer func() { <-sem }() // release the token when done + dc := NewDirectoryCrawler(sharedCache) + err := dc.Crawl(path, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Errorf("RECACHE ERROR: %s", err.Error()) + } + + // Get the parent directory from the cache + parentDir := filepath.Dir(path) + parentItem, found := sharedCache.Get(parentDir) + if found { + // Update the parent directory's Children field to include the new sub-directory + info, err := os.Stat(path) + if err != nil { + log.Errorf("RECACHE ERROR: %s", err.Error()) + } else { + newItem := NewItem(path, info, cfg.CachePrintNew, cfg.RootDir, cfg.CrawlerParseMIME) + parentItem.Children = append(parentItem.Children, newItem) + // Update the parent directory in the cache + sharedCache.Add(parentDir, parentItem) + } + } + }() +} diff --git a/src/cache/watcher.go b/src/cache/watcher.go new file mode 100644 index 0000000..822fce9 --- /dev/null +++ b/src/cache/watcher.go @@ -0,0 +1,105 @@ +package cache + +import ( + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + lru "github.com/hashicorp/golang-lru/v2" + "github.com/radovskyb/watcher" + "github.com/sirupsen/logrus" + "strings" + "sync" + "time" +) + +var log *logrus.Logger + +func StartWatcher(basePath string, sharedCache *lru.Cache[string, *data.Item], cfg *config.Config) (*watcher.Watcher, error) { + log = logging.GetLogger() + w := watcher.New() + var wg sync.WaitGroup + crawlerChan := make(chan struct{}, cfg.DirectoryCrawlers) // limit to cfg.DirectoryCrawlers concurrent crawlers + + go func() { + for { + select { + case event := <-w.Event: + // Ignore events outside of basePath + if !strings.HasPrefix(event.Path, basePath) { + if cfg.CachePrintChanges { + log.Warnf("Ignoring file outside the base path: %s", event.Path) + } + continue + } + if event.Op == watcher.Create { + if cfg.CachePrintChanges { + log.Debugf("WATCHER - File created: %s", event.Path) + } + } + if event.Op == watcher.Write { + if cfg.CachePrintChanges { + log.Debugf("WATCHER - File modified: %s", event.Path) + } + } + if event.Op == watcher.Remove { + if cfg.CachePrintChanges { + log.Debugf("WATCHER - File removed: %s", event.Path) + } + sharedCache.Remove(event.Path) // remove the entry from the cache + continue // skip the rest of the loop for this event + } + if event.Op == watcher.Rename { + if cfg.CachePrintChanges { + log.Debugf("WATCHER- File renamed: %s", event.Path) + } + sharedCache.Remove(event.Path) + continue + } + if event.Op == watcher.Chmod { + if cfg.CachePrintChanges { + log.Debugf("WATCHER - File chmod: %s", event.Path) + } + } + + crawlerChan <- struct{}{} // block if there are already 4 crawlers + wg.Add(1) + go func() { + defer wg.Done() + dc := NewDirectoryCrawler(sharedCache) + err := dc.Crawl(event.Path, cfg.CachePrintNew, cfg.CrawlWorkers, cfg.RootDir, cfg.CrawlerParseMIME) + if err != nil { + log.Warnf("WATCHER - Crawl failed: %s", err) + } + <-crawlerChan // release + }() + case err := <-w.Error: + log.Errorf("WATCHER - %s", err) + case <-w.Closed: + return + } + } + }() + + // Watch test_folder recursively for changes. + if err := w.AddRecursive(basePath); err != nil { + log.Fatalf("WATCHER RECURSIVE): %s", err) + } + + go func() { + // Start the watching process - it'll check for changes every 100ms. + if err := w.Start(time.Second * time.Duration(cfg.WatchInterval)); err != nil { + log.Fatalf("WATCHER: %s", err) + } + }() + + // Print the filenames of the cache entries every 5 seconds + ticker := time.NewTicker(60 * time.Second) + go func(c *lru.Cache[string, *data.Item]) { + for range ticker.C { + keys := c.Keys() + log.Debugf("%d items in the cache.", len(keys)) + } + }(sharedCache) + + return w, nil +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..a9f34f6 --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "errors" + "github.com/spf13/viper" + "strings" +) + +type Config struct { + RootDir string + HTTPPort string + WatchMode string + CrawlModeCrawlInterval int + DirectoryCrawlers int + CrawlWorkers int + WatchInterval int + CacheSize int + CacheTime int + CachePrintNew bool + CachePrintChanges bool + InitialCrawl bool + CacheRecacheCrawlerLimit int + CrawlerParseMIME bool + HttpAPIListCacheControl int + HttpAPIDlCacheControl int + HttpAllowDirMimeParse bool + HttpAdminKey string + HttpAllowDuringInitialCrawl bool + RestrictedDownloadPaths []string + ApiSearchMaxResults int + ApiSearchShowChildren bool +} + +func LoadConfig(configFile string) (*Config, error) { + viper.SetConfigFile(configFile) + viper.SetDefault("http_port", "8080") + viper.SetDefault("watch_interval", 1) + viper.SetDefault("watch_mode", "crawl") + viper.SetDefault("crawl_mode_crawl_interval", 3600) + viper.SetDefault("directory_crawlers", 4) + viper.SetDefault("crawl_workers", 10) + viper.SetDefault("cache_size", 100000000) + viper.SetDefault("cache_time", 30) + viper.SetDefault("cache_print_new", false) + viper.SetDefault("cache_print_changes", true) + viper.SetDefault("initial_crawl", false) + viper.SetDefault("cache_recache_crawler_limit", 50) + viper.SetDefault("crawler_parse_mime", false) + viper.SetDefault("http_api_list_cache_control", 600) + viper.SetDefault("http_api_download_cache_control", 600) + viper.SetDefault("http_allow_dir_mime_parse", true) + viper.SetDefault("restricted_download_paths", []string{}) + viper.SetDefault("api_search_max_results", 1000) + viper.SetDefault("api_search_show_children", false) + viper.SetDefault("http_allow_during_initial_crawl", false) + + err := viper.ReadInConfig() + if err != nil { + return nil, err + } + + restrictedPaths := viper.GetStringSlice("restricted_download_paths") + for i, path := range restrictedPaths { + restrictedPaths[i] = strings.TrimSuffix(path, "/") + } + + rootDir := strings.TrimSuffix(viper.GetString("root_dir"), "/") + + config := &Config{ + RootDir: rootDir, + HTTPPort: viper.GetString("http_port"), + WatchMode: viper.GetString("watch_mode"), + CrawlModeCrawlInterval: viper.GetInt("crawl_mode_crawl_interval"), + WatchInterval: viper.GetInt("watch_interval"), + DirectoryCrawlers: viper.GetInt("crawl_mode_crawl_interval"), + CrawlWorkers: viper.GetInt("crawl_workers"), + CacheSize: viper.GetInt("cache_size"), + CacheTime: viper.GetInt("cache_time"), + CachePrintNew: viper.GetBool("cache_print_new"), + CachePrintChanges: viper.GetBool("cache_print_changes"), + InitialCrawl: viper.GetBool("initial_crawl"), + CacheRecacheCrawlerLimit: viper.GetInt("cache_recache_crawler_limit"), + CrawlerParseMIME: viper.GetBool("crawler_parse_mime"), + HttpAPIListCacheControl: viper.GetInt("http_api_list_cache_control"), + HttpAPIDlCacheControl: viper.GetInt("http_api_download_cache_control"), + HttpAllowDirMimeParse: viper.GetBool("http_allow_dir_mime_parse"), + HttpAdminKey: viper.GetString("api_admin_key"), + HttpAllowDuringInitialCrawl: viper.GetBool("http_allow_during_initial_crawl"), + RestrictedDownloadPaths: restrictedPaths, + ApiSearchMaxResults: viper.GetInt("api_search_max_results"), + ApiSearchShowChildren: viper.GetBool("api_search_show_children"), + } + + if config.WatchMode != "crawl" && config.WatchMode != "watch" { + return nil, errors.New("watch_mode must be 'crawl' or 'watch'") + } + + if config.CacheTime < 0 { + return nil, errors.New("cache_time must be a positive number") + } + + if config.DirectoryCrawlers < 1 { + return nil, errors.New("crawl_mode_crawl_interval must be more than 1") + } + + if config.CrawlWorkers < 1 { + return nil, errors.New("crawl_workers must be more than 1") + } + + if config.CacheSize < 1 { + return nil, errors.New("crawl_workers must be more than 1") + } + + if config.CacheRecacheCrawlerLimit < 1 { + return nil, errors.New("cache_recache_crawler_limit must be more than 0") + } + + if config.HttpAPIListCacheControl < 0 { + return nil, errors.New("http_api_list_cache_control must not be less than 0") + } + + if config.HttpAPIDlCacheControl < 0 { + return nil, errors.New("http_api_download_cache_control must not be less than 0") + } + + if config.HttpAdminKey == "" { + return nil, errors.New("api_admin_key is required") + } + + if config.ApiSearchMaxResults < 1 { + return nil, errors.New("api_search_max_results must not be less than 1") + } + + return config, nil +} diff --git a/src/crazyfs.go b/src/crazyfs.go new file mode 100644 index 0000000..cdebdbb --- /dev/null +++ b/src/crazyfs.go @@ -0,0 +1,142 @@ +package main + +import ( + "crazyfs/api" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "crazyfs/logging" + "errors" + "flag" + "fmt" + lru "github.com/hashicorp/golang-lru/v2" + "github.com/sirupsen/logrus" + "net/http" + "os" + "path/filepath" + "time" +) + +var log *logrus.Logger +var cfg *config.Config + +var lruSize int + +type cliConfig struct { + configFile string + initialCrawl bool + debug bool + help bool +} + +// TODO: optional serving of frontend +// TODO: admin api to clear cache, get number of items in cache, get memory usage +// TODO: health api endpoint that tells us if the server is still starting +// TODO: set global http headers rather than randomly setting them in routes + +func main() { + cliArgs := parseArgs() + if cliArgs.help { + flag.Usage() + os.Exit(0) + } + + fmt.Println("=== Crazy File Server ===") + if cliArgs.debug { + logging.InitLogger(logrus.DebugLevel) + } else { + logging.InitLogger(logrus.InfoLevel) + } + log = logging.GetLogger() + log.Infoln("Initializing") + + if cliArgs.configFile == "" { + exePath, err := os.Executable() + if err != nil { + panic(err) + } + exeDir := filepath.Dir(exePath) + + if _, err := os.Stat(filepath.Join(exeDir, "config.yml")); err == nil { + if _, err := os.Stat(filepath.Join(exeDir, "config.yaml")); err == nil { + log.Fatalln("Both config.yml and config.yaml exist in the executable directory. Please specify one with the --config flag.") + } + cliArgs.configFile = filepath.Join(exeDir, "config.yml") + } else if _, err := os.Stat(filepath.Join(exeDir, "config.yaml")); err == nil { + cliArgs.configFile = filepath.Join(exeDir, "config.yaml") + } else { + log.Fatalln("No config file found in the executable directory. Please provide one with the --config flag.") + } + } + + if _, err := os.Stat(cliArgs.configFile); errors.Is(err, os.ErrNotExist) { + log.Fatalf("Config file does not exist: %s", cliArgs.configFile) + } + + cache.FollowSymlinks = false + + var err error + cfg, err = config.LoadConfig(cliArgs.configFile) + if err != nil { + log.Fatalf("Failed to load config file: %s", err) + } + + sharedCache, err := lru.New[string, *data.Item](cfg.CacheSize) + if err != nil { + log.Fatal(err) + } + + cache.InitRecacheSemaphore(cfg.CacheRecacheCrawlerLimit) + + // Start the webserver before doing the long crawl + r := api.NewRouter(cfg, sharedCache) + //log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", cfg.HTTPPort), r)) + go func() { + err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.HTTPPort), r) + if err != nil { + log.Fatalf("Failed to start: %s", err) + } + }() + log.Infof("Server started on port %s", cfg.HTTPPort) + + lruSize = cfg.CacheSize + + if cliArgs.initialCrawl || cfg.InitialCrawl { + log.Infoln("Preforming initial crawl...") + start := time.Now() + cache.InitialCrawl(sharedCache, cfg) + duration := time.Since(start).Round(time.Second) + keys := sharedCache.Keys() + log.Infof("Initial crawl completed in %s. %d directories and files added to the cache.", duration, len(keys)) + } + + if cfg.WatchMode == "watch" { + log.Debugln("Starting the watcher process") + watcher, err := cache.StartWatcher(cfg.RootDir, sharedCache, cfg) + if err != nil { + log.Fatalf("Failed to start watcher process: %s", err) + } + log.Infoln("Started the watcher process") + defer watcher.Close() + } else if cfg.WatchMode == "crawl" { + //log.Debugln("Starting the crawler") + err := cache.StartCrawler(cfg.RootDir, sharedCache, cfg) + if err != nil { + log.Fatalf("Failed to start timed crawler process: %s", err) + } + log.Infoln("Started the timed crawler process") + } + + select {} +} + +func parseArgs() cliConfig { + var cliArgs cliConfig + flag.StringVar(&cliArgs.configFile, "config", "", "Path to the config file") + flag.BoolVar(&cliArgs.initialCrawl, "initial-crawl", false, "Do an initial crawl to fill the cache") + flag.BoolVar(&cliArgs.initialCrawl, "i", false, "Do an initial crawl to fill the cache") + flag.BoolVar(&cliArgs.debug, "d", false, "Enable debug mode") + flag.BoolVar(&cliArgs.debug, "debug", false, "Enable debug mode") + flag.Parse() + return cliArgs +} diff --git a/src/data/struct.go b/src/data/struct.go new file mode 100644 index 0000000..b538f87 --- /dev/null +++ b/src/data/struct.go @@ -0,0 +1,16 @@ +package data + +type Item struct { + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + Extension *string `json:"extension"` + Modified string `json:"modified"` + Mode uint32 `json:"mode"` + IsDir bool `json:"isDir"` + IsSymlink bool `json:"isSymlink"` + Type *string `json:"type"` + Children []*Item `json:"children"` + Content string `json:"content,omitempty"` + Cached int64 `json:"cached"` +} diff --git a/src/file/image.go b/src/file/image.go new file mode 100644 index 0000000..4f72ff1 --- /dev/null +++ b/src/file/image.go @@ -0,0 +1,82 @@ +package file + +import ( + "bytes" + "errors" + "fmt" + "github.com/chai2010/webp" + "github.com/joway/libimagequant-go/pngquant" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "log" + "os" +) + +func ConvertToPNG(filename string, contentType string) ([]byte, error) { + imageBytes, err := os.Open(filename) + if err != nil { + log.Fatal(err) + } + defer imageBytes.Close() + + switch contentType { + case "image/png": + imageBytes, err := io.ReadAll(imageBytes) + if err != nil { + return nil, errors.New("unable to read png") + } + return imageBytes, nil + case "image/jpeg": + img, err := jpeg.Decode(imageBytes) + if err != nil { + return nil, errors.New("unable to decode jpeg") + } + buf := new(bytes.Buffer) + if err := png.Encode(buf, img); err != nil { + return nil, errors.New("unable to encode png") + } + return buf.Bytes(), nil + case "image/webp": + img, err := webp.Decode(imageBytes) + if err != nil { + return nil, errors.New("unable to decode webp") + } + buf := new(bytes.Buffer) + if err := png.Encode(buf, img); err != nil { + return nil, errors.New("unable to encode png") + } + case "image/gif": + img, err := gif.Decode(imageBytes) + if err != nil { + return nil, errors.New("unable to decode gif") + } + buf := new(bytes.Buffer) + if err := png.Encode(buf, img); err != nil { + return nil, errors.New("unable to encode png") + } + return buf.Bytes(), nil + } + + return nil, errors.New(fmt.Sprintf("unable to convert %#v to png", contentType)) +} + +func CompressPNGFile(inputImg image.Image, quality int) (*bytes.Buffer, error) { + // Compress the image using pngquant + compressedImg, err := pngquant.Compress(inputImg, quality, pngquant.SPEED_FASTEST) + if err != nil { + return nil, err + } + + // Create a bytes.Buffer and encode the compressed image into it + buf := new(bytes.Buffer) + err = (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(buf, compressedImg) + //err = png.Encode(buf, compressedImg) + if err != nil { + return nil, err + } + + return buf, nil +} diff --git a/src/file/zipstream.go b/src/file/zipstream.go new file mode 100644 index 0000000..fd333b9 --- /dev/null +++ b/src/file/zipstream.go @@ -0,0 +1,207 @@ +package file + +import ( + "archive/zip" + "compress/flate" + "crazyfs/api/helpers" + "crazyfs/cache" + "crazyfs/config" + "crazyfs/data" + "encoding/json" + lru "github.com/hashicorp/golang-lru/v2" + kzip "github.com/klauspost/compress/zip" + "io" + "net/http" + "os" + "path/filepath" +) + +func ZipHandler(dirPath string, w http.ResponseWriter, r *http.Request, compressionLevel int) { + // The compressionLevel parameter should be a value between -2 and 9 inclusive, where -2 means default compression, 1 means best speed, and 9 means best compression. + // Set to 0 to disable compression (store mode) + + // You need to write the headers and status code before any bytes + w.Header().Set("Content-Type", "application/zip") + // the filename which will be suggested in the save file dialog + w.WriteHeader(http.StatusOK) + + zipWriter := zip.NewWriter(w) + + // Set the compression level + if compressionLevel > 0 { + zipWriter.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, compressionLevel) + }) + } + + // Walk through the directory and add each file to the zip + filepath.Walk(dirPath, func(filePath string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + + // Ensure the file path is relative to the directory being zipped + relativePath, err := filepath.Rel(dirPath, filePath) + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + header.Name = relativePath + + if compressionLevel > 0 { + header.Method = zip.Deflate + } else { + header.Method = zip.Store + } + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) + + err := zipWriter.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func ZipHandlerCompress(dirPath string, w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + + zipWriter := kzip.NewWriter(w) + // Walk through the directory and add each file to the zip + filepath.Walk(dirPath, func(filePath string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + + // Ensure the file path is relative to the directory being zipped + relativePath, err := filepath.Rel(dirPath, filePath) + if err != nil { + return err + } + + writer, err := zipWriter.Create(relativePath) + if err != nil { + return err + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) + + err := zipWriter.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} +func ZipHandlerCompressMultiple(paths []string, w http.ResponseWriter, r *http.Request, cfg *config.Config, sharedCache *lru.Cache[string, *data.Item]) { + zipWriter := kzip.NewWriter(w) + // Walk through each file and add it to the zip + for _, path := range paths { + relPath := cache.StripRootDir(filepath.Join(cfg.RootDir, path), cfg.RootDir) + fullPath := filepath.Join(cfg.RootDir, relPath) + + // Check if the path is in the restricted download paths + for _, restrictedPath := range cfg.RestrictedDownloadPaths { + if relPath == restrictedPath { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": http.StatusForbidden, + "error": "not allowed to download this path", + }) + return + } + } + + // Try to get the data from the cache + item, found := sharedCache.Get(relPath) + if !found { + item = helpers.HandleFileNotFound(relPath, fullPath, sharedCache, cfg, w) + } + if item == nil { + // The errors have already been handled in handleFileNotFound() so we're good to just exit + return + } + + if !item.IsDir { + writer, err := zipWriter.Create(relPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + file, err := os.Open(fullPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer file.Close() + + _, err = io.Copy(writer, file) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + w.Header().Set("Content-Disposition", `attachment; filename="files.zip"`) + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + + // If it's a directory, walk through it and add each file to the zip + filepath.Walk(fullPath, func(filePath string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + + // Ensure the file path is relative to the directory being zipped + relativePath, err := filepath.Rel(fullPath, filePath) + if err != nil { + return err + } + + writer, err := zipWriter.Create(filepath.Join(relPath, relativePath)) + if err != nil { + return err + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) + } + } + + err := zipWriter.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..885b400 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,37 @@ +module crazyfs + +go 1.20 + +require ( + github.com/chai2010/webp v1.1.1 + github.com/disintegration/imaging v1.6.2 + github.com/gabriel-vasile/mimetype v1.4.2 + github.com/gorilla/mux v1.8.0 + github.com/hashicorp/golang-lru/v2 v2.0.4 + github.com/joway/libimagequant-go v0.1.0 + github.com/klauspost/compress v1.16.7 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/radovskyb/watcher v1.0.7 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.16.0 +) + +require ( + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..ec55e8f --- /dev/null +++ b/src/go.sum @@ -0,0 +1,511 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= +github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0= +github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/joway/libimagequant-go v0.1.0 h1:OweHnJoksB6WgZVmQSrF9Ya682Xe7NlxVGbxAs2gpKw= +github.com/joway/libimagequant-go v0.1.0/go.mod h1:WJMFExuw4wdfIvwCVdGU1iZGCmgJ4GAmOfv/6fUqnow= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= +github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/src/logging/http.go b/src/logging/http.go new file mode 100644 index 0000000..e3906b8 --- /dev/null +++ b/src/logging/http.go @@ -0,0 +1,27 @@ +package logging + +import ( + "net" + "net/http" +) + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (sw *statusWriter) WriteHeader(status int) { + sw.status = status + sw.ResponseWriter.WriteHeader(status) +} + +// TODO: handle the proxy http headers + +func LogRequest(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := statusWriter{ResponseWriter: w, status: http.StatusOK} // set default status + handler.ServeHTTP(&sw, r) + ip, _, _ := net.SplitHostPort(r.RemoteAddr) // Get the IP address without port number + log.Infof("%s - %d - %s from %s", r.Method, sw.status, r.URL.RequestURI(), ip) + }) +} diff --git a/src/logging/logging.go b/src/logging/logging.go new file mode 100644 index 0000000..af79a8b --- /dev/null +++ b/src/logging/logging.go @@ -0,0 +1,27 @@ +package logging + +import ( + "github.com/sirupsen/logrus" +) + +var log *logrus.Logger + +func init() { + log = logrus.New() + + // Set log output format + customFormatter := new(logrus.TextFormatter) + customFormatter.TimestampFormat = "2006-01-02 15:04:05" + customFormatter.FullTimestamp = true + log.SetFormatter(customFormatter) +} + +// InitLogger initializes the global logger with the specified log level +func InitLogger(logLevel logrus.Level) { + log.SetLevel(logLevel) +} + +// GetLogger returns the global logger instance +func GetLogger() *logrus.Logger { + return log +}