From 7c55413677a069cc3475e6aaaad776597643fa36 Mon Sep 17 00:00:00 2001 From: Graham Steffaniak Date: Wed, 30 Apr 2025 12:43:28 -0500 Subject: [PATCH 1/4] updated swagger --- CHANGELOG.md | 27 +++++++++++++------------- backend/auth/auth.go | 17 ++++++++++++++++ backend/auth/hook.go | 2 +- backend/database/storage/bolt/users.go | 8 ++++++++ backend/database/users/storage.go | 4 ++-- backend/http/api.go | 3 ++- backend/http/auth.go | 17 ---------------- backend/http/httpRouter.go | 11 ++--------- backend/http/middleware.go | 3 ++- backend/http/swagger.go | 20 +++++++++++++++++++ 10 files changed, 68 insertions(+), 44 deletions(-) create mode 100644 backend/http/swagger.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da23c65..68f4c6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,35 +13,36 @@ All notable changes to this project will be documented in this file. For commit - Enhanced user settings page with more toggle options. - Replaced checkboxes with toggles switches https://github.com/gtsteffaniak/filebrowser/issues/461 - Refreshed Breadcrumbs style. - - main navbar icon is multipurpose menu, close, back and animates + - Main navbar icon is multipurpose menu, close, back and animates - Enhanced source info on the UI - User must have permission `realtime: true` property to get realtime events. - Sources shows status of the directory `ready`, `indexing`, and `unavailable` - - top-right overflow menu for deleting / editing files in peview https://github.com/gtsteffaniak/filebrowser/issues/456 - - helpful UI animation for drag and drop files, to get feedback where the drop target is. - - more consistent theme color https://github.com/gtsteffaniak/filebrowser/issues/538 + - Top-right overflow menu for deleting / editing files in peview https://github.com/gtsteffaniak/filebrowser/issues/456 + - Helpful UI animation for drag and drop files, to get feedback where the drop target is. + - More consistent theme color https://github.com/gtsteffaniak/filebrowser/issues/538 - New file preview types: - Video thumbnails available via new media integration (see configuration wiki for help) https://github.com/gtsteffaniak/filebrowser/issues/351 - Office file previews if you have office integration enabled. https://github.com/gtsteffaniak/filebrowser/issues/460 **Notes**: - sesssionId is now unique per window. Previously it was shared accross browser tabs. - - disableUsedPercentage is a backend property now, so users can't "hack" the information to be shown. - - updated documentation for resources api https://github.com/gtsteffaniak/filebrowser/issues/560 - - updated placeholder for scopes https://github.com/gtsteffaniak/filebrowser/issues/475 + - DisableUsedPercentage is a backend property now, so users can't "hack" the information to be shown. + - Updated documentation for resources api https://github.com/gtsteffaniak/filebrowser/issues/560 + - Updated placeholder for scopes https://github.com/gtsteffaniak/filebrowser/issues/475 + - When user's API permissions are removed, any api keys the user had will be revoked. **Bug Fixes**: - Nil pointer error when source media is disconnected while running. - - source selection buggy https://github.com/gtsteffaniak/filebrowser/issues/537 - - upload folder structure https://github.com/gtsteffaniak/filebrowser/issues/539 + - Source selection buggy https://github.com/gtsteffaniak/filebrowser/issues/537 + - Upload folder structure https://github.com/gtsteffaniak/filebrowser/issues/539 - Editing files on multiple sources https://github.com/gtsteffaniak/filebrowser/issues/535 - Prevent the user from changing the password https://github.com/gtsteffaniak/filebrowser/issues/550 - Links in setting page does not navigate to correct location https://github.com/gtsteffaniak/filebrowser/issues/474 - Url encoding issue https://github.com/gtsteffaniak/filebrowser/issues/530 - - certain file types being treated as folders https://github.com/gtsteffaniak/filebrowser/issues/555 - - source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/557 - - onlyoffice support on proxy auth https://github.com/gtsteffaniak/filebrowser/issues/559 - - downloading with user scope https://github.com/gtsteffaniak/filebrowser/issues/564 + - Certain file types being treated as folders https://github.com/gtsteffaniak/filebrowser/issues/555 + - Source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/557 + - Onlyoffice support on proxy auth https://github.com/gtsteffaniak/filebrowser/issues/559 + - Downloading with user scope https://github.com/gtsteffaniak/filebrowser/issues/564 - User disableSettings property to be respected. - Non admin users updating admin settings. - Right click context issue on safari desktop. diff --git a/backend/auth/auth.go b/backend/auth/auth.go index d8f5c9fd..eef7c92d 100644 --- a/backend/auth/auth.go +++ b/backend/auth/auth.go @@ -2,10 +2,16 @@ package auth import ( "net/http" + "sync" "github.com/gtsteffaniak/filebrowser/backend/database/users" ) +var ( + revokedApiKeyList map[string]bool + revokeMu sync.Mutex +) + // Auther is the authentication interface. type Auther interface { // Auth is called to authenticate a request. @@ -13,3 +19,14 @@ type Auther interface { // LoginPage indicates if this auther needs a login page. LoginPage() bool } + +func IsRevokedApiKey(key string) bool { + _, exists := revokedApiKeyList[key] + return exists +} + +func RevokeAPIKey(key string) { + revokeMu.Lock() + delete(revokedApiKeyList, key) + revokeMu.Unlock() +} diff --git a/backend/auth/hook.go b/backend/auth/hook.go index b2d083d4..5a72d343 100644 --- a/backend/auth/hook.go +++ b/backend/auth/hook.go @@ -181,7 +181,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) { if len(a.Fields.Values) > 1 { u = a.GetUser(u) // update user with provided fields - err := a.Users.Update(u, true) + err := a.Users.Update(u, u.Permissions.Admin) if err != nil { return nil, err } diff --git a/backend/database/storage/bolt/users.go b/backend/database/storage/bolt/users.go index 1b6f4477..9f64be72 100644 --- a/backend/database/storage/bolt/users.go +++ b/backend/database/storage/bolt/users.go @@ -9,6 +9,7 @@ import ( storm "github.com/asdine/storm/v3" "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files" + "github.com/gtsteffaniak/filebrowser/backend/auth" "github.com/gtsteffaniak/filebrowser/backend/common/errors" "github.com/gtsteffaniak/filebrowser/backend/common/logger" "github.com/gtsteffaniak/filebrowser/backend/common/settings" @@ -123,6 +124,13 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str return fmt.Errorf("failed to update user field: %s, error: %v", field, err) } } + + // last revoke api keys if needed. + if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") { + for _, key := range existingUser.ApiKeys { + auth.RevokeAPIKey(key.Key) // add to blacklist + } + } return nil } diff --git a/backend/database/users/storage.go b/backend/database/users/storage.go index 4837bda0..80e4f84e 100644 --- a/backend/database/users/storage.go +++ b/backend/database/users/storage.go @@ -87,7 +87,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error { user.ApiKeys = make(map[string]AuthToken) } user.ApiKeys[name] = key - err = s.Update(user, false, "ApiKeys") + err = s.Update(user, true, "ApiKeys") if err != nil { return err } @@ -105,7 +105,7 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error { user.ApiKeys = make(map[string]AuthToken) } delete(user.ApiKeys, name) - err = s.Update(user, false, "ApiKeys") + err = s.Update(user, true, "ApiKeys") if err != nil { return err } diff --git a/backend/http/api.go b/backend/http/api.go index 1a936dd5..aeb1e035 100644 --- a/backend/http/api.go +++ b/backend/http/api.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/gtsteffaniak/filebrowser/backend/auth" "github.com/gtsteffaniak/filebrowser/backend/database/users" ) @@ -101,7 +102,7 @@ func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte return http.StatusNotFound, err } - revokeAPIKey(keyInfo.Key) // add to blacklist + auth.RevokeAPIKey(keyInfo.Key) // add to blacklist response := HttpResponse{ Message: "successfully deleted api key from user", } diff --git a/backend/http/auth.go b/backend/http/auth.go index a7207eba..49e4f519 100644 --- a/backend/http/auth.go +++ b/backend/http/auth.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "strings" - "sync" "time" jwt "github.com/golang-jwt/jwt/v4" @@ -24,11 +23,6 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/database/users" ) -var ( - revokedApiKeyList map[string]bool - revokeMu sync.Mutex -) - // first checks for cookie // then checks for header Authorization as Bearer token // then checks for query parameter @@ -250,17 +244,6 @@ func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, return 0, nil } -func isRevokedApiKey(key string) bool { - _, exists := revokedApiKeyList[key] - return exists -} - -func revokeAPIKey(key string) { - revokeMu.Lock() - delete(revokedApiKeyList, key) - revokeMu.Unlock() -} - func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) { _, ok := user.ApiKeys[name] if ok { diff --git a/backend/http/httpRouter.go b/backend/http/httpRouter.go index 445f64ec..2df9b912 100644 --- a/backend/http/httpRouter.go +++ b/backend/http/httpRouter.go @@ -15,7 +15,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/common/settings" "github.com/gtsteffaniak/filebrowser/backend/common/version" "github.com/gtsteffaniak/filebrowser/backend/database/storage" - httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware + // http-swagger middleware ) // Embed the files in the frontend/dist directory @@ -135,14 +135,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler) // Swagger - router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), - httpSwagger.Handler( - httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), //The url pointing to API definition - httpSwagger.DeepLinking(true), - httpSwagger.DocExpansion("none"), - httpSwagger.DomID("swagger-ui"), - ), - ) + router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler)) var scheme string port := "" diff --git a/backend/http/middleware.go b/backend/http/middleware.go index 3f7a0fb0..8c6c4a00 100644 --- a/backend/http/middleware.go +++ b/backend/http/middleware.go @@ -11,6 +11,7 @@ import ( jwt "github.com/golang-jwt/jwt/v4" "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files" + "github.com/gtsteffaniak/filebrowser/backend/auth" "github.com/gtsteffaniak/filebrowser/backend/common/logger" "github.com/gtsteffaniak/filebrowser/backend/database/share" "github.com/gtsteffaniak/filebrowser/backend/database/users" @@ -136,7 +137,7 @@ func withUserHelper(fn handleFunc) handleFunc { if !token.Valid { return http.StatusUnauthorized, fmt.Errorf("invalid token") } - if isRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() { + if auth.IsRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() { return http.StatusUnauthorized, fmt.Errorf("token expired or revoked") } // Check if the token is about to expire and send a header to renew it diff --git a/backend/http/swagger.go b/backend/http/swagger.go new file mode 100644 index 00000000..5b96aa10 --- /dev/null +++ b/backend/http/swagger.go @@ -0,0 +1,20 @@ +package http + +import ( + "net/http" + + httpSwagger "github.com/swaggo/http-swagger" +) + +func swaggerHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + if !d.user.Permissions.Api { + return http.StatusForbidden, nil + } + httpSwagger.Handler( + httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), + httpSwagger.DeepLinking(true), + httpSwagger.DocExpansion("none"), + httpSwagger.DomID("swagger-ui"), + ).ServeHTTP(w, r) + return http.StatusOK, nil +} From 128783ef4133fb45634faa619f938c20c8374eda Mon Sep 17 00:00:00 2001 From: Graham Steffaniak Date: Wed, 30 Apr 2025 18:02:22 -0500 Subject: [PATCH 2/4] updated backend --- backend/common/settings/config.go | 2 - backend/common/settings/structs.go | 4 +- backend/http/middleware.go | 13 ++-- backend/http/preview.go | 73 +++++++++++-------- backend/http/public.go | 36 ++------- backend/http/static.go | 2 +- backend/preview/preview.go | 57 +++++---------- backend/preview/video.go | 41 ++++++++++- backend/swagger/docs/docs.go | 2 +- backend/swagger/docs/swagger.json | 2 +- backend/swagger/docs/swagger.yaml | 2 +- frontend/src/components/files/ListingItem.vue | 4 +- .../src/components/files/PopupPreview.vue | 11 ++- frontend/src/views/files/ListingView.vue | 1 + 14 files changed, 126 insertions(+), 124 deletions(-) diff --git a/backend/common/settings/config.go b/backend/common/settings/config.go index 2be03932..98033945 100644 --- a/backend/common/settings/config.go +++ b/backend/common/settings/config.go @@ -232,8 +232,6 @@ func loadEnvConfig() { func setDefaults() Settings { return Settings{ Server: Server{ - EnableThumbnails: true, - ResizePreview: false, Port: 80, NumImageProcessors: 4, BaseURL: "", diff --git a/backend/common/settings/structs.go b/backend/common/settings/structs.go index af36f55f..a2399f4d 100644 --- a/backend/common/settings/structs.go +++ b/backend/common/settings/structs.go @@ -26,8 +26,8 @@ type Server struct { Socket string `json:"socket"` TLSKey string `json:"tlsKey"` TLSCert string `json:"tlsCert"` - EnableThumbnails bool `json:"enableThumbnails"` - ResizePreview bool `json:"resizePreview"` + DisablePreviews bool `json:"disablePreview"` + ResizePreviews bool `json:"resizePreview"` Port int `json:"port"` BaseURL string `json:"baseURL"` Logging []LogConfig `json:"logging"` diff --git a/backend/http/middleware.go b/backend/http/middleware.go index 8c6c4a00..d02c5dcc 100644 --- a/backend/http/middleware.go +++ b/backend/http/middleware.go @@ -19,12 +19,13 @@ import ( ) type requestContext struct { - user *users.User - raw interface{} - path string - token string - share *share.Link - ctx context.Context + user *users.User + raw interface{} + fileInfo iteminfo.ExtendedFileInfo + path string + token string + share *share.Link + ctx context.Context } type HttpResponse struct { diff --git a/backend/http/preview.go b/backend/http/preview.go index ad5ebe78..a3b8584b 100644 --- a/backend/http/preview.go +++ b/backend/http/preview.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files" "github.com/gtsteffaniak/filebrowser/backend/common/settings" @@ -35,17 +36,17 @@ type FileCache interface { // @Failure 404 {object} map[string]string "File not found" // @Failure 415 {object} map[string]string "Unsupported file type for preview" // @Failure 500 {object} map[string]string "Internal server error" +// @Failure 501 {object} map[string]string "Preview generation not implemented" // @Router /api/preview [get] func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + if config.Server.DisablePreviews { + return http.StatusNotImplemented, fmt.Errorf("preview is disabled") + } path := r.URL.Query().Get("path") source := r.URL.Query().Get("source") if source == "" { source = settings.Config.Server.DefaultSource.Name } - previewSize := r.URL.Query().Get("size") - if previewSize != "small" { - previewSize = "large" - } if path == "" { return http.StatusBadRequest, fmt.Errorf("invalid request path") } @@ -62,34 +63,8 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) ( if err != nil { return errToStatus(err), err } - if fileInfo.Type == "directory" { - return http.StatusBadRequest, fmt.Errorf("can't create preview for directory") - } - setContentDisposition(w, r, fileInfo.Name) - if !preview.AvailablePreview(fileInfo) { - return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type) - } - - if (previewSize == "large" && !config.Server.ResizePreview) || - (previewSize == "small" && !config.Server.EnableThumbnails) { - return rawFileHandler(w, r, fileInfo) - } - pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", source, path) - rawUrl := pathUrl - if config.Server.InternalUrl != "" { - rawUrl = config.Server.InternalUrl + pathUrl - } - rawUrl = rawUrl + "&auth=" + d.token - previewImg, err := preview.GetPreviewForFile(fileInfo, previewSize, rawUrl) - if err == preview.ErrUnsupportedFormat { - return rawFileHandler(w, r, fileInfo) - } - if err != nil { - return http.StatusInternalServerError, err - } - w.Header().Set("Cache-Control", "private") - http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(previewImg)) - return 0, nil + d.fileInfo = fileInfo + return previewHelperFunc(w, r, d) } func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) { @@ -110,3 +85,37 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend http.ServeContent(w, r, file.Name, file.ModTime, fd) return 0, nil } + +func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + previewSize := r.URL.Query().Get("size") + if previewSize != "small" { + previewSize = "large" + } + if d.fileInfo.Type == "directory" { + return http.StatusBadRequest, fmt.Errorf("can't create preview for directory") + } + setContentDisposition(w, r, d.fileInfo.Name) + isImage := strings.HasPrefix(d.fileInfo.Type, "image") + if !config.Server.ResizePreviews && isImage { + return rawFileHandler(w, r, d.fileInfo) + } + if !preview.AvailablePreview(d.fileInfo) { + if isImage { + return rawFileHandler(w, r, d.fileInfo) + } + return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", d.fileInfo.Type) + } + pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path) + rawUrl := pathUrl + if config.Server.InternalUrl != "" { + rawUrl = config.Server.InternalUrl + pathUrl + } + rawUrl = rawUrl + "&auth=" + d.token + previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, rawUrl) + if err != nil { + return http.StatusInternalServerError, err + } + w.Header().Set("Cache-Control", "private") + http.ServeContent(w, r, d.fileInfo.RealPath, d.fileInfo.ModTime, bytes.NewReader(previewImg)) + return 0, nil +} diff --git a/backend/http/public.go b/backend/http/public.go index 82454771..9dcac431 100644 --- a/backend/http/public.go +++ b/backend/http/public.go @@ -1,7 +1,6 @@ package http import ( - "bytes" "encoding/json" "fmt" "net/http" @@ -14,7 +13,6 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/common/utils" "github.com/gtsteffaniak/filebrowser/backend/database/users" "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo" - "github.com/gtsteffaniak/filebrowser/backend/preview" _ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs" ) @@ -113,15 +111,14 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { // @Failure 500 {object} map[string]string "Internal server error" // @Router /api/preview [get] func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + if config.Server.DisablePreviews { + return http.StatusNotImplemented, fmt.Errorf("preview is disabled") + } path := r.URL.Query().Get("path") source := r.URL.Query().Get("source") if source == "" { source = settings.Config.Server.DefaultSource.Name } - previewSize := r.URL.Query().Get("size") - if previewSize != "small" { - previewSize = "large" - } if path == "" { return http.StatusBadRequest, fmt.Errorf("invalid request path") } @@ -138,29 +135,6 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont if fileInfo.Type == "directory" { return http.StatusBadRequest, fmt.Errorf("can't create preview for directory") } - setContentDisposition(w, r, fileInfo.Name) - if !preview.AvailablePreview(fileInfo) { - return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type) - } - - if (previewSize == "large" && !config.Server.ResizePreview) || - (previewSize == "small" && !config.Server.EnableThumbnails) { - return rawFileHandler(w, r, fileInfo) - } - pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", source, path) - rawUrl := pathUrl - if config.Server.InternalUrl != "" { - rawUrl = config.Server.InternalUrl + pathUrl - } - rawUrl = rawUrl + "&auth=" + d.token - previewImg, err := preview.GetPreviewForFile(fileInfo, previewSize, rawUrl) - if err == preview.ErrUnsupportedFormat { - return rawFileHandler(w, r, fileInfo) - } - if err != nil { - return http.StatusInternalServerError, err - } - w.Header().Set("Cache-Control", "private") - http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(previewImg)) - return 0, nil + d.fileInfo = fileInfo + return previewHelperFunc(w, r, d) } diff --git a/backend/http/static.go b/backend/http/static.go index 8ad98d74..7791aa8c 100644 --- a/backend/http/static.go +++ b/backend/http/static.go @@ -51,7 +51,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT "PasswordAuth": config.Auth.Methods.PasswordAuth, "LoginPage": auther.LoginPage(), "CSS": false, - "EnableThumbs": config.Server.EnableThumbnails, + "EnableThumbs": !config.Server.DisablePreviews, "ExternalLinks": config.Frontend.ExternalLinks, "ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"), "OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url, diff --git a/backend/preview/preview.go b/backend/preview/preview.go index 30a01267..c0917055 100644 --- a/backend/preview/preview.go +++ b/backend/preview/preview.go @@ -32,7 +32,6 @@ type Service struct { func New(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service { var fileCache diskcache.Interface - // Use file cache if cacheDir is specified if cacheDir != "" { var err error @@ -52,7 +51,6 @@ func New(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service { if err != nil { logger.Fatal(fmt.Sprintf("the configured ffmpeg path is not a valid %s, err: %v", ffmpegPath, err)) } - ffmpegPath = "" } return &Service{ sem: make(chan struct{}, concurrencyLimit), @@ -67,17 +65,9 @@ func Start(concurrencyLimit int, ffmpegPath, cacheDir string) error { } func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, rawUrl string) ([]byte, error) { - if !AvailablePreview(file) { - return nil, ErrUnsupportedMedia - } - // tell preview to use image - if !ConvertableImage(file) { - return nil, ErrUnsupportedFormat - } cacheKey := CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime) - if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil { - return nil, fmt.Errorf("failed to load from cache: %w", err) - } else if found { + data, found, _ := service.fileCache.Load(context.Background(), cacheKey) + if found { return data, nil } return GeneratePreview(file, previewSize, rawUrl) @@ -100,11 +90,9 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, rawUrl string) } else if strings.HasPrefix(file.Type, "video") { outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "video", CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime)+".jpg") defer os.Remove(outPathPattern) // always clean up preview after its used (should be in cache now) - - if err = service.GenerateVideoPreview(file.RealPath, outPathPattern, 5); err != nil { + if err = service.GenerateVideoPreview(file.RealPath, outPathPattern); err != nil { return nil, fmt.Errorf("failed to generate video preview: %w", err) } - // Read and return the generated preview outFile, err := os.Open(outPathPattern) if err != nil { @@ -118,13 +106,11 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, rawUrl string) } else { return nil, fmt.Errorf("unsupported media type: %s", ext) } - - go func() { - cacheKey := CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime) - if err := service.fileCache.Store(context.Background(), cacheKey, data); err != nil { - logger.Error(fmt.Sprintf("failed to cache resized image: %v", err)) - } - }() + fmt.Println("preview size", previewSize, "file cache", settings.Config.Server.CacheDir) + cacheKey := CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime) + if err := service.fileCache.Store(context.Background(), cacheKey, data); err != nil { + logger.Error(fmt.Sprintf("failed to cache resized image: %v", err)) + } return data, nil } @@ -165,14 +151,17 @@ func CacheKey(realPath, previewSize string, modTime time.Time) string { } func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) { - err := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "small", file.ItemInfo.ModTime)) - if err != nil { - logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err)) + errSmall := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "small", file.ItemInfo.ModTime)) + if errSmall != nil { + errLarge := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "large", file.ItemInfo.ModTime)) + if errLarge != nil { + logger.Debug(fmt.Sprintf("Could not delete thumbnail: %v", file.Name)) + } } } func AvailablePreview(file iteminfo.ExtendedFileInfo) bool { - if strings.HasPrefix(file.Type, "video") { + if strings.HasPrefix(file.Type, "video") && service.ffmpegPath != "" { return true } if file.OnlyOfficeId != "" { @@ -181,7 +170,9 @@ func AvailablePreview(file iteminfo.ExtendedFileInfo) bool { if file.Type == "application/pdf" { return true } - if strings.HasPrefix(file.Type, "image") { + ext := strings.ToLower(filepath.Ext(file.Name)) + switch ext { + case ".jpg", ".jpeg", ".png", ".bmp", ".tiff": return true } if file.OnlyOfficeId != "" { @@ -193,18 +184,6 @@ func AvailablePreview(file iteminfo.ExtendedFileInfo) bool { return false } -func ConvertableImage(file iteminfo.ExtendedFileInfo) bool { - if strings.HasPrefix(file.Type, "video") && service.ffmpegPath != "" { - return true - } - ext := strings.ToLower(filepath.Ext(file.Name)) - switch ext { - case ".jpg", ".jpeg", ".png", ".bmp", ".tiff": - return true - } - return false -} - func CheckValidFFmpeg(path string) error { cmd := exec.Command( path, diff --git a/backend/preview/video.go b/backend/preview/video.go index 381267ee..0039990f 100644 --- a/backend/preview/video.go +++ b/backend/preview/video.go @@ -1,26 +1,59 @@ package preview import ( + "bytes" + "fmt" "os" "os/exec" "strconv" + "strings" ) // GenerateVideoPreview generates a single preview image from a video using ffmpeg. // videoPath: path to the input video file. // outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg"). // seekTime: how many seconds into the video to seek before capturing the frame. -func (s *Service) GenerateVideoPreview(videoPath, outputPath string, seekTime int) error { +func (s *Service) GenerateVideoPreview(videoPath, outputPath string) error { + // Step 1: Get video stream duration (v:0) + probeCmd := exec.Command( + "ffprobe", + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + videoPath, + ) + + var probeOut bytes.Buffer + probeCmd.Stdout = &probeOut + probeCmd.Stderr = os.Stderr + + if err := probeCmd.Run(); err != nil { + return fmt.Errorf("ffprobe failed: %w", err) + } + + durationStr := strings.TrimSpace(probeOut.String()) + durationFloat, err := strconv.ParseFloat(durationStr, 64) + if err != nil || durationFloat <= 0 { + return fmt.Errorf("invalid duration: %v", err) + } + + // Step 2: Truncate and compute 10% of video length + duration := int(durationFloat) + seekSeconds := duration / 10 + seekTime := strconv.Itoa(seekSeconds) + + // Step 3: Extract frame at seek time cmd := exec.Command( s.ffmpegPath, - "-ss", strconv.Itoa(seekTime), // seek to a better frame + "-ss", seekTime, "-i", videoPath, "-frames:v", "1", - "-q:v", "10", // quality 1 is best, 31 is worst + "-q:v", "10", + "-y", // overwrite output outputPath, ) - // Optional: capture stdout/stderr for debugging cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/backend/swagger/docs/docs.go b/backend/swagger/docs/docs.go index 3cb048fe..ff30e149 100644 --- a/backend/swagger/docs/docs.go +++ b/backend/swagger/docs/docs.go @@ -1897,7 +1897,7 @@ const docTemplate = `{ "database": { "type": "string" }, - "enableThumbnails": { + "disablePreview": { "type": "boolean" }, "externalUrl": { diff --git a/backend/swagger/docs/swagger.json b/backend/swagger/docs/swagger.json index e27abd1f..6d72eaa6 100644 --- a/backend/swagger/docs/swagger.json +++ b/backend/swagger/docs/swagger.json @@ -1886,7 +1886,7 @@ "database": { "type": "string" }, - "enableThumbnails": { + "disablePreview": { "type": "boolean" }, "externalUrl": { diff --git a/backend/swagger/docs/swagger.yaml b/backend/swagger/docs/swagger.yaml index e6a647a1..c1a0abce 100644 --- a/backend/swagger/docs/swagger.yaml +++ b/backend/swagger/docs/swagger.yaml @@ -259,7 +259,7 @@ definitions: type: string database: type: string - enableThumbnails: + disablePreview: type: boolean externalUrl: type: string diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue index 3598d952..17da40a8 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -161,8 +161,10 @@ export default { return true; }, thumbnailUrl() { + if (!enableThumbs) { + return ""; + } let path = url.removeTrailingSlash(state.req.path) + "/" + this.name; - if (getters.currentView() == "share") { let urlPath = getters.routePath("share"); // Step 1: Split the path by '/' diff --git a/frontend/src/components/files/PopupPreview.vue b/frontend/src/components/files/PopupPreview.vue index 2b206311..55db109b 100644 --- a/frontend/src/components/files/PopupPreview.vue +++ b/frontend/src/components/files/PopupPreview.vue @@ -52,9 +52,14 @@ export default { let left = this.cursorX - width / 2; - // Apply 100px shift if cursor is in the left half - if ((this.cursorX < innerWidth / 2) && !state.isMobile) { - left += 120; + if (state.isMobile) { + // Center the popup if it's mobile + left = (innerWidth - width) / 2; + } else { + // Apply 100px shift if cursor is in the left half + if (this.cursorX < innerWidth / 2) { + left += 120; + } } // Clamp to viewport diff --git a/frontend/src/views/files/ListingView.vue b/frontend/src/views/files/ListingView.vue index d984ade9..06b2bb52 100644 --- a/frontend/src/views/files/ListingView.vue +++ b/frontend/src/views/files/ListingView.vue @@ -187,6 +187,7 @@ export default { this.colunmsResize(); }, scrolling() { + mutations.setPreviewSource(""); const scrollContainer = this.$refs.listingView; if (!scrollContainer) return; From 31043a1ad3dfe00bbb8cd2f8bb1aff299dad52bf Mon Sep 17 00:00:00 2001 From: Graham Steffaniak Date: Thu, 1 May 2025 09:25:54 -0500 Subject: [PATCH 3/4] updated with more fixes --- backend/common/settings/config_test.go | 19 +++++ backend/common/settings/structs.go | 31 +++++---- backend/common/settings/validConfig.yaml | 2 +- backend/indexing/iteminfo/conditions.go | 33 ++++++--- backend/swagger/docs/docs.go | 3 + backend/swagger/docs/swagger.json | 3 + backend/swagger/docs/swagger.yaml | 2 + frontend/src/components/Action.vue | 3 + .../src/components/files/PopupPreview.vue | 69 ++++++++++++------- frontend/src/store/getters.js | 6 +- frontend/src/views/bars/Default.vue | 32 ++++++--- frontend/src/views/settings/Profile.vue | 14 ++-- 12 files changed, 149 insertions(+), 68 deletions(-) diff --git a/backend/common/settings/config_test.go b/backend/common/settings/config_test.go index 9ac02847..97d9a03e 100644 --- a/backend/common/settings/config_test.go +++ b/backend/common/settings/config_test.go @@ -1,6 +1,7 @@ package settings import ( + "os" "reflect" "testing" @@ -52,6 +53,24 @@ func TestConfigLoadChanged(t *testing.T) { } } +func TestConfigLoadEnvVars(t *testing.T) { + defaultConfig := setDefaults() + expectedKey := "MYKEY" + // mock environment variables + os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey) + err := loadConfigWithDefaults("./validConfig.yaml") + if err != nil { + t.Fatalf("error loading config file: %v", err) + } + if Config.Integrations.OnlyOffice.Secret != expectedKey { + t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret) + } + // Use go-cmp to compare the two structs + if diff := cmp.Diff(defaultConfig, Config); diff == "" { + t.Errorf("No change when there should have been (-want +got):\n%s", diff) + } +} + func TestConfigLoadSpecificValues(t *testing.T) { defaultConfig := setDefaults() err := loadConfigWithDefaults("./validConfig.yaml") diff --git a/backend/common/settings/structs.go b/backend/common/settings/structs.go index a2399f4d..194eeed0 100644 --- a/backend/common/settings/structs.go +++ b/backend/common/settings/structs.go @@ -22,21 +22,22 @@ type Settings struct { } type Server struct { - NumImageProcessors int `json:"numImageProcessors"` - Socket string `json:"socket"` - TLSKey string `json:"tlsKey"` - TLSCert string `json:"tlsCert"` - DisablePreviews bool `json:"disablePreview"` - ResizePreviews bool `json:"resizePreview"` - Port int `json:"port"` - BaseURL string `json:"baseURL"` - Logging []LogConfig `json:"logging"` - Database string `json:"database"` - Sources []Source `json:"sources" validate:"required,dive"` - ExternalUrl string `json:"externalUrl"` - InternalUrl string `json:"internalUrl"` // used by integrations - CacheDir string `json:"cacheDir"` - MaxArchiveSizeGB int64 `json:"maxArchiveSize"` + NumImageProcessors int `json:"numImageProcessors"` + Socket string `json:"socket"` + TLSKey string `json:"tlsKey"` + TLSCert string `json:"tlsCert"` + DisablePreviews bool `json:"disablePreview"` + ResizePreviews bool `json:"resizePreview"` + DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` + Port int `json:"port"` + BaseURL string `json:"baseURL"` + Logging []LogConfig `json:"logging"` + Database string `json:"database"` + Sources []Source `json:"sources" validate:"required,dive"` + ExternalUrl string `json:"externalUrl"` + InternalUrl string `json:"internalUrl"` // used by integrations + CacheDir string `json:"cacheDir"` + MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // not exposed to config SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key diff --git a/backend/common/settings/validConfig.yaml b/backend/common/settings/validConfig.yaml index a5c9208b..be966b56 100644 --- a/backend/common/settings/validConfig.yaml +++ b/backend/common/settings/validConfig.yaml @@ -3,7 +3,7 @@ server: socket: "" tlsKey: "" tlsCert: "" - enableThumbnails: false + disablePreview: false resizePreview: true port: 80 baseURL: "/" diff --git a/backend/indexing/iteminfo/conditions.go b/backend/indexing/iteminfo/conditions.go index 7011cd73..b01accfb 100644 --- a/backend/indexing/iteminfo/conditions.go +++ b/backend/indexing/iteminfo/conditions.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/gtsteffaniak/filebrowser/backend/common/settings" ) var AllFiletypeOptions = []string{ @@ -234,20 +236,31 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) { if i.Type == "" { i.Type = ExtendedMimeTypeCheck(ext) } - if i.Type == "blob" { - // Read only the first 512 bytes for efficient MIME detection - file, err := os.Open(realPath) - if err != nil { - - } else { - defer file.Close() - buffer := make([]byte, 512) - n, _ := file.Read(buffer) // Ignore errors from Read - i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0] + // do header detection for certain files to ensure the type is correct for undetected or ambiguous files + switch ext { + case ".ts", "blob": + if !settings.Config.Server.DisableTypeDetectionByHeader { + i.Type = DetectTypeByHeader(realPath) } } } +// DetectTypeByHeader detects the MIME type of a file based on its header. +func DetectTypeByHeader(realPath string) string { + file, err := os.Open(realPath) + if err != nil { + return "blob" + } + defer file.Close() + + buffer := make([]byte, 512) + n, err := file.Read(buffer) + if err != nil { + return "blob" + } + return http.DetectContentType(buffer[:n]) +} + // returns true if the file name contains the search term // returns file type if the file name contains the search term // returns size of file/dir if the file name contains the search term diff --git a/backend/swagger/docs/docs.go b/backend/swagger/docs/docs.go index ff30e149..ac8b575d 100644 --- a/backend/swagger/docs/docs.go +++ b/backend/swagger/docs/docs.go @@ -1900,6 +1900,9 @@ const docTemplate = `{ "disablePreview": { "type": "boolean" }, + "disableTypeDetectionByHeader": { + "type": "boolean" + }, "externalUrl": { "type": "string" }, diff --git a/backend/swagger/docs/swagger.json b/backend/swagger/docs/swagger.json index 6d72eaa6..c02fbc47 100644 --- a/backend/swagger/docs/swagger.json +++ b/backend/swagger/docs/swagger.json @@ -1889,6 +1889,9 @@ "disablePreview": { "type": "boolean" }, + "disableTypeDetectionByHeader": { + "type": "boolean" + }, "externalUrl": { "type": "string" }, diff --git a/backend/swagger/docs/swagger.yaml b/backend/swagger/docs/swagger.yaml index c1a0abce..a4e876c3 100644 --- a/backend/swagger/docs/swagger.yaml +++ b/backend/swagger/docs/swagger.yaml @@ -261,6 +261,8 @@ definitions: type: string disablePreview: type: boolean + disableTypeDetectionByHeader: + type: boolean externalUrl: type: string internalUrl: diff --git a/frontend/src/components/Action.vue b/frontend/src/components/Action.vue index 377be599..42a10d44 100644 --- a/frontend/src/components/Action.vue +++ b/frontend/src/components/Action.vue @@ -53,6 +53,9 @@ export default { return state.stickSidebar; }, }, + mounted() { + this.reEvalAction(); + }, watch: { $route() { this.reEvalAction() diff --git a/frontend/src/components/files/PopupPreview.vue b/frontend/src/components/files/PopupPreview.vue index 55db109b..207063a6 100644 --- a/frontend/src/components/files/PopupPreview.vue +++ b/frontend/src/components/files/PopupPreview.vue @@ -1,11 +1,12 @@