Compare commits

...
7 Commits
102 changed files with 2791 additions and 1427 deletions

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ rice-box.go
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
+30
View File
@@ -2,6 +2,36 @@
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.7.5-beta
**New Features**
- new `./filebrowser.exe setup` command for creating a config.yaml on first run. https://github.com/gtsteffaniak/filebrowser/issues/675
- new 2FA/OTP support for password-based users.
- `auth.password.enforcedOtp` option to enforce 2FA usage for password users.
**Notes**:
- logging uses localtime, optional UTC config added https://github.com/gtsteffaniak/filebrowser/issues/665
- generated config example now includes defaults https://github.com/gtsteffaniak/filebrowser/issues/590
- `server.debugMedia` config option added to help debug ffmpeg issues in the future (don't enable unless debugging an issue)
- more translations additions from english settings https://github.com/gtsteffaniak/filebrowser/issues/653
- visual tweaks https://github.com/gtsteffaniak/filebrowser/issues/652
- enhanced markdown viewer with code view spec
**BugFixes**:
- long video names ffmpeg issue fixed https://github.com/gtsteffaniak/filebrowser/issues/669
- certain files not passing content https://github.com/gtsteffaniak/filebrowser/issues/657
- https://github.com/gtsteffaniak/filebrowser/issues/668
- allow edit markdown files
- rename button doesn't close prompt https://github.com/gtsteffaniak/filebrowser/issues/664
- webm video preview issue https://github.com/gtsteffaniak/filebrowser/issues/673
- fix signup issue https://github.com/gtsteffaniak/filebrowser/issues/648
- fix default source bug
- https://github.com/gtsteffaniak/filebrowser/issues/666
- fix 500 error for subtitle videos https://github.com/gtsteffaniak/filebrowser/issues/678
- spaces and special characters in source name issue https://github.com/gtsteffaniak/filebrowser/issues/679
![image](https://github.com/user-attachments/assets/28e4e67e-31a1-4107-9294-0e715e87b558)
## v0.7.4-beta
**Notes**:
+1 -1
View File
@@ -21,7 +21,7 @@
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password, and proxy.
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Revamped UI
4. ✅ Simplified configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
+1 -1
View File
@@ -39,7 +39,7 @@ RUN apk add --no-cache curl xz && \
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap
RUN apk --no-cache add ca-certificates mailcap tzdata
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
+20 -41
View File
@@ -18,15 +18,17 @@ import (
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var OnlyOfficeCache = cache.NewCache(48 * time.Hour)
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
response := iteminfo.ExtendedFileInfo{}
if opts.Source == "" {
@@ -68,15 +70,16 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content {
if opts.Content && strings.HasPrefix(info.Type, "text") {
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(realPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.Path, info.Name, err)
return response, err
}
response.Content = content
} else {
logger.Debug(fmt.Sprintf("skipping large text file contents (20MB limit): "+info.Path, info.Name))
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
}
}
response.FileInfo = *info
@@ -95,11 +98,11 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
key, ok := OnlyOfficeCache.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
OnlyOfficeCache.Set(realPath, documentKey)
return documentKey
}
return key
@@ -268,52 +271,28 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
return idx.RefreshFileInfo(opts)
}
// getContent reads and returns the file content only if it's ASCII-readable.
// getContent reads and returns the file content if it's UTF-8 readable.
func getContent(realPath string) (string, error) {
const headerSize = 4096
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
buf := make([]byte, headerSize)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
// Check if header is ASCII
if !isASCII(buf[:n]) {
return "", nil
}
// Now read full file
// Read the entire file in one go. This is more efficient.
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
// Check if the file content is valid UTF-8.
// This allows for all characters, including ASCII and emojis.
if !utf8.Valid(content) {
// File is not valid text (e.g., a binary image), so we reject it.
return "", nil
}
if stringContent == "" {
// Handle the special case for an empty file, as in your original code.
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
func isASCII(data []byte) bool {
for _, b := range data {
if b < 0x09 || (b > 0x0D && b < 0x20) || b > 0x7E {
return false
}
}
return true
// The file is valid, so return its string content.
return string(content), nil
}
func IsNamedPipe(mode os.FileMode) bool {
+5 -6
View File
@@ -1,14 +1,13 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// MoveFile moves a file from src to dst.
@@ -23,14 +22,14 @@ func MoveFile(src, dst string) error {
// fallback
err = CopyFile(src, dst)
if err != nil {
logger.Error(fmt.Sprintf("CopyFile failed %v %v %v ", src, dst, err))
logger.Errorf("CopyFile failed %v %v %v ", src, dst, err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Error(fmt.Sprintf("os.Remove failed %v %v ", src, err))
logger.Errorf("os.Remove failed %v %v ", src, err)
}
}()
@@ -181,14 +180,14 @@ func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
logger.Errorf("failed clear cache dir: %v", err)
}
for _, entry := range entries {
path := filepath.Join(cacheDir, entry.Name())
err = os.RemoveAll(path)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
logger.Errorf("failed clear cache dir: %v", err)
}
}
+2 -2
View File
@@ -9,9 +9,9 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type hookCred struct {
@@ -51,7 +51,7 @@ func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
if err != nil {
return nil, err
}
logger.Debug(fmt.Sprintf("hook auth %v", action))
logger.Debugf("hook auth %v", action)
switch action {
case "auth":
+38 -24
View File
@@ -8,38 +8,26 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type jsonCred struct {
Password string `json:"password"`
Username string `json:"username"`
ReCaptcha string `json:"recaptcha"`
}
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
}
// Auth authenticates the user via a json in content body.
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
var cred jsonCred
if r.Body == nil {
logger.Error("nil body error")
return nil, os.ErrPermission
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
logger.Error("decode body error")
return nil, os.ErrPermission
}
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
password := r.URL.Query().Get("password")
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
totpCode := r.URL.Query().Get("code")
// If ReCaptcha is enabled, check the code.
if a.ReCaptcha != nil && len(a.ReCaptcha.Secret) > 0 {
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
if err != nil {
return nil, err
@@ -49,16 +37,42 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(cred.Username)
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(cred.Password, u.Password)
err = users.CheckPwd(password, user.Password)
if err != nil {
return nil, err
}
return u, nil
// check for OTP for password
if user.TOTPSecret != "" {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
err = VerifyTotpCode(user, totpCode, userStore)
if err != nil {
logger.Error("OTP verification failed")
return nil, err
}
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
user.LoginMethod = users.LoginMethodPassword
// Perform the user update
err = userStore.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
//return
}
return user, nil
}
// LoginPage tells that json auth doesn't require a login page.
+8
View File
@@ -1,7 +1,11 @@
package auth
import (
"encoding/base64"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is a storage backend for auth storage.
@@ -35,6 +39,10 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
if err != nil {
return nil, err
}
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
if err != nil {
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
}
return store, nil
}
+200
View File
@@ -0,0 +1,200 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// Constants for TOTP (Time-based One-Time Password) configuration.
const (
// IssuerName is the name of the application or service.
IssuerName = "FileBrowser Quantum"
// TokenValidTime defines the total duration a token is considered valid.
// Note: The actual validation window might be slightly longer depending on the period.
TokenValidTime = time.Minute * 2
// TOTPPeriod is the standard duration in seconds that a TOTP code is valid.
TOTPPeriod uint = 30
// TOTPSecretSize is the byte length of the shared secret.
TOTPSecretSize uint = 20
// TOTPDigits specifies the number of digits in the OTP code.
TOTPDigits = otp.DigitsSix
// TOTPAlgorithm is the hashing algorithm to use.
TOTPAlgorithm = otp.AlgorithmSHA1
)
var (
// TOTPSkew allows for a certain number of periods of clock drift.
// We calculate it to best match the TokenValidTime.
// (2 * Skew + 1) * Period >= TokenValidTime
// For a 2-minute (120s) window with a 30s period, we need a Skew of 2.
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
encryptionKey []byte
TotpCache = cache.NewCache(5 * time.Minute)
)
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
// Generate a new TOTP key using the defined constants.
key, err := totp.Generate(totp.GenerateOpts{
Issuer: IssuerName,
AccountName: user.Username,
Period: TOTPPeriod,
SecretSize: TOTPSecretSize,
Digits: TOTPDigits,
Algorithm: TOTPAlgorithm,
})
if err != nil {
return "", fmt.Errorf("error generating TOTP key: %w", err)
}
secretText := key.Secret()
nonce := ""
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretText+"||"+nonce)
url := "otpauth://totp/FileBrowser%20Quantum?secret=" + secretText
return url, nil
}
// encryptSecret uses AES-GCM to encrypt a plaintext secret.
// It returns the base64-encoded ciphertext and nonce, or an error.
func encryptSecret(secret string, key []byte) (string, string, error) {
if len(key) != 32 {
return "", "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
}
// decryptSecret uses AES-GCM to decrypt a ciphertext using its key and nonce.
// It returns the plaintext secret or an error.
func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
if len(encryptionKey) != 32 {
return "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
}
ciphertext, err := base64.StdEncoding.DecodeString(b64Ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
nonce, err := base64.StdEncoding.DecodeString(b64Nonce)
if err != nil {
return "", fmt.Errorf("failed to decode nonce: %w", err)
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
// This error often means the key is wrong or the data is corrupt
return "", fmt.Errorf("failed to decrypt secret: %w", err)
}
return string(plaintext), nil
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username).(string)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
}
totpSecret = splitSecret[0]
totpNonce = splitSecret[1]
}
secretToValidate := totpSecret
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is configured, we must decrypt the secret first.
if totpNonce == "" {
return fmt.Errorf("secret is encrypted but nonce is missing")
}
decryptedSecret, err := decryptSecret(totpSecret, totpNonce)
if err != nil {
return fmt.Errorf("failed to decrypt TOTP secret: %w", err)
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
Digits: TOTPDigits,
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation: %v", err)
}
if !valid {
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
}
// save user
if err := userStore.Update(user, user.Permissions.Admin, "TOTPSecret", "TOTPNonce"); err != nil {
logger.Debug("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
return nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/cache [no test files]
? github.com/gtsteffaniak/go-cache/cache [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
+248 -9
View File
@@ -1,16 +1,21 @@
package cmd
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
@@ -19,6 +24,9 @@ var (
// return bool to indicate if the program should continue running
func runCLI() bool {
generateYaml()
// Global flags
var help bool
// Override the default usage output to use generalUsage()
@@ -56,6 +64,9 @@ func runCLI() bool {
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
@@ -77,7 +88,8 @@ func runCLI() bool {
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
@@ -93,35 +105,262 @@ func runCLI() bool {
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
logger.Infof("Creating non-admin user: %s\n", username)
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
logger.Errorf("could not create user: %v", err)
}
return false
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
err = store.Users.Save(user, true, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
logger.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return false
case "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 : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false
}
}
return true
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
}
// Frontend defines settings related to the web interface.
type Frontend struct {
Name string `yaml:"name,omitempty"`
}
// Source defines a directory to be served.
type Source struct {
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
}
// Server defines server-specific configurations.
type Server struct {
Port int `yaml:"port"`
Database string `yaml:"database"`
Sources []Source `yaml:"sources"`
Logging []LogConfig `json:"logging"`
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Auth struct {
AdminUsername string `yaml:"adminUsername"`
AdminPassword string `yaml:"adminPassword"`
}
// Settings is the top-level configuration structure.
type Settings struct {
Server Server `yaml:"server"`
Frontend Frontend `yaml:"frontend,omitempty"`
Auth Auth `yaml:"auth"`
UserDefaults UserDefaults `yaml:"userDefaults"`
}
// askQuestion displays a prompt and reads a line of input from the user.
// It returns the defaultValue if the user's input is empty.
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
return input
}
// askYesNoQuestion prompts the user with a yes/no question and returns a boolean.
// It retries until valid input ("yes", "y", "no", "n") is received.
// The defaultValue must be "yes" or "no".
func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string) bool {
for {
answer := askQuestion(reader, prompt, defaultValue)
answer = strings.ToLower(answer)
if answer == "yes" || answer == "y" {
return true
}
if answer == "no" || answer == "n" {
return false
}
fmt.Println("Error: Invalid input. Please enter 'yes' or 'no'.")
}
}
// createConfig orchestrates the configuration process by asking the user a series of questions.
func createConfig(configpath string) {
// check if config file exists
if _, err := os.Stat("config.yaml"); err == nil {
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
return
}
reader := bufio.NewReader(os.Stdin)
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
// 1. Ask for the source filesystem path (with validation)
for {
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
if err == nil {
var isDir bool
// Resolve symlinks and get the real path
realPath, isDir, _ = utils.ResolveSymlinks(absolutePath)
if realPath != "" && isDir {
break // Valid path found, exit loop
}
}
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
}
// 2. Ask for the source name
defaultSourceName := filepath.Base(realPath)
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
if sourceName != defaultSourceName {
config.Server.Sources[0].Name = sourceName
}
// 3. Ask for server port (with validation)
for {
portStr := askQuestion(reader, "What port should the server listen on?", "80")
port, err := strconv.Atoi(portStr)
if err == nil && (port >= 1 && port <= 65535) {
config.Server.Port = port
break // Port is valid, exit loop
}
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
}
for {
config.Server.Database = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.Database, ".db") {
break // Valid path found, exit loop
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
}
// 4. Ask for the application brand name
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
// 5. Ask for admin username and password
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
// 6. Ask boolean (Yes/No) questions using the helper
config.UserDefaults.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
fmt.Println("--- Configuration Complete ---")
// marshall yaml and write to file
// Marshal the struct to YAML bytes
yamlData, err := yaml.Marshal(&config)
if err != nil {
return
}
// Write the YAML data to the file
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
if err != nil {
return
}
// cleanup database if it exists
if _, err := os.Stat(config.Server.Database); err == nil {
response := askYesNoQuestion(reader, "Database specified already exists. Move databse file to backup to start fresh?", "no")
if !response {
return
}
// move database file to backup
backupPath := config.Server.Database + ".bak"
err = os.Rename(config.Server.Database, backupPath)
if err != nil {
fmt.Printf("Error moving database file to backup: %v\n", err)
} else {
fmt.Printf("Database file moved to backup: %s\n", backupPath)
}
}
}
func generateYaml() {
if os.Getenv("FILEBROWSER_GENERATE_CONFIG") != "" {
logger.Info("Generating config.yaml")
settings.GenerateYaml()
os.Exit(0)
}
}
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
})
}
+1 -1
View File
@@ -3,8 +3,8 @@ package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// healthcheck attempt to save test file against configured url
+10 -9
View File
@@ -8,13 +8,13 @@ import (
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/swaggo/swag"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
@@ -27,7 +27,7 @@ func getStore(configFile string) bool {
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
logger.Fatalf("could not load db info: %v", err)
}
store = s
return hasDB
@@ -69,11 +69,12 @@ func StartFilebrowser() {
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Info(fmt.Sprintf("Auth Methods : %v", settings.Config.Auth.AuthMethods))
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
logger.Infof("Using Config file : %v", configPath)
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sourceList))
logger.Infof("Sources : %v", sourceList)
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
@@ -90,7 +91,7 @@ func StartFilebrowser() {
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
logger.Fatalf("Error starting filebrowser: %v", err)
}
close(done) // Signal that the server has stopped
}()
@@ -117,9 +118,9 @@ func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings
numWorkers := settings.Config.Server.NumImageProcessors
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
// setup disk cache
err := preview.Start(numWorkers, ffpmpegPath, cacheDir)
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("Error starting preview service: %v", err))
logger.Fatalf("Error starting preview service: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
+6 -6
View File
@@ -5,9 +5,9 @@ import (
"reflect"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var createBackup = []bool{}
@@ -16,7 +16,7 @@ func validateUserInfo() {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
if err != nil {
logger.Fatal(fmt.Sprintf("could not load users: %v", err))
logger.Fatalf("could not load users: %v", err)
}
for _, user := range usersList {
updateUser := false
@@ -41,12 +41,12 @@ func validateUserInfo() {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatal(fmt.Sprintf("Unable to create automatic backup of database due to error: %v", err))
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
err := store.Users.Save(user, false, true)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
logger.Errorf("could not update user: %v", err)
}
}
}
@@ -54,14 +54,14 @@ func validateUserInfo() {
logger.Info("Resetting admin user to default username and password.")
adminUser, err := store.Users.Get(1)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load admin user: %v", err))
logger.Fatalf("could not load admin user: %v", err)
}
adminUser.Username = settings.Config.Auth.AdminUsername
adminUser.Password = settings.Config.Auth.AdminPassword
adminUser.Permissions.Admin = true
err = store.Users.Save(adminUser, true, true)
if err != nil {
logger.Error(fmt.Sprintf("could not Save admin user: %v", err))
logger.Errorf("could not Save admin user: %v", err)
}
}
}
-88
View File
@@ -1,88 +0,0 @@
package cache
import (
"sync"
"time"
)
var (
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, 1*time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
JwtCache = NewCache(1*time.Hour, 72*time.Hour)
)
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
}
go newCache.cleanupExpiredJob(cleanup)
return &newCache
}
type KeyCache struct {
data map[string]cachedValue
mu sync.RWMutex
expiresAfter time.Duration
}
type cachedValue struct {
value interface{}
expiresAt time.Time
}
func (c *KeyCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(c.expiresAfter),
}
}
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()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(exp),
}
}
func (c *KeyCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.data[key]
if !ok || time.Now().After(cached.expiresAt) {
return nil
}
return cached.value
}
func (c *KeyCache) cleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, cached := range c.data {
if now.After(cached.expiresAt) {
delete(c.data, key)
}
}
}
// should automatically run for all cache types as part of init.
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for range ticker.C {
c.cleanupExpired()
}
}
+3
View File
@@ -18,4 +18,7 @@ var (
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrUnauthorized = errors.New("user unauthorized")
)
-141
View File
@@ -1,141 +0,0 @@
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
debugEnabled 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
}
var flags int
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),
debugEnabled: slices.Contains(levels, DEBUG),
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 "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// 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 "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// 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
})
}
-150
View File
@@ -1,150 +0,0 @@
package logger
import (
"fmt"
"log"
"os"
"slices"
"time"
)
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[2;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 ", // with consistent space padding
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, // with consistent space padding
"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
}
}
writeOut := msg
formattedTime := time.Now().Format("2006/01/02 15:04:05")
if logger.colors && color != "" {
formattedTime = formattedTime + color
}
if prefix || logger.debugEnabled {
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
} else {
logger.logger.SetPrefix(formattedTime + " ")
}
if logger.colors && color != "" {
writeOut = 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)
}
if level == levels.FATAL {
os.Exit(1)
}
}
}
func Api(msg string, statusCode int) {
// redirects are not warnings anymore
// content not modified is not a warning anymore
if statusCode > 304 && 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)
} else {
log.Println("[DEBUG] ", msg)
}
}
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)
} else {
log.Fatal("[FATAL] ", msg)
}
}
+7 -5
View File
@@ -8,7 +8,7 @@ import (
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/go-logger/logger"
)
type Auth struct {
@@ -18,6 +18,7 @@ type Auth struct {
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
ResetAdminOnStart bool `json:"resetAdminOnStart"` // if set to true, the admin user will be reset to the default username and password on startup.
TotpSecret string `json:"totpSecret"` // secret used to encrypt TOTP secrets
AuthMethods []string `json:"-"`
}
@@ -29,10 +30,11 @@ type LoginMethods struct {
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty,min=5"`
Signup bool `json:"signup" validate:"omitempty"` // currently not used by filebrowser
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty,min=5"` // minimum pasword length required.
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
}
type ProxyAuthConfig struct {
+44 -18
View File
@@ -7,16 +7,21 @@ import (
"path/filepath"
"slices"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var Config Settings
const (
generatorPath = "/relative/or/absolute/path"
)
func Initialize(configFile string) {
err := loadConfigWithDefaults(configFile)
if err != nil {
@@ -29,11 +34,12 @@ func Initialize(configFile string) {
errmsg += "please review your config for typos and invalid keys which are no longer supported. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
setupAuth()
setupSources()
setupSources(false)
setupUrls()
setupFrontend()
}
@@ -55,16 +61,16 @@ func setupFrontend() {
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
logger.Fatal(fmt.Sprintf("could not find configured source path: %v", err))
logger.Fatalf("could not find configured source path: %v", err)
}
// check path exists
if _, err = os.Stat(realPath); os.IsNotExist(err) {
logger.Fatal(fmt.Sprintf("configured source path does not exist: %v", realPath))
logger.Fatalf("configured source path does not exist: %v", realPath)
}
return realPath
}
func setupSources() {
func setupSources(generate bool) {
if len(Config.Server.Sources) == 0 {
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
} else {
@@ -74,7 +80,11 @@ func setupSources() {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
source.Path = realPath // use absolute path
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -101,6 +111,9 @@ func setupSources() {
potentialDefaultSource := Config.Server.DefaultSource
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
if generate {
realPath = generatorPath // use placeholder path
}
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
if first {
@@ -119,12 +132,22 @@ func setupSources() {
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warning(fmt.Sprintf("source %v is not configured correctly, skipping", sourcePathOnly.Path))
logger.Warningf("source %v is not configured correctly, skipping", sourcePathOnly.Path)
}
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = potentialDefaultSource
}
sourceList2 := []Source{}
for _, s := range sourceList {
if s.Path == Config.Server.DefaultSource.Path {
s.Config.DefaultEnabled = true
Config.Server.SourceMap[s.Path] = s
Config.Server.NameToSource[s.Name] = s
}
sourceList2 = append(sourceList2, s)
}
sourceList = sourceList2
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
@@ -159,7 +182,7 @@ func setupAuth() {
if Config.Auth.Methods.OidcAuth.Enabled {
err := validateOidcAuth()
if err != nil {
logger.Fatal(fmt.Sprintf("Error validating OIDC auth: %v", err))
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
@@ -182,12 +205,15 @@ func setupLogging() {
}
}
for _, logConfig := range Config.Server.Logging {
err := logger.SetupLogger(
logConfig.Output,
logConfig.Levels,
logConfig.ApiLevels,
logConfig.NoColors,
)
logConfig := logger.JsonConfig{
Levels: logConfig.Levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
}
err := logger.SetupLogger(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
@@ -199,7 +225,7 @@ func loadConfigWithDefaults(configFile string) error {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return err
logger.Warningf("could not open config file '%v', using default settings", configFile)
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
@@ -212,7 +238,7 @@ func loadConfigWithDefaults(configFile string) error {
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, err)
}
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
@@ -375,7 +401,7 @@ func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug(fmt.Sprint("Could not get scope from source name: ", sourceName))
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
@@ -383,7 +409,7 @@ func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (stri
return scope.Scope, nil
}
}
logger.Debug(fmt.Sprintf("scope not found for source %v", sourceName))
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
@@ -1,9 +1,7 @@
// cmd/yamlgen/main.go
package main
package settings
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/parser"
@@ -13,7 +11,6 @@ import (
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"gopkg.in/yaml.v3"
)
@@ -117,7 +114,7 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
// attach validate and comments inline
var parts []string
if cm := comm[typeName][sf.Name]; cm != "" {
parts = append(parts, fmt.Sprintf("\"%s\"", cm))
parts = append(parts, cm)
}
if vt := sf.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
@@ -169,19 +166,29 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
}
}
func main() {
input := flag.String("input", "settings/settings.go", "path to Go source file or directory containing structs")
output := flag.String("output", "settings/config.generated.yaml", "path to write generated YAML")
flag.Parse()
func GenerateYaml() {
_ = loadConfigWithDefaults("")
Config.Server.Sources = []Source{
{
Path: ".",
},
}
comm, err := collectComments(*input)
setupLogging()
setupAuth()
setupSources(true)
setupUrls()
setupFrontend()
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
output := "generated.yaml" // "output YAML file"
comm, err := collectComments(input)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing comments: %v\n", err)
os.Exit(1)
}
cfg := &settings.Settings{}
node, err := buildNode(reflect.ValueOf(cfg), comm)
node, err := buildNode(reflect.ValueOf(Config), comm)
if err != nil {
fmt.Fprintf(os.Stderr, "error building YAML node: %v\n", err)
os.Exit(1)
@@ -200,51 +207,49 @@ func main() {
aligned := alignComments(rawBuf.String())
if err := os.WriteFile(*output, []byte(aligned), 0644); err != nil {
if err := os.WriteFile(output, []byte(aligned), 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated YAML with comments: %s\n", *output)
fmt.Printf("Generated YAML with comments: %s\n", output)
}
// alignComments scans YAML text and aligns all inline '#' within each indentation block
// formatLine applies padding to a single line so its comment starts at a target column.
func formatLine(line string) string {
const targetCol = 42
const defaultPadding = 1
idx := strings.Index(line, "#")
// If there's no comment, do nothing.
if idx == -1 {
return line
}
// Separate the content (before #) from the comment (from # onwards).
contentPart := line[:idx]
commentPart := line[idx:]
// The YAML encoder adds a space; trim it so we measure only the content length.
trimmedContent := strings.TrimRight(contentPart, " ")
// If the content already extends past our target column, just add a single space.
if len(trimmedContent) >= targetCol {
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
}
// Otherwise, calculate the needed padding to reach the target column and add it.
paddingNeeded := targetCol - len(trimmedContent)
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
}
// alignComments formats each line of the YAML string independently.
func alignComments(input string) string {
lines := strings.Split(input, "\n")
var out []string
blockStart := 0
maxPos := 0
for i, line := range lines {
trim := strings.TrimLeft(line, " ")
// new block when indentation decreases
if i > 0 {
prevIndent := len(lines[i-1]) - len(strings.TrimLeft(lines[i-1], " "))
curIndent := len(line) - len(trim)
if curIndent < prevIndent {
for j := blockStart; j < i; j++ {
out = append(out, padLine(lines[j], maxPos))
}
blockStart = i
maxPos = 0
}
}
if idx := strings.Index(line, "#"); idx >= 0 {
if idx > maxPos {
maxPos = idx
}
}
if i == len(lines)-1 {
for j := blockStart; j <= i; j++ {
out = append(out, padLine(lines[j], maxPos))
}
}
}
return strings.Join(out, "\n")
}
outputLines := make([]string, len(lines))
// padLine inserts spaces before '#' so it lands at column maxPos
func padLine(line string, maxPos int) string {
if idx := strings.Index(line, "#"); idx >= 0 && idx < maxPos {
return line[:idx] + strings.Repeat(" ", maxPos-idx) + line[idx:]
for i, line := range lines {
outputLines[i] = formatLine(line)
}
return line
return strings.Join(outputLines, "\n")
}
+3 -1
View File
@@ -31,7 +31,8 @@ type Server struct {
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging"`
Database string `json:"database"` // path to the database file
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
@@ -66,6 +67,7 @@ type LogConfig struct {
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
+3 -3
View File
@@ -13,13 +13,13 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func CheckErr(source string, err error) {
if err != nil {
logger.Fatal(fmt.Sprintf("%s: %v", source, err))
logger.Fatalf("%s: %v", source, err)
}
}
@@ -73,7 +73,7 @@ func PrintStructFields(v interface{}) {
fieldValue = fieldValue[:100] + "..."
}
logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue))
logger.Debugf("Field: %s, %s\n", fieldType.Name, fieldValue)
}
}
+2 -3
View File
@@ -1,15 +1,14 @@
package bolt
import (
"fmt"
"time"
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/go-logger/logger"
)
type shareBackend struct {
@@ -63,7 +62,7 @@ func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, err
if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 {
err = s.Delete(v[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
logger.Errorf("expired share could not be deleted: %v", err.Error())
}
} else {
filteredList = append(filteredList, v[i])
+12 -5
View File
@@ -11,10 +11,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type usersBackend struct {
@@ -69,7 +69,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
@@ -141,7 +145,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
logger.Debug(fmt.Sprintf("Saving user [%s] changepass: %v", user.Username, changePass))
logger.Debugf("Saving user [%s] changepass: %v", user.Username, changePass)
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
@@ -221,9 +225,12 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// which=all can't update password
if strings.ToLower(field.Name) != "password" && strings.ToLower(field.Name) != "id" && strings.ToLower(field.Name) != "username" {
fields = append(fields, field.Name)
switch strings.ToLower(field.Name) {
case "id", "username", "loginmethod", "password":
// Skip these fields
continue
}
fields = append(fields, field.Name)
}
}
newfields := []string{}
+4 -4
View File
@@ -8,12 +8,12 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// Storage is a storage powered by a Backend which makes the necessary
@@ -37,7 +37,7 @@ func InitializeDb(path string) (*Storage, bool, error) {
if strings.Contains(err.Error(), "timeout") {
logger.Fatal("the database is locked, please close all other instances of filebrowser before starting.")
}
logger.Fatal(fmt.Sprintf("could not open database: %v", err))
logger.Fatalf("could not open database: %v", err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
if err != nil {
@@ -100,7 +100,7 @@ func quickSetup(store *Storage) {
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
logger.Debug(fmt.Sprintf("Creating user as admin: %v %v", user.Username, user.Password))
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
err = store.Users.Save(user, true, true)
utils.CheckErr("store.Users.Save", err)
}
@@ -123,7 +123,7 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
logger.Debug(fmt.Sprintf("Creating user: %v %v", userInfo.Username, userInfo.Scopes))
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
settings.ApplyUserDefaults(newUser)
if asAdmin {
userInfo.Permissions = settings.AdminPerms()
+3
View File
@@ -60,7 +60,10 @@ type User struct {
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
}
+9 -17
View File
@@ -1,15 +1,19 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.24.0
go 1.24.2
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.14.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063
github.com/gtsteffaniak/go-logger v0.1.2
github.com/pquerna/otp v1.5.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
@@ -17,6 +21,7 @@ require (
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -47,6 +52,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
@@ -57,12 +63,10 @@ require (
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/coreos/go-oidc/v3 v3.14.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
@@ -76,7 +80,7 @@ require (
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
@@ -94,9 +98,8 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db // indirect
github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
@@ -135,14 +138,6 @@ require (
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx v1.2.31 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
@@ -164,7 +159,6 @@ require (
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
@@ -188,7 +182,6 @@ require (
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
@@ -231,7 +224,6 @@ require (
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
+13 -30
View File
@@ -102,6 +102,9 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A=
github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs=
github.com/breml/bidichk v0.3.2/go.mod h1:VzFLBxuYtT23z5+iVkamXO386OB+/sVwZOpIj6zXGos=
github.com/breml/errchkjson v0.4.0 h1:gftf6uWZMtIa/Is3XJgibewBm2ksAQSY/kABDNFTAdk=
@@ -142,8 +145,6 @@ github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@@ -200,8 +201,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -259,8 +260,6 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -271,8 +270,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db h1:juWRoz5Y+/j2ffgMV0yPnZD5sd1EkeJOsrnMMyLIFrM=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 h1:HeOFbnyPys/vx/t+d4fwZM782mnjRVtbjxVkDittTUs=
github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -366,6 +365,10 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM7348kulYBXYN82Bowx7Iu9PHe0shqQ2QR8=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v0.1.2 h1:0g6UxxNwU+Q+7IVRw+Yn7AuYsIK0ukiTgbzjTLm+Uy4=
github.com/gtsteffaniak/go-logger v0.1.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -442,25 +445,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54=
github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk=
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
@@ -534,6 +518,8 @@ github.com/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L
github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -593,8 +579,6 @@ github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxX
github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8=
github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g=
github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
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/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -640,7 +624,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+30 -357
View File
@@ -1,7 +1,6 @@
package http
import (
"crypto/tls"
"encoding/json"
libError "errors"
"fmt"
@@ -10,19 +9,17 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// first checks for cookie
@@ -95,7 +92,7 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with proxy authentication, bypassing and updating login method", data.user.Username))
logger.Warningf("user %s is not allowed to login with proxy authentication, bypassing and updating login method", data.user.Username)
data.user.LoginMethod = users.LoginMethodProxy
// Perform the user update
err := store.Users.Update(data.user, true, "LoginMethod")
@@ -117,54 +114,14 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return
}
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
// currently only supports user/pass
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username))
user.LoginMethod = users.LoginMethodPassword
// Perform the user update
err = store.Users.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
//return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
missingOtp := d.user.TOTPSecret == ""
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
return printToken(w, r, d.user) // Pass the data object
}
// logoutHandler handles user logout, specifically used for OIDC.
@@ -173,7 +130,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
// @Tags Auth
// @Success 302 {string} string "Redirect to redirect URL if configured in oidc config."
// @Router /api/auth/logout [get]
func logoutHandler(w http.ResponseWriter, r *http.Request) {
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
@@ -184,10 +141,11 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) {
if oidcCfg.Enabled && oidcCfg.LogoutRedirectUrl != "" {
logoutUrl = oidcCfg.LogoutRedirectUrl
http.Redirect(w, r, logoutUrl, http.StatusFound)
return
return 302, nil
}
http.Redirect(w, r, logoutUrl, http.StatusFound)
return 302, nil
}
type signupBody struct {
@@ -208,41 +166,32 @@ type signupBody struct {
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
}
if r.Body == nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
return http.StatusBadRequest, fmt.Errorf("no post body provided")
}
info := &signupBody{}
err := json.NewDecoder(r.Body).Decode(info)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
return http.StatusBadRequest, fmt.Errorf("invalid request body: %v", err)
}
if info.Password == "" || info.Username == "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
user := users.User{
Username: info.Username,
NonAdminEditable: users.NonAdminEditable{
Password: info.Password,
},
LoginMethod: users.LoginMethodPassword,
}
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = info.Username
user.Password = info.Password
err = store.Users.Save(user, true, false)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
} else if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
err = storage.CreateUser(user, false)
if err != nil {
logger.Debug(err.Error())
w.WriteHeader(http.StatusConflict)
return http.StatusConflict, fmt.Errorf("user already exists")
}
return 201, nil
}
// renewHandler refreshes the authentication token for a logged-in user.
@@ -266,11 +215,11 @@ func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int,
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
return 401, errors.ErrUnauthorized
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed.Key)); err != nil {
return http.StatusInternalServerError, err
return 401, errors.ErrUnauthorized
}
return 0, nil
}
@@ -337,279 +286,3 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
return 200, nil
}
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
// It exchanges the authorization code for tokens, attempts to verify the ID token.
// If ID token verification or essential claims extraction fails, it falls back to the UserInfo endpoint.
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if oidcCfg.Provider == nil || oidcCfg.Verifier == nil {
// Ensure Provider and Verifier are initialized on application startup
// This check is good, keep it.
logger.Error("OIDC provider or verifier not initialized.")
return http.StatusInternalServerError, fmt.Errorf("OIDC provider or verifier not initialized")
}
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
ctx = oidc.ClientContext(ctx, customClient)
}
code := r.URL.Query().Get("code")
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
// The redirect URI MUST match the one registered with the OIDC provider
// and used in the initial /api/auth/oidc/login handler.
// Using r.Host here might be tricky if running behind a proxy.
// Consider using a fixed redirect URL from settings if possible.
redirectURL := fmt.Sprintf("%s://%s/api/auth/oidc/callback", getScheme(r), r.Host)
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
// Exchange the authorization code for tokens
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
logger.Error(fmt.Sprintf("failed to exchange token: %v", err))
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// --- Attempt to process ID Token ---
if ok && rawIDToken != "" {
logger.Debug("ID token found in token response, attempting verification.")
// Verify the ID token
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
if err != nil {
logger.Warning(fmt.Sprintf("failed to verify ID token: %v. Falling back to UserInfo endpoint.", err))
// Verification failed, claimsFromIDToken remains false
} else {
var claims map[string]interface{}
// Decode the ID token claims into a map to handle arbitrary structure
// This is where the JWE unmarshalling error occurs if the token is encrypted
if err := idToken.Claims(&claims); err != nil {
logger.Warning(fmt.Sprintf("failed to decode ID token claims: %v. Falling back to UserInfo endpoint.", err))
// Claims decoding failed, claimsFromIDToken remains false
} else {
// Successfully verified and decoded ID token claims
logger.Debug(fmt.Sprintf("ID Token verified and claims decoded: %+v", claims))
// Populate userdata from ID token claims
if name, ok := claims["name"].(string); ok {
userdata.Name = name
}
if preferredUsername, ok := claims["preferred_username"].(string); ok {
userdata.PreferredUsername = preferredUsername
}
if email, ok := claims["email"].(string); ok {
userdata.Email = email
}
if sub, ok := claims["sub"].(string); ok {
userdata.Sub = sub
}
if phone, ok := claims["phone_number"].(string); ok {
userdata.Phone = phone
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
if userdata.Email != "" {
claimsFromIDToken = true
loginUsername = userdata.Email
}
case "username":
if userdata.PreferredUsername != "" {
claimsFromIDToken = true
loginUsername = userdata.PreferredUsername
}
case "phone":
if userdata.Phone != "" {
claimsFromIDToken = true
loginUsername = userdata.Phone
}
}
}
}
} else {
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
// claimsFromIDToken remains false
}
// --- Fallback to UserInfo endpoint if ID token processing did not provide essential claims ---
if !claimsFromIDToken {
// Use the access token obtained from the initial exchange
// oauth2Config.TokenSource creates a token source that uses the provided token.
userInfoResp, err := oidcCfg.Provider.UserInfo(ctx, oauth2Config.TokenSource(ctx, token))
if err != nil {
logger.Error(fmt.Sprintf("failed to fetch user info from endpoint: %v", err))
return http.StatusInternalServerError, fmt.Errorf("failed to fetch user info from endpoint: %v", err)
}
// Decode the UserInfo response directly into the userdata struct
// The UserInfo endpoint is expected to return standard JSON
if err := userInfoResp.Claims(&userdata); err != nil {
logger.Error(fmt.Sprintf("failed to decode user info from endpoint: %v", err))
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
loginUsername = userdata.Email
case "username":
loginUsername = userdata.PreferredUsername
case "phone":
loginUsername = userdata.Phone
}
}
if loginUsername == "" {
logger.Error("No valid username found in ID token or UserInfo response.")
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
}
// Proceed to log the user in with the OIDC data
// userdata struct now contains info from either verified ID token or UserInfo endpoint
return loginWithOidcUser(w, r, loginUsername)
}
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
// This function remains largely the same, but includes the 'fb_redirect' parameter
// to redirect the user back to the original page after successful login.
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if !oidcCfg.Enabled {
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(),
RedirectURL: fmt.Sprintf("%s/api/auth/oidc/callback", origin),
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
nonce := utils.InsecureRandomIdentifier(16)
fbRedirect := r.URL.Query().Get("fb_redirect")
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
authURL := oauth2Config.AuthCodeURL(state)
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string) (int, error) {
logger.Debug(fmt.Sprintf("Successfully authenticated OIDC username: %s", username))
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
}
if user.LoginMethod != users.LoginMethodOidc {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with oidc authentication, bypassing and updating login method", user.Username))
user.LoginMethod = users.LoginMethodOidc
err = store.Users.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := "/" // Default redirect path
if state != "" {
// Assuming state is in the format "nonce:fb_redirect"
parts := strings.SplitN(state, ":", 2)
if len(parts) == 2 {
// TODO: Validate the nonce part against the stored nonce for CSRF protection
// For this example, we'll just extract the redirect part
extractedRedirect := parts[1]
if extractedRedirect != "" {
fbRedirect = extractedRedirect
}
}
}
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"io"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
type messenger struct {
@@ -40,7 +40,7 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
f, ok := w.(http.Flusher)
if !ok {
logger.Debug(fmt.Sprintf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId))
logger.Debugf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId)
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
}
@@ -63,7 +63,7 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
return http.StatusOK, nil
case <-clientGone:
logger.Debug(fmt.Sprintf("client disconnected. user: %s, SessionId: %s", username, sessionId))
logger.Debugf("client disconnected. user: %s, SessionId: %s", username, sessionId)
return http.StatusOK, nil
case msg := <-events.BroadcastChan:
+2 -3
View File
@@ -1,12 +1,11 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -15,7 +14,7 @@ func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
if err != nil {
logger.Debug(fmt.Sprintf("error getting index info: %v", err))
logger.Debugf("error getting index info: %v", err)
continue
}
reducedIndexes[source] = reducedIndex
+14 -13
View File
@@ -11,10 +11,10 @@ import (
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/go-logger/logger"
// http-swagger middleware
)
@@ -46,7 +46,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
store = storage
config = &settings.Config
var err error
// --- START: ADD THIS DECRYPTION LOGIC ---
if embeddedFS {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
@@ -72,10 +72,11 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
// Auth routes
api.HandleFunc("POST /auth/login", loginHandler)
api.HandleFunc("GET /auth/logout", logoutHandler)
api.HandleFunc("GET /auth/signup", signupHandler)
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
api.HandleFunc("GET /auth/logout", withoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
@@ -151,7 +152,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
logger.Fatalf("Could not load certificate: %v", err)
}
// Create a custom TLS configuration
@@ -168,15 +169,15 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
logger.Infof("Running at : %s", fullURL)
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not start TLS server: %v", err))
logger.Fatalf("Could not start TLS server: %v", err)
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
logger.Fatalf("Server error: %v", err)
}
} else {
// Set HTTP scheme and the default port for HTTP
@@ -187,11 +188,11 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
logger.Infof("Running at : %s", fullURL)
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
logger.Fatalf("Server error: %v", err)
}
}
}()
@@ -204,7 +205,7 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error(fmt.Sprintf("HTTP server forced to shut down: %v", err))
logger.Errorf("HTTP server forced to shut down: %v", err)
} else {
logger.Info("HTTP server shut down gracefully.")
}
+63 -7
View File
@@ -12,10 +12,11 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
type requestContext struct {
@@ -78,7 +79,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
})
file.Token = link.Token
if err != nil {
logger.Error(fmt.Sprintf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err))
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
// Set the file info in the `data` object
@@ -99,6 +100,53 @@ func withAdminHelper(fn handleFunc) handleFunc {
})
}
func withoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
return fn(w, r, data)
}
}
// allow user without OTP to pass
func userWithoutOTPhelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
username := r.URL.Query().Get("username")
password := r.URL.Query().Get("password")
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
return 401, errors.ErrUnauthorized
}
d.user = user
} else if username == "" || password == "" {
return withUserHelper(fn)(w, r, d)
} else {
if !config.Auth.Methods.PasswordAuth.Enabled {
return 401, errors.ErrUnauthorized
}
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
return 401, errors.ErrUnauthorized
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
if err == errors.ErrNoTotpProvided {
return 403, err
}
return 401, errors.ErrUnauthorized
}
d.user = user
}
return fn(w, r, d)
}
}
// Middleware to retrieve and authenticate user
func withUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
@@ -107,7 +155,7 @@ func withUserHelper(fn handleFunc) handleFunc {
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
logger.Errorf("no auth: %v", err)
return http.StatusInternalServerError, err
}
return fn(w, r, data)
@@ -192,14 +240,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
logger.Errorf("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 {
logger.Debug(fmt.Sprintf("Error writing error response: %v", writeErr))
logger.Debugf("Error writing error response: %v", writeErr)
}
return
}
@@ -237,6 +285,14 @@ func withUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withUserHelper(fn))
}
func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
return wrapHandler(userWithoutOTPhelper(fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
return wrapHandler(withSelfOrAdminHelper(fn))
}
@@ -307,14 +363,14 @@ func LoggingMiddleware(next http.Handler) http.Handler {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)
logger.Api(
logger.Api(wrappedWriter.StatusCode,
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL), wrappedWriter.StatusCode)
fullURL))
})
}
+293
View File
@@ -0,0 +1,293 @@
package http
import (
"crypto/tls"
"fmt"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/oauth2"
)
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
// It exchanges the authorization code for tokens, attempts to verify the ID token.
// If ID token verification or essential claims extraction fails, it falls back to the UserInfo endpoint.
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if oidcCfg.Provider == nil || oidcCfg.Verifier == nil {
// Ensure Provider and Verifier are initialized on application startup
// This check is good, keep it.
logger.Error("OIDC provider or verifier not initialized.")
return http.StatusInternalServerError, fmt.Errorf("OIDC provider or verifier not initialized")
}
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
ctx = oidc.ClientContext(ctx, customClient)
}
code := r.URL.Query().Get("code")
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
// The redirect URI MUST match the one registered with the OIDC provider
// and used in the initial /api/auth/oidc/login handler.
// Using r.Host here might be tricky if running behind a proxy.
// Consider using a fixed redirect URL from settings if possible.
redirectURL := fmt.Sprintf("%s://%s/api/auth/oidc/callback", getScheme(r), r.Host)
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
// Exchange the authorization code for tokens
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
logger.Errorf("failed to exchange token: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// --- Attempt to process ID Token ---
if ok && rawIDToken != "" {
logger.Debug("ID token found in token response, attempting verification.")
// Verify the ID token
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
if err != nil {
logger.Warningf("failed to verify ID token: %v. Falling back to UserInfo endpoint.", err)
// Verification failed, claimsFromIDToken remains false
} else {
var claims map[string]interface{}
// Decode the ID token claims into a map to handle arbitrary structure
// This is where the JWE unmarshalling error occurs if the token is encrypted
if err := idToken.Claims(&claims); err != nil {
logger.Warningf("failed to decode ID token claims: %v. Falling back to UserInfo endpoint.", err)
// Claims decoding failed, claimsFromIDToken remains false
} else {
// Successfully verified and decoded ID token claims
logger.Debugf("ID Token verified and claims decoded: %+v", claims)
// Populate userdata from ID token claims
if name, ok := claims["name"].(string); ok {
userdata.Name = name
}
if preferredUsername, ok := claims["preferred_username"].(string); ok {
userdata.PreferredUsername = preferredUsername
}
if email, ok := claims["email"].(string); ok {
userdata.Email = email
}
if sub, ok := claims["sub"].(string); ok {
userdata.Sub = sub
}
if phone, ok := claims["phone_number"].(string); ok {
userdata.Phone = phone
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
if userdata.Email != "" {
claimsFromIDToken = true
loginUsername = userdata.Email
}
case "username":
if userdata.PreferredUsername != "" {
claimsFromIDToken = true
loginUsername = userdata.PreferredUsername
}
case "phone":
if userdata.Phone != "" {
claimsFromIDToken = true
loginUsername = userdata.Phone
}
}
}
}
} else {
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
// claimsFromIDToken remains false
}
// --- Fallback to UserInfo endpoint if ID token processing did not provide essential claims ---
if !claimsFromIDToken {
// Use the access token obtained from the initial exchange
// oauth2Config.TokenSource creates a token source that uses the provided token.
userInfoResp, err := oidcCfg.Provider.UserInfo(ctx, oauth2Config.TokenSource(ctx, token))
if err != nil {
logger.Errorf("failed to fetch user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to fetch user info from endpoint: %v", err)
}
// Decode the UserInfo response directly into the userdata struct
// The UserInfo endpoint is expected to return standard JSON
if err := userInfoResp.Claims(&userdata); err != nil {
logger.Errorf("failed to decode user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
switch oidcCfg.UserIdentifier {
case "email":
loginUsername = userdata.Email
case "username":
loginUsername = userdata.PreferredUsername
case "phone":
loginUsername = userdata.Phone
}
}
if loginUsername == "" {
logger.Error("No valid username found in ID token or UserInfo response.")
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
}
// Proceed to log the user in with the OIDC data
// userdata struct now contains info from either verified ID token or UserInfo endpoint
return loginWithOidcUser(w, r, loginUsername)
}
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
// This function remains largely the same, but includes the 'fb_redirect' parameter
// to redirect the user back to the original page after successful login.
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if !oidcCfg.Enabled {
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
oauth2Config := &oauth2.Config{
ClientID: oidcCfg.ClientID,
ClientSecret: oidcCfg.ClientSecret,
Endpoint: oidcCfg.Provider.Endpoint(),
RedirectURL: fmt.Sprintf("%s/api/auth/oidc/callback", origin),
Scopes: strings.Split(oidcCfg.Scopes, " "),
}
nonce := utils.InsecureRandomIdentifier(16)
fbRedirect := r.URL.Query().Get("fb_redirect")
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
authURL := oauth2Config.AuthCodeURL(state)
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string) (int, error) {
logger.Debugf("Successfully authenticated OIDC username: %s", username)
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
}
if user.LoginMethod != users.LoginMethodOidc {
logger.Warningf("user %s is not allowed to login with oidc authentication, bypassing and updating login method", user.Username)
user.LoginMethod = users.LoginMethodOidc
err = store.Users.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := "/" // Default redirect path
if state != "" {
// Assuming state is in the format "nonce:fb_redirect"
parts := strings.SplitN(state, ":", 2)
if len(parts) == 2 {
// TODO: Validate the nonce part against the stored nonce for CSRF protection
// For this example, we'll just extract the redirect part
extractedRedirect := parts[1]
if extractedRedirect != "" {
fbRedirect = extractedRedirect
}
}
}
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+8 -5
View File
@@ -9,17 +9,20 @@ import (
"net/url"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var OnlyOfficeCache = cache.NewCache(48 * time.Hour)
const (
onlyOfficeStatusDocumentClosedWithChanges = 2
onlyOfficeStatusDocumentClosedWithNoChanges = 4
@@ -196,7 +199,7 @@ func getOnlyOfficeId(source, path string) (string, error) {
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)
cachedDocumentKey, ok := OnlyOfficeCache.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
@@ -206,11 +209,11 @@ func getOnlyOfficeId(source, path string) (string, error) {
func deleteOfficeId(source, path string) {
idx := indexing.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
logger.Errorf("deleteOfficeId: failed to find source index for user home dir creation: %s", source)
return
}
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
OnlyOfficeCache.Delete(realpath)
}
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
+8 -3
View File
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -40,15 +41,20 @@ type FileCache interface {
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
@@ -90,7 +96,6 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
+10 -3
View File
@@ -8,11 +8,11 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
@@ -52,7 +52,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
var status int
status, err = rawFilesHandler(w, r, d, fileList)
if err != nil {
logger.Error(fmt.Sprintf("public share handler: error processing filelist: %v", err))
logger.Errorf("public share handler: error processing filelist: %v", err)
return status, fmt.Errorf("error processing filelist: %v", f)
}
return status, nil
@@ -118,6 +118,13 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
@@ -129,7 +136,7 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
Expand: true,
})
if err != nil {
logger.Debug(fmt.Sprintf("public preview handler: error getting file info: %v", err))
logger.Debugf("public preview handler: error getting file info: %v", err)
return 400, fmt.Errorf("file not found")
}
if fileInfo.Type == "directory" {
+6 -6
View File
@@ -13,10 +13,10 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
@@ -232,7 +232,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 MB, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
}
// serve content allows for range requests.
// video scrubbing, etc.
@@ -295,7 +295,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
}
// Set headers AFTER computing actual archive size
@@ -307,7 +307,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
_, err = io.Copy(w, fd)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Error(fmt.Sprintf("Failed to copy archive data to response: %v", err))
logger.Errorf("Failed to copy archive data to response: %v", err)
return http.StatusInternalServerError, err
}
@@ -362,7 +362,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
return err
}
}
@@ -385,7 +385,7 @@ func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) erro
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
return err
}
}
+35 -4
View File
@@ -13,12 +13,12 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
)
// resourceGetHandler retrieves information about a resource.
@@ -41,6 +41,13 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -109,6 +116,13 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -163,6 +177,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
@@ -188,7 +209,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
fileInfo, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
logger.Debug(fmt.Sprintf("Resource already exists: %v", fileInfo.RealPath))
logger.Debugf("Resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
@@ -226,6 +247,13 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
@@ -324,7 +352,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// check target dir exists
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
if err != nil {
logger.Debug(fmt.Sprintf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err))
logger.Debugf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err)
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
@@ -349,7 +377,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
err = patchAction(r.Context(), action, realSrc, realDest, d, isSrcDir, srcIndex, dstIndex)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
}
return errToStatus(err), err
}
@@ -404,6 +432,9 @@ func inspectIndex(w http.ResponseWriter, r *http.Request) {
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
// decode url encoded source name
source, _ = url.QueryUnescape(source)
}
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
+7
View File
@@ -60,6 +60,13 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
scope := r.URL.Query().Get("scope")
unencodedScope, err := url.QueryUnescape(scope)
+14
View File
@@ -69,6 +69,13 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -192,6 +199,13 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
} else {
var err error
// decode url encoded source name
sourceName, err = url.QueryUnescape(sourceName)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
}
source := config.Server.NameToSource[sourceName]
+1 -1
View File
@@ -54,7 +54,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
"SourceCount": len(config.Server.SourceMap),
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
+60
View File
@@ -0,0 +1,60 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/go-logger/logger"
)
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
// @Summary Generate OTP
// @Description Generates a new TOTP secret and QR code for the authenticated user.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} map[string]string "OTP secret generated successfully."
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
logger.Debug("Generating OTP for user:", d.user.Username)
url, err := auth.GenerateOtpForUser(d.user, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
response := map[string]string{
"message": "OTP secret generated successfully.",
"url": url, // The otpauth:// URL for QR code generation
}
return renderJSON(w, r, response)
}
// verifyOTPHandler handles the verification of a TOTP code.
// @Summary Verify OTP
// @Description Verifies the provided TOTP code for the authenticated user.
// @Tags OTP
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param code query string true "TOTP code to verify"
// @Success 200 {object} HttpResponse "OTP token is valid."
// @Failure 401 {object} map[string]string "Unauthorized - invalid TOTP token"
// @Router /api/auth/otp/verify [post]
func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
code := r.URL.Query().Get("code")
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := auth.VerifyTotpCode(d.user, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
response := HttpResponse{
Status: http.StatusOK,
Message: "OTP token is valid.",
}
// On success, return a simple confirmation.
return renderJSON(w, r, response)
}
+7
View File
@@ -88,6 +88,9 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
func prepForFrontend(u *users.User) {
u.Password = ""
u.ApiKeys = nil
u.OtpEnabled = u.TOTPSecret != ""
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = settings.ConvertToFrontendScopes(u.Scopes)
}
@@ -205,6 +208,10 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if err = json.Unmarshal(body, &req); err != nil {
return http.StatusBadRequest, err
}
if !req.Data.OtpEnabled {
req.Data.TOTPSecret = ""
req.Data.TOTPNonce = ""
}
// Perform the user update
err = store.Users.Update(req.Data, d.user.Permissions.Admin, req.Which...)
+11 -9
View File
@@ -10,13 +10,15 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
// reduced index is json exposed to the client
type ReducedIndex struct {
IdxName string `json:"name"`
@@ -77,7 +79,7 @@ func Initialize(source settings.Source, mock bool) {
indexesMutex.Unlock()
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
logger.Info(fmt.Sprintf("initializing index: [%v]", newIndex.Source.Name))
logger.Infof("initializing index: [%v]", newIndex.Source.Name)
newIndex.RunIndexing("/", false)
go newIndex.setupIndexingScanners()
} else {
@@ -121,7 +123,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 {
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
logger.Errorf("error indexing directory %v : %v", combinedPath+item.Name, err)
}
}
return nil
@@ -169,7 +171,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, quick, recursive)
if err != nil {
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
logger.Errorf("Failed to index directory %s: %v", dirPath, err)
continue
}
}
@@ -247,8 +249,8 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previous
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Source.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := cache.RealPath.Get(joinedPath + ":isdir").(bool)
cached, ok := cache.RealPath.Get(joinedPath).(string)
isDir, _ := RealPathCache.Get(joinedPath + ":isdir").(bool)
cached, ok := RealPathCache.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -260,8 +262,8 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
// Resolve symlinks and get the real path
realPath, isDir, err := utils.ResolveSymlinks(absolutePath)
if err == nil {
cache.RealPath.Set(joinedPath, realPath)
cache.RealPath.Set(joinedPath+":isdir", isDir)
RealPathCache.Set(joinedPath, realPath)
RealPathCache.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
+12 -13
View File
@@ -2,11 +2,10 @@ package indexing
import (
"encoding/json"
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
// schedule in minutes
@@ -36,7 +35,7 @@ func (idx *Index) newScanner(origin string) {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
logger.Debugf("Next scan in %v", sleepTime)
time.Sleep(sleepTime)
if fullScanCounter == 5 {
idx.RunIndexing(origin, false) // Full scan
@@ -64,7 +63,7 @@ func (idx *Index) PostScan() {
func (idx *Index) UpdateSchedule() {
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
logger.Debugf("Files changed during indexing [%v], adjusting schedule.", idx.Name)
// Move to at least the full-scan anchor or reduce interval
if idx.CurrentSchedule > fullScanAnchor {
idx.CurrentSchedule = fullScanAnchor
@@ -95,7 +94,7 @@ func (idx *Index) SendSourceUpdateEvent() {
}
reducedIndex, err := GetIndexInfo(idx.Name)
if err != nil {
logger.Error(fmt.Sprintf("Error getting index info: %v", err))
logger.Errorf("Error getting index info: %v", err)
return
}
sourceAsMap := map[string]ReducedIndex{
@@ -103,7 +102,7 @@ func (idx *Index) SendSourceUpdateEvent() {
}
message, err := json.Marshal(sourceAsMap)
if err != nil {
logger.Error(fmt.Sprintf("Error marshalling source update message: %v", err))
logger.Errorf("Error marshalling source update message: %v", err)
return
}
events.SendSourceUpdate(idx.Name, string(message))
@@ -114,9 +113,9 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
logger.Debug(fmt.Sprintf("Starting quick scan for [%v]", idx.Source.Name))
logger.Debugf("Starting quick scan for [%v]", idx.Source.Name)
} else {
logger.Debug(fmt.Sprintf("Starting full scan for [%v]", idx.Source.Name))
logger.Debugf("Starting full scan for [%v]", idx.Source.Name)
idx.NumDirs = 0
idx.NumFiles = 0
}
@@ -125,7 +124,7 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
logger.Errorf("Error during indexing: %v", err)
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
@@ -133,7 +132,7 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
idx.LastIndexedUnix = idx.LastIndexed.Unix()
if quick {
idx.QuickScanTime = int(time.Since(startTime).Seconds())
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.QuickScanTime))
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.QuickScanTime)
} else {
idx.FullScanTime = int(time.Since(startTime).Seconds())
// update smart indexing
@@ -148,14 +147,14 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
idx.Assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles))
logger.Infof("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
} else {
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles))
logger.Debugf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.FullScanTime))
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.FullScanTime)
}
idx.PostScan()
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/go-logger/logger"
)
// detects subtitles for video files.
+10 -7
View File
@@ -3,15 +3,18 @@ package indexing
import (
"fmt"
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/shirou/gopsutil/v3/disk"
)
var DiskUsageCache = cache.NewCache(30 * time.Second)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
idx.mu.Lock()
@@ -87,11 +90,11 @@ func GetIndex(name string) *Index {
// todo: update everywhere else so this isn't needed.
source, ok := settings.Config.Server.SourceMap[name]
if !ok {
logger.Error(fmt.Sprintf("index %s not found", name))
logger.Errorf("index %s not found", name)
}
index, ok = indexes[source.Name]
if !ok {
logger.Error(fmt.Sprintf("index %s not found", name))
logger.Errorf("index %s not found", name)
}
}
@@ -105,11 +108,11 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
}
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
_, ok = cache.DiskUsage.Get(cacheKey).(bool)
_, ok = DiskUsageCache.Get(cacheKey).(bool)
if !ok {
usage, err := disk.Usage(sourcePath)
if err != nil {
logger.Error(fmt.Sprintf("error getting disk usage for %s: %v", sourcePath, err))
logger.Errorf("error getting disk usage for %s: %v", sourcePath, err)
idx.SetStatus(UNAVAILABLE)
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
}
@@ -118,7 +121,7 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
Used: usage.Used,
}
idx.SetUsage(latestUsage)
cache.DiskUsage.Set(cacheKey, true)
DiskUsageCache.Set(cacheKey, true)
}
return idx.ReducedIndex, nil
}
+6 -3
View File
@@ -5,12 +5,15 @@ import (
"sort"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
)
var SearchResultsCache = cache.NewCache(15 * time.Second)
var (
sessionInProgress sync.Map
maxSearchResults = 100
@@ -34,12 +37,12 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string)
cachedDirs, ok := SearchResultsCache.Get(idx.Source.Path + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = idx.getDirsInScope(scope)
cache.SearchResults.Set(idx.Source.Path+scope, directories)
SearchResultsCache.Set(idx.Source.Path+scope, directories)
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
+2 -2
View File
@@ -277,7 +277,7 @@ func TestService_Resize(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1, "", "")
svc := NewPreviewGenerator(1, "", "")
source := test.source(t)
defer source.Close()
@@ -435,7 +435,7 @@ func TestService_FormatFromExtension(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1, "", "")
svc := NewPreviewGenerator(1, "", "")
got, err := svc.FormatFromExtension(test.ext)
require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
if err != nil {
+60 -33
View File
@@ -3,6 +3,8 @@ package preview
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"os"
@@ -13,9 +15,9 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
@@ -29,9 +31,10 @@ type Service struct {
ffmpegPath string
ffprobePath string
fileCache diskcache.Interface
debug bool
}
func New(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
@@ -41,35 +44,36 @@ func New(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
if cacheDir == "tmp" {
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. filebrowser is trying to use the default `server.cacheDir: tmp` , but you can change this location if you need to. Please see configuration wiki for more information about this error. https://github.com/gtsteffaniak/filebrowser/wiki/Configuration")
}
logger.Fatal(fmt.Sprintf("failed to create file cache path, which is now require to run the server: %v", err))
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
ffprobePath := ""
ffmpegMainPath := ""
var err error
if ffmpegPath != "" {
ffmpegMainPath, err = CheckValidFFmpeg(ffmpegPath)
if err != nil {
logger.Fatal(fmt.Sprintf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", ffmpegPath, err))
}
ffprobePath, err = CheckValidFFprobe(ffmpegPath)
if err != nil {
logger.Fatal(fmt.Sprintf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", ffmpegPath, err))
}
ffmpegMainPath, err := CheckValidFFmpeg(ffmpegPath)
if err != nil && ffmpegPath != "" {
logger.Fatalf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", ffmpegPath, err)
}
ffprobePath, errprobe := CheckValidFFprobe(ffmpegPath)
if errprobe != nil && ffmpegPath != "" {
logger.Fatalf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", ffmpegPath, err)
}
if errprobe == nil && err == nil {
logger.Infof("Media Enabled : %v", errprobe == nil)
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
}
return &Service{
sem: make(chan struct{}, concurrencyLimit),
ffmpegPath: ffmpegMainPath,
ffprobePath: ffprobePath,
fileCache: fileCache,
debug: settings.Config.Server.DebugMedia,
}
}
func Start(concurrencyLimit int, ffmpegPath, cacheDir string) error {
service = New(concurrencyLimit, ffmpegPath, cacheDir)
func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) error {
service = NewPreviewGenerator(concurrencyLimit, ffmpegPath, cacheDir)
return nil
}
@@ -111,7 +115,10 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
seekPercentage = 10
}
// Generate thumbnail image from video
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "video", CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime, seekPercentage)+".jpg")
hasher := sha1.New() //nolint:gosec
_, _ = hasher.Write([]byte(CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "video", hash) + ".jpg"
defer os.Remove(outPathPattern) // cleanup
if err = service.GenerateVideoPreview(file.RealPath, outPathPattern, seekPercentage); err != nil {
return nil, fmt.Errorf("failed to generate video preview: %w", err)
@@ -130,7 +137,7 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
// Cache and return
cacheKey := CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, resizedBytes); err != nil {
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
}
@@ -172,7 +179,7 @@ func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
if errSmall != nil {
errLarge := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "large", file.ItemInfo.ModTime, 0))
if errLarge != nil {
logger.Debug(fmt.Sprintf("Could not delete thumbnail: %v", file.Name))
logger.Debugf("Could not delete thumbnail: %v", file.Name)
}
}
}
@@ -201,24 +208,44 @@ func AvailablePreview(file iteminfo.ExtendedFileInfo) bool {
return false
}
// CheckValidFFmpeg checks for a valid ffmpeg executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFmpeg(path string) (string, error) {
var exeExt string
if runtime.GOOS == "windows" {
exeExt = ".exe"
}
ffmpegPath := filepath.Join(path, "ffmpeg"+exeExt)
cmd := exec.Command(ffmpegPath, "-version")
return ffmpegPath, cmd.Run()
return checkExecutable(path, "ffmpeg")
}
// CheckValidFFprobe checks for a valid ffprobe executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFprobe(path string) (string, error) {
var exeExt string
return checkExecutable(path, "ffprobe")
}
// checkExecutable is an internal helper function to find and validate an executable.
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
func checkExecutable(providedPath, execName string) (string, error) {
// Add .exe extension for Windows systems
if runtime.GOOS == "windows" {
exeExt = ".exe"
execName += ".exe"
}
ffprobePath := filepath.Join(path, "ffprobe"+exeExt)
cmd := exec.Command(ffprobePath, "-version")
return ffprobePath, cmd.Run()
var finalPath string
var err error
if providedPath != "" {
// A path was provided, so we'll use it.
finalPath = filepath.Join(providedPath, execName)
} else {
// No path was provided, so search the system's PATH for the executable.
finalPath, err = exec.LookPath(execName)
if err != nil {
// The executable was not found in the system's PATH.
return "", err
}
}
// Verify the executable is valid by running the "-version" command.
cmd := exec.Command(finalPath, "-version")
err = cmd.Run()
return finalPath, err
}
+26 -8
View File
@@ -3,9 +3,12 @@ package preview
import (
"bytes"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/gtsteffaniak/go-logger/logger"
)
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
@@ -13,30 +16,43 @@ import (
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
// seekTime: how many seconds into the video to seek before capturing the frame.
func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) error {
// Step 1: Get video stream duration (v:0)
// Step 1: Get video duration from the container format
probeCmd := exec.Command(
s.ffprobePath,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=duration",
// Use format=duration for better compatibility
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
var probeOut bytes.Buffer
probeCmd.Stdout = &probeOut
//probeCmd.Stderr = os.Stderr
if s.debug {
probeCmd.Stderr = os.Stderr
}
if err := probeCmd.Run(); err != nil {
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
return fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(probeOut.String())
if durationStr == "" || durationStr == "N/A" {
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
return fmt.Errorf("could not determine video duration")
}
durationFloat, err := strconv.ParseFloat(durationStr, 64)
if err != nil || durationFloat <= 0 {
if err != nil {
// The original error you saw would be caught here if "N/A" was still the output
return fmt.Errorf("invalid duration: %v", err)
}
if durationFloat <= 0 {
return fmt.Errorf("video duration must be positive")
}
// The rest of your function remains the same...
// Step 2: Get the duration of the video in whole seconds
duration := int(durationFloat)
@@ -56,8 +72,10 @@ func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageS
outputPath,
)
//cmd.Stdout = os.Stdout
//cmd.Stderr = os.Stderr
if s.debug {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd.Run()
}
+114 -1
View File
@@ -32,6 +32,92 @@ const docTemplate = `{
}
}
},
"/api/auth/otp/generate": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"OTP"
],
"summary": "Generate OTP",
"responses": {
"200": {
"description": "OTP secret generated successfully.",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/otp/verify": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Verifies the provided TOTP code for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"OTP"
],
"summary": "Verify OTP",
"parameters": [
{
"type": "string",
"description": "TOTP code to verify",
"name": "code",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OTP token is valid.",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"401": {
"description": "Unauthorized - invalid TOTP token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/createApiKey": {
"post": {
"description": "Create an API key with specified name, duration, and permissions.",
@@ -1677,6 +1763,10 @@ const docTemplate = `{
"tokenExpirationHours": {
"description": "the number of hours until the token expires. Default is 2 hours.",
"type": "integer"
},
"totpSecret": {
"description": "secret used to encrypt TOTP secrets",
"type": "string"
}
}
},
@@ -1783,6 +1873,10 @@ const docTemplate = `{
"output": {
"description": "output location. (eg. \"stdout\" or \"path/to/file.log\")",
"type": "string"
},
"utc": {
"description": "use UTC time in the output instead of local time",
"type": "boolean"
}
}
},
@@ -1876,7 +1970,12 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"enforcedOtp": {
"description": "if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.",
"type": "boolean"
},
"minLength": {
"description": "minimum pasword length required.",
"type": "integer",
"minimum": 5
},
@@ -1889,7 +1988,7 @@ const docTemplate = `{
]
},
"signup": {
"description": "currently not used by filebrowser",
"description": "allow signups on login page if enabled -- not secure.",
"type": "boolean"
}
}
@@ -1947,6 +2046,10 @@ const docTemplate = `{
"description": "path to the database file",
"type": "string"
},
"debugMedia": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"disablePreviewResize": {
"description": "disable resizing of previews for faster loading over slow connections",
"type": "boolean"
@@ -2360,6 +2463,10 @@ const docTemplate = `{
"loginMethod": {
"$ref": "#/definitions/users.LoginMethod"
},
"otpEnabled": {
"description": "true if TOTP is enabled, false otherwise",
"type": "boolean"
},
"password": {
"type": "string"
},
@@ -2409,6 +2516,12 @@ const docTemplate = `{
"description": "theme color to use: eg. #ff0000, or var(--red), var(--purple), etc",
"type": "string"
},
"totpNonce": {
"type": "string"
},
"totpSecret": {
"type": "string"
},
"username": {
"type": "string"
},
+114 -1
View File
@@ -21,6 +21,92 @@
}
}
},
"/api/auth/otp/generate": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"OTP"
],
"summary": "Generate OTP",
"responses": {
"200": {
"description": "OTP secret generated successfully.",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/otp/verify": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Verifies the provided TOTP code for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"OTP"
],
"summary": "Verify OTP",
"parameters": [
{
"type": "string",
"description": "TOTP code to verify",
"name": "code",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OTP token is valid.",
"schema": {
"$ref": "#/definitions/http.HttpResponse"
}
},
"401": {
"description": "Unauthorized - invalid TOTP token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/createApiKey": {
"post": {
"description": "Create an API key with specified name, duration, and permissions.",
@@ -1666,6 +1752,10 @@
"tokenExpirationHours": {
"description": "the number of hours until the token expires. Default is 2 hours.",
"type": "integer"
},
"totpSecret": {
"description": "secret used to encrypt TOTP secrets",
"type": "string"
}
}
},
@@ -1772,6 +1862,10 @@
"output": {
"description": "output location. (eg. \"stdout\" or \"path/to/file.log\")",
"type": "string"
},
"utc": {
"description": "use UTC time in the output instead of local time",
"type": "boolean"
}
}
},
@@ -1865,7 +1959,12 @@
"enabled": {
"type": "boolean"
},
"enforcedOtp": {
"description": "if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.",
"type": "boolean"
},
"minLength": {
"description": "minimum pasword length required.",
"type": "integer",
"minimum": 5
},
@@ -1878,7 +1977,7 @@
]
},
"signup": {
"description": "currently not used by filebrowser",
"description": "allow signups on login page if enabled -- not secure.",
"type": "boolean"
}
}
@@ -1936,6 +2035,10 @@
"description": "path to the database file",
"type": "string"
},
"debugMedia": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"disablePreviewResize": {
"description": "disable resizing of previews for faster loading over slow connections",
"type": "boolean"
@@ -2349,6 +2452,10 @@
"loginMethod": {
"$ref": "#/definitions/users.LoginMethod"
},
"otpEnabled": {
"description": "true if TOTP is enabled, false otherwise",
"type": "boolean"
},
"password": {
"type": "string"
},
@@ -2398,6 +2505,12 @@
"description": "theme color to use: eg. #ff0000, or var(--red), var(--purple), etc",
"type": "string"
},
"totpNonce": {
"type": "string"
},
"totpSecret": {
"type": "string"
},
"username": {
"type": "string"
},
+77 -1
View File
@@ -107,6 +107,9 @@ definitions:
tokenExpirationHours:
description: the number of hours until the token expires. Default is 2 hours.
type: integer
totpSecret:
description: secret used to encrypt TOTP secrets
type: string
type: object
settings.ExternalLink:
properties:
@@ -181,6 +184,9 @@ definitions:
output:
description: output location. (eg. "stdout" or "path/to/file.log")
type: string
utc:
description: use UTC time in the output instead of local time
type: boolean
type: object
settings.LoginMethods:
properties:
@@ -252,7 +258,12 @@ definitions:
properties:
enabled:
type: boolean
enforcedOtp:
description: if set to true, TOTP is enforced for all password users users.
Otherwise, users can choose to enable TOTP.
type: boolean
minLength:
description: minimum pasword length required.
minimum: 5
type: integer
recaptcha:
@@ -260,7 +271,7 @@ definitions:
- $ref: '#/definitions/settings.Recaptcha'
description: recaptcha config, only used if signup is enabled
signup:
description: currently not used by filebrowser
description: allow signups on login page if enabled -- not secure.
type: boolean
type: object
settings.ProxyAuthConfig:
@@ -301,6 +312,10 @@ definitions:
database:
description: path to the database file
type: string
debugMedia:
description: output ffmpeg stdout for media integration -- careful can produces
lots of output!
type: boolean
disablePreviewResize:
description: disable resizing of previews for faster loading over slow connections
type: boolean
@@ -602,6 +617,9 @@ definitions:
type: boolean
loginMethod:
$ref: '#/definitions/users.LoginMethod'
otpEnabled:
description: true if TOTP is enabled, false otherwise
type: boolean
password:
type: string
perm:
@@ -638,6 +656,10 @@ definitions:
description: 'theme color to use: eg. #ff0000, or var(--red), var(--purple),
etc'
type: string
totpNonce:
type: string
totpSecret:
type: string
username:
type: string
viewMode:
@@ -658,6 +680,60 @@ paths:
summary: User Logout
tags:
- Auth
/api/auth/otp/generate:
post:
consumes:
- application/json
description: Generates a new TOTP secret and QR code for the authenticated user.
produces:
- application/json
responses:
"200":
description: OTP secret generated successfully.
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
security:
- ApiKeyAuth: []
summary: Generate OTP
tags:
- OTP
/api/auth/otp/verify:
post:
consumes:
- application/json
description: Verifies the provided TOTP code for the authenticated user.
parameters:
- description: TOTP code to verify
in: query
name: code
required: true
type: string
produces:
- application/json
responses:
"200":
description: OTP token is valid.
schema:
$ref: '#/definitions/http.HttpResponse'
"401":
description: Unauthorized - invalid TOTP token
schema:
additionalProperties:
type: string
type: object
security:
- ApiKeyAuth: []
summary: Verify OTP
tags:
- OTP
/api/createApiKey:
post:
consumes:
+2
View File
@@ -28,6 +28,8 @@
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"file-loader": "^6.2.0",
"glob": "^9.3.5",
"highlight.js": "^11.11.1",
"marked": "^15.0.6",
"normalize.css": "^8.0.1",
"qrcode.vue": "^3.4.1",
+91 -84
View File
@@ -1,96 +1,103 @@
server:
numImageProcessors: 0 # "number of concurrent image processing jobs"
socket: "" # "socket to listen on"
tlsKey: "" # "path to TLS key"
tlsCert: "" # "path to TLS cert"
disablePreviews: false # "disable all previews thumbnails, simple icons will be used"
disablePreviewResize: false # "disable resizing of previews for faster loading over slow connections"
disableTypeDetectionByHeader: false # "disable type detection by header, useful if filesystem is slow."
port: 0 # "port to listen on"
baseURL: "" # "base URL for the server, the subpath that the server is running on."
numImageProcessors: 4 # number of concurrent image processing jobs
socket: "" # socket to listen on
tlsKey: "" # path to TLS key
tlsCert: "" # path to TLS cert
disablePreviews: false # disable all previews thumbnails, simple icons will be used
disablePreviewResize: false # disable resizing of previews for faster loading over slow connections
disableTypeDetectionByHeader: false # disable type detection by header, useful if filesystem is slow.
port: 80 # port to listen on
baseURL: / # base URL for the server, the subpath that the server is running on.
logging:
- levels: "" # "separated list of log levels to enable. (eg. "info|warning|error|debug")"
apiLevels: "" # "separated list of log levels to enable for the API. (eg. "info|warning|error")"
output: "" # "output location. (eg. "stdout" or "path/to/file.log")"
noColors: false # "disable colors in the output"
json: false # "output in json format, currently not supported"
database: "" # "path to the database file"
sources: # validate:required,dive
- path: "" # "file system path. (Can be relative)" validate:required
name: "" # "display name"
- levels: "" # separated list of log levels to enable. (eg. "info|warning|error|debug")
apiLevels: "" # separated list of log levels to enable for the API. (eg. "info|warning|error")
output: stdout # output location. (eg. "stdout" or "path/to/file.log")
noColors: false # disable colors in the output
json: false # output in json format, currently not supported
utc: false # use UTC time in the output instead of local time
debugMedia: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
database: database.db # path to the database file
sources: # validate:required,dive
- path: /relative/or/absolute/path # file system path. (Can be relative) validate:required
name: backend # display name
config:
indexingInterval: 0 # "optional manual overide interval in seconds to re-index the source"
disabled: false # "disable the indexing of this source"
maxWatchers: 0 # "number of concurrent watchers to use for this source, currently not supported"
indexingInterval: 0 # optional manual overide interval in seconds to re-index the source
disabled: false # disable the indexing of this source
maxWatchers: 0 # number of concurrent watchers to use for this source, currently not supported
neverWatchPaths: []
ignoreHidden: false # "ignore hidden files and folders."
ignoreZeroSizeFolders: false # "ignore folders with 0 size"
exclude: # "exclude files and folders from indexing, if include is not set"
ignoreHidden: false # ignore hidden files and folders.
ignoreZeroSizeFolders: false # ignore folders with 0 size
exclude: # exclude files and folders from indexing, if include is not set
files: []
folders: []
fileEndsWith: []
include: # "include files and folders from indexing, if exclude is not set"
include: # include files and folders from indexing, if exclude is not set
files: []
folders: []
fileEndsWith: []
defaultUserScope: "" # "default "/" should match folders under path"
defaultEnabled: false # "should be added as a default source for new users?"
createUserDir: false # "create a user directory for each user"
externalUrl: "" # "used by share links if set"
internalUrl: "" # "used by integrations if set, this is the url that an integration service will use to communicate with filebrowser"
cacheDir: "" # "path to the cache directory, used for thumbnails and other cached files"
maxArchiveSize: 0 # "max pre-archive combined size of files/folder that are allowed to be archived (in GB)"
defaultUserScope: / # default "/" should match folders under path
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user
externalUrl: "" # used by share links if set
internalUrl: "" # used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
cacheDir: tmp # path to the cache directory, used for thumbnails and other cached files
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
auth:
tokenExpirationHours: 0 # "the number of hours until the token expires. Default is 2 hours."
tokenExpirationHours: 2 # the number of hours until the token expires. Default is 2 hours.
methods:
proxy: # validate:omitempty
proxy: # validate:omitempty
enabled: false
createUser: false # "create user if not exists"
header: "" # "required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username."
noauth: false # "if set to true, overrides all other auth methods and disables authentication"
password: # validate:omitempty
enabled: false
minLength: 0 # validate:omitempty,min=5
signup: false # "currently not used by filebrowser" validate:omitempty
recaptcha: # "recaptcha config, only used if signup is enabled" validate:omitempty
host: "" # validate:required
key: "" # validate:required
secret: "" # validate:required
oidc: # validate:omitempty
enabled: false # "whether to enable OIDC authentication"
clientId: "" # "client id of the OIDC application"
clientSecret: "" # "client secret of the OIDC application"
issuerUrl: "" # "authorization URL of the OIDC provider"
scopes: "" # "scopes to request from the OIDC provider"
userIdentifier: "" # "the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone""
disableVerifyTLS: false # "disable TLS verification for the OIDC provider. This is insecure and should only be used for testing."
logoutRedirectUrl: "" # "if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected."
key: "" # "the key used to sign the JWT tokens. If not set, a random key will be generated."
adminUsername: "" # "the username of the admin user. If not set, the default is "admin"."
adminPassword: "" # "the password of the admin user. If not set, the default is "admin"."
resetAdminOnStart: false # "if set to true, the admin user will be reset to the default username and password on startup."
createUser: false # create user if not exists
header: "" # required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
noauth: false # if set to true, overrides all other auth methods and disables authentication
password: # validate:omitempty
enabled: true
minLength: 5 # minimum pasword length required. validate:omitempty,min=5
signup: false # allow signups on login page if enabled -- not secure. validate:omitempty
recaptcha: # recaptcha config, only used if signup is enabled validate:omitempty
host: "" # validate:required
key: "" # validate:required
secret: "" # validate:required
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
oidc: # validate:omitempty
enabled: false # whether to enable OIDC authentication
clientId: "" # client id of the OIDC application
clientSecret: "" # client secret of the OIDC application
issuerUrl: "" # authorization URL of the OIDC provider
scopes: "" # scopes to request from the OIDC provider
userIdentifier: "" # the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
disableVerifyTLS: false # disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
key: "" # the key used to sign the JWT tokens. If not set, a random key will be generated.
adminUsername: admin # the username of the admin user. If not set, the default is "admin".
adminPassword: admin # the password of the admin user. If not set, the default is "admin".
resetAdminOnStart: false # if set to true, the admin user will be reset to the default username and password on startup.
totpSecret: "" # secret used to encrypt TOTP secrets
frontend:
name: "" # "display name"
disableDefaultLinks: false # "disable default links in the sidebar"
disableUsedPercentage: false # "disable used percentage for the sources in the sidebar"
name: FileBrowser Quantum # display name
disableDefaultLinks: false # disable default links in the sidebar
disableUsedPercentage: false # disable used percentage for the sources in the sidebar
externalLinks:
- text: "" # "the text to display on the link" validate:required
title: "" # "the title to display on hover"
url: "" # "the url to link to" validate:required
- text: (untracked) # the text to display on the link validate:required
title: untracked # the title to display on hover
url: https://github.com/gtsteffaniak/filebrowser/releases/ # the url to link to validate:required
- text: Help # the text to display on the link validate:required
title: "" # the title to display on hover
url: https://github.com/gtsteffaniak/filebrowser/wiki # the url to link to validate:required
userDefaults:
stickySidebar: false # "keep sidebar open when navigating"
darkMode: false # "should dark mode be enabled"
locale: "" # "language to use: eg. de, en, or fr"
viewMode: "" # "view mode to use: eg. normal, list, grid, or compact"
singleClick: false # "open directory on single click, also enables middle click to open in new tab"
showHidden: false # "show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files"
dateFormat: false # "when false, the date is relative, when true, the date is an exact timestamp"
gallerySize: 0 # "0-9 - the size of the gallery thumbnails"
themeColor: "" # "theme color to use: eg. #ff0000, or var(--red), var(--purple), etc"
quickDownload: false # "show icon to download in one click"
disableOnlyOfficeExt: "" # "comma separated list of file extensions to disable onlyoffice preview for"
lockPassword: false # "disable the user from changing their password"
disableSettings: false # "disable the user from viewing the settings page"
stickySidebar: true # keep sidebar open when navigating
darkMode: true # should dark mode be enabled
locale: en # language to use: eg. de, en, or fr
viewMode: normal # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: var(--blue) # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disableOnlyOfficeExt: .txt .csv .html .pdf # comma separated list of file extensions to disable onlyoffice preview for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
preview:
highQuality: false
image: false
@@ -104,11 +111,11 @@ userDefaults:
modify: false
share: false
realtime: false
loginMethod: "" # "login method to use: eg. password, proxy, oidc"
loginMethod: password # login method to use: eg. password, proxy, oidc
integrations:
office: # validate:omitempty
url: "" # "The URL to the OnlyOffice Document Server, needs to be accessible to the user." validate:required
internalUrl: "" # "An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy."
secret: "" # validate:required
media: # validate:omitempty
ffmpegPath: "" # "path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)"
office: # validate:omitempty
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
internalUrl: "" # An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
secret: "" # validate:required
media: # validate:omitempty
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
+1 -1
View File
@@ -2,7 +2,7 @@
import fs from 'fs-extra';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import glob from 'glob';
import * as glob from 'glob';
import * as deepl from 'deepl-node';
// --- Configuration ---
+140 -67
View File
@@ -1,118 +1,191 @@
import { fetchURL, fetchJSON } from "@/api/utils";
import { getApiPath } from "@/utils/url.js";
import { notify } from "@/notify"; // Import notify for error handling
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
import { setNewToken } from '@/utils/auth.js' // Import setNewToken for token management
export async function getAllUsers() {
export async function getAllUsers () {
try {
const apiPath = getApiPath("api/users");
return await fetchJSON(apiPath);
const apiPath = getApiPath('api/users')
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || "Failed to fetch users");
throw err; // Re-throw to handle further if needed
notify.showError(err.message || 'Failed to fetch users')
throw err // Re-throw to handle further if needed
}
}
export async function generateOTP (username, password) {
const params = { username, password }
try {
let apiPath = getApiPath('api/auth/otp/generate', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
return await res.json()
} catch (error) {
notify.showError(error || 'Failed to generate OTP')
throw error
}
}
export async function verifyOtp (username, password, otp) {
const params = { username, password, code: otp }
try {
let apiPath = getApiPath('api/auth/otp/verify', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
if (res.status != 200) {
throw new Error('Failed to verify OTP')
}
} catch (error) {
notify.showError(error || 'Failed to generate OTP')
throw error
}
}
export async function login(username, password, recaptcha, otp) {
const params = { username, password, recaptcha, code: otp };
let apiPath = getApiPath('api/auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
body = { message: bodyText };
}
if (res.status === 200) {
await setNewToken(bodyText);
} else if (res.status === 403) {
const msg = body.message || 'Forbidden';
throw new Error(msg);
} else {
throw new Error(body.message || bodyText || 'Failed to login');
}
}
export async function get(id) {
try {
const apiPath = getApiPath("api/users", { id: id });
return await fetchJSON(apiPath);
const apiPath = getApiPath('api/users', { id: id })
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to fetch user with ID: ${id}`);
throw err;
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
throw err
}
}
export async function getApiKeys() {
export async function getApiKeys () {
try {
const apiPath = getApiPath("api/auth/tokens");
return await fetchJSON(apiPath);
const apiPath = getApiPath('api/auth/tokens')
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to get api keys`);
throw err;
notify.showError(err.message || `Failed to get api keys`)
throw err
}
}
export async function signupLogin (username, password, otp) {
const params = { username, password, otp }
let apiPath = getApiPath('api/auth/signup', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
export async function createApiKey(params) {
if (res.status !== 201) {
throw new Error(res.status)
}
}
export async function createApiKey (params) {
try {
const apiPath = getApiPath("api/auth/token", params);
const apiPath = getApiPath('api/auth/token', params)
await fetchURL(apiPath, {
method: "PUT",
});
method: 'PUT'
})
} catch (err) {
notify.showError(err.message || `Failed to create API key`);
throw err;
notify.showError(err.message || `Failed to create API key`)
throw err
}
}
export function deleteApiKey(params) {
export function deleteApiKey (params) {
try {
const apiPath = getApiPath("api/auth/token", params);
const apiPath = getApiPath('api/auth/token', params)
fetchURL(apiPath, {
method: "DELETE",
});
method: 'DELETE'
})
} catch (err) {
notify.showError(err.message || `Failed to delete API key`);
throw err;
notify.showError(err.message || `Failed to delete API key`)
throw err
}
}
export async function create(user) {
export async function create (user) {
try {
const apiPath = getApiPath("api/users");
const apiPath = getApiPath('api/users')
const res = await fetchURL(apiPath, {
method: "POST",
method: 'POST',
body: JSON.stringify({
what: "user",
what: 'user',
which: [],
data: user,
}),
});
data: user
})
})
if (res.status === 201) {
return res.headers.get("Location");
return res.headers.get('Location')
} else {
throw new Error("Failed to create user");
throw new Error('Failed to create user')
}
} catch (err) {
notify.showError(err.message || "Error creating user");
throw err;
notify.showError(err.message || 'Error creating user')
throw err
}
}
export async function update(user, which = ["all"]) {
try {
// List of keys to exclude from the "which" array
const excludeKeys = ["id", "name"];
// Filter out the keys from "which"
which = which.filter(item => !excludeKeys.includes(item));
if (user.username === "publicUser") {
return;
}
const apiPath = getApiPath("api/users", { id: user.id });
await fetchURL(apiPath, {
method: "PUT",
body: JSON.stringify({
what: "user",
which: which,
data: user,
}),
});
} catch (err) {
notify.showError(err.message || `Failed to update user with ID: ${user.id}`);
throw err;
export async function update (user, which = ['all']) {
// List of keys to exclude from the "which" array
const excludeKeys = ['id', 'name']
// Filter out the keys from "which"
which = which.filter(item => !excludeKeys.includes(item))
if (user.username === 'publicUser') {
return
}
const apiPath = getApiPath('api/users', { id: user.id })
await fetchURL(apiPath, {
method: 'PUT',
body: JSON.stringify({
what: 'user',
which: which,
data: user
})
})
}
export async function remove(id) {
export async function remove (id) {
try {
const apiPath = getApiPath("api/users", { id: id });
const apiPath = getApiPath('api/users', { id: id })
await fetchURL(apiPath, {
method: "DELETE",
});
method: 'DELETE'
})
} catch (err) {
notify.showError(err.message || `Failed to delete user with ID: ${id}`);
throw err;
notify.showError(err.message || `Failed to delete user with ID: ${id}`)
throw err
}
}
+16 -10
View File
@@ -63,23 +63,28 @@ export default {
},
watch: {
$route() {
this.reEvalAction()
this.reEvalAction();
},
req() {
this.reEvalAction()
this.reEvalAction();
},
currentView() {
this.reEvalAction()
this.reEvalAction();
},
},
methods: {
reEvalAction() {
const currentView = getters.currentView()
if (currentView == "settings" ) {
const currentView = getters.currentView();
if (currentView == "settings") {
mutations.setActiveSettingsView(getters.currentHash());
mutations.setMultiButtonState("close")
} else if (currentView == "editor" || currentView == "preview" || currentView == "onlyOfficeEditor" || currentView == "markdownViewer") {
mutations.setMultiButtonState("close")
mutations.setMultiButtonState("close");
} else if (
currentView == "editor" ||
currentView == "preview" ||
currentView == "onlyOfficeEditor" ||
currentView == "markdownViewer"
) {
mutations.setMultiButtonState("close");
} else {
mutations.setMultiButtonState("menu");
}
@@ -121,7 +126,8 @@ export default {
stroke: var(--textPrimary);
stroke-width: 5.5;
stroke-linecap: round;
transition: stroke 400ms ease, transform 400ms ease, opacity 400ms ease, stroke-dasharray 400ms ease, stroke-dashoffset 400ms ease;
transition: stroke 400ms ease, transform 400ms ease, opacity 400ms ease,
stroke-dasharray 400ms ease, stroke-dashoffset 400ms ease;
transform-origin: 50% 50%;
}
@@ -150,7 +156,7 @@ export default {
/* Back (Arrow) */
.ham5.back .top {
transform: translate(0,0.2em) rotate(45deg) scaleX(0.5);
transform: translate(0, 0.2em) rotate(45deg) scaleX(0.5);
}
.ham5.back .bottom {
+1 -1
View File
@@ -19,7 +19,7 @@
</li>
</ul>
<div v-if="isCardView" class="gallery-size">
{{ $t('files.size') }}:<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t('files.size') }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input
v-model="gallerySize"
type="range"
+7 -12
View File
@@ -11,7 +11,7 @@
:class="{ 'dark-mode': isDarkMode, centered: centered }"
>
<div v-if="selectedCount > 0" class="button selected-count-header">
<span>{{ selectedCount }} {{ $t('prompts.selected') }} </span>
<span>{{ selectedCount }} {{ $t("prompts.selected") }} </span>
</div>
<action
@@ -103,14 +103,9 @@
class="button no-select"
:class="{ 'dark-mode': isDarkMode }"
>
<action
v-if="showDelete"
icon="delete"
:label="$t('buttons.delete')"
show="delete"
/>
<action v-if="showDelete" icon="delete" :label="$t('buttons.delete')" show="delete" />
<action v-if="showSave" icon="save" :label="$t('buttons.save')" @action="save()" />
<action v-if="showEdit" icon="edit" @action="edit()" />
<action v-if="showEdit" icon="edit" :label="$t('buttons.edit')" @action="edit()" />
</div>
</template>
@@ -137,13 +132,13 @@ export default {
},
computed: {
noItems() {
return !this.showEdit && !this.showSave && !this.showDelete
return !this.showEdit && !this.showSave && !this.showDelete;
},
showEdit() {
return window.location.hash == "#edit" && state.user.permissions.modify;
return window.location.hash != "#edit" && state.user.permissions.modify;
},
showDelete() {
return state.user.permissions.modify && getters.currentView() == "preview"
return state.user.permissions.modify && getters.currentView() == "preview";
},
showSave() {
return getters.currentView() == "editor" && state.user.permissions.modify;
@@ -266,7 +261,7 @@ export default {
notify.showError("Error saving file: ", e);
}
mutations.closeHovers();
mutations.closeHovers();
},
},
};
+11 -12
View File
@@ -81,25 +81,23 @@
<!-- Inputs for filtering by file size -->
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p> {{ $t('search.smallerThan') }} </p>
<p>{{ $t("search.smallerThan") }}</p>
<input
class="sizeInput"
v-model="smallerThan"
type="number"
min="0"
placeholder="number"
/>
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="sizeInputWrapper">
<p> {{ $t('search.largerThan') }} </p>
<p>{{ $t("search.largerThan") }}</p>
<input
class="sizeInput"
v-model="largerThan"
type="number"
placeholder="number"
/>
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</div>
@@ -112,12 +110,12 @@
<!-- Message when no results are found -->
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
<div class="helpButton" @click="toggleHelp()"> {{ $t('sidebar.help') }} </div>
<div class="helpButton" @click="toggleHelp()">{{ $t("sidebar.help") }}</div>
</div>
<!-- Help text section -->
<div class="helpText" v-if="showHelp">
<p>{{ $t('search.helpText1') }}</p>
<p>{{ $t('search.helpText2') }}</p>
<p>{{ $t("search.helpText1") }}</p>
<p>{{ $t("search.helpText2") }}</p>
</div>
<!-- List of search results -->
<ul v-show="results.length > 0">
@@ -131,7 +129,7 @@
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" />
<span class="text-container">
{{ basePath(s.path, s.type == "directory") }}/{{ baseName(s.path) }}/ <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ basePath(s.path, s.type == "directory") }}/{{ baseName(s.path) }}/<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
</a>
@@ -394,11 +392,12 @@ export default {
if (path.startsWith("/")) {
path = path.slice(1); // remove leading slash
}
const encodedPath = encodeURIComponent(path).replace("/%20/g", "/");
let fullpath = path;
if (serverHasMultipleSources) {
fullpath = "/files/" + this.selectedSource + "/" + encodeURIComponent(path);
fullpath = "/files/" + this.selectedSource + "/" + encodedPath;
} else {
fullpath = "/files/" + encodeURIComponent(path);
fullpath = "/files/" + encodedPath;
}
return fullpath;
},
+6 -1
View File
@@ -121,6 +121,7 @@ export default {
let index = 0;
const updateThumbnailUrl = () => {
const img = new Image();
if (state.popupPreviewSource === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
@@ -129,8 +130,12 @@ export default {
const currentUrl = sequence[index];
const img = new Image();
img.onload = () => {
if (state.popupPreviewSource === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
}
// Set the thumbnail or popup preview
if (state.user.preview.popup) {
mutations.setPreviewSource(currentUrl);
@@ -62,7 +62,7 @@ export default {
this.popupStyle = {
"max-width": "90%",
"max-height": "75%",
"margin": "1em",
margin: "1em",
transform: "translate(0, 4em)",
};
return;
+2 -3
View File
@@ -19,9 +19,7 @@
<hr />
</div>
<div v-else class="thumb-letters no-select">
<i class="material-icons" :class="{ 'primary-icons': isFolder }">
{{ isFolder ? "folder" : "description" }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
<i class="material-icons" :class="{ 'primary-icons': isFolder }"> {{ isFolder ? "folder" : "description" }} </i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<div
@@ -130,6 +128,7 @@ export default {
const content = this.$refs.wrapper;
this.isVisible = true;
this.scheduleHide();
mutations.setPreviewSource("");
this.updateThumbPosition(content.scrollTop);
mutations.updateListing({
...state.listing,
@@ -1,7 +1,7 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t('api.title') }}</h2>
<h2>{{ $t('api.title') }}:</h2> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="card-content">
@@ -29,6 +29,7 @@ import CreateApi from "./CreateApi.vue";
import ActionApi from "./ActionApi.vue";
import Sidebar from "../sidebar/Sidebar.vue";
import buttons from "@/utils/buttons";
import Totp from "./Totp.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
export default {
@@ -46,6 +47,7 @@ export default {
Help,
Replace,
ReplaceRename,
Totp,
Upload,
ShareDelete,
Sidebar,
+3 -4
View File
@@ -5,9 +5,7 @@
</div>
<div class="card-content">
<p>
{{ $t("prompts.renameMessage") }} <code>{{ oldName() }}</code>: <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
<p>{{ $t("prompts.renameMessage") }} <code>{{ oldName() }}</code>:</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input
class="input input--block"
v-focus
@@ -28,7 +26,7 @@
</button>
<button
@click="submit"
class="button button--flat"
class="button busubmittton--flat"
type="submit"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')"
@@ -100,6 +98,7 @@ export default {
newLink = url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
await filesApi.moveCopy([{ from: oldLink, to: newLink }], "move");
mutations.closeHovers();
if (!this.isListing) {
this.$router.push({ path: newLink });
return;
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="card floating create-api__prompt__card" id="create-api">
<div class="card-title">
<h2>{{ $t("otp.name") }}</h2>
</div>
<div v-if="error !== ''" class="wrong-login">{{ error }}</div>
<div class="card-content">
<p v-if="generate">{{ $t("otp.generate") }}</p>
<div v-if="generate" class="share__box__element share__box__center">
<p>{{ this.url }}</p>
<qrcode-vue class="qrcode" :value="this.url" size="200" level="M"></qrcode-vue>
</div>
<p>{{ $t("otp.verifyInstructions") }}</p>
<input
class="input input--block"
type="text"
v-model="code"
@keyup.enter="verifyCode"
:placeholder="$t('otp.codeInputPlaceholder')"
/>
</div>
<div class="card-action">
<button
@click="closeHovers"
class="button button--flat button--grey"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--blue"
@click="verifyCode"
:title="$t('buttons.verify')"
>
{{ $t("buttons.verify") }}
</button>
</div>
</div>
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { usersApi } from "@/api";
import { initAuth } from "@/utils/auth";
import QrcodeVue from "qrcode.vue";
export default {
name: "totp",
components: {
QrcodeVue,
},
data() {
return {
error: "",
code: "",
url: "",
};
},
props: {
redirect: {
type: String,
default: "",
},
generate: {
type: Boolean,
default: false,
},
username: {
type: String,
default: "",
},
password: {
type: String,
default: "",
},
},
async mounted() {
if (this.generate) {
this.generateNewCode();
}
},
methods: {
async generateNewCode() {
try {
const resp = await usersApi.generateOTP(this.username, this.password);
this.url = resp.url;
} catch (error) {
this.error = this.$t("otp.generationFailed");
return;
}
},
async verifyCode(event) {
event.preventDefault();
if (!this.code) {
this.error = this.$t("otp.invalidCodeType");
notify.showError(this.$t("otp.invalidCodeType"));
return;
}
try {
await usersApi.verifyOtp(this.username, this.password, this.code);
if (this.redirect != "") {
await usersApi.login(this.username, this.password, this.redirect, this.code);
await initAuth();
if (this.redirect) {
this.$router.push(this.redirect);
}
} else {
mutations.closeHovers();
}
} catch (error) {
this.error = this.$t("otp.verificationFailed");
return;
}
},
closeHovers() {
mutations.closeHovers();
},
},
};
</script>
+21 -2
View File
@@ -37,6 +37,17 @@
{{ $t("buttons.update") }}
</button>
</div>
<div
style="display: flex; flex-direction: column"
v-if="stateUser.username == user.username"
>
<div class="settings-items">
<ToggleSwitch class="item" v-model="user.otpEnabled" :name="$t('otp.name')" />
</div>
<button class="button" type="button" v-if="user.otpEnabled" :onclick="newOTP">
{{ $t("buttons.generateNewOtp") }}
</button>
</div>
<hr />
</div>
<div v-if="stateUser.permissions.admin">
@@ -159,7 +170,7 @@
<select v-model="user.loginMethod" class="input input--block" id="loginMethod">
<option value="password">Password</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="oidc">OIDC</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="proxy">Proxy</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="proxy">Proxy</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</select>
</div>
<permissions v-if="stateUser.permissions.admin" :permissions="user.permissions" />
@@ -169,7 +180,7 @@
<script>
import Languages from "./Languages.vue";
import Permissions from "./Permissions.vue";
import { state } from "@/store";
import { mutations, state } from "@/store";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { notify } from "@/notify";
import { usersApi, settingsApi } from "@/api";
@@ -248,6 +259,14 @@ export default {
},
},
methods: {
newOTP() {
mutations.showHover({
name: "totp",
props: {
generate: true,
},
});
},
async submitUpdatePassword() {
event.preventDefault();
if (this.invalidPassword) {
+6
View File
@@ -227,3 +227,9 @@ input.sizeInput:disabled {
border-top-left-radius: 0;
background-color: var(--surfaceSecondary);
}
.qrcode {
border: 0.25em;
border-style: solid;
border-color: white;
}
+2
View File
@@ -173,6 +173,8 @@ table tr>*:last-child {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
-webkit-box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
z-index: 99999;
max-width: 40em;
width: 90%;
+1 -1
View File
@@ -37,7 +37,7 @@
margin: .5em 0 0;
}
#login .wrong {
.wrong-login {
background: var(--red);
color: #fff;
padding: .5em;
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "المجلد",
"submit": "إرسال",
"openFile": "فتح الملف",
"continue": "تابع"
"continue": "تابع",
"edit": "تحرير",
"generateNewOtp": "إعادة تعيين وإنشاء رمز ثنائي العامل الجديد",
"verify": "تحقق"
},
"download": {
"downloadFile": "Download File",
@@ -254,7 +257,6 @@
"userUpdated": "تم تعديل المستخدم",
"username": "إسم المستخدم",
"users": "المستخدمين",
"loginMethod": "طريقة تسجيل الدخول",
"modifyOtherUser": "تعديل المستخدم:",
"modifyCurrentUser": "تعديل المستخدم الحالي",
"themeColor": "لون السمة",
@@ -279,7 +281,9 @@
"userNotAdmin": "المستخدم ليس مسؤولاً",
"enterPassword": "أدخل كلمة المرور",
"enterPasswordAgain": "أدخل كلمة المرور مرة أخرى",
"passwordsDoNotMatch": "كلمات المرور غير متطابقة"
"passwordsDoNotMatch": "كلمات المرور غير متطابقة",
"loginMethod": "طريقة تسجيل الدخول",
"shareSettings": "مشاركة الإعدادات"
},
"sidebar": {
"help": "مساعدة",
@@ -350,5 +354,28 @@
"toggleSticky": "تبديل الوضع اللاصق",
"of": "من",
"used": "مستعمل"
},
"profileSettings": {
"setDateFormat": "تعيين تنسيق التاريخ الدقيق",
"showHiddenFiles": "إظهار الملفات المخفية",
"showQuickDownload": "إظهار أيقونة التنزيل للوصول السريع",
"previewImages": "معاينة الصور",
"previewVideos": "معاينة مقاطع الفيديو",
"previewMotionVideos": "معاينة الحركة لمقاطع الفيديو",
"highQualityPreview": "معاينة عالية الجودة",
"previewOffice": "معاينة الملفات المكتبية",
"popupPreview": "تمكين المعاينة المنبثقة",
"filePreviewOptions": "خيارات معاينة الملف"
},
"otp": {
"name": "المصادقة الثنائية",
"verifyInstructions": "أدخل الرمز من تطبيق المصادقة الخاص بك",
"generate": "امسح رمز الاستجابة السريعة ضوئياً باستخدام تطبيق المصادقة الخاص بك",
"codeInputPlaceholder": "رمز مكون من 6 أرقام",
"invalidCodeType": "يجب أن يتكون رمز التحقق من 6 أرقام باللغة الإنجليزية",
"enabledSuccessfully": "تم تمكين OTP بنجاح",
"verificationFailed": "فشل التحقق",
"verificationSucceed": "نجاح التحقق",
"generationFailed": "فشل التوليد"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Upload",
"openFile": "Datei öffnen",
"continue": "Fortfahren",
"copyDownloadLinkToClipboard": "Download-Link in die Zwischenablage kopieren"
"copyDownloadLinkToClipboard": "Download-Link in die Zwischenablage kopieren",
"edit": "bearbeiten",
"generateNewOtp": "Zurücksetzen und Generieren eines neuen Zwei-Faktor-Codes",
"verify": "Überprüfen Sie"
},
"download": {
"downloadFile": "Datei herunterladen",
@@ -260,7 +263,6 @@
"userUpdated": "Benutzer aktualisiert!",
"username": "Benutzername",
"users": "Benutzer",
"loginMethod": "Login-Methode",
"modifyOtherUser": "Benutzer ändern:",
"modifyCurrentUser": "Aktuellen Benutzer ändern",
"themeColor": "Designfarbe",
@@ -279,7 +281,9 @@
"userNotAdmin": "Benutzer ist kein Administrator",
"enterPassword": "Passwort eingeben",
"enterPasswordAgain": "Geben Sie das Passwort erneut ein",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein"
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"loginMethod": "Login-Methode",
"shareSettings": "Einstellungen teilen"
},
"sidebar": {
"help": "Hilfe",
@@ -350,5 +354,28 @@
"toggleSticky": "Sticky-Modus umschalten",
"of": "von",
"used": "belegt"
},
"profileSettings": {
"setDateFormat": "Genaues Datumsformat einstellen",
"showHiddenFiles": "Versteckte Dateien anzeigen",
"showQuickDownload": "Download-Symbol für schnellen Zugriff anzeigen",
"previewImages": "Vorschaubilder",
"previewVideos": "Vorschau Videos",
"previewMotionVideos": "Bewegungsvorschau für Videos",
"highQualityPreview": "Hochwertige Vorschau",
"previewOffice": "Vorschau von Office-Dateien",
"popupPreview": "Pop-up-Vorschau einschalten",
"filePreviewOptions": "Optionen für die Dateivorschau"
},
"otp": {
"name": "Zwei-Faktoren-Authentifizierung",
"verifyInstructions": "Geben Sie den Code aus Ihrer Authenticator-App ein",
"generate": "Scannen Sie den QR-Code mit Ihrer Authenticator-App",
"codeInputPlaceholder": "6-stelliger Code",
"invalidCodeType": "Der Verifizierungscode sollte aus 6 englischen Ziffern bestehen.",
"enabledSuccessfully": "OTP erfolgreich aktiviert",
"verificationFailed": "Verifizierung fehlgeschlagen",
"verificationSucceed": "Überprüfung Erfolgreich",
"generationFailed": "Generation gescheitert"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Μεταφόρτωση",
"openFile": "Άνοιγμα αρχείου",
"continue": "Συνέχεια",
"shell": "Εναλλαγή κελύφους"
"shell": "Εναλλαγή κελύφους",
"edit": "Επεξεργασία",
"generateNewOtp": "Επαναφορά και δημιουργία νέου κωδικού δύο παραγόντων",
"verify": "Επαλήθευση"
},
"download": {
"downloadFile": "Λήψη αρχείου",
@@ -267,7 +270,6 @@
"userUpdated": "Ο χρήστης ενημερώθηκε!",
"username": "Όνομα χρήστη",
"users": "Χρήστες",
"loginMethod": "Μέθοδος σύνδεσης",
"modifyOtherUser": "Τροποποίηση χρήστη:",
"modifyCurrentUser": "Τροποποίηση τρέχοντος χρήστη",
"themeColor": "Χρώμα θέματος",
@@ -283,7 +285,9 @@
"userNotAdmin": "Ο χρήστης δεν είναι διαχειριστής",
"enterPassword": "Εισάγετε τον κωδικό πρόσβασης",
"enterPasswordAgain": "Πληκτρολογήστε ξανά τον κωδικό πρόσβασης",
"passwordsDoNotMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν"
"passwordsDoNotMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν",
"loginMethod": "Μέθοδος σύνδεσης",
"shareSettings": "Ρυθμίσεις κοινής χρήσης"
},
"sidebar": {
"help": "Βοήθεια",
@@ -351,5 +355,28 @@
"toggleSticky": "Εναλλαγή της λειτουργίας Sticky",
"of": "του",
"used": "χρησιμοποιημένο"
},
"profileSettings": {
"setDateFormat": "Ορισμός ακριβούς μορφής ημερομηνίας",
"showHiddenFiles": "Εμφάνιση κρυφών αρχείων",
"showQuickDownload": "Εμφάνιση εικονιδίου λήψης για γρήγορη πρόσβαση",
"previewImages": "Προεπισκόπηση εικόνων",
"previewVideos": "Προεπισκόπηση βίντεο",
"previewMotionVideos": "Προεπισκόπηση κίνησης για βίντεο",
"highQualityPreview": "Προεπισκόπηση υψηλής ποιότητας",
"previewOffice": "Προεπισκόπηση αρχείων γραφείου",
"popupPreview": "Ενεργοποίηση αναδυόμενης προεπισκόπησης",
"filePreviewOptions": "Επιλογές προεπισκόπησης αρχείων"
},
"otp": {
"name": "Αυθεντικοποίηση δύο παραγόντων",
"verifyInstructions": "Εισάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας",
"generate": "Σαρώστε τον κωδικό QR με την εφαρμογή ελέγχου ταυτότητας",
"codeInputPlaceholder": "6ψήφιος κωδικός",
"invalidCodeType": "Ο κωδικός επαλήθευσης πρέπει να είναι 6 αγγλικά ψηφία",
"enabledSuccessfully": "Το OTP ενεργοποιήθηκε με επιτυχία",
"verificationFailed": "Η επαλήθευση απέτυχε",
"verificationSucceed": "Επαλήθευση Succeed",
"generationFailed": "Γενιά που απέτυχε"
}
}
+29 -4
View File
@@ -1,6 +1,8 @@
{
"buttons": {
"generateNewOtp": "Reset and generate new two-factor code",
"cancel": "Cancel",
"verify": "Verify",
"close": "Close",
"copy": "Copy",
"copyFile": "Copy file",
@@ -37,7 +39,8 @@
"update": "Update",
"upload": "Upload",
"openFile": "Open file",
"continue": "Continue"
"continue": "Continue",
"edit": "Edit"
},
"download": {
"downloadFile": "Download File",
@@ -184,6 +187,18 @@
"helpText1": "Search occurs on each character you type (3 character minimum for search terms).",
"helpText2": "The index: Search utilizes the index which automatically gets updated on the configured interval (default: 5 minutes). Searching when the program has just started may result in incomplete results."
},
"profileSettings": {
"setDateFormat": "Set exact date format",
"showHiddenFiles": "Show hidden files",
"showQuickDownload": "Show download icon for quick access",
"previewImages": "Preview images",
"previewVideos": "Preview videos",
"previewMotionVideos": "Motion preview for videos",
"highQualityPreview": "High quality preview",
"previewOffice": "Preview office files",
"popupPreview": "Enable pop-up preview",
"filePreviewOptions": "File preview options"
},
"settings": {
"enterPassword": "Enter password",
"enterPasswordAgain": "Enter the password again",
@@ -195,7 +210,6 @@
"themeColor": "Theme Color",
"disableOfficePreviewDescription": "A space-separated list of file extensions to disable the OnlyOffice viewer for. (e.g., '.txt .html').",
"disableOfficePreview": "Disable onlyoffice viewer for certain file extensions",
"filePreviewOptions": "File preview options",
"listingOptions": "Listing options",
"emptyGlobal": "Currently there are no global config options, You can edit the configuration yaml to adjust system settings.",
"scopes": "Define allowed sources and the user scope within them",
@@ -234,6 +248,7 @@
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you want to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This apply to both user commands and event hooks.",
"globalRules": "This is a global set of allow and disallow rules. They apply to every user. You can define specific rules on each user's settings to override these ones.",
"globalSettings": "Global Settings",
"shareSettings": "Share Settings",
"api": "API Keys",
"insertPath": "Insert the path",
"insertRegex": "Insert regex expression",
@@ -261,7 +276,6 @@
"rules": "Rules",
"rulesHelp": "Here you can define a set of allow and disallow rules for this specific user. The blocked files won't show up in the listings and they wont be accessible to the user. We support regex and paths relative to the users scope.\n",
"scope": "Scope",
"setDateFormat": "Set exact date format",
"settingsUpdated": "Settings updated!",
"shareDuration": "Share Duration",
"shareManagement": "Share Management",
@@ -308,7 +322,7 @@
"unit": "Time Unit"
},
"api": {
"title": "API Keys:",
"title": "API Keys",
"keyName": "API Key Name:",
"createdAt": "Created At:",
"expiresAt": "Expires At:",
@@ -346,5 +360,16 @@
"toggleSticky": "Toggle Sticky Mode",
"of": "of",
"used": "used"
},
"otp": {
"name": "Two-Factor Authentication",
"verifyInstructions": "Enter the code from your authenticator app",
"generate": "Scan the QR code with your authenticator app",
"codeInputPlaceholder": "6-digit code",
"invalidCodeType": "Verification code should be 6 english digits",
"enabledSuccessfully": "OTP enabled successfully",
"verificationFailed": "Verification Failed",
"verificationSucceed": "Verification Succeed",
"generationFailed": "Generation Failed"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Subir",
"openFile": "Abrir archivo",
"copyDownloadLinkToClipboard": "Copiar enlace de descarga al portapapeles",
"continue": "Continúe en"
"continue": "Continúe en",
"edit": "Editar",
"generateNewOtp": "Restablecer y generar un nuevo código de dos factores",
"verify": "Verifique"
},
"download": {
"downloadFile": "Descargar fichero",
@@ -260,7 +263,6 @@
"userUpdated": "¡Usuario actualizado!",
"username": "Usuario",
"users": "Usuarios",
"loginMethod": "Método de inicio de sesión",
"modifyOtherUser": "Modificar usuario:",
"modifyCurrentUser": "Modificar el usuario actual",
"themeColor": "Tema Color",
@@ -279,7 +281,9 @@
"userNotAdmin": "El usuario no es administrador",
"enterPassword": "Introduzca la contraseña",
"enterPasswordAgain": "Vuelva a introducir la contraseña",
"passwordsDoNotMatch": "Las contraseñas no coinciden"
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"loginMethod": "Método de inicio de sesión",
"shareSettings": "Compartir configuración"
},
"sidebar": {
"help": "Ayuda",
@@ -350,5 +354,28 @@
"toggleSticky": "Conmutar el modo adhesivo",
"of": "de",
"used": "usado"
},
"profileSettings": {
"setDateFormat": "Establecer el formato exacto de la fecha",
"showHiddenFiles": "Mostrar archivos ocultos",
"showQuickDownload": "Mostrar icono de descarga para acceso rápido",
"previewImages": "Vista previa de las imágenes",
"previewVideos": "Previsualizar vídeos",
"previewMotionVideos": "Previsualización de vídeos en movimiento",
"highQualityPreview": "Vista previa de alta calidad",
"previewOffice": "Vista previa de archivos ofimáticos",
"popupPreview": "Activar la vista previa emergente",
"filePreviewOptions": "Opciones de previsualización de archivos"
},
"otp": {
"name": "Autenticación de dos factores",
"verifyInstructions": "Introduce el código de tu aplicación de autenticación",
"generate": "Escanea el código QR con tu aplicación de autenticación",
"codeInputPlaceholder": "Código de 6 cifras",
"invalidCodeType": "El código de verificación debe tener 6 dígitos en inglés",
"enabledSuccessfully": "OTP activado correctamente",
"verificationFailed": "Verificación fallida",
"verificationSucceed": "Verificación Éxito",
"generationFailed": "Generación fallida"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Importer",
"openFile": "Ouvrir le fichier",
"copyDownloadLinkToClipboard": "Copier le lien de téléchargement dans le presse-papiers",
"continue": "Continuer"
"continue": "Continuer",
"edit": "Editer",
"generateNewOtp": "Réinitialiser et générer un nouveau code à deux facteurs",
"verify": "Vérifier"
},
"download": {
"downloadFile": "Télécharger le fichier",
@@ -257,7 +260,6 @@
"userUpdated": "Utilisateur mis à jour !",
"username": "Nom d'utilisateur",
"users": "Utilisateurs",
"loginMethod": "Méthode de connexion",
"modifyOtherUser": "Modifier l'utilisateur :",
"modifyCurrentUser": "Modifier l'utilisateur actuel",
"themeColor": "Couleur du thème",
@@ -279,7 +281,9 @@
"userNotAdmin": "L'utilisateur n'est pas un administrateur",
"enterPassword": "Saisir le mot de passe",
"enterPasswordAgain": "Saisir à nouveau le mot de passe",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas"
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"loginMethod": "Méthode de connexion",
"shareSettings": "Partager les paramètres"
},
"sidebar": {
"help": "Aide",
@@ -350,5 +354,28 @@
"toggleSticky": "Basculer le mode collant",
"of": "de",
"used": "utilisé"
},
"profileSettings": {
"setDateFormat": "Définir le format exact de la date",
"showHiddenFiles": "Afficher les fichiers cachés",
"showQuickDownload": "Afficher l'icône de téléchargement pour un accès rapide",
"previewImages": "Images de prévisualisation",
"previewVideos": "Prévisualisation des vidéos",
"previewMotionVideos": "Prévisualisation des mouvements pour les vidéos",
"highQualityPreview": "Aperçu de haute qualité",
"previewOffice": "Prévisualisation des fichiers bureautiques",
"popupPreview": "Activer la prévisualisation des fenêtres contextuelles",
"filePreviewOptions": "Options de prévisualisation des fichiers"
},
"otp": {
"name": "Authentification à deux facteurs",
"verifyInstructions": "Saisissez le code de votre application d'authentification",
"generate": "Scannez le code QR avec votre application d'authentification",
"codeInputPlaceholder": "Code à 6 chiffres",
"invalidCodeType": "Le code de vérification doit être composé de 6 chiffres anglais",
"enabledSuccessfully": "OTP activé avec succès",
"verificationFailed": "Échec de la vérification",
"verificationSucceed": "Réussir la vérification",
"generationFailed": "Génération échouée"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Feltöltés",
"openFile": "Fájl megnyitása",
"copyDownloadLinkToClipboard": "Letöltési link másolása a vágólapra",
"continue": "Folytatás"
"continue": "Folytatás",
"edit": "Szerkesztés",
"generateNewOtp": "Új kétfaktoros kód visszaállítása és generálása",
"verify": "Ellenőrizze a címet."
},
"download": {
"downloadFile": "Fájl letöltése",
@@ -258,7 +261,6 @@
"userUpdated": "Felhasználó frissítve!",
"username": "Felhasználói név",
"users": "Felhasználók",
"loginMethod": "Bejelentkezési módszer",
"modifyOtherUser": "Felhasználó módosítása:",
"modifyCurrentUser": "Jelenlegi felhasználó módosítása",
"themeColor": "Téma színe",
@@ -279,7 +281,9 @@
"userNotAdmin": "A felhasználó nem rendszergazda",
"enterPassword": "Jelszó megadása",
"enterPasswordAgain": "Adja meg újra a jelszót",
"passwordsDoNotMatch": "A jelszavak nem egyeznek"
"passwordsDoNotMatch": "A jelszavak nem egyeznek",
"loginMethod": "Bejelentkezési módszer",
"shareSettings": "Beállítások megosztása"
},
"sidebar": {
"help": "Súgó",
@@ -350,5 +354,28 @@
"toggleSticky": "Ragadós mód átkapcsolása",
"of": "a",
"used": "használt"
},
"profileSettings": {
"setDateFormat": "Pontos dátumformátum beállítása",
"showHiddenFiles": "Rejtett fájlok megjelenítése",
"showQuickDownload": "Letöltés ikon megjelenítése a gyors hozzáférés érdekében",
"previewImages": "Képek előnézete",
"previewVideos": "Videók előnézete",
"previewMotionVideos": "Videók mozgásnézetének előnézete",
"highQualityPreview": "Kiváló minőségű előnézet",
"previewOffice": "Office fájlok előnézete",
"popupPreview": "Pop-up előnézet engedélyezése",
"filePreviewOptions": "Fájl előnézeti beállítások"
},
"otp": {
"name": "Kétfaktoros hitelesítés",
"verifyInstructions": "Írja be a kódot a hitelesítők alkalmazásából",
"generate": "Szkennelje be a QR-kódot a hitelesítő alkalmazással",
"codeInputPlaceholder": "6 számjegyű kód",
"invalidCodeType": "Az ellenőrző kódnak 6 angol számjegyűnek kell lennie",
"enabledSuccessfully": "OTP sikeresen engedélyezve",
"verificationFailed": "Ellenőrzés sikertelen",
"verificationSucceed": "Ellenőrzés Succeed",
"generationFailed": "Nemzedék sikertelen"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "Cartella",
"submit": "Invia",
"openFile": "Aprire il file",
"continue": "Continua"
"continue": "Continua",
"edit": "Modifica",
"generateNewOtp": "Reimpostare e generare un nuovo codice a due fattori",
"verify": "Verifica"
},
"download": {
"downloadFile": "Scarica file",
@@ -254,7 +257,6 @@
"userUpdated": "Utente aggiornato!",
"username": "Nome utente",
"users": "Utenti",
"loginMethod": "Metodo di accesso",
"modifyOtherUser": "Modificare l'utente:",
"modifyCurrentUser": "Modificare l'utente corrente",
"themeColor": "Colore del tema",
@@ -279,7 +281,9 @@
"userNotAdmin": "L'utente non è un amministratore",
"enterPassword": "Inserire la password",
"enterPasswordAgain": "Immettere nuovamente la password",
"passwordsDoNotMatch": "Le password non corrispondono"
"passwordsDoNotMatch": "Le password non corrispondono",
"loginMethod": "Metodo di accesso",
"shareSettings": "Condividi le impostazioni"
},
"sidebar": {
"help": "Aiuto",
@@ -350,5 +354,28 @@
"toggleSticky": "Alterna la modalità appiccicosa",
"of": "di",
"used": "usato"
},
"profileSettings": {
"setDateFormat": "Impostare il formato esatto della data",
"showHiddenFiles": "Mostra i file nascosti",
"showQuickDownload": "Mostra l'icona di download per un accesso rapido",
"previewImages": "Anteprima delle immagini",
"previewVideos": "Anteprima dei video",
"previewMotionVideos": "Anteprima di movimento per i video",
"highQualityPreview": "Anteprima di alta qualità",
"previewOffice": "Anteprima dei file office",
"popupPreview": "Abilita l'anteprima a comparsa",
"filePreviewOptions": "Opzioni di anteprima dei file"
},
"otp": {
"name": "Autenticazione a due fattori",
"verifyInstructions": "Immettere il codice dall'app Autenticatore",
"generate": "Eseguire la scansione del codice QR con l'app Authenticator",
"codeInputPlaceholder": "Codice a 6 cifre",
"invalidCodeType": "Il codice di verifica deve essere di 6 cifre inglesi",
"enabledSuccessfully": "OTP abilitato con successo",
"verificationFailed": "Verifica fallita",
"verificationSucceed": "Verifica riuscita",
"generationFailed": "Generazione fallita"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "フォルダ",
"submit": "投稿する",
"openFile": "ファイルを開く",
"continue": "続ける"
"continue": "続ける",
"edit": "編集",
"generateNewOtp": "リセットして新しい2ファクタコードを生成する",
"verify": "ベリファイ"
},
"download": {
"downloadFile": "Download File",
@@ -254,7 +257,6 @@
"userUpdated": "ユーザーは更新されました!",
"username": "ユーザー名",
"users": "ユーザー",
"loginMethod": "ログイン方法",
"modifyOtherUser": "ユーザーを変更する:",
"modifyCurrentUser": "現在のユーザーを変更する",
"themeColor": "テーマカラー",
@@ -279,7 +281,9 @@
"userNotAdmin": "ユーザーが管理者でない",
"enterPassword": "パスワードを入力",
"enterPasswordAgain": "もう一度パスワードを入力する",
"passwordsDoNotMatch": "パスワードが一致しない"
"passwordsDoNotMatch": "パスワードが一致しない",
"loginMethod": "ログイン方法",
"shareSettings": "共有設定"
},
"sidebar": {
"help": "ヘルプ",
@@ -350,5 +354,28 @@
"toggleSticky": "スティッキーモードの切り替え",
"of": "の",
"used": "中古"
},
"profileSettings": {
"setDateFormat": "正確な日付書式を設定する",
"showHiddenFiles": "隠しファイルを表示する",
"showQuickDownload": "クイックアクセスのためにダウンロードアイコンを表示する",
"previewImages": "プレビュー画像",
"previewVideos": "プレビュー映像",
"previewMotionVideos": "ビデオのモーション・プレビュー",
"highQualityPreview": "高画質プレビュー",
"previewOffice": "オフィスファイルのプレビュー",
"popupPreview": "ポップアップ・プレビューを有効にする",
"filePreviewOptions": "ファイルプレビューオプション"
},
"otp": {
"name": "二要素認証",
"verifyInstructions": "認証アプリからコードを入力",
"generate": "認証アプリでQRコードをスキャンする",
"codeInputPlaceholder": "6桁コード",
"invalidCodeType": "認証コードは英数字6桁で入力してください。",
"enabledSuccessfully": "OTPの有効化に成功",
"verificationFailed": "検証失敗",
"verificationSucceed": "検証の成功",
"generationFailed": "世代が失敗"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "폴더",
"submit": "제출하기",
"openFile": "파일 열기",
"continue": "계속하기"
"continue": "계속하기",
"edit": "편집",
"generateNewOtp": "새로운 2단계 인증 코드 재설정 및 생성",
"verify": "확인"
},
"download": {
"downloadFile": "파일 다운로드",
@@ -254,7 +257,6 @@
"userUpdated": "사용자 수정됨!",
"username": "사용자 이름",
"users": "사용자",
"loginMethod": "로그인 방법",
"modifyOtherUser": "사용자를 수정합니다:",
"modifyCurrentUser": "현재 사용자 수정",
"themeColor": "테마 색상",
@@ -279,7 +281,9 @@
"userNotAdmin": "사용자가 관리자가 아닙니다.",
"enterPassword": "비밀번호 입력",
"enterPasswordAgain": "비밀번호를 다시 입력합니다.",
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다."
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다.",
"loginMethod": "로그인 방법",
"shareSettings": "공유 설정"
},
"sidebar": {
"help": "도움말",
@@ -350,5 +354,28 @@
"toggleSticky": "고정 모드 토글",
"of": "의",
"used": "사용됨"
},
"profileSettings": {
"setDateFormat": "정확한 날짜 형식 설정",
"showHiddenFiles": "숨겨진 파일 표시",
"showQuickDownload": "빠른 액세스를 위한 다운로드 아이콘 표시",
"previewImages": "이미지 미리보기",
"previewVideos": "동영상 미리보기",
"previewMotionVideos": "동영상 모션 미리보기",
"highQualityPreview": "고품질 미리보기",
"previewOffice": "오피스 파일 미리 보기",
"popupPreview": "팝업 미리 보기 사용",
"filePreviewOptions": "파일 미리 보기 옵션"
},
"otp": {
"name": "2단계 인증",
"verifyInstructions": "인증 앱에서 코드를 입력합니다.",
"generate": "인증 앱으로 QR 코드를 스캔합니다.",
"codeInputPlaceholder": "6자리 코드",
"invalidCodeType": "인증 코드는 영문 6자리여야 합니다.",
"enabledSuccessfully": "OTP 활성화 성공",
"verificationFailed": "인증 실패",
"verificationSucceed": "인증 성공",
"generationFailed": "세대 실패"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "Folder",
"submit": "Prześlij",
"openFile": "Otwórz plik",
"continue": "Kontynuuj"
"continue": "Kontynuuj",
"edit": "Edytuj",
"generateNewOtp": "Resetowanie i generowanie nowego kodu dwuskładnikowego",
"verify": "Weryfikacja"
},
"download": {
"downloadFile": "Pobierz plik",
@@ -254,7 +257,6 @@
"userUpdated": "Użytkownik zapisany!",
"username": "Nazwa użytkownika",
"users": "Użytkownicy",
"loginMethod": "Metoda logowania",
"modifyOtherUser": "Modyfikacja użytkownika:",
"modifyCurrentUser": "Modyfikacja bieżącego użytkownika",
"themeColor": "Kolor motywu",
@@ -279,7 +281,9 @@
"userNotAdmin": "Użytkownik nie jest administratorem",
"enterPassword": "Wprowadź hasło",
"enterPasswordAgain": "Wprowadź hasło ponownie",
"passwordsDoNotMatch": "Hasła nie są zgodne"
"passwordsDoNotMatch": "Hasła nie są zgodne",
"loginMethod": "Metoda logowania",
"shareSettings": "Ustawienia udostępniania"
},
"sidebar": {
"help": "Pomoc",
@@ -350,5 +354,28 @@
"toggleSticky": "Przełączanie trybu samoprzylepnego",
"of": "z",
"used": "używany"
},
"profileSettings": {
"setDateFormat": "Ustaw dokładny format daty",
"showHiddenFiles": "Pokaż ukryte pliki",
"showQuickDownload": "Pokaż ikonę pobierania dla szybkiego dostępu",
"previewImages": "Podgląd obrazów",
"previewVideos": "Podgląd wideo",
"previewMotionVideos": "Podgląd ruchu dla filmów",
"highQualityPreview": "Podgląd wysokiej jakości",
"previewOffice": "Podgląd plików biurowych",
"popupPreview": "Włącz wyskakujący podgląd",
"filePreviewOptions": "Opcje podglądu plików"
},
"otp": {
"name": "Uwierzytelnianie dwuskładnikowe",
"verifyInstructions": "Wprowadź kod z aplikacji uwierzytelniającej",
"generate": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej",
"codeInputPlaceholder": "6-cyfrowy kod",
"invalidCodeType": "Kod weryfikacyjny powinien składać się z 6 angielskich cyfr",
"enabledSuccessfully": "OTP został pomyślnie włączony",
"verificationFailed": "Weryfikacja nie powiodła się",
"verificationSucceed": "Sukces weryfikacji",
"generationFailed": "Generacja nieudana"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Enviar",
"openFile": "Abrir",
"copyDownloadLinkToClipboard": "Copiar o link de download para a área de transferência",
"continue": "Continuar"
"continue": "Continuar",
"edit": "Editar",
"generateNewOtp": "Redefinir e gerar um novo código de dois fatores",
"verify": "Verificar"
},
"download": {
"downloadFile": "Baixar arquivo",
@@ -259,7 +262,6 @@
"userUpdated": "Usuário atualizado!",
"username": "Nome do usuário",
"users": "Usuários",
"loginMethod": "Método de login",
"modifyOtherUser": "Modificar usuário:",
"modifyCurrentUser": "Modificar o usuário atual",
"themeColor": "Cor do tema",
@@ -279,7 +281,9 @@
"userNotAdmin": "O usuário não é um administrador",
"enterPassword": "Digite a senha",
"enterPasswordAgain": "Digite a senha novamente",
"passwordsDoNotMatch": "As senhas não correspondem"
"passwordsDoNotMatch": "As senhas não correspondem",
"loginMethod": "Método de login",
"shareSettings": "Compartilhar configurações"
},
"sidebar": {
"help": "Ajuda",
@@ -350,5 +354,28 @@
"toggleSticky": "Alternar o modo de aderência",
"of": "de",
"used": "usado"
},
"profileSettings": {
"setDateFormat": "Definir o formato exato da data",
"showHiddenFiles": "Mostrar arquivos ocultos",
"showQuickDownload": "Mostrar o ícone de download para acesso rápido",
"previewImages": "Visualizar imagens",
"previewVideos": "Visualizar vídeos",
"previewMotionVideos": "Visualização de movimento para vídeos",
"highQualityPreview": "Visualização de alta qualidade",
"previewOffice": "Visualizar arquivos do Office",
"popupPreview": "Ativar visualização pop-up",
"filePreviewOptions": "Opções de visualização de arquivos"
},
"otp": {
"name": "Autenticação de dois fatores",
"verifyInstructions": "Digite o código do seu aplicativo autenticador",
"generate": "Digitalize o código QR com seu aplicativo autenticador",
"codeInputPlaceholder": "Código de 6 dígitos",
"invalidCodeType": "O código de verificação deve ter 6 dígitos em inglês",
"enabledSuccessfully": "OTP ativado com sucesso",
"verificationFailed": "Falha na verificação",
"verificationSucceed": "Verificação de sucesso",
"generationFailed": "Falha na geração"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"folder": "Dosar",
"submit": "Trimite",
"openFile": "Deschideți fișierul",
"continue": "Continuați"
"continue": "Continuați",
"edit": "Editare",
"generateNewOtp": "Resetați și generați un nou cod cu doi factori",
"verify": "Verificare"
},
"download": {
"downloadFile": "Descarcă fișier",
@@ -254,7 +257,6 @@
"userUpdated": "Utilizator actualizat!",
"username": "Utilizator",
"users": "Utilizatori",
"loginMethod": "Metoda de conectare",
"modifyOtherUser": "Modificați utilizatorul:",
"modifyCurrentUser": "Modificarea utilizatorului curent",
"themeColor": "Culoare temă",
@@ -279,7 +281,9 @@
"userNotAdmin": "Utilizatorul nu este administrator",
"enterPassword": "Introduceți parola",
"enterPasswordAgain": "Introduceți din nou parola",
"passwordsDoNotMatch": "Parolele nu se potrivesc"
"passwordsDoNotMatch": "Parolele nu se potrivesc",
"loginMethod": "Metoda de conectare",
"shareSettings": "Setări de partajare"
},
"sidebar": {
"help": "Ajutor",
@@ -350,5 +354,28 @@
"toggleSticky": "Schimbarea modului lipicios",
"of": "de",
"used": "utilizate"
},
"profileSettings": {
"setDateFormat": "Setați formatul exact al datei",
"showHiddenFiles": "Afișează fișierele ascunse",
"showQuickDownload": "Afișați pictograma de descărcare pentru acces rapid",
"previewImages": "Previzualizare imagini",
"previewVideos": "Previzualizare videoclipuri",
"previewMotionVideos": "Previzualizare mișcare pentru videoclipuri",
"highQualityPreview": "Previzualizare de înaltă calitate",
"previewOffice": "Previzualizare fișiere office",
"popupPreview": "Activați previzualizarea pop-up",
"filePreviewOptions": "Opțiuni de previzualizare a fișierelor"
},
"otp": {
"name": "Autentificare cu doi factori",
"verifyInstructions": "Introduceți codul din aplicația dvs. de autentificare",
"generate": "Scanați codul QR cu aplicația dvs. de autentificare",
"codeInputPlaceholder": "Cod de 6 cifre",
"invalidCodeType": "Codul de verificare trebuie să fie format din 6 cifre în limba engleză",
"enabledSuccessfully": "OTP activat cu succes",
"verificationFailed": "Verificarea a eșuat",
"verificationSucceed": "Verificarea reușită",
"generationFailed": "Generație eșuată"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Загрузить",
"openFile": "Открыть файл",
"copyDownloadLinkToClipboard": "Скопируйте ссылку на скачивание в буфер обмена",
"continue": "Продолжить"
"continue": "Продолжить",
"edit": "Редактировать",
"generateNewOtp": "Сброс и генерация нового двухфакторного кода",
"verify": "Проверьте"
},
"download": {
"downloadFile": "Скачать файл",
@@ -256,7 +259,6 @@
"userUpdated": "Пользователь изменен!",
"username": "Имя пользователя",
"users": "Пользователи",
"loginMethod": "Метод входа в систему",
"modifyOtherUser": "Измените пользователя:",
"modifyCurrentUser": "Изменить текущего пользователя",
"themeColor": "Цвет темы",
@@ -279,7 +281,9 @@
"userNotAdmin": "Пользователь не является администратором",
"enterPassword": "Введите пароль",
"enterPasswordAgain": "Введите пароль еще раз",
"passwordsDoNotMatch": "Пароли не совпадают"
"passwordsDoNotMatch": "Пароли не совпадают",
"loginMethod": "Метод входа в систему",
"shareSettings": "Поделиться настройками"
},
"sidebar": {
"help": "Помощь",
@@ -350,5 +354,28 @@
"toggleSticky": "Переключение режима липкости",
"of": "из",
"used": "используется"
},
"profileSettings": {
"setDateFormat": "Установите точный формат даты",
"showHiddenFiles": "Показать скрытые файлы",
"showQuickDownload": "Показывайте значок загрузки для быстрого доступа",
"previewImages": "Предварительные изображения",
"previewVideos": "Предварительный просмотр видео",
"previewMotionVideos": "Предварительный просмотр движения для видео",
"highQualityPreview": "Высококачественный предварительный просмотр",
"previewOffice": "Предварительный просмотр офисных файлов",
"popupPreview": "Включить предварительный просмотр всплывающих окон",
"filePreviewOptions": "Параметры предварительного просмотра файлов"
},
"otp": {
"name": "Двухфакторная аутентификация",
"verifyInstructions": "Введите код из приложения аутентификатора.",
"generate": "Отсканируйте QR-код с помощью приложения-аутентификатора.",
"codeInputPlaceholder": "6-значный код",
"invalidCodeType": "Код проверки должен состоять из 6 английских цифр",
"enabledSuccessfully": "OTP успешно активирован",
"verificationFailed": "Верификация не удалась",
"verificationSucceed": "Проверка Успех",
"generationFailed": "Поколение не удалось"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"upload": "Nahrať",
"openFile": "Otvoriť súbor",
"copyDownloadLinkToClipboard": "Skopírujte odkaz na stiahnutie do schránky",
"continue": "Pokračovať"
"continue": "Pokračovať",
"edit": "Upraviť",
"generateNewOtp": "Obnovenie a generovanie nového dvojfaktorového kódu",
"verify": "Overiť"
},
"download": {
"downloadFile": "Stiahnuť súbor",
@@ -255,7 +258,6 @@
"userUpdated": "Používateľ upravený!",
"username": "Meno používateľa",
"users": "Používatelia",
"loginMethod": "Spôsob prihlásenia",
"modifyOtherUser": "Upravte používateľa:",
"modifyCurrentUser": "Úprava aktuálneho používateľa",
"themeColor": "Farba témy",
@@ -279,7 +281,9 @@
"userNotAdmin": "Používateľ nie je správca",
"enterPassword": "Zadajte heslo",
"enterPasswordAgain": "Znovu zadajte heslo",
"passwordsDoNotMatch": "Heslá sa nezhodujú"
"passwordsDoNotMatch": "Heslá sa nezhodujú",
"loginMethod": "Spôsob prihlásenia",
"shareSettings": "Zdieľať nastavenia"
},
"sidebar": {
"help": "Pomoc",
@@ -350,5 +354,28 @@
"toggleSticky": "Prepnutie režimu Sticky",
"of": "z",
"used": "použité"
},
"profileSettings": {
"setDateFormat": "Nastavenie presného formátu dátumu",
"showHiddenFiles": "Zobrazenie skrytých súborov",
"showQuickDownload": "Zobrazenie ikony na stiahnutie pre rýchly prístup",
"previewImages": "Náhľad obrázkov",
"previewVideos": "Náhľad videí",
"previewMotionVideos": "Náhľad pohybu pre videá",
"highQualityPreview": "Vysokokvalitný náhľad",
"previewOffice": "Náhľad kancelárskych súborov",
"popupPreview": "Povolenie pop-up náhľadu",
"filePreviewOptions": "Možnosti náhľadu súborov"
},
"otp": {
"name": "Dvojfaktorové overovanie",
"verifyInstructions": "Zadajte kód z aplikácie autentifikátora",
"generate": "Naskenujte kód QR pomocou aplikácie autentifikátora",
"codeInputPlaceholder": "6-miestny kód",
"invalidCodeType": "Overovací kód by mal mať 6 anglických číslic",
"enabledSuccessfully": "OTP úspešne povolený",
"verificationFailed": "Overenie sa nepodarilo",
"verificationSucceed": "Overovanie Úspech",
"generationFailed": "Generácia zlyhala"
}
}
+30 -3
View File
@@ -37,7 +37,10 @@
"copyDownloadLinkToClipboard": "İndirme bağlantısını panoya kopyala",
"file": "Dosya",
"folder": "Klasör",
"continue": "Devam et"
"continue": "Devam et",
"edit": "Düzenle",
"generateNewOtp": "Sıfırlama ve yeni iki faktörlü kod oluşturma",
"verify": "Doğrulama"
},
"download": {
"downloadFile": "Dosyayı indir",
@@ -255,7 +258,6 @@
"userUpdated": "Kullanıcı güncellendi!",
"username": "Kullanıcı adı",
"users": "Kullanıcılar",
"loginMethod": "Giriş yöntemi",
"modifyOtherUser": "Kullanıcıyı değiştir:",
"modifyCurrentUser": "Geçerli kullanıcıyı değiştir",
"themeColor": "Tema Rengi",
@@ -279,7 +281,9 @@
"userNotAdmin": "Kullanıcı yönetici değil",
"enterPassword": "Şifre girin",
"enterPasswordAgain": "Şifreyi tekrar girin",
"passwordsDoNotMatch": "Parolalar eşleşmiyor"
"passwordsDoNotMatch": "Parolalar eşleşmiyor",
"loginMethod": "Giriş yöntemi",
"shareSettings": "Paylaşım Ayarları"
},
"sidebar": {
"help": "Yardım",
@@ -350,5 +354,28 @@
"toggleSticky": "Yapışkan Modunu Değiştir",
"of": ".",
"used": "kullanılmış"
},
"profileSettings": {
"setDateFormat": "Tam tarih biçimini ayarlama",
"showHiddenFiles": "Gizli dosyaları göster",
"showQuickDownload": "Hızlı erişim için indirme simgesini göster",
"previewImages": "Önizleme görüntüleri",
"previewVideos": "Önizleme videoları",
"previewMotionVideos": "Videolar için hareket önizlemesi",
"highQualityPreview": "Yüksek kaliteli önizleme",
"previewOffice": "Ofis dosyalarını önizleme",
"popupPreview": "Açılır pencere önizlemesini etkinleştir",
"filePreviewOptions": "Dosya önizleme seçenekleri"
},
"otp": {
"name": "İki Faktörlü Kimlik Doğrulama",
"verifyInstructions": "Kimlik doğrulayıcı uygulamanızdan kodu girin",
"generate": "QR kodunu kimlik doğrulayıcı uygulamanızla tarayın",
"codeInputPlaceholder": "6 haneli kod",
"invalidCodeType": "Doğrulama kodu 6 İngilizce hane olmalıdır",
"enabledSuccessfully": "OTP başarıyla etkinleştirildi",
"verificationFailed": "Doğrulama Başarısız",
"verificationSucceed": "Doğrulama Başarılı",
"generationFailed": "Nesil Başarısız"
}
}
+3
View File
@@ -23,6 +23,9 @@ export const mutations = {
state.req.hasUpdate = true;
},
setPreviewSource: (value) => {
if (value === state.popupPreviewSource) {
return;
}
state.popupPreviewSource = value;
emitStateChanged();
},
-39
View File
@@ -28,29 +28,6 @@ export async function validateLogin() {
return
}
export async function login(username, password, recaptcha) {
const data = { username, password, recaptcha };
try {
let apiPath = getApiPath("api/auth/login")
const res = await fetch(apiPath, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const body = await res.text();
if (res.status === 200) {
await setNewToken(body);
} else {
throw new Error(body);
}
} catch (error) {
throw new Error("Login failed");
}
}
export async function renew(jwt) {
let apiPath = getApiPath("api/auth/renew")
const res = await fetch(apiPath, {
@@ -79,22 +56,6 @@ export function generateRandomCode(length) {
return code;
}
export async function signupLogin(username, password) {
const data = { username, password };
let apiPath = getApiPath("api/auth/signup")
const res = await fetch(apiPath, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (res.status !== 200) {
throw new Error(res.status);
}
}
export function logout() {
if (state.user.loginMethod == "oidc") {
document.cookie = "auth=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
+7 -5
View File
@@ -1,3 +1,5 @@
import i18n from '@/i18n'; // Import the default export (your i18n instance)
const name = window.FileBrowser.Name;
const disableExternal = window.FileBrowser.DisableExternal;
const externalLinks = window.FileBrowser.ExternalLinks;
@@ -22,12 +24,12 @@ const mediaAvailable = window.FileBrowser.MediaAvailable;
const origin = window.location.origin;
const settings = [
{ id: 'profile', label: 'Profile Management', component: 'ProfileSettings' },
{ id: 'shares', label: 'Share Management', component: 'SharesSettings', permissions: { share: true } },
{ id: 'api', label: 'API Keys', component: 'ApiKeys', permissions: { api: true } },
{ id: 'profile', label: i18n.global.t('settings.profileSettings'), component: 'ProfileSettings' },
{ id: 'shares', label: i18n.global.t('settings.shareSettings'), component: 'SharesSettings', permissions: { share: true } },
{ id: 'api', label: i18n.global.t('api.title'), component: 'ApiKeys', permissions: { api: true } },
//{ id: 'global', label: 'Global', component: 'GlobalSettings', permissions: { admin: true } },
{ id: 'users', label: 'User Management', component: 'UserManagement' },
]
{ id: 'users', label: i18n.global.t('settings.userManagement'), component: 'UserManagement' },
];
export {
mediaAvailable,
+46 -9
View File
@@ -8,7 +8,7 @@
<h3>{{ loginName }}</h3>
</div>
<div v-if="passwordAvailable" class="password-entry">
<div v-if="error !== ''" class="wrong">{{ error }}</div>
<div v-if="error !== ''" class="wrong-login">{{ error }}</div>
<input
autofocus
class="input input--block"
@@ -42,20 +42,24 @@
</p>
</div>
<div v-if="oidcAvailable" class="password-entry">
<div v-if="passwordAvailable" class="or">{{ $t('login.or') }}</div>
<a href="/api/auth/oidc/login" class="button button--block direct-login"> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div v-if="passwordAvailable" class="or">{{ $t("login.or") }}</div>
<a href="/api/auth/oidc/login" class="button button--block direct-login">
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
OpenID Connect
</a>
</div>
</form>
</div>
<prompts :class="{ 'dark-mode': isDarkMode }"></prompts>
</template>
<script>
import router from "@/router";
import { state } from "@/store";
import { mutations, state } from "@/store";
import Prompts from "@/components/prompts/Prompts.vue";
import Icon from "@/components/files/Icon.vue";
import { signupLogin, login, initAuth } from "@/utils/auth";
import { usersApi } from "@/api";
import { initAuth } from "@/utils/auth";
import {
name,
logoURL,
@@ -71,6 +75,7 @@ export default {
name: "login",
components: {
Icon,
Prompts,
},
computed: {
signup: () => signup,
@@ -132,14 +137,46 @@ export default {
}
try {
if (this.createMode) {
await signupLogin(this.username, this.password);
await usersApi.signupLogin(this.username, this.password);
}
await login(this.username, this.password, captcha);
await usersApi.login(this.username, this.password, captcha);
await initAuth();
router.push({ path: redirect });
} catch (e) {
console.error(e);
if (e.message == 409) {
if (e.message.includes("OTP authentication is enforced")) {
mutations.showHover({
name: "totp",
props: {
username: this.username,
password: this.password,
recaptcha: captcha,
redirect: redirect,
},
});
}
if (e.message.includes("OTP is enforced, but user is not yet configured")) {
mutations.showHover({
name: "totp",
props: {
username: this.username,
password: this.password,
recaptcha: captcha,
redirect: redirect,
generate: true,
},
});
} else if (e.message.includes("OTP code is required for user")) {
mutations.showHover({
name: "totp",
props: {
username: this.username,
password: this.password,
recaptcha: captcha,
redirect: redirect,
generate: false,
},
});
} else if (e.message == 409) {
this.error = this.$t("login.usernameTaken");
} else {
this.error = this.$t("login.wrongCredentials");
+1 -1
View File
@@ -72,7 +72,7 @@ export default {
return !this.showEdit && !this.showSave && !this.showDelete;
},
showEdit() {
return window.location.hash == "#edit" && state.user.permissions.modify;
return window.location.hash != "#edit" && state.user.permissions.modify;
},
showDelete() {
return state.user.permissions.modify && getters.currentView() == "preview";
+3 -3
View File
@@ -187,7 +187,6 @@ export default {
this.colunmsResize();
},
scrolling() {
mutations.setPreviewSource("");
const scrollContainer = this.$refs.listingView;
if (!scrollContainer) return;
@@ -350,6 +349,8 @@ export default {
window.addEventListener("click", this.clickClear);
window.addEventListener("keyup", this.clearCtrKey);
window.addEventListener("dragover", this.preventDefault);
this.$el.addEventListener("touchmove", this.handleTouchMove);
this.$el.addEventListener("contextmenu", this.openContext);
// Adjust contextmenu listener based on browser
if (state.isSafari) {
@@ -358,7 +359,6 @@ export default {
passive: true,
});
this.$el.addEventListener("mousedown", this.openContextForSafari);
this.$el.addEventListener("touchmove", this.handleTouchMove);
// Also clear the timeout if the user clicks or taps quickly
this.$el.addEventListener("touchend", this.cancelContext);
@@ -376,6 +376,7 @@ export default {
window.removeEventListener("keydown", this.keyEvent);
window.removeEventListener("resize", this.windowsResize);
this.$el.removeEventListener("contextmenu", this.openContext);
this.$el.addEventListener("touchmove", this.handleTouchMove);
// If Safari, remove touchstart listener
if (state.isSafari) {
@@ -383,7 +384,6 @@ export default {
this.$el.removeEventListener("mousedown", this.openContextForSafari);
this.$el.removeEventListener("touchend", this.cancelContext);
this.$el.removeEventListener("mouseup", this.cancelContext);
this.$el.removeEventListener("touchmove", this.handleTouchMove);
}
},
methods: {
+72 -7
View File
@@ -1,11 +1,15 @@
<template>
<div id="markedown-viewer" v-html="renderedContent"></div>
<div id="markedown-viewer" ref="viewer" v-html="renderedContent"></div>
</template>
<script lang="ts">
import { marked } from "marked";
import DOMPurify from 'dompurify';
import { state } from "@/store";
import hljs from 'highlight.js';
// --- We have removed all `marked.use()` configuration ---
// This allows marked to function with its robust defaults.
export default {
name: "markdownViewer",
@@ -14,25 +18,86 @@ export default {
content: "",
};
},
methods: {
// This theme switcher logic is correct and remains.
setHighlightTheme(isDark: boolean) {
const THEME_LINK_ID = 'highlight-theme-link';
const lightTheme = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
const darkTheme = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
const themeUrl = isDark ? darkTheme : lightTheme;
let link = document.getElementById(THEME_LINK_ID) as HTMLLinkElement;
if (link) {
link.href = themeUrl;
} else {
link = document.createElement('link');
link.id = THEME_LINK_ID;
link.rel = 'stylesheet';
link.href = themeUrl;
document.head.appendChild(link);
}
},
// NEW METHOD: Finds and highlights all code blocks in the rendered content.
applyHighlighting() {
const viewer = this.$refs.viewer as HTMLElement;
if (viewer) {
// This tells highlight.js to find and style every code block.
viewer.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
}
},
watch: {
// We now watch the `content` property.
content() {
// When the content changes, Vue updates the DOM. We use `nextTick`
// to wait for that update to finish before applying highlighting.
this.$nextTick(() => {
this.applyHighlighting();
});
},
darkMode() {
this.setHighlightTheme(state.user.darkMode);
}
},
computed: {
darkMode() {
// This computed property returns the current dark mode state.
return state.user.darkMode;
},
renderedContent() {
return DOMPurify.sanitize(marked(this.content));
// We now let marked run with its default, reliable settings.
// It will correctly render tables and create basic code blocks.
return DOMPurify.sanitize(marked(this.content, { gfm: true }));
},
},
mounted() {
const fileContent =
state.req.content == "empty-file-x6OlSil" ? "" : state.req.content || "";
this.content = fileContent
this.setHighlightTheme(state.user.darkMode);
// Set initial content. The `watch` will trigger the first highlight.
const fileContent = state.req.content == "empty-file-x6OlSil" ? "" : state.req.content || "";
this.content = fileContent;
},
unmounted() {
// Cleanup logic is correct and remains.
const link = document.getElementById('highlight-theme-link');
if (link) {
document.head.removeChild(link);
}
}
};
</script>
<style>
/* This style block is now plain CSS, no "lang=scss" needed */
#markedown-viewer {
margin: 1em;
padding: 1em;
background-color: var(--alt-background);
border-radius: 1em;
}
</style>
c
#markedown-viewer pre {
border-radius: 8px;
}
</style>
+8 -3
View File
@@ -174,9 +174,14 @@ export default {
return [];
}
let subs = [];
for (const element of state.req.subtitles) {
const ext = getFileExtension(element);
const resp = await filesApi.fetchFiles(element, true); // Fetch .srt file
for (let subtitleFile of state.req.subtitles) {
if (state.serverHasMultipleSources) {
subtitleFile = "/files/" + state.req.source + subtitleFile;
} else {
subtitleFile = "/files" + subtitleFile;
}
const ext = getFileExtension(subtitleFile);
const resp = await filesApi.fetchFiles(subtitleFile, true); // Fetch .srt file
let vttContent = resp.content;
// Convert SRT to VTT (assuming srt2vtt() does this)
vttContent = convertToVTT(ext, resp.content);
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more