diff --git a/.gitignore b/.gitignore index 6da80487..529ea53e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ *.db *.bak +*.log +*.mjs _old rice-box.go .idea/ -/backend -/backend.exe +/backend/backend +/backend/backend.exe /frontend/dist /frontend/pkg /frontend/test-results diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a352c5..7d82b150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version). +## v0.4.0-beta + + **New Features** + - Better logging https://github.com/gtsteffaniak/filebrowser/issues/288 + - highly configurable + - api logs include user + - onlyOffice support for editing only office files (inspired from https://github.com/filebrowser/filebrowser/pull/2954) + + **Notes** + - Breadcrumbs will only show on file listing (not on previews or editors) + - Config file is now optional. It will run with default settings without one and throw a `[WARN ]` message. + - Added more descriptions to swagger API + ## v0.3.7-beta **Notes**: diff --git a/Dockerfile.playwright b/Dockerfile.playwright index 7f13307d..68fb5d01 100644 --- a/Dockerfile.playwright +++ b/Dockerfile.playwright @@ -1,5 +1,6 @@ FROM gtstef/playwright-base -WORKDIR /app -COPY [ "./backend/filebrowser*", "./"] +WORKDIR /app/frontend COPY [ "./frontend/", "./" ] -RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && npx playwright test +WORKDIR /app/backend/ +COPY [ "./backend/filebrowser*", "./"] +RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && cd ../frontend && npx playwright test diff --git a/Dockerfile.playwright-base b/Dockerfile.playwright-base index 8647b45a..6456b983 100644 --- a/Dockerfile.playwright-base +++ b/Dockerfile.playwright-base @@ -1,5 +1,4 @@ FROM node:22-slim -WORKDIR /app -COPY ./frontend/package.json ./ +WORKDIR /app/frontend RUN npm i @playwright/test -RUN npx playwright install --with-deps firefox \ No newline at end of file +RUN npx playwright install --with-deps firefox diff --git a/README.md b/README.md index 9019a672..723d6dd3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API) Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help. +## Office File Support + +See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing. ## Migration from the original filebrowser @@ -115,7 +118,7 @@ Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | -ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | +FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB | @@ -142,17 +145,16 @@ Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | +office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | -collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | -Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ \ No newline at end of file +Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ diff --git a/backend/auth/hook.go b/backend/auth/hook.go index 85178d98..5786d8e5 100644 --- a/backend/auth/hook.go +++ b/backend/auth/hook.go @@ -3,13 +3,13 @@ package auth import ( "encoding/json" "fmt" - "log" "net/http" "os" "os/exec" "strings" "github.com/gtsteffaniak/filebrowser/backend/errors" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/users" ) @@ -164,7 +164,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) { return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome) } u.Scope = userHome - log.Printf("user: %s, home dir: [%s].", u.Username, userHome) + logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome)) err = a.Users.Save(u) if err != nil { diff --git a/backend/utils/cache.go b/backend/cache/cache.go similarity index 78% rename from backend/utils/cache.go rename to backend/cache/cache.go index 53618223..8305ffee 100644 --- a/backend/utils/cache.go +++ b/backend/cache/cache.go @@ -1,4 +1,4 @@ -package utils +package cache import ( "sync" @@ -6,12 +6,13 @@ import ( ) var ( - DiskUsageCache = newCache(30*time.Second, 24*time.Hour) - RealPathCache = newCache(48*time.Hour, 72*time.Hour) - SearchResultsCache = newCache(15*time.Second, time.Hour) + DiskUsage = NewCache(30*time.Second, 24*time.Hour) + RealPath = NewCache(48*time.Hour, 72*time.Hour) + SearchResults = NewCache(15*time.Second, time.Hour) + OnlyOffice = NewCache(48*time.Hour, 1*time.Hour) ) -func newCache(expires time.Duration, cleanup time.Duration) *KeyCache { +func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache { newCache := KeyCache{ data: make(map[string]cachedValue), expiresAfter: expires, // default @@ -40,6 +41,12 @@ func (c *KeyCache) Set(key string, value interface{}) { } } +func (c *KeyCache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.data, key) +} + func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) { c.mu.Lock() defer c.mu.Unlock() diff --git a/backend/cmd/root.go b/backend/cmd/root.go index bca41196..a7add1ef 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -3,7 +3,6 @@ package cmd import ( "flag" "fmt" - "log" "os" "strings" @@ -11,6 +10,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/files" fbhttp "github.com/gtsteffaniak/filebrowser/backend/http" "github.com/gtsteffaniak/filebrowser/backend/img" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/storage" "github.com/gtsteffaniak/filebrowser/backend/swagger/docs" @@ -25,21 +25,22 @@ func getStore(config string) (*storage.Storage, bool) { settings.Initialize(config) store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database) if err != nil { - log.Fatal("could not load db info: ", err) + logger.Fatal(fmt.Sprintf("could not load db info: %v", err)) } return store, hasDB } func generalUsage() { fmt.Printf(`usage: ./filebrowser [options] - commands: - -v Print the version - -c Print the default config file - set -u Username and password for the new user - set -a Create user as admin - set -s Specify a user scope - set -h Print this help message - ` + "\n") +commands: + -h Print help + -c Print the default config file + version Print version information + set -u Username and password for the new user + set -a Create user as admin + set -s Specify a user scope + set -h Print this help message +`) } func StartFilebrowser() { @@ -90,9 +91,9 @@ func StartFilebrowser() { getStore(dbConfig) // Create the user logic if asAdmin { - log.Printf("Creating user as admin: %s\n", username) + logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username)) } else { - log.Printf("Creating user: %s\n", username) + logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username)) } newUser := users.User{ Username: username, @@ -103,14 +104,15 @@ func StartFilebrowser() { } err = storage.CreateUser(newUser, asAdmin) if err != nil { - log.Fatal("Could not create user: ", err) + logger.Fatal(fmt.Sprintf("could not create user: %v", err)) } return case "version": - fmt.Println("FileBrowser Quantum - A modern web-based file manager") - fmt.Printf("Version : %v\n", version.Version) - fmt.Printf("Commit : %v\n", version.CommitSHA) - fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version) + fmt.Printf(`FileBrowser Quantum - A modern web-based file manager +Version : %v +Commit : %v +Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v +`, version.Version, version.CommitSHA, version.Version) return } } @@ -119,15 +121,15 @@ func StartFilebrowser() { if !dbExists { database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database) } - log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version) - log.Printf("Using Config file : %v", configPath) - log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true") - log.Println(database) sources := []string{} for _, v := range settings.Config.Server.Sources { sources = append(sources, v.Name+": "+v.Path) } - log.Println("Sources :", sources) + logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version)) + logger.Info(fmt.Sprintf("Using Config file : %v", configPath)) + logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")) + logger.Info(database) + logger.Info(fmt.Sprintf("Sources : %v", sources)) serverConfig := settings.Config.Server swagInfo := docs.SwaggerInfo @@ -136,19 +138,19 @@ func StartFilebrowser() { // initialize indexing and schedule indexing ever n minutes (default 5) sourceConfigs := settings.Config.Server.Sources if len(sourceConfigs) == 0 { - log.Fatal("No sources configured, exiting...") + logger.Fatal("No sources configured, exiting...") } for _, source := range sourceConfigs { go files.Initialize(source) } if err := rootCMD(store, &serverConfig); err != nil { - log.Fatal("Error starting filebrowser:", err) + logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err)) } } func rootCMD(store *storage.Storage, serverConfig *settings.Server) error { if serverConfig.NumImageProcessors < 1 { - log.Fatal("Image resize workers count could not be < 1") + logger.Fatal("Image resize workers count could not be < 1") } imgSvc := img.New(serverConfig.NumImageProcessors) @@ -160,7 +162,7 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error { var err error fileCache, err = diskcache.NewFileCache(cacheDir) if err != nil { - log.Fatalf("failed to create file cache: %v", err) + logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err)) } } else { // No-op cache if no cacheDir is specified diff --git a/backend/cmd/rule_rm.go b/backend/cmd/rule_rm.go deleted file mode 100644 index 8e12068c..00000000 --- a/backend/cmd/rule_rm.go +++ /dev/null @@ -1,68 +0,0 @@ -package cmd - -import ( - "strconv" - - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/settings" - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - rulesCmd.AddCommand(rulesRmCommand) - rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove") - _ = rulesRmCommand.MarkFlagRequired("index") -} - -var rulesRmCommand = &cobra.Command{ - Use: "rm [index_end]", - Short: "Remove a global rule or user rule", - Long: `Remove a global rule or user rule. The provided index -is the same that's printed when you run 'rules ls'. Note -that after each removal/addition, the index of the -commands change. So be careful when removing them after each -other. - -You can also specify an optional parameter (index_end) so -you can remove all commands from 'index' to 'index_end', -including 'index_end'.`, - Args: func(cmd *cobra.Command, args []string) error { - if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd - return err - } - - for _, arg := range args { - if _, err := strconv.Atoi(arg); err != nil { - return err - } - } - - return nil - }, - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - i, err := strconv.Atoi(args[0]) - utils.CheckErr("strconv.Atoi", err) - f := i - if len(args) == 2 { //nolint:gomnd - f, err = strconv.Atoi(args[1]) - utils.CheckErr("strconv.Atoi", err) - } - - user := func(u *users.User) { - u.Rules = append(u.Rules[:i], u.Rules[f+1:]...) - err := store.Users.Save(u) - utils.CheckErr("store.Users.Save", err) - } - - global := func(s *settings.Settings) { - s.Rules = append(s.Rules[:i], s.Rules[f+1:]...) - err := store.Settings.Save(s) - utils.CheckErr("store.Settings.Save", err) - } - - runRules(store, cmd, user, global) - }), -} diff --git a/backend/cmd/rules.go b/backend/cmd/rules.go deleted file mode 100644 index 5249bc7a..00000000 --- a/backend/cmd/rules.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - - "github.com/gtsteffaniak/filebrowser/backend/settings" - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply") - rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply") -} - -var rulesCmd = &cobra.Command{ - Use: "rules", - Short: "Rules management utility", - Long: `On each subcommand you'll have available at least two flags: -"username" and "id". You must either set only one of them -or none. If you set one of them, the command will apply to -an user, otherwise it will be applied to the global set or -rules.`, - Args: cobra.NoArgs, -} - -func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) { - id := getUserIdentifier(cmd.Flags()) - if id != nil { - user, err := st.Users.Get("", id) - utils.CheckErr("st.Users.Get", err) - - if usersFn != nil { - usersFn(user) - } - - printRules(user.Rules, id) - return - } - - s, err := st.Settings.Get() - utils.CheckErr("st.Settings.Get", err) - - if globalFn != nil { - globalFn(s) - } - - printRules(s.Rules, id) -} - -func getUserIdentifier(flags *pflag.FlagSet) interface{} { - id := mustGetUint(flags, "id") - username := mustGetString(flags, "username") - - if id != 0 { - return id - } else if username != "" { - return username - } - - return nil -} - -func printRules(rulez []users.Rule, id interface{}) { - - for id, rule := range rulez { - fmt.Printf("(%d) ", id) - if rule.Regex { - if rule.Allow { - fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw) - } else { - fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw) - } - } else { - if rule.Allow { - fmt.Printf("Allow Path: \t%s\n", rule.Path) - } else { - fmt.Printf("Disallow Path: \t%s\n", rule.Path) - } - } - } -} diff --git a/backend/cmd/rules_add.go b/backend/cmd/rules_add.go deleted file mode 100644 index 2ae17b9b..00000000 --- a/backend/cmd/rules_add.go +++ /dev/null @@ -1,59 +0,0 @@ -package cmd - -import ( - "regexp" - - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/settings" - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - rulesCmd.AddCommand(rulesAddCmd) - rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule") - rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule") -} - -var rulesAddCmd = &cobra.Command{ - Use: "add ", - Short: "Add a global rule or user rule", - Long: `Add a global rule or user rule.`, - Args: cobra.ExactArgs(1), - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - allow := mustGetBool(cmd.Flags(), "allow") - regex := mustGetBool(cmd.Flags(), "regex") - exp := args[0] - - if regex { - regexp.MustCompile(exp) - } - - rule := users.Rule{ - Allow: allow, - Regex: regex, - } - - if regex { - rule.Regexp = &users.Regexp{Raw: exp} - } else { - rule.Path = exp - } - - user := func(u *users.User) { - u.Rules = append(u.Rules, rule) - err := store.Users.Save(u) - utils.CheckErr("store.Users.Save", err) - } - - global := func(s *settings.Settings) { - s.Rules = append(s.Rules, rule) - err := store.Settings.Save(s) - utils.CheckErr("store.Settings.Save", err) - } - - runRules(store, cmd, user, global) - }), -} diff --git a/backend/cmd/rules_ls.go b/backend/cmd/rules_ls.go deleted file mode 100644 index e55db473..00000000 --- a/backend/cmd/rules_ls.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmd - -import ( - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/spf13/cobra" -) - -func init() { - rulesCmd.AddCommand(rulesLsCommand) -} - -var rulesLsCommand = &cobra.Command{ - Use: "ls", - Short: "List global rules or user specific rules", - Long: `List global rules or user specific rules.`, - Args: cobra.NoArgs, - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - runRules(store, cmd, nil, nil) - }), -} diff --git a/backend/cmd/users.go b/backend/cmd/users.go deleted file mode 100644 index fb471d5f..00000000 --- a/backend/cmd/users.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strconv" - "text/tabwriter" - - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/users" -) - -var usersCmd = &cobra.Command{ - Use: "users", - Short: "Users management utility", - Long: `Users management utility.`, - Args: cobra.NoArgs, -} - -func printUsers(usrs []*users.User) { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd - fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock") - - for _, u := range usrs { - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n", - u.ID, - u.Username, - u.Scope, - u.Locale, - u.ViewMode, - u.SingleClick, - u.Perm.Admin, - u.Perm.Execute, - u.Perm.Create, - u.Perm.Rename, - u.Perm.Modify, - u.Perm.Delete, - u.Perm.Share, - u.Perm.Download, - u.LockPassword, - ) - } - - w.Flush() -} - -func parseUsernameOrID(arg string) (username string, id uint) { - id64, err := strconv.ParseUint(arg, 10, 64) - if err != nil { - return arg, 0 - } - return "", uint(id64) -} diff --git a/backend/cmd/users_add.go b/backend/cmd/users_add.go deleted file mode 100644 index bcf5f729..00000000 --- a/backend/cmd/users_add.go +++ /dev/null @@ -1,42 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - usersCmd.AddCommand(usersAddCmd) -} - -var usersAddCmd = &cobra.Command{ - Use: "add ", - Short: "Create a new user", - Long: `Create a new user and add it to the database.`, - Args: cobra.ExactArgs(2), //nolint:gomnd - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - user := &users.User{ - Username: args[0], - Password: args[1], - LockPassword: mustGetBool(cmd.Flags(), "lockPassword"), - } - servSettings, err := store.Settings.GetServer() - utils.CheckErr("store.Settings.GetServer()", err) - // since getUserDefaults() polluted s.Defaults.Scope - // which makes the Scope not the one saved in the db - // we need the right s.Defaults.Scope here - s2, err := store.Settings.Get() - utils.CheckErr("store.Settings.Get()", err) - - userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root) - utils.CheckErr("s2.MakeUserDir", err) - user.Scope = userHome - - err = store.Users.Save(user) - utils.CheckErr("store.Users.Save", err) - printUsers([]*users.User{user}) - }), -} diff --git a/backend/cmd/users_export.go b/backend/cmd/users_export.go deleted file mode 100644 index bd4cfc52..00000000 --- a/backend/cmd/users_export.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/utils" - "github.com/spf13/cobra" -) - -func init() { - usersCmd.AddCommand(usersExportCmd) -} - -var usersExportCmd = &cobra.Command{ - Use: "export ", - Short: "Export all users to a file.", - Long: `Export all users to a json or yaml file. Please indicate the -path to the file where you want to write the users.`, - Args: jsonYamlArg, - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - list, err := store.Users.Gets("") - utils.CheckErr("store.Users.Gets", err) - - err = marshal(args[0], list) - utils.CheckErr("marshal", err) - }), -} diff --git a/backend/cmd/users_find.go b/backend/cmd/users_find.go deleted file mode 100644 index 0bb40112..00000000 --- a/backend/cmd/users_find.go +++ /dev/null @@ -1,53 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - usersCmd.AddCommand(usersFindCmd) - usersCmd.AddCommand(usersLsCmd) -} - -var usersFindCmd = &cobra.Command{ - Use: "find ", - Short: "Find a user by username or id", - Long: `Find a user by username or id. If no flag is set, all users will be printed.`, - Args: cobra.ExactArgs(1), - Run: findUsers, -} - -var usersLsCmd = &cobra.Command{ - Use: "ls", - Short: "List all users.", - Args: cobra.NoArgs, - Run: findUsers, -} - -var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - var ( - list []*users.User - user *users.User - err error - ) - - if len(args) == 1 { - username, id := parseUsernameOrID(args[0]) - if username != "" { - user, err = store.Users.Get("", username) - } else { - user, err = store.Users.Get("", id) - } - - list = []*users.User{user} - } else { - list, err = store.Users.Gets("") - } - - utils.CheckErr("findUsers", err) - printUsers(list) -}) diff --git a/backend/cmd/users_import.go b/backend/cmd/users_import.go deleted file mode 100644 index 6ac73e95..00000000 --- a/backend/cmd/users_import.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "os" - "strconv" - - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - usersCmd.AddCommand(usersImportCmd) - usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo") - usersImportCmd.Flags().Bool("replace", false, "replace the entire user base") -} - -var usersImportCmd = &cobra.Command{ - Use: "import ", - Short: "Import users from a file", - Long: `Import users from a file. The path must be for a json or yaml -file. You can use this command to import new users to your -installation. For that, just don't place their ID on the files -list or set it to 0.`, - Args: jsonYamlArg, - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - fd, err := os.Open(args[0]) - utils.CheckErr("os.Open", err) - defer fd.Close() - - list := []*users.User{} - err = unmarshal(args[0], &list) - utils.CheckErr("unmarshal", err) - - if mustGetBool(cmd.Flags(), "replace") { - oldUsers, err := store.Users.Gets("") - utils.CheckErr("store.Users.Gets", err) - - err = marshal("users.backup.json", list) - utils.CheckErr("marshal users.backup.json", err) - - for _, user := range oldUsers { - err = store.Users.Delete(user.ID) - utils.CheckErr("store.Users.Delete", err) - } - } - - overwrite := mustGetBool(cmd.Flags(), "overwrite") - - for _, user := range list { - onDB, err := store.Users.Get("", user.ID) - - // User exists in DB. - if err == nil { - if !overwrite { - newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered") - utils.CheckErr("", newErr) - } - - // If the usernames mismatch, check if there is another one in the DB - // with the new username. If there is, print an error and cancel the - // operation - if user.Username != onDB.Username { - if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet - newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID) - utils.CheckErr("usernameConflictError", newErr) - } - } - } else { - // If it doesn't exist, set the ID to 0 to automatically get a new - // one that make sense in this DB. - user.ID = 0 - } - - err = store.Users.Save(user) - utils.CheckErr("store.Users.Save", err) - } - }), -} - -func usernameConflictError(username string, originalID, newID uint) error { - return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`, - newID, username, originalID) -} diff --git a/backend/cmd/users_rm.go b/backend/cmd/users_rm.go deleted file mode 100644 index 4d7176d0..00000000 --- a/backend/cmd/users_rm.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/utils" - "github.com/spf13/cobra" -) - -func init() { - usersCmd.AddCommand(usersRmCmd) -} - -var usersRmCmd = &cobra.Command{ - Use: "rm ", - Short: "Delete a user by username or id", - Long: `Delete a user by username or id`, - Args: cobra.ExactArgs(1), - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - username, id := parseUsernameOrID(args[0]) - var err error - - if username != "" { - err = store.Users.Delete(username) - } else { - err = store.Users.Delete(id) - } - - utils.CheckErr("usersRmCmd", err) - log.Println("user deleted successfully") - }), -} diff --git a/backend/cmd/users_update.go b/backend/cmd/users_update.go deleted file mode 100644 index b2824d16..00000000 --- a/backend/cmd/users_update.go +++ /dev/null @@ -1,40 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/users" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func init() { - usersCmd.AddCommand(usersUpdateCmd) -} - -var usersUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Updates an existing user", - Long: `Updates an existing user. Set the flags for the -options you want to change.`, - Args: cobra.ExactArgs(1), - Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) { - username, id := parseUsernameOrID(args[0]) - - var ( - err error - user *users.User - ) - - if id != 0 { - user, err = store.Users.Get("", id) - } else { - user, err = store.Users.Get("", username) - } - utils.CheckErr("store.Users.Get", err) - - err = store.Users.Update(user) - utils.CheckErr("store.Users.Update", err) - printUsers([]*users.User{user}) - }), -} diff --git a/backend/cmd/utils.go b/backend/cmd/utils.go deleted file mode 100644 index 94a57cdb..00000000 --- a/backend/cmd/utils.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - - "github.com/goccy/go-yaml" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - - "github.com/gtsteffaniak/filebrowser/backend/storage" - "github.com/gtsteffaniak/filebrowser/backend/utils" -) - -func mustGetString(flags *pflag.FlagSet, flag string) string { - s, err := flags.GetString(flag) - utils.CheckErr("mustGetString", err) - return s -} - -func mustGetBool(flags *pflag.FlagSet, flag string) bool { - b, err := flags.GetBool(flag) - utils.CheckErr("mustGetBool", err) - return b -} - -func mustGetUint(flags *pflag.FlagSet, flag string) uint { - b, err := flags.GetUint(flag) - utils.CheckErr("mustGetUint", err) - return b -} - -type cobraFunc func(cmd *cobra.Command, args []string) -type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage) - -func marshal(filename string, data interface{}) error { - fd, err := os.Create(filename) - - utils.CheckErr("os.Create", err) - defer fd.Close() - - switch ext := filepath.Ext(filename); ext { - case ".json": - encoder := json.NewEncoder(fd) - encoder.SetIndent("", " ") - return encoder.Encode(data) - case ".yml", ".yaml": //nolint:goconst - _, err := yaml.Marshal(fd) - return err - default: - return errors.New("invalid format: " + ext) - } -} - -func unmarshal(filename string, data interface{}) error { - fd, err := os.Open(filename) - utils.CheckErr("os.Open", err) - defer fd.Close() - - switch ext := filepath.Ext(filename); ext { - case ".json": - return json.NewDecoder(fd).Decode(data) - case ".yml", ".yaml": - return yaml.NewDecoder(fd).Decode(data) - default: - return errors.New("invalid format: " + ext) - } -} - -func jsonYamlArg(cmd *cobra.Command, args []string) error { - if err := cobra.ExactArgs(1)(cmd, args); err != nil { - return err - } - - switch ext := filepath.Ext(args[0]); ext { - case ".json", ".yml", ".yaml": - return nil - default: - return errors.New("invalid format: " + ext) - } -} - -func cobraCmd(fn pythonFunc) cobraFunc { - return func(cmd *cobra.Command, args []string) { - } -} diff --git a/backend/filebrowser b/backend/filebrowser new file mode 100755 index 00000000..8f78d299 Binary files /dev/null and b/backend/filebrowser differ diff --git a/backend/filebrowser-playwright.yaml b/backend/filebrowser-playwright.yaml index b9aca8f9..3a2d7d16 100644 --- a/backend/filebrowser-playwright.yaml +++ b/backend/filebrowser-playwright.yaml @@ -1,7 +1,7 @@ server: port: 80 baseURL: "/" - root: "./tests/playwright-files" + root: "../frontend/tests/playwright-files" auth: method: password signup: false diff --git a/backend/files/conditions.go b/backend/files/conditions.go index 17b08050..4d4b36e5 100644 --- a/backend/files/conditions.go +++ b/backend/files/conditions.go @@ -2,6 +2,7 @@ package files import ( "mime" + "path/filepath" "regexp" "strconv" "strings" @@ -52,6 +53,17 @@ var documentTypes = []string{ ".fb2", // FictionBook } +var onlyOfficeSupported = []string{ + ".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub", + ".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt", + ".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml", + ".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls", + ".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps", + ".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx", + ".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi", + ".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps", +} + // Text-based file extensions var textTypes = []string{ // Common Text Formats @@ -256,3 +268,13 @@ func isArchive(extension string) bool { } return false } + +func isOnlyOffice(name string) bool { + extention := filepath.Ext(name) + for _, typefile := range onlyOfficeSupported { + if extention == typefile { + return true + } + } + return false +} diff --git a/backend/files/file.go b/backend/files/file.go index 1b5a72fd..a6996119 100644 --- a/backend/files/file.go +++ b/backend/files/file.go @@ -21,9 +21,12 @@ import ( "time" "unicode/utf8" + "github.com/gtsteffaniak/filebrowser/backend/cache" "github.com/gtsteffaniak/filebrowser/backend/errors" "github.com/gtsteffaniak/filebrowser/backend/fileutils" + "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/users" + "github.com/gtsteffaniak/filebrowser/backend/utils" ) var ( @@ -32,30 +35,32 @@ var ( ) type ItemInfo struct { - Name string `json:"name"` - Size int64 `json:"size"` - ModTime time.Time `json:"modified"` - Type string `json:"type"` + Name string `json:"name"` // name of the file + Size int64 `json:"size"` // length in bytes for regular files + ModTime time.Time `json:"modified"` // modification time + Type string `json:"type"` // type of the file, either "directory" or a file mimetype } // FileInfo describes a file. // reduced item is non-recursive reduced "Items", used to pass flat items array type FileInfo struct { ItemInfo - Files []ItemInfo `json:"files"` - Folders []ItemInfo `json:"folders"` - Path string `json:"path"` + Files []ItemInfo `json:"files"` // files in the directory + Folders []ItemInfo `json:"folders"` // folders in the directory + Path string `json:"path"` // path scoped to the associated index } // for efficiency, a response will be a pointer to the data // extra calculated fields can be added here type ExtendedFileInfo struct { *FileInfo - Content string `json:"content,omitempty"` - Subtitles []string `json:"subtitles,omitempty"` - Checksums map[string]string `json:"checksums,omitempty"` - Token string `json:"token,omitempty"` - RealPath string `json:"-"` + Content string `json:"content,omitempty"` // text content of a file, if requested + Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files + Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file + Token string `json:"token,omitempty"` // token for the file -- used for sharing + OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files + Source string `json:"source"` // associated index source for the file + RealPath string `json:"-"` } // FileOptions are the options when getting a file info. @@ -134,9 +139,23 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) { } response.FileInfo = info response.RealPath = realPath + if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) { + response.OnlyOfficeId = generateOfficeId(realPath) + } return response, nil } +func generateOfficeId(realPath string) string { + key, ok := cache.OnlyOffice.Get(realPath).(string) + if !ok { + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + documentKey := utils.HashSHA256(realPath + timestamp) + cache.OnlyOffice.Set(realPath, documentKey) + return documentKey + } + return key +} + // Checksum checksums a given File for a given User, using a specific // algorithm. The checksums data is saved on File object. func GetChecksum(fullPath, algo string) (map[string]string, error) { diff --git a/backend/files/indexingFiles.go b/backend/files/indexingFiles.go index c2bb27f0..db155b0c 100644 --- a/backend/files/indexingFiles.go +++ b/backend/files/indexingFiles.go @@ -2,7 +2,6 @@ package files import ( "fmt" - "log" "os" "path/filepath" "slices" @@ -10,6 +9,8 @@ import ( "sync" "time" + "github.com/gtsteffaniak/filebrowser/backend/cache" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/utils" ) @@ -52,11 +53,11 @@ func Initialize(source settings.Source) { if !newIndex.Source.Config.Disabled { time.Sleep(time.Second) - log.Println("Initializing index and assessing file system complexity") + logger.Info("Initializing index and assessing file system complexity") newIndex.RunIndexing("/", false) go newIndex.setupIndexingScanners() } else { - log.Println("Indexing disabled for source: ", newIndex.Source.Name) + logger.Debug("Indexing disabled for source: " + newIndex.Source.Name) } } @@ -96,7 +97,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err for _, item := range cacheDirItems { err = idx.indexDirectory(combinedPath+item.Name, quick, true) if err != nil { - fmt.Printf("error indexing directory %v : %v", combinedPath+item.Name, err) + logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err)) } } return nil @@ -147,7 +148,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err // Recursively index the subdirectory err = idx.indexDirectory(dirPath, quick, recursive) if err != nil { - log.Printf("Failed to index directory %s: %v", dirPath, err) + logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err)) continue } } @@ -226,8 +227,8 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int6 func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) { combined := append([]string{idx.Source.Path}, relativePath...) joinedPath := filepath.Join(combined...) - isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool) - cached, ok := utils.RealPathCache.Get(joinedPath).(string) + isDir, _ := cache.RealPath.Get(joinedPath + ":isdir").(bool) + cached, ok := cache.RealPath.Get(joinedPath).(string) if ok && cached != "" { return cached, isDir, nil } @@ -239,8 +240,8 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) { // Resolve symlinks and get the real path realPath, isDir, err := resolveSymlinks(absolutePath) if err == nil { - utils.RealPathCache.Set(joinedPath, realPath) - utils.RealPathCache.Set(joinedPath+":isdir", isDir) + cache.RealPath.Set(joinedPath, realPath) + cache.RealPath.Set(joinedPath+":isdir", isDir) } return realPath, isDir, err } diff --git a/backend/files/indexingSchedule.go b/backend/files/indexingSchedule.go index cc58707f..fea5839e 100644 --- a/backend/files/indexingSchedule.go +++ b/backend/files/indexingSchedule.go @@ -1,8 +1,10 @@ package files import ( - "log" + "fmt" "time" + + "github.com/gtsteffaniak/filebrowser/backend/logger" ) // schedule in minutes @@ -32,7 +34,7 @@ func (idx *Index) newScanner(origin string) { } // Log and sleep before indexing - log.Printf("Next scan in %v\n", sleepTime) + logger.Debug(fmt.Sprintf("Next scan in %v\n", sleepTime)) time.Sleep(sleepTime) idx.scannerMu.Lock() @@ -74,9 +76,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) { prevNumDirs := idx.NumDirs prevNumFiles := idx.NumFiles if quick { - log.Println("Starting quick scan") + logger.Debug("Starting quick scan") } else { - log.Println("Starting full scan") + logger.Debug("Starting full scan") idx.NumDirs = 0 idx.NumFiles = 0 } @@ -85,8 +87,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) { // Perform the indexing operation err := idx.indexDirectory("/", quick, true) if err != nil { - log.Printf("Error during indexing: %v", err) + logger.Error(fmt.Sprintf("Error during indexing: %v", err)) } + firstRun := idx.LastIndexed == time.Time{} // Update the LastIndexed time idx.LastIndexed = time.Now() idx.indexingTime = int(time.Since(startTime).Seconds()) @@ -102,12 +105,20 @@ func (idx *Index) RunIndexing(origin string, quick bool) { } else { idx.assessment = "normal" } - log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", idx.assessment, idx.NumDirs, idx.NumFiles) + if firstRun { + logger.Info(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles)) + } else { + logger.Debug(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles)) + } if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles { idx.FilesChangedDuringIndexing = true } } - log.Printf("Time Spent Indexing : %v seconds\n", idx.indexingTime) + if firstRun { + logger.Info(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime)) + } else { + logger.Debug(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime)) + } } func (idx *Index) setupIndexingScanners() { diff --git a/backend/files/search.go b/backend/files/search.go index a4a3ff14..266e141a 100644 --- a/backend/files/search.go +++ b/backend/files/search.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/gtsteffaniak/filebrowser/backend/cache" "github.com/gtsteffaniak/filebrowser/backend/utils" ) @@ -23,18 +24,18 @@ type SearchResult struct { func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult { // Remove slashes scope = idx.makeIndexPath(scope) - runningHash := utils.GenerateRandomHash(4) + runningHash := utils.InsecureRandomIdentifier(4) sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map searchOptions := ParseSearch(search) results := make(map[string]SearchResult, 0) count := 0 var directories []string - cachedDirs, ok := utils.SearchResultsCache.Get(idx.Source.Path + scope).([]string) + cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string) if ok { directories = cachedDirs } else { directories = idx.getDirsInScope(scope) - utils.SearchResultsCache.Set(idx.Source.Path+scope, directories) + cache.SearchResults.Set(idx.Source.Path+scope, directories) } for _, searchTerm := range searchOptions.Terms { if searchTerm == "" { diff --git a/backend/go.mod b/backend/go.mod index b850e62b..0138e49c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,13 +7,11 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dsoprea/go-exif/v3 v3.0.1 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 - github.com/goccy/go-yaml v1.15.13 + github.com/goccy/go-yaml v1.15.15 github.com/golang-jwt/jwt/v4 v4.5.1 github.com/google/go-cmp v0.6.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/afero v1.11.0 - github.com/spf13/cobra v1.8.1 - github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.4 @@ -35,7 +33,6 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8df0c77a..a220938f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,7 +6,6 @@ github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwv github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac= github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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= @@ -47,8 +46,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg= -github.com/goccy/go-yaml v1.15.13/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.15.15 h1:5turdzAlutS2Q7/QR/9R99Z1K0J00qDb4T0pHJcZ5ew= +github.com/goccy/go-yaml v1.15.15/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= @@ -65,8 +64,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -86,15 +83,10 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -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/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/backend/http/auth.go b/backend/http/auth.go index 80eb2f2b..f74b0fb6 100644 --- a/backend/http/auth.go +++ b/backend/http/auth.go @@ -4,7 +4,6 @@ import ( "encoding/json" libError "errors" "fmt" - "log" "net/http" "net/url" "os" @@ -18,6 +17,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/errors" "github.com/gtsteffaniak/filebrowser/backend/files" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/share" "github.com/gtsteffaniak/filebrowser/backend/users" @@ -46,9 +46,18 @@ func extractToken(r *http.Request) (string, error) { } } + auth := r.URL.Query().Get("auth") + if auth != "" { + hasToken = true + if strings.Count(auth, ".") == 2 { + return auth, nil + } + } + // Check for Authorization header authHeader := r.Header.Get("Authorization") if authHeader != "" { + hasToken = true // Split the header to get "Bearer {token}" parts := strings.Split(authHeader, " ") @@ -58,14 +67,6 @@ func extractToken(r *http.Request) (string, error) { } } - auth := r.URL.Query().Get("auth") - if auth != "" { - hasToken = true - if strings.Count(auth, ".") == 2 { - return auth, nil - } - } - if hasToken { return "", fmt.Errorf("invalid token provided") } @@ -129,12 +130,12 @@ func signupHandler(w http.ResponseWriter, r *http.Request) { userHome, err := config.MakeUserDir(user.Username, user.Scope, files.RootPaths["default"]) if err != nil { - log.Printf("create user: failed to mkdir user home dir: [%s]", userHome) + logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } user.Scope = userHome - log.Printf("new user: %s, home dir: [%s].", user.Username, userHome) + logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome)) err = store.Users.Save(&user) if err == errors.ErrExist { http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) @@ -151,7 +152,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in } func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) { - signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, user.Perm) + signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, user.Perm) if err != nil { if strings.Contains(err.Error(), "key already exists with same name") { return http.StatusConflict, err diff --git a/backend/http/middleware.go b/backend/http/middleware.go index f6e18c30..62ad2369 100644 --- a/backend/http/middleware.go +++ b/backend/http/middleware.go @@ -4,7 +4,6 @@ import ( "compress/gzip" "encoding/json" "fmt" - "log" "net/http" "path/filepath" "strings" @@ -12,15 +11,16 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/gtsteffaniak/filebrowser/backend/files" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/runner" - "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/users" ) type requestContext struct { user *users.User *runner.Runner - raw interface{} + raw interface{} + token string } type HttpResponse struct { @@ -94,7 +94,7 @@ func withAdminHelper(fn handleFunc) handleFunc { // Middleware to retrieve and authenticate user func withUserHelper(fn handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) { - if settings.Config.Auth.Method == "noauth" { + if config.Auth.Method == "noauth" { var err error // Retrieve the user from the store and store it in the context data.user, err = store.Users.Get(files.RootPaths["default"], "admin") @@ -108,8 +108,10 @@ func withUserHelper(fn handleFunc) handleFunc { } tokenString, err := extractToken(r) if err != nil { + logger.Debug(fmt.Sprintf("error extracting from request %v", err)) return http.StatusUnauthorized, err } + data.token = tokenString var tk users.AuthToken token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc) @@ -126,11 +128,15 @@ func withUserHelper(fn handleFunc) handleFunc { if tk.Expires < time.Now().Add(time.Hour).Unix() { w.Header().Add("X-Renew-Token", "true") } + // Retrieve the user from the store and store it in the context data.user, err = store.Users.Get(files.RootPaths["default"], tk.BelongsTo) if err != nil { return http.StatusInternalServerError, err } + + setUserInResponseWriter(w, data.user) + // Call the handler function, passing in the context return fn(w, r, data) } @@ -172,14 +178,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc { // Marshal the error response to JSON errorBytes, marshalErr := json.Marshal(response) if marshalErr != nil { - log.Printf("Error marshalling error response: %v", marshalErr) + logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr)) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Write the JSON error response if _, writeErr := w.Write(errorBytes); writeErr != nil { - log.Printf("Error writing error response: %v", writeErr) + logger.Error(fmt.Sprintf("Error writing error response: %v", writeErr)) } return } @@ -233,6 +239,7 @@ type ResponseWriterWrapper struct { StatusCode int wroteHeader bool PayloadSize int + User string } // WriteHeader captures the status code and ensures it's only written once @@ -255,6 +262,16 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) { return w.ResponseWriter.Write(b) } +// Helper function to set the user in the ResponseWriterWrapper +func setUserInResponseWriter(w http.ResponseWriter, user *users.User) { + // Wrap the response writer to set the user field + if wrappedWriter, ok := w.(*ResponseWriterWrapper); ok { + if user != nil { + wrappedWriter.User = user.Username + } + } +} + // LoggingMiddleware logs each request and its status code. func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -267,30 +284,23 @@ func LoggingMiddleware(next http.Handler) http.Handler { // Call the next handler. next.ServeHTTP(wrappedWriter, r) - // Determine the color based on the status code. - color := "\033[32m" // Default green color - if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 { - color = "\033[33m" // Yellow for client errors (4xx) - } else if wrappedWriter.StatusCode >= 500 { - color = "\033[31m" // Red for server errors (5xx) - } - // Capture the full URL path including the query parameters. fullURL := r.URL.Path if r.URL.RawQuery != "" { fullURL += "?" + r.URL.RawQuery } - - // Log the request, status code, and response size. - log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s", - color, - r.Method, - wrappedWriter.StatusCode, // Captured status code - r.RemoteAddr, - time.Since(start).String(), - fullURL, - "\033[0m", // Reset color - ) + truncUser := wrappedWriter.User + if len(truncUser) > 12 { + truncUser = truncUser[:10] + ".." + } + logger.Api( + fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"", + r.Method, + wrappedWriter.StatusCode, // Captured status code + r.RemoteAddr, + truncUser, + time.Since(start).String(), + fullURL), wrappedWriter.StatusCode) }) } diff --git a/backend/http/middleware_test.go b/backend/http/middleware_test.go index 283bb7f5..5c135b75 100644 --- a/backend/http/middleware_test.go +++ b/backend/http/middleware_test.go @@ -105,7 +105,7 @@ func TestWithAdminHelper(t *testing.T) { data := &requestContext{ user: tc.user, } - token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, tc.user.Perm) + token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm) if err != nil { t.Fatalf("Error making token for request: %v", err) } diff --git a/backend/http/onlyOffice.go b/backend/http/onlyOffice.go new file mode 100644 index 00000000..322be3f7 --- /dev/null +++ b/backend/http/onlyOffice.go @@ -0,0 +1,209 @@ +package http + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/golang-jwt/jwt/v4" + "github.com/gtsteffaniak/filebrowser/backend/cache" + "github.com/gtsteffaniak/filebrowser/backend/files" + "github.com/gtsteffaniak/filebrowser/backend/settings" +) + +const ( + onlyOfficeStatusDocumentClosedWithChanges = 2 + onlyOfficeStatusDocumentClosedWithNoChanges = 4 + onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6 +) + +type OnlyOfficeCallback struct { + ChangesURL string `json:"changesurl,omitempty"` + Key string `json:"key,omitempty"` + Status int `json:"status,omitempty"` + URL string `json:"url,omitempty"` + Users []string `json:"users,omitempty"` + UserData string `json:"userdata,omitempty"` +} + +func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + if settings.Config.Integrations.OnlyOffice.Url == "" { + return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings") + } + + if !d.user.Perm.Modify { + return http.StatusForbidden, nil + } + encodedUrl := r.URL.Query().Get("url") + source := r.URL.Query().Get("source") + if source == "" { + source = "default" + } + // Decode the URL-encoded path + url, err := url.QueryUnescape(encodedUrl) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err) + } + + // get path from url + pathParts := strings.Split(url, "/api/raw?files=/") + path := pathParts[len(pathParts)-1] + urlFirst := pathParts[0] + if settings.Config.Server.InternalUrl != "" { + urlFirst = settings.Config.Server.InternalUrl + replacement := strings.Split(url, "/api/raw")[0] + url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1) + } + fileInfo, err := files.FileInfoFaster(files.FileOptions{ + Path: filepath.Join(d.user.Scope, path), + Modify: d.user.Perm.Modify, + Source: source, + Expand: false, + ReadHeader: config.Server.TypeDetectionByHeader, + Checker: d.user, + }) + + if err != nil { + return errToStatus(err), err + } + + id, err := getOnlyOfficeId(source, fileInfo.Path) + if err != nil { + return http.StatusNotFound, err + } + split := strings.Split(fileInfo.Name, ".") + fileType := split[len(split)-1] + + theme := "light" + if d.user.DarkMode { + theme = "dark" + } + + clientConfig := map[string]interface{}{ + "document": map[string]interface{}{ + "fileType": fileType, + "key": id, + "title": fileInfo.Name, + "url": url + "&auth=" + d.token, + "permissions": map[string]interface{}{ + "edit": d.user.Perm.Modify, + "download": d.user.Perm.Download, + "print": d.user.Perm.Download, + }, + }, + "editorConfig": map[string]interface{}{ + "callbackUrl": fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token), + "user": map[string]interface{}{ + "id": strconv.FormatUint(uint64(d.user.ID), 10), + "name": d.user.Username, + }, + "customization": map[string]interface{}{ + "autosave": true, + "forcesave": true, + "uiTheme": theme, + }, + "lang": d.user.Locale, + "mode": "edit", + }, + } + if settings.Config.Integrations.OnlyOffice.Secret != "" { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig)) + signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret)) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT") + } + clientConfig["token"] = signature + } + return renderJSON(w, r, clientConfig) +} + +func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return http.StatusInternalServerError, err + } + + var data OnlyOfficeCallback + err = json.Unmarshal(body, &data) + if err != nil { + return http.StatusInternalServerError, err + } + + encodedPath := r.URL.Query().Get("path") + source := r.URL.Query().Get("source") + if source == "" { + source = "default" + } + // Decode the URL-encoded path + path, err := url.QueryUnescape(encodedPath) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err) + } + if data.Status == onlyOfficeStatusDocumentClosedWithChanges || + data.Status == onlyOfficeStatusDocumentClosedWithNoChanges { + // Refer to only-office documentation + // - https://api.onlyoffice.com/editors/coedit + // - https://api.onlyoffice.com/editors/callback + // + // When the document is fully closed by all editors, + // then the document key should no longer be re-used. + deleteOfficeId(source, path) + } + + if data.Status == onlyOfficeStatusDocumentClosedWithChanges || + data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen { + if !d.user.Perm.Modify { + return http.StatusForbidden, nil + } + + doc, err := http.Get(data.URL) + if err != nil { + return http.StatusInternalServerError, err + } + defer doc.Body.Close() + + err = d.Runner.RunHook(func() error { + fileOpts := files.FileOptions{ + Path: path, + Source: source, + } + writeErr := files.WriteFile(fileOpts, doc.Body) + if writeErr != nil { + return writeErr + } + return nil + }, "save", path, "", d.user) + + if err != nil { + return http.StatusInternalServerError, err + } + } + + resp := map[string]int{ + "error": 0, + } + return renderJSON(w, r, resp) +} + +func getOnlyOfficeId(source, path string) (string, error) { + idx := files.GetIndex(source) + realpath, _, _ := idx.GetRealPath(path) + // error is intentionally ignored in order treat errors + // the same as a cache-miss + cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string) + if ok { + return cachedDocumentKey, nil + } + return "", fmt.Errorf("document key not found") +} +func deleteOfficeId(source, path string) { + idx := files.GetIndex(source) + realpath, _, _ := idx.GetRealPath(path) + cache.OnlyOffice.Delete(realpath) +} diff --git a/backend/http/preview.go b/backend/http/preview.go index 5a5b5258..aee97a53 100644 --- a/backend/http/preview.go +++ b/backend/http/preview.go @@ -13,6 +13,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/files" "github.com/gtsteffaniak/filebrowser/backend/img" + "github.com/gtsteffaniak/filebrowser/backend/logger" ) type ImgService interface { @@ -141,7 +142,7 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFi go func() { cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime) if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil { - fmt.Printf("failed to cache resized image: %v", err) + logger.Error(fmt.Sprintf("failed to cache resized image: %v", err)) } }() diff --git a/backend/http/raw.go b/backend/http/raw.go index 29874af5..b9df2f8b 100644 --- a/backend/http/raw.go +++ b/backend/http/raw.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "log" "net/http" "net/url" "os" @@ -15,6 +14,7 @@ import ( "strings" "github.com/gtsteffaniak/filebrowser/backend/files" + "github.com/gtsteffaniak/filebrowser/backend/logger" ) func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) { @@ -211,13 +211,14 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, default: return http.StatusInternalServerError, errors.New("format not implemented") } - baseDirName := filepath.Base(filepath.Dir(realPath)) if baseDirName == "" || baseDirName == "/" { baseDirName = "download" } + if len(fileList) == 1 && isDir { + baseDirName = filepath.Base(realPath) + } downloadFileName := url.PathEscape(baseDirName + extension) - w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName) // Create the archive and stream it directly to the response if extension == ".zip" { @@ -242,7 +243,7 @@ func createZip(w io.Writer, d *requestContext, filenames ...string) error { for _, fname := range filenames { err := addFile(fname, d, nil, zipWriter, false) if err != nil { - log.Printf("Failed to add %s to ZIP: %v", fname, err) + logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err)) } } @@ -261,7 +262,7 @@ func createTarGz(w io.Writer, d *requestContext, filenames ...string) error { for _, fname := range filenames { err := addFile(fname, d, tarWriter, nil, false) if err != nil { - log.Printf("Failed to add %s to TAR.GZ: %v", fname, err) + logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err)) } } diff --git a/backend/http/resource.go b/backend/http/resource.go index 630b3c4f..7c65d6c6 100644 --- a/backend/http/resource.go +++ b/backend/http/resource.go @@ -13,9 +13,9 @@ import ( "github.com/shirou/gopsutil/v3/disk" + "github.com/gtsteffaniak/filebrowser/backend/cache" "github.com/gtsteffaniak/filebrowser/backend/errors" "github.com/gtsteffaniak/filebrowser/backend/files" - "github.com/gtsteffaniak/filebrowser/backend/utils" ) // resourceGetHandler retrieves information about a resource. @@ -397,7 +397,7 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, if source == "" { source = "default" } - value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse) + value, ok := cache.DiskUsage.Get(source).(DiskUsageResponse) if ok { return renderJSON(w, r, &value) } @@ -415,7 +415,7 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, Total: usage.Total, Used: usage.Used, } - utils.DiskUsageCache.Set(source, latestUsage) + cache.DiskUsage.Set(source, latestUsage) return renderJSON(w, r, &latestUsage) } diff --git a/backend/http/router.go b/backend/http/router.go index b9bff04e..88686d22 100644 --- a/backend/http/router.go +++ b/backend/http/router.go @@ -5,11 +5,11 @@ import ( "embed" "fmt" "io/fs" - "log" "net/http" "os" "text/template" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/storage" "github.com/gtsteffaniak/filebrowser/backend/version" @@ -56,7 +56,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { // Embedded mode: Serve files from the embedded assets assetFs, err = fs.Sub(assets, "embed") if err != nil { - log.Fatal("Could not embed frontend. Does dist exist?") + logger.Fatal("Could not embed frontend. Does dist exist?") } } else { assetFs = dirFS{Dir: http.Dir("http/dist")} @@ -114,6 +114,9 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { api.HandleFunc("GET /settings", withAdmin(settingsGetHandler)) api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler)) + api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler)) + api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler)) + api.HandleFunc("GET /search", withUser(searchHandler)) apiPath := config.Server.BaseURL + "api" router.Handle(apiPath+"/", http.StripPrefix(apiPath, api)) @@ -143,7 +146,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { // Load the TLS certificate and key cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey) if err != nil { - log.Fatalf("could not load certificate: %v", err) + logger.Fatal(fmt.Sprintf("could not load certificate: %v", err)) } // Create a custom TLS listener @@ -158,17 +161,17 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { // Listen on TCP and wrap with TLS listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig) if err != nil { - log.Fatalf("could not start TLS server: %v", err) + logger.Fatal(fmt.Sprintf("could not start TLS server: %v", err)) } if config.Server.Port != 443 { port = fmt.Sprintf(":%d", config.Server.Port) } // Build the full URL with host and port fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL) - log.Printf("Running at : %s", fullURL) + logger.Info(fmt.Sprintf("Running at : %s", fullURL)) err = http.Serve(listener, muxWithMiddleware(router)) if err != nil { - log.Fatalf("could not start server: %v", err) + logger.Fatal(fmt.Sprintf("could not start server: %v", err)) } } else { // Set HTTP scheme and the default port for HTTP @@ -178,10 +181,10 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { } // Build the full URL with host and port fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL) - log.Printf("Running at : %s", fullURL) + logger.Info(fmt.Sprintf("Running at : %s", fullURL)) err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router)) if err != nil { - log.Fatalf("could not start server: %v", err) + logger.Fatal(fmt.Sprintf("could not start server: %v", err)) } } } diff --git a/backend/http/static.go b/backend/http/static.go index c008c2c4..e5f10301 100644 --- a/backend/http/static.go +++ b/backend/http/static.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io/fs" - "log" "net/http" "os" "path/filepath" @@ -12,6 +11,7 @@ import ( "text/template" "github.com/gtsteffaniak/filebrowser/backend/auth" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/version" ) @@ -64,6 +64,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT "ReCaptchaHost": config.Auth.Recaptcha.Host, "ExternalLinks": config.Frontend.ExternalLinks, "ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"), + "OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url, } if config.Frontend.Files != "" { @@ -71,7 +72,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT _, err := os.Stat(fPath) //nolint:govet if err != nil && !os.IsNotExist(err) { - log.Printf("couldn't load custom styles: %v", err) + logger.Error(fmt.Sprintf("couldn't load custom styles: %v", err)) } if err == nil { diff --git a/backend/logger/setup.go b/backend/logger/setup.go new file mode 100644 index 00000000..a59ddbb0 --- /dev/null +++ b/backend/logger/setup.go @@ -0,0 +1,132 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "slices" + "strings" +) + +// Logger wraps the standard log.Logger with log level functionality +type Logger struct { + logger *log.Logger + levels []LogLevel + apiLevels []LogLevel + stdout bool + disabled bool + disabledAPI bool + colors bool +} + +var stdOutLoggerExists bool + +// NewLogger creates a new Logger instance with separate file and stdout loggers +func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*Logger, error) { + var fileWriter io.Writer = io.Discard + stdout := filepath == "" + // Configure file logging + if !stdout { + file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %v", err) + } + fileWriter = file + } + flags := log.Ldate | log.Ltime + if slices.Contains(levels, DEBUG) { + flags |= log.Lshortfile + } + logger := log.New(os.Stdout, "", flags) + if filepath != "" { + logger = log.New(fileWriter, "", flags) + } + if stdout { + stdOutLoggerExists = true + } + return &Logger{ + logger: logger, + levels: levels, + apiLevels: apiLevels, + disabled: slices.Contains(levels, DISABLED), + disabledAPI: slices.Contains(apiLevels, DISABLED), + colors: !noColors, + stdout: stdout, + }, nil +} + +// SetupLogger configures the logger with file and stdout options and their respective log levels +func SetupLogger(output, levels, apiLevels string, noColors bool) error { + upperLevels := []LogLevel{} + for _, level := range SplitByMultiple(levels) { + if level == "" { + break + } + upperLevel := strings.ToUpper(level) + if upperLevel == "WARNING" || upperLevel == "WARN" { + upperLevel = "WARN " + } + // Convert level strings to LogLevel + level, ok := stringToLevel[upperLevel] + if !ok { + loggers = []*Logger{} + return fmt.Errorf("invalid file log level: %s", upperLevel) + } + upperLevels = append(upperLevels, level) + } + if len(upperLevels) == 0 { + upperLevels = []LogLevel{INFO, ERROR, WARNING} + } + upperApiLevels := []LogLevel{} + for _, level := range SplitByMultiple(apiLevels) { + if level == "" { + break + } + upperLevel := strings.ToUpper(level) + if upperLevel == "WARNING" || upperLevel == "WARN" { + upperLevel = "WARN " + } + // Convert level strings to LogLevel + level, ok := stringToLevel[strings.ToUpper(upperLevel)] + if !ok { + return fmt.Errorf("invalid api log level: %s", upperLevel) + } + upperApiLevels = append(upperApiLevels, level) + } + if len(upperApiLevels) == 0 { + upperApiLevels = []LogLevel{INFO, ERROR, WARNING} + } + if slices.Contains(upperLevels, DISABLED) && slices.Contains(upperApiLevels, DISABLED) { + // both disabled, not creating a logger + loggers = []*Logger{} + return nil + } + outputStdout := strings.ToUpper(output) + if outputStdout == "STDOUT" { + output = "" + } + if output == "" && stdOutLoggerExists { + // stdout logger already exists... don't create another + return fmt.Errorf("stdout logger already exists, could not set config levels=[%v] apiLevels=[%v] noColors=[%v]", levels, apiLevels, noColors) + } + // Create the logger + logger, err := NewLogger(output, upperLevels, upperApiLevels, noColors) + if err != nil { + loggers = []*Logger{} + return err + } + loggers = append(loggers, logger) + return nil +} +func SplitByMultiple(str string) []string { + delimiters := []rune{'|', ',', ' '} + return strings.FieldsFunc(str, func(r rune) bool { + for _, d := range delimiters { + if r == d { + return true + } + } + return false + }) +} diff --git a/backend/logger/write.go b/backend/logger/write.go new file mode 100644 index 00000000..bcbd32c3 --- /dev/null +++ b/backend/logger/write.go @@ -0,0 +1,137 @@ +package logger + +import ( + "fmt" + "log" + "slices" +) + +type LogLevel int + +const ( + DISABLED LogLevel = 0 + ERROR LogLevel = 1 + FATAL LogLevel = 1 + WARNING LogLevel = 2 + INFO LogLevel = 3 + DEBUG LogLevel = 4 + API LogLevel = 10 + // COLORS + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + GRAY = "\033[37m" +) + +var ( + loggers []*Logger +) + +type levelConsts struct { + INFO string + FATAL string + ERROR string + WARNING string + DEBUG string + API string + DISABLED string +} + +var levels = levelConsts{ + INFO: "INFO", + FATAL: "FATAL", + ERROR: "ERROR", + WARNING: "WARN ", // with consistent space padding + DEBUG: "DEBUG", + DISABLED: "DISABLED", + API: "API", +} + +// stringToLevel maps string representation to LogLevel +var stringToLevel = map[string]LogLevel{ + "DEBUG": DEBUG, + "INFO": INFO, + "ERROR": ERROR, + "DISABLED": DISABLED, + "WARN ": WARNING, // with consistent space padding + "FATAL": FATAL, + "API": API, +} + +// Log prints a log message if its level is greater than or equal to the logger's levels +func Log(level string, msg string, prefix, api bool, color string) { + LEVEL := stringToLevel[level] + for _, logger := range loggers { + if api { + if logger.disabledAPI || !slices.Contains(logger.apiLevels, LEVEL) { + continue + } + } else { + if logger.disabled || !slices.Contains(logger.levels, LEVEL) { + continue + } + } + if logger.stdout && LEVEL == FATAL { + continue + } + writeOut := msg + if prefix { + writeOut = fmt.Sprintf("[%s] ", level) + writeOut + } + if logger.colors && color != "" { + writeOut = color + writeOut + "\033[0m" + } + err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line + if err != nil { + log.Printf("failed to log message '%v' with error `%v`", msg, err) + } + } +} + +func Api(msg string, statusCode int) { + if statusCode >= 300 && statusCode < 500 { + Log(levels.WARNING, msg, false, true, YELLOW) + } else if statusCode >= 500 { + Log(levels.ERROR, msg, false, true, RED) + } else { + Log(levels.INFO, msg, false, true, GREEN) + } +} + +// Helper methods for specific log levels +func Debug(msg string) { + if len(loggers) > 0 { + Log(levels.DEBUG, msg, true, false, GRAY) + } +} + +func Info(msg string) { + if len(loggers) > 0 { + Log(levels.INFO, msg, false, false, "") + } else { + log.Println(msg) + } +} + +func Warning(msg string) { + if len(loggers) > 0 { + Log(levels.WARNING, msg, true, false, YELLOW) + } else { + log.Println("[WARN ]: " + msg) + } +} + +func Error(msg string) { + if len(loggers) > 0 { + Log(levels.ERROR, msg, true, false, RED) + } else { + log.Println("[ERROR] : ", msg) + } +} + +func Fatal(msg string) { + if len(loggers) > 0 { + Log(levels.FATAL, msg, true, false, RED) + } + log.Fatal("[FATAL] : ", msg) +} diff --git a/backend/run_tests.sh b/backend/run_tests.sh deleted file mode 100755 index 4a391d1b..00000000 --- a/backend/run_tests.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -## TEST file used by docker testing containers -checkExit() { - if [ "$?" -ne 0 ];then - exit 1 - fi -} - -if command -v go &> /dev/null -then - printf "\n == Running tests == \n" - go test -race -parallel -v ./... - checkExit -else - echo "ERROR: unable to perform tests" - exit 1 -fi diff --git a/backend/scripts/bump_version.sh b/backend/scripts/bump_version.sh deleted file mode 100755 index 43df6276..00000000 --- a/backend/scripts/bump_version.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e - -if ! [ -x "$(command -v standard-version)" ]; then - echo "standard-version is not installed. please run 'npm i -g standard-version'" - exit 1 -fi - -standard-version --dry-run --skip -read -p "Continue (y/n)? " -n 1 -r -echo ; -if [[ $REPLY =~ ^[Yy]$ ]]; then - standard-version -s ; -fi \ No newline at end of file diff --git a/backend/scripts/commitlint.sh b/backend/scripts/commitlint.sh deleted file mode 100755 index d5895ce2..00000000 --- a/backend/scripts/commitlint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -e - -if ! [ -x "$(command -v commitlint)" ]; then - echo "commitlint is not installed. please run 'npm i -g commitlint'" - exit 1 -fi - -for commit_hash in $(git log --pretty=format:%H origin/master..HEAD); do - commitlint -f ${commit_hash}~1 -t ${commit_hash} -done diff --git a/backend/settings/config.go b/backend/settings/config.go index a902bff8..6445a915 100644 --- a/backend/settings/config.go +++ b/backend/settings/config.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/goccy/go-yaml" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/users" "github.com/gtsteffaniak/filebrowser/backend/version" ) @@ -15,29 +16,32 @@ import ( var Config Settings func Initialize(configFile string) { - yamlData := loadConfigFile(configFile) - Config = setDefaults() - err := yaml.Unmarshal(yamlData, &Config) + yamlData, err := loadConfigFile(configFile) if err != nil { - log.Fatalf("Error unmarshaling YAML data: %v", err) + logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err)) + } + Config = setDefaults() + err = yaml.Unmarshal(yamlData, &Config) + if err != nil { + logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err)) } Config.UserDefaults.Perm = Config.UserDefaults.Permissions // Convert relative path to absolute path if len(Config.Server.Sources) > 0 { // TODO allow multipe sources not named default for _, source := range Config.Server.Sources { - realPath, err := filepath.Abs(source.Path) - if err != nil { - log.Fatalf("Error getting source path: %v", err) + realPath, err2 := filepath.Abs(source.Path) + if err2 != nil { + logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2)) } source.Path = realPath source.Name = "default" // Modify the local copy of the map value Config.Server.Sources["default"] = source // Assign the modified value back to the map } } else { - realPath, err := filepath.Abs(Config.Server.Root) - if err != nil { - log.Fatalf("Error getting source path: %v", err) + realPath, err2 := filepath.Abs(Config.Server.Root) + if err2 != nil { + logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2)) } Config.Server.Sources = map[string]Source{ "default": { @@ -67,28 +71,46 @@ func Initialize(configFile string) { Url: "https://github.com/gtsteffaniak/filebrowser/wiki", }) } + if len(Config.Server.Logging) == 0 { + Config.Server.Logging = []LogConfig{ + { + Output: "stdout", + }, + } + } + for _, logConfig := range Config.Server.Logging { + err = logger.SetupLogger( + logConfig.Output, + logConfig.Levels, + logConfig.ApiLevels, + logConfig.NoColors, + ) + if err != nil { + log.Println("[ERROR] Failed to set up logger:", err) + } + } + } -func loadConfigFile(configFile string) []byte { +func loadConfigFile(configFile string) ([]byte, error) { // Open and read the YAML file yamlFile, err := os.Open(configFile) if err != nil { - log.Println(err) - os.Exit(1) + return nil, err } defer yamlFile.Close() stat, err := yamlFile.Stat() if err != nil { - log.Fatalf("error getting file information: %s", err.Error()) + return nil, err } yamlData := make([]byte, stat.Size()) _, err = yamlFile.Read(yamlData) if err != nil { - log.Fatalf("Error reading YAML data: %v", err) + return nil, err } - return yamlData + return yamlData, nil } func setDefaults() Settings { @@ -101,7 +123,6 @@ func setDefaults() Settings { NumImageProcessors: 4, BaseURL: "", Database: "database.db", - Log: "stdout", Root: ".", }, Auth: Auth{ diff --git a/backend/settings/config_test.go b/backend/settings/config_test.go index 36018e7a..5fed6493 100644 --- a/backend/settings/config_test.go +++ b/backend/settings/config_test.go @@ -35,7 +35,7 @@ func Test_loadConfigFile(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) { + if got, _ := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) { t.Errorf("loadConfigFile() = %v, want %v", got, tt.want) } }) diff --git a/backend/settings/dir.go b/backend/settings/dir.go index 128d9083..5ae43ec8 100644 --- a/backend/settings/dir.go +++ b/backend/settings/dir.go @@ -3,12 +3,13 @@ package settings import ( "errors" "fmt" - "log" "os" "path" "path/filepath" "regexp" "strings" + + "github.com/gtsteffaniak/filebrowser/backend/logger" ) var ( @@ -22,7 +23,7 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string, if userScope == "" && s.Server.CreateUserDir { username = cleanUsername(username) if username == "" || username == "-" || username == "." { - log.Printf("create user: invalid user for home dir creation: [%s]", username) + logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username)) return "", errors.New("invalid user for home dir creation") } userScope = path.Join(s.Server.UserHomeBasePath, username) diff --git a/backend/settings/settings_test.go b/backend/settings/settings_test.go index 3a3da0bb..d7409eba 100644 --- a/backend/settings/settings_test.go +++ b/backend/settings/settings_test.go @@ -1,22 +1,23 @@ package settings import ( - "log" + "fmt" "testing" "github.com/goccy/go-yaml" "github.com/google/go-cmp/cmp" + "github.com/gtsteffaniak/filebrowser/backend/logger" ) func TestConfigLoadChanged(t *testing.T) { - yamlData := loadConfigFile("./testingConfig.yaml") + yamlData, _ := loadConfigFile("./testingConfig.yaml") // Marshal the YAML data to a more human-readable format newConfig := setDefaults() Config := setDefaults() err := yaml.Unmarshal(yamlData, &newConfig) if err != nil { - log.Fatalf("Error unmarshaling YAML data: %v", err) + logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err)) } // Use go-cmp to compare the two structs if diff := cmp.Diff(newConfig, Config); diff == "" { @@ -25,14 +26,14 @@ func TestConfigLoadChanged(t *testing.T) { } func TestConfigLoadSpecificValues(t *testing.T) { - yamlData := loadConfigFile("./testingConfig.yaml") + yamlData, _ := loadConfigFile("./testingConfig.yaml") // Marshal the YAML data to a more human-readable format newConfig := setDefaults() Config := setDefaults() err := yaml.Unmarshal(yamlData, &newConfig) if err != nil { - log.Fatalf("Error unmarshaling YAML data: %v", err) + logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err)) } testCases := []struct { fieldName string diff --git a/backend/settings/structs.go b/backend/settings/structs.go index 388c2d25..19477c1c 100644 --- a/backend/settings/structs.go +++ b/backend/settings/structs.go @@ -13,6 +13,7 @@ type Settings struct { Frontend Frontend `json:"frontend"` Users []UserDefaults `json:"users,omitempty"` UserDefaults UserDefaults `json:"userDefaults"` + Integrations Integrations `json:"integrations"` } type Auth struct { @@ -47,13 +48,33 @@ type Server struct { Port int `json:"port"` BaseURL string `json:"baseURL"` Address string `json:"address"` - Log string `json:"log"` + Logging []LogConfig `json:"logging"` Database string `json:"database"` Root string `json:"root"` UserHomeBasePath string `json:"userHomeBasePath"` CreateUserDir bool `json:"createUserDir"` Sources map[string]Source `json:"sources"` ExternalUrl string `json:"externalUrl"` + InternalUrl string `json:"internalUrl"` // used by integrations +} + +type Integrations struct { + OnlyOffice OnlyOffice `json:"office"` +} + +// onlyoffice secret is stored in the local.json file +// docker exec /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string' +type OnlyOffice struct { + Url string `json:"url"` + Secret string `json:"secret"` +} + +type LogConfig struct { + Levels string `json:"levels"` + ApiLevels string `json:"apiLevels"` + Output string `json:"output"` + NoColors bool `json:"noColors"` + Json bool `json:"json"` } type Source struct { diff --git a/backend/settings/testingConfig.yaml b/backend/settings/testingConfig.yaml index ace6179c..4769322e 100644 --- a/backend/settings/testingConfig.yaml +++ b/backend/settings/testingConfig.yaml @@ -10,7 +10,6 @@ server: port: 80 baseURL: "/" address: "" - log: "stdout" database: "mydb.db" root: "/srv" auth: diff --git a/backend/storage/bolt/share.go b/backend/storage/bolt/share.go index c070169d..2c868418 100644 --- a/backend/storage/bolt/share.go +++ b/backend/storage/bolt/share.go @@ -1,13 +1,14 @@ package bolt import ( - "log" + "fmt" "time" "github.com/asdine/storm/v3" "github.com/asdine/storm/v3/q" "github.com/gtsteffaniak/filebrowser/backend/errors" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/share" ) @@ -68,7 +69,7 @@ func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) { if v[i].Expire < time.Now().Unix() { err = s.Delete(v[i].PasswordHash) if err != nil { - log.Println("expired share could not be deleted: ", err.Error()) + logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error())) } } else { filteredList = append(filteredList, v[i]) diff --git a/backend/storage/storage.go b/backend/storage/storage.go index f3731b18..17f53834 100644 --- a/backend/storage/storage.go +++ b/backend/storage/storage.go @@ -2,7 +2,6 @@ package storage import ( "fmt" - "log" "os" "path/filepath" @@ -10,6 +9,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/auth" "github.com/gtsteffaniak/filebrowser/backend/errors" "github.com/gtsteffaniak/filebrowser/backend/files" + "github.com/gtsteffaniak/filebrowser/backend/logger" "github.com/gtsteffaniak/filebrowser/backend/settings" "github.com/gtsteffaniak/filebrowser/backend/share" "github.com/gtsteffaniak/filebrowser/backend/storage/bolt" @@ -118,15 +118,15 @@ func CreateUser(userInfo users.User, asAdmin bool) error { // create new home directory userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, files.RootPaths["default"]) if err != nil { - log.Printf("create user: failed to mkdir user home dir: [%s]", userHome) + logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome)) return err } newUser.Scope = userHome - log.Printf("user: %s, home dir: [%s].", newUser.Username, userHome) + logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", newUser.Username, userHome)) idx := files.GetIndex("default") _, _, err = idx.GetRealPath(newUser.Scope) if err != nil { - log.Println("user path is not valid", newUser.Scope) + logger.Error(fmt.Sprintf("user path is not valid: %v", newUser.Scope)) return nil } err = store.Users.Save(&newUser) diff --git a/backend/swagger/docs/docs.go b/backend/swagger/docs/docs.go index a20adf8b..156dcbc0 100644 --- a/backend/swagger/docs/docs.go +++ b/backend/swagger/docs/docs.go @@ -1171,30 +1171,37 @@ const docTemplate = `{ "type": "object", "properties": { "files": { + "description": "files in the directory", "type": "array", "items": { "$ref": "#/definitions/files.ItemInfo" } }, "folders": { + "description": "folders in the directory", "type": "array", "items": { "$ref": "#/definitions/files.ItemInfo" } }, "modified": { + "description": "modification time", "type": "string" }, "name": { + "description": "name of the file", "type": "string" }, "path": { + "description": "path scoped to the associated index", "type": "string" }, "size": { + "description": "length in bytes for regular files", "type": "integer" }, "type": { + "description": "type of the file, either \"directory\" or a file mimetype", "type": "string" } } @@ -1203,15 +1210,19 @@ const docTemplate = `{ "type": "object", "properties": { "modified": { + "description": "modification time", "type": "string" }, "name": { + "description": "name of the file", "type": "string" }, "size": { + "description": "length in bytes for regular files", "type": "integer" }, "type": { + "description": "type of the file, either \"directory\" or a file mimetype", "type": "string" } } diff --git a/backend/swagger/docs/swagger.json b/backend/swagger/docs/swagger.json index dbb1b77d..b74fd341 100644 --- a/backend/swagger/docs/swagger.json +++ b/backend/swagger/docs/swagger.json @@ -1160,30 +1160,37 @@ "type": "object", "properties": { "files": { + "description": "files in the directory", "type": "array", "items": { "$ref": "#/definitions/files.ItemInfo" } }, "folders": { + "description": "folders in the directory", "type": "array", "items": { "$ref": "#/definitions/files.ItemInfo" } }, "modified": { + "description": "modification time", "type": "string" }, "name": { + "description": "name of the file", "type": "string" }, "path": { + "description": "path scoped to the associated index", "type": "string" }, "size": { + "description": "length in bytes for regular files", "type": "integer" }, "type": { + "description": "type of the file, either \"directory\" or a file mimetype", "type": "string" } } @@ -1192,15 +1199,19 @@ "type": "object", "properties": { "modified": { + "description": "modification time", "type": "string" }, "name": { + "description": "name of the file", "type": "string" }, "size": { + "description": "length in bytes for regular files", "type": "integer" }, "type": { + "description": "type of the file, either \"directory\" or a file mimetype", "type": "string" } } diff --git a/backend/swagger/docs/swagger.yaml b/backend/swagger/docs/swagger.yaml index 0d1aaee9..eaeac072 100644 --- a/backend/swagger/docs/swagger.yaml +++ b/backend/swagger/docs/swagger.yaml @@ -2,33 +2,44 @@ definitions: files.FileInfo: properties: files: + description: files in the directory items: $ref: '#/definitions/files.ItemInfo' type: array folders: + description: folders in the directory items: $ref: '#/definitions/files.ItemInfo' type: array modified: + description: modification time type: string name: + description: name of the file type: string path: + description: path scoped to the associated index type: string size: + description: length in bytes for regular files type: integer type: + description: type of the file, either "directory" or a file mimetype type: string type: object files.ItemInfo: properties: modified: + description: modification time type: string name: + description: name of the file type: string size: + description: length in bytes for regular files type: integer type: + description: type of the file, either "directory" or a file mimetype type: string type: object files.SearchResult: diff --git a/backend/test/atest b/backend/test/atest deleted file mode 100755 index e69de29b..00000000 diff --git a/backend/test/test b/backend/test/test deleted file mode 100755 index e69de29b..00000000 diff --git a/backend/test/tests b/backend/test/tests deleted file mode 100755 index e69de29b..00000000 diff --git a/backend/utils/main.go b/backend/utils/main.go index 48018422..9f700af0 100644 --- a/backend/utils/main.go +++ b/backend/utils/main.go @@ -2,17 +2,20 @@ package utils import ( "crypto/rand" + "crypto/sha256" + "encoding/hex" "fmt" - "log" math "math/rand" "reflect" "strings" "time" + + "github.com/gtsteffaniak/filebrowser/backend/logger" ) func CheckErr(source string, err error) { if err != nil { - log.Fatalf("%s: %v", source, err) + logger.Fatal(fmt.Sprintf("%s: %v", source, err)) } } @@ -33,7 +36,7 @@ func CapitalizeFirst(s string) string { return strings.ToUpper(string(s[0])) + s[1:] } -func GenerateRandomHash(length int) string { +func InsecureRandomIdentifier(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz0123456789" math.New(math.NewSource(time.Now().UnixNano())) result := make([]byte, length) @@ -49,7 +52,7 @@ func PrintStructFields(v interface{}) { // Ensure the input is a struct if val.Kind() != reflect.Struct { - fmt.Println("Provided value is not a struct") + logger.Debug("Provided value is not a struct") return } @@ -66,7 +69,7 @@ func PrintStructFields(v interface{}) { fieldValue = fieldValue[:100] + "..." } - fmt.Printf("Field: %s, %s\n", fieldType.Name, fieldValue) + logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue)) } } @@ -84,3 +87,8 @@ func GetParentDirectoryPath(path string) string { } return path[:lastSlash] } + +func HashSHA256(data string) string { + bytes := sha256.Sum256([]byte(data)) + return hex.EncodeToString(bytes[:]) +} diff --git a/frontend/global-setup.ts b/frontend/global-setup.ts index c103f018..52ccb901 100644 --- a/frontend/global-setup.ts +++ b/frontend/global-setup.ts @@ -10,9 +10,9 @@ async function globalSetup() { await page.getByPlaceholder("Password").fill("admin"); await page.getByRole("button", { name: "Login" }).click(); await page.waitForURL("**/files/", { timeout: 100 }); - await expect(page).toHaveTitle('FileBrowser Quantum - Files'); let cookies = await context.cookies(); expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined(); + await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files'); await page.context().storageState({ path: "./loginAuth.json" }); await browser.close(); } diff --git a/frontend/package.json b/frontend/package.json index 1028b60e..7f59908a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "test": "vitest run " }, "dependencies": { - "@playwright/test": "^1.49.1", + "@onlyoffice/document-editor-vue": "^1.4.0", "ace-builds": "^1.24.2", "clipboard": "^2.0.4", "css-vars-ponyfill": "^2.4.3", @@ -33,6 +33,7 @@ "vue-router": "^4.3.0" }, "devDependencies": { + "@playwright/test": "^1.49.1", "@intlify/unplugin-vue-i18n": "^4.0.0", "@vitejs/plugin-vue": "^5.0.4", "@vue/eslint-config-typescript": "^13.0.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 5cafb7b0..d36487a7 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ reporter: "line", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - actionTimeout: 500, + actionTimeout: 5000, storageState: "loginAuth.json", /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: "http://127.0.0.1", diff --git a/frontend/public/index.html b/frontend/public/index.html index 4b78a701..83b9e8fa 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -13,7 +13,7 @@ - + diff --git a/frontend/src/api/files.js b/frontend/src/api/files.js index f767ce52..036acd64 100644 --- a/frontend/src/api/files.js +++ b/frontend/src/api/files.js @@ -2,6 +2,7 @@ import { fetchURL, adjustedData } from "./utils"; import { removePrefix, getApiPath } from "@/utils/url.js"; import { state } from "@/store"; import { notify } from "@/notify"; +import { externalUrl } from "@/utils/constants"; // Notify if errors occur export async function fetchFiles(url, content = false) { @@ -167,13 +168,16 @@ export async function checksum(url, algo) { } } -export function getDownloadURL(path, inline) { +export function getDownloadURL(path, inline, useExternal) { try { const params = { files: encodeURIComponent(removePrefix(decodeURI(path),"files")), ...(inline && { inline: "true" }), }; const apiPath = getApiPath("api/raw", params); + if (externalUrl && useExternal) { + return externalUrl+apiPath + } return window.origin+apiPath } catch (err) { notify.showError(err.message || "Error getting download URL"); diff --git a/frontend/src/assets/fonts/material/icons.woff2 b/frontend/src/assets/fonts/material/icons.woff2 new file mode 100644 index 00000000..f1fd22ff Binary files /dev/null and b/frontend/src/assets/fonts/material/icons.woff2 differ diff --git a/frontend/src/assets/fonts/material/symbols-outlined.woff b/frontend/src/assets/fonts/material/symbols-outlined.woff new file mode 100644 index 00000000..4d30431a Binary files /dev/null and b/frontend/src/assets/fonts/material/symbols-outlined.woff differ diff --git a/frontend/src/css/base.css b/frontend/src/css/base.css index 22eed131..605dcc3c 100644 --- a/frontend/src/css/base.css +++ b/frontend/src/css/base.css @@ -86,7 +86,6 @@ over /* Main Content */ main { position: fixed; - padding: .5em; padding-top: 4em; overflow: scroll; top: 0; @@ -95,8 +94,15 @@ main { display: flex; flex-direction: column; } + main > div { - height: calc(100% - 3em); + height: 100%; +} + +.main-padding { + padding: 0.5em; + padding-top: 4em; + } .breadcrumbs { diff --git a/frontend/src/css/fonts.css b/frontend/src/css/fonts.css index 24be58aa..63248b2d 100644 --- a/frontend/src/css/fonts.css +++ b/frontend/src/css/fonts.css @@ -166,3 +166,48 @@ src: local('Roboto Bold'), local('Roboto-Bold'), url(../assets/fonts/roboto/bold-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; } + +/* fallback */ +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url(../assets/fonts/material/icons.woff2) format('woff2'); +} +/* fallback */ +@font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 400; + src: url(../assets/fonts/material/symbols-outlined.woff2) format('woff'); +} + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; +} diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js index 12760247..aefb3926 100644 --- a/frontend/src/store/getters.js +++ b/frontend/src/store/getters.js @@ -100,7 +100,7 @@ export const getters = { if (typeof getters.currentPromptName() === "string" && !getters.isStickySidebar()) { visible = false; } - if (getters.currentView() == "editor" || getters.currentView() == "preview") { + if (getters.currentView() == "editor" || getters.currentView() == "preview" || getters.currentView() == "onlyOfficeEditor") { visible = false; } return visible @@ -123,6 +123,9 @@ export const getters = { const shouldOverlaySidebar = getters.isSidebarVisible() && !getters.isStickySidebar() return hasPrompt || shouldOverlaySidebar; }, + showBreadCrumbs: () => { + return getters.currentView() == "listingView" ; + }, routePath: (trimModifier="") => { return removePrefix(state.route.path,trimModifier) }, @@ -136,6 +139,8 @@ export const getters = { if (state.req.type !== undefined) { if (state.req.type == "directory") { return "listingView"; + } else if (state.req?.onlyOfficeId) { + return "onlyOfficeEditor"; } else if ("content" in state.req) { return "editor"; } else { diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js index 55e27d8a..0260532c 100644 --- a/frontend/src/store/mutations.js +++ b/frontend/src/store/mutations.js @@ -140,21 +140,16 @@ export const mutations = { emitStateChanged(); }, addSelected: (value) => { - console.log("addSelected", value) state.selected.push(value); emitStateChanged(); }, removeSelected: (value) => { - console.log("removeSelected", value) - let i = state.selected.indexOf(value); if (i === -1) return; state.selected.splice(i, 1); emitStateChanged(); }, resetSelected: () => { - console.log("resetSelected") - state.selected = []; mutations.setMultiple(false); emitStateChanged(); diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js index 0c261589..cfeecff7 100644 --- a/frontend/src/utils/auth.js +++ b/frontend/src/utils/auth.js @@ -7,6 +7,7 @@ import { recaptcha, loginPage } from "@/utils/constants"; export async function setNewToken(token) { document.cookie = `auth=${token}; path=/`; + mutations.setJWT(token); mutations.setSession(generateRandomCode(8)); } @@ -44,7 +45,6 @@ export async function login(username, password, recaptcha) { } export async function renew(jwt) { - console.log("Renewing token"); let apiPath = getApiPath("api/auth/renew") const res = await fetch(apiPath, { method: "POST", diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 309e5693..c6c2b7f1 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -18,6 +18,7 @@ const enableThumbs = window.FileBrowser.EnableThumbs; const resizePreview = window.FileBrowser.ResizePreview; const enableExec = window.FileBrowser.EnableExec; const externalUrl = window.FileBrowser.ExternalUrl +const onlyOfficeUrl = window.FileBrowser.OnlyOfficeUrl const origin = window.location.origin; const settings = [ @@ -49,5 +50,6 @@ export { enableExec, origin, darkMode, - settings + settings, + onlyOfficeUrl, }; diff --git a/frontend/src/utils/cookie.js b/frontend/src/utils/cookie.js index 72d59be4..604a7dee 100644 --- a/frontend/src/utils/cookie.js +++ b/frontend/src/utils/cookie.js @@ -4,3 +4,13 @@ export default function (name) { ); return document.cookie.replace(re, "$1"); } + +export function getCookie(name) { + let cookie = document.cookie + .split(";") + .find((cookie) => cookie.includes(name + "=")); + if (cookie != null) { + return cookie.split("=")[1]; + } + return "" +} \ No newline at end of file diff --git a/frontend/src/utils/upload.js b/frontend/src/utils/upload.js index fa5d894a..4c54229d 100644 --- a/frontend/src/utils/upload.js +++ b/frontend/src/utils/upload.js @@ -3,8 +3,6 @@ import url from "@/utils/url.js"; import { filesApi } from "@/api"; export function checkConflict(files, items) { - console.log("testing",files) - if (typeof items === "undefined" || items === null) { items = []; } diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue index 6a959bd8..52490abc 100644 --- a/frontend/src/views/Files.vue +++ b/frontend/src/views/Files.vue @@ -1,6 +1,6 @@ @@ -36,10 +37,12 @@ export default { mutations.closeHovers(); return; } - mutations.replaceRequest({}); - let uri = url.removeLastDir(state.route.path) + "/"; - router.push({ path: uri }); mutations.closeHovers(); + setTimeout(() => { + mutations.replaceRequest({}); + let uri = url.removeLastDir(state.route.path) + "/"; + router.push({ path: uri }); + }, 50); }, }, }; diff --git a/frontend/src/views/bars/EditorBar.vue b/frontend/src/views/bars/EditorBar.vue index 1a521b64..7922ad4a 100644 --- a/frontend/src/views/bars/EditorBar.vue +++ b/frontend/src/views/bars/EditorBar.vue @@ -9,6 +9,10 @@ :label="$t('buttons.save')" @action="save()" /> + diff --git a/frontend/src/views/files/OnlyOfficeEditor.vue b/frontend/src/views/files/OnlyOfficeEditor.vue new file mode 100644 index 00000000..16897356 --- /dev/null +++ b/frontend/src/views/files/OnlyOfficeEditor.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/settings/User.vue b/frontend/src/views/settings/User.vue index 07ba4dad..6dfad36c 100644 --- a/frontend/src/views/settings/User.vue +++ b/frontend/src/views/settings/User.vue @@ -110,7 +110,7 @@ export default { event.preventDefault(); try { if (this.isNew) { - await usersApi.create(this.userPayload); + await usersApi.create(this.userPayload); // Use the computed property this.$router.push({ path: "/settings", hash: "#users-main" }); notify.showSuccess(this.$t("settings.userCreated")); } else { diff --git a/makefile b/makefile index 73877e0d..a214d429 100644 --- a/makefile +++ b/makefile @@ -51,7 +51,8 @@ test-backend: test-frontend: cd frontend && npm run test -test-playwright: run-frontend build-backend +test-playwright: run-frontend + cd backend && GOOS=linux go build -o filebrowser . && cd .. && \ docker build -t filebrowser-playwright-tests -f Dockerfile.playwright . docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests