Beta/v0.8.6 (#1233)

Co-authored-by: Max <mail@kjmax.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
Co-authored-by: Alexander Noyle <alexnoyle@icloud.com>
This commit is contained in:
authored and GitHub committed 2025-09-17 09:12:43 -05:00
1 parent e398f1f5af
commit 9ff92e079f
74 files changed
+1868 -698

No files matched your search

+19
View File
@@ -2,6 +2,25 @@
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.8.6-beta
**Notes**:
- Please remove `indexAbumArt` config option from your config: it has been deprecated... I found a way to detect album art without impacting indexing performance -- so its default behavior.
- Download button should be invisible before password input #1162
- opus files / opus files detected wrongly #1164
- changed autoplay behavior on plyr to not start playing if blocked instead of playing muted.
- QR-Code disappearing / sliding out from shared link #1155
- audio player matches dark/light mode better
**BugFixes**:
- Copy/Move index update changes to be more thorough and update album artwork #1220 #1219
- fix preview related issues #1225 #1223
- Scrollbar missing on user-edit-prompt in v0.8.5-beta #1221
- copy share download link inconsistency #1207
- fixed some onlyoffice related issues #1192 #1068
- Creating a file, then a folder with the same name makes the folder unusable #1167
- fix plyr issue switching from video to audio via next/previous button.
## v0.8.5-beta
**New Features**:
+2
View File
@@ -19,6 +19,8 @@ frontend:
auth:
methods:
password:
enabled: false
proxy:
enabled: true
header: "X-Username"
+2 -2
View File
@@ -2,7 +2,7 @@ package diskcache
import (
"context"
"crypto/sha1" //nolint:gosec
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
@@ -106,7 +106,7 @@ func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
}
func (f *FileCache) getFileName(key string) string {
hasher := sha1.New() //nolint:gosec
hasher := sha1.New()
_, _ = hasher.Write([]byte(key))
hash := hex.EncodeToString(hasher.Sum(nil))
return filepath.Join(f.dir, fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash))
+174
View File
@@ -177,3 +177,177 @@ func TestSortItems(t *testing.T) {
})
}
}
func TestDeleteFilesCacheClearing(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "filebrowser_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create a test file
testFile := filepath.Join(tempDir, "Test Object")
err = os.WriteFile(testFile, []byte("test content"), 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Initialize a mock index
idx := indexing.Index{
Source: settings.Source{
Path: tempDir,
},
}
// Get the real path and cache it
_, isDir, err := idx.GetRealPath("/Test Object")
if err != nil {
t.Fatalf("Failed to get real path: %v", err)
}
// Verify the file is detected as a file (not directory)
if isDir {
t.Errorf("Expected file to be detected as file, but got directory")
}
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
Name: "test",
Path: tempDir,
}, true) // true for mock mode
// Delete the file
err = DeleteFiles("test", testFile, tempDir)
if err != nil {
t.Fatalf("Failed to delete file: %v", err)
}
// Create a directory with the same name
testDir := filepath.Join(tempDir, "Test Object")
err = os.Mkdir(testDir, 0755)
if err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Get the real path again - it should now detect it as a directory
// The cache should have been cleared, so it should re-detect the type
realPath2, isDir2, err := idx.GetRealPath("/Test Object")
if err != nil {
t.Fatalf("Failed to get real path after recreation: %v", err)
}
// Verify the directory is detected as a directory
if !isDir2 {
t.Errorf("Expected directory to be detected as directory, but got file. Real path: %s", realPath2)
}
// Clean up
os.RemoveAll(tempDir)
}
func TestOverrideDirectoryToFile(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "filebrowser_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
Name: "test",
Path: tempDir,
}, true) // true for mock mode
// Create a directory first
testDir := filepath.Join(tempDir, "Test Object")
err = os.Mkdir(testDir, 0755)
if err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Now try to create a file with the same name (should work with override)
fileOpts := iteminfo.FileOptions{
Path: "/Test Object",
Source: "test",
}
// Create a test reader with some content
testContent := "This is test file content"
reader := strings.NewReader(testContent)
err = WriteFile(fileOpts, reader)
if err != nil {
t.Fatalf("Failed to create file over directory: %v", err)
}
// Verify the file was created and the directory was removed
stat, err := os.Stat(testDir)
if err != nil {
t.Fatalf("File should exist but got error: %v", err)
}
if stat.IsDir() {
t.Errorf("Expected file but got directory")
}
// Verify the content
content, err := os.ReadFile(testDir)
if err != nil {
t.Fatalf("Failed to read file content: %v", err)
}
if string(content) != testContent {
t.Errorf("Expected content %q but got %q", testContent, string(content))
}
// Clean up
os.RemoveAll(tempDir)
}
func TestOverrideFileToDirectory(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "filebrowser_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
Name: "test",
Path: tempDir,
}, true) // true for mock mode
// Create a file first
testFile := filepath.Join(tempDir, "Test Object")
err = os.WriteFile(testFile, []byte("test content"), 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Now try to create a directory with the same name (should work with override)
fileOpts := iteminfo.FileOptions{
Path: "/Test Object/",
Source: "test",
}
err = WriteDirectory(fileOpts)
if err != nil {
t.Fatalf("Failed to create directory over file: %v", err)
}
// Verify the directory was created and the file was removed
stat, err := os.Stat(testFile)
if err != nil {
t.Fatalf("Directory should exist but got error: %v", err)
}
if !stat.IsDir() {
t.Errorf("Expected directory but got file")
}
// Clean up
os.RemoveAll(tempDir)
}
+131 -24
View File
@@ -168,6 +168,18 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
}
defer file.Close()
// Check file size first to prevent reading extremely large files
fileInfo, err := file.Stat()
if err != nil {
return err
}
// Skip files larger than 50MB to prevent memory issues
maxSize := int64(50)
if fileInfo.Size() > maxSize*1024*1024 {
return fmt.Errorf("file exceeds metadata check limit: %d MB", maxSize)
}
m, err := tag.ReadFrom(file)
if err != nil {
return err
@@ -185,10 +197,10 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
track, _ := m.Track()
item.AudioMeta.Track = track
// Extract album art and encode as base64
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// Limit album art size to prevent excessive response sizes (max 1MB)
if len(picture.Data) <= 1024*1024 {
// More aggressive size limit to prevent memory issues (max 2MB)
if len(picture.Data) <= 2*1024*1024 {
item.AudioMeta.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
@@ -210,6 +222,17 @@ func DeleteFiles(source, absPath string, absDirPath string) error {
if index.Config.DisableIndexing {
return nil
}
// Clear RealPathCache entries for the deleted path to prevent cache issues
// when a folder with the same name is created later
indexPath := index.MakeIndexPath(absPath)
realPath, _, err := index.GetRealPath(indexPath)
if err == nil {
// Clear both the path and the isdir cache entries
indexing.RealPathCache.Delete(realPath)
indexing.RealPathCache.Delete(realPath + ":isdir")
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
@@ -218,7 +241,7 @@ func DeleteFiles(source, absPath string, absDirPath string) error {
return nil
}
func RefreshIndex(source string, path string, isDir bool) error {
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
@@ -227,7 +250,7 @@ func RefreshIndex(source string, path string, isDir bool) error {
return nil
}
path = idx.MakeIndexPath(path)
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir})
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
}
// validateMoveDestination checks if a move/rename operation is valid
@@ -269,21 +292,47 @@ func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
if err != nil {
return err
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
idx := indexing.GetIndex(sourceIndex)
if idx == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
// For move operations:
// 1. Delete the source from the index (recursively if it's a directory)
// 2. Recursively index the destination to capture the entire moved tree
// Get indexes for deletion and refresh operations
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx == nil {
return fmt.Errorf("could not get source index: %v", sourceIndex)
}
idx2 := indexing.GetIndex(destIndex)
if idx2 == nil {
return fmt.Errorf("could not get index: %v ", destIndex)
dstIdx := indexing.GetIndex(destIndex)
if dstIdx == nil {
return fmt.Errorf("could not get destination index: %v", destIndex)
}
// Delete from source index (recursively for directories)
go srcIdx.DeleteMetadata(realsrc, isSrcDir, isSrcDir) //nolint:errcheck
// Clear RealPathCache entries for the moved path to prevent cache issues
// when a file/folder with the same name is created later
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
srcRealPath, _, err := srcIdx.GetRealPath(srcIndexPath)
if err == nil {
// Clear both the path and the isdir cache entries
indexing.RealPathCache.Delete(srcRealPath)
indexing.RealPathCache.Delete(srcRealPath + ":isdir")
}
// For move operations, refresh the parent directory to capture the moved file
refreshPath := realdst
refreshIsDir := isDestDir
if !isSrcDir {
// If moving a file (regardless of destination), refresh the parent directory
refreshPath = filepath.Dir(realdst)
refreshIsDir = true
}
go RefreshIndex(destIndex, refreshPath, refreshIsDir, true) //nolint:errcheck
// Use backend source paths to match how shares are stored
go s.UpdateShares(idx.Path, idx.MakeIndexPath(realsrc), idx2.Path, idx2.MakeIndexPath(realdst)) //nolint:errcheck
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
return nil
}
@@ -297,9 +346,31 @@ func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
if err != nil {
return err
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
// For copy operations:
// 1. Shallow refresh of source (just to update access times if needed)
// 2. Recursively index the destination to capture the entire copied tree
// Refresh source (parent directory if it's a file)
srcRefreshPath := realsrc
srcRefreshIsDir := isSrcDir
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
srcRefreshIsDir = true
}
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
// Refresh destination (parent directory if it's a file)
dstRefreshPath := realdst
dstRefreshIsDir := isDestDir
if !isSrcDir {
// If copying a file (regardless of destination), refresh the parent directory
dstRefreshPath = filepath.Dir(realdst)
dstRefreshIsDir = true
}
go RefreshIndex(destIndex, dstRefreshPath, dstRefreshIsDir, true) //nolint:errcheck
return nil
}
@@ -309,12 +380,31 @@ func WriteDirectory(opts iteminfo.FileOptions) error {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
var stat os.FileInfo
var err error
// Check if the destination exists and is a file
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
// If it's a file and we're trying to create a directory, remove the file first
err = os.Remove(realPath)
if err != nil {
return fmt.Errorf("could not remove existing file to create directory: %v", err)
}
// Clear the cache for the removed file
indexPath := idx.MakeIndexPath(opts.Path)
realPath, _, err = idx.GetRealPath(indexPath)
if err == nil {
indexing.RealPathCache.Delete(realPath)
indexing.RealPathCache.Delete(realPath + ":isdir")
}
}
// Ensure the parent directories exist
err := os.MkdirAll(realPath, fileutils.PermDir)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true)
return RefreshIndex(idx.Name, opts.Path, true, true)
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
@@ -322,14 +412,31 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(filepath.Dir(dst), fileutils.PermDir)
err := os.MkdirAll(filepath.Dir(realPath), fileutils.PermDir)
if err != nil {
return err
}
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
// Clear the cache for the removed directory
indexPath := idx.MakeIndexPath(opts.Path)
realPath, _, err = idx.GetRealPath(indexPath)
if err == nil {
indexing.RealPathCache.Delete(realPath)
indexing.RealPathCache.Delete(realPath + ":isdir")
}
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
@@ -340,7 +447,7 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
if err != nil {
return err
}
return RefreshIndex(opts.Source, opts.Path, false)
return RefreshIndex(opts.Source, opts.Path, false, false)
}
// getContent reads and returns the file content if it's considered an editable text file.
+3 -2
View File
@@ -590,10 +590,11 @@ var types = map[string]string{
".xpi": "application/x-xpinstall",
".wasm": "application/wasm",
".m4a": "audio/mp4",
".flac": "audio/x-flac",
".flac": "audio/flac",
".amr": "audio/amr",
".aac": "audio/aac",
".opus": "video/ogg",
".opus": "audio/ogg", // https://wiki.xiph.org/MIME_Types_and_File_Extensions
".ogx": "application/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".caf": "audio/x-caf",
+4 -4
View File
@@ -412,10 +412,10 @@ func setDefaults(generate bool) Settings {
},
},
}
// set default image preview types
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = (t == HEICImagePreview) // default is heic
s.Integrations.Media.Convert.ImagePreview[t] = false
}
return s
}
@@ -562,10 +562,10 @@ func loadCustomFavicon() {
// Validate file format based on extension
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp":
case ".ico", ".png", ".svg":
// Valid favicon formats
default:
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .jpg, .gif, .svg, .webp", ext)
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .svg", ext)
Config.Frontend.Favicon = "" // Unset invalid path
return
}
+55
View File
@@ -542,6 +542,11 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
for _, key := range v.MapKeys() {
// Handle key
keyStr := fmt.Sprintf("%v", key.Interface())
@@ -866,3 +871,53 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
+1 -1
View File
@@ -14,7 +14,7 @@ type AuthMethod string
// GenerateKey generates a key of 512 bits.
func GenerateKey() ([]byte, error) {
b := make([]byte, 64) //nolint:gomnd
b := make([]byte, 64)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
+3 -1
View File
@@ -74,6 +74,7 @@ type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
//RAWImagePreview ImagePreviewType = "raw"
)
func (i ImagePreviewType) String() string {
@@ -83,6 +84,7 @@ func (i ImagePreviewType) String() string {
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
//RAWImagePreview,
}
type LogConfig struct {
@@ -113,7 +115,7 @@ type SourceConfig struct {
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
IndexAlbumArt bool `json:"indexAlbumArt"` // check for album art in audio files and remember it for folder previews. Can slow down indexing.
IndexAlbumArt bool `json:"indexAlbumArt"` // deprecated: always enabled since 0.8.6
}
type IncludeIndexFilter struct {
+96
View File
@@ -0,0 +1,96 @@
package share
import (
"os"
"path/filepath"
)
// IsSingleFileShare determines if this share is for a single file (not a directory).
// It checks both the file extension and filesystem to determine if the path points to a file.
func (l *Link) IsSingleFileShare() bool {
if l.Path == "" {
return false
}
// First, check if the path has a file extension (common indicator of a file)
ext := filepath.Ext(l.Path)
if ext != "" {
// If it has an extension, it's likely a file, but let's verify with filesystem
return l.isFileOnFilesystem()
}
// If no extension, check if it's a directory by looking at the filesystem
return !l.isDirectoryOnFilesystem()
}
// isFileOnFilesystem checks if the path exists and is a file on the filesystem
func (l *Link) isFileOnFilesystem() bool {
// Construct the full path using Source and Path
fullPath := l.Path
if l.Source != "" {
// If Source is provided, it might be a relative path from the source
fullPath = filepath.Join(l.Source, l.Path)
}
info, err := os.Stat(fullPath)
if err != nil {
// If we can't stat the file, fall back to extension check
return filepath.Ext(l.Path) != ""
}
return !info.IsDir()
}
// isDirectoryOnFilesystem checks if the path exists and is a directory on the filesystem
func (l *Link) isDirectoryOnFilesystem() bool {
// Construct the full path using Source and Path
fullPath := l.Path
if l.Source != "" {
// If Source is provided, it might be a relative path from the source
fullPath = filepath.Join(l.Source, l.Path)
}
info, err := os.Stat(fullPath)
if err != nil {
// If we can't stat the file, assume it's a file if it has an extension
return filepath.Ext(l.Path) == ""
}
return info.IsDir()
}
// IsExpired checks if the share has expired based on the Expire timestamp
func (l *Link) IsExpired() bool {
if l.Expire == 0 {
return false // No expiration set
}
// This would need time.Now().Unix() but we avoid importing time here
// The actual expiration check is handled in the storage layer
return false // Placeholder - actual implementation would check current time
}
// HasPassword checks if the share is password protected
func (l *Link) HasPassword() bool {
return l.PasswordHash != ""
}
// IsPermanent checks if the share is permanent (no expiration)
func (l *Link) IsPermanent() bool {
return l.Expire == 0
}
// GetFileExtension returns the file extension of the shared file
func (l *Link) GetFileExtension() string {
if l.Path == "" {
return ""
}
return filepath.Ext(l.Path)
}
// GetFileName returns just the filename (without path) of the shared item
func (l *Link) GetFileName() string {
if l.Path == "" {
return ""
}
return filepath.Base(l.Path)
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
@@ -57,7 +58,7 @@ func dbExists(path string) (bool, error) {
d := filepath.Dir(path)
_, err = os.Stat(d)
if os.IsNotExist(err) {
if err := os.MkdirAll(d, 0700); err != nil { //nolint:govet,gomnd
if err = os.MkdirAll(d, fileutils.PermDir); err != nil {
return false, err
}
return false, nil
+4 -2
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
)
// ImageService handles image operations with ffmpeg
@@ -154,7 +156,7 @@ func (s *ImageService) ConvertHEICToJPEGDirect(heicPath string, targetWidth, tar
// Create temporary output file
outputDir := s.cacheDir
err = os.MkdirAll(outputDir, 0755)
err = os.MkdirAll(outputDir, fileutils.PermDir)
if err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
@@ -208,7 +210,7 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
// Create temporary directory for tile processing
outputDir := s.cacheDir
tempDir := filepath.Join(outputDir, fmt.Sprintf("heic_tiles_%d", os.Getpid()))
err := os.MkdirAll(tempDir, 0755)
err := os.MkdirAll(tempDir, fileutils.PermDir)
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
+2
View File
@@ -137,6 +137,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicAPI.HandleFunc("GET /resources", withHashFile(publicShareHandler))
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
// Settings routes
@@ -153,6 +154,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
// Mount the public API sub-router
+145 -18
View File
@@ -1,6 +1,7 @@
package http
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -35,6 +36,17 @@ type OnlyOfficeCallback struct {
UserData string `json:"userdata,omitempty"`
}
// OnlyOfficeJWTPayload represents the JWT payload structure for OnlyOffice callbacks
type OnlyOfficeJWTPayload struct {
Key string `json:"key"`
Status int `json:"status"`
Users []string `json:"users"`
Actions []struct {
Type int `json:"type"`
UserID string `json:"userid"`
} `json:"actions"`
}
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
@@ -179,7 +191,10 @@ func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
baseURL := settings.Config.Server.BaseURL
if settings.Config.Server.InternalUrl != "" {
baseURL = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
// Ensure proper URL joining without double slashes
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
}
var downloadURL string
@@ -202,7 +217,10 @@ func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
baseURL := settings.Config.Server.BaseURL
if settings.Config.Server.InternalUrl != "" {
baseURL = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
// Ensure proper URL joining without double slashes
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
}
var callbackURL string
@@ -229,21 +247,8 @@ func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
return callbackURL
}
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Parse OnlyOffice callback data
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Errorf("OnlyOffice callback: failed to read request body: %v", err)
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
logger.Errorf("OnlyOffice callback: failed to parse JSON: %v", err)
return http.StatusInternalServerError, err
}
// processOnlyOfficeCallback handles the common callback processing logic for both GET and POST requests
func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *requestContext, data *OnlyOfficeCallback) (int, error) {
// Extract clean parameters from query string
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
@@ -291,7 +296,6 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
//
// When the document is fully closed by all editors,
// the document key should no longer be re-used.
logger.Debugf("OnlyOffice: document closed, cleaning up document ID for source=%s, path=%s", source, path)
deleteOfficeId(source, path)
}
@@ -347,6 +351,61 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
return renderJSON(w, r, resp)
}
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Parse callback data based on request method
var callbackData *OnlyOfficeCallback
var err error
if r.Method == "GET" {
// OnlyOffice sends callback data in Authorization header as JWT
callbackData, err = parseOnlyOfficeCallbackFromJWT(r)
} else if r.Method == "POST" {
// OnlyOffice sends callback data in request body as JSON
callbackData, err = parseOnlyOfficeCallbackFromJSON(r)
} else {
return http.StatusMethodNotAllowed, fmt.Errorf("unsupported method: %s", r.Method)
}
if err != nil {
logger.Errorf("OnlyOffice callback: failed to parse callback data: %v", err)
return http.StatusBadRequest, err
}
// Process the callback data using shared logic
return processOnlyOfficeCallback(w, r, d, callbackData)
}
// parseOnlyOfficeCallbackFromJWT extracts callback data from JWT in Authorization header
func parseOnlyOfficeCallbackFromJWT(r *http.Request) (*OnlyOfficeCallback, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, errors.New("missing Authorization header")
}
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, errors.New("invalid Authorization header format")
}
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
return parseOnlyOfficeJWT(jwtToken)
}
// parseOnlyOfficeCallbackFromJSON extracts callback data from JSON request body
func parseOnlyOfficeCallbackFromJSON(r *http.Request) (*OnlyOfficeCallback, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %v", err)
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("failed to parse JSON: %v", err)
}
return &data, nil
}
func getOnlyOfficeId(realpath string) (string, error) {
// error is intentionally ignored in order treat errors
// the same as a cache-miss
@@ -366,3 +425,71 @@ func deleteOfficeId(source, path string) {
realpath, _, _ := idx.GetRealPath(path)
utils.OnlyOfficeCache.Delete(realpath)
}
// parseOnlyOfficeJWT parses the JWT token from OnlyOffice callback
func parseOnlyOfficeJWT(tokenString string) (*OnlyOfficeCallback, error) {
// Parse the JWT token without signature verification since OnlyOffice uses different signing
// We'll parse it manually to avoid signature validation issues
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part) with fallback to standard base64
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// Fallback to standard base64 decoding
payloadBytes, err = base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %v", err)
}
}
var claims jwt.MapClaims
err = json.Unmarshal(payloadBytes, &claims)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal JWT claims: %v", err)
}
// Extract payload from claims with fallback
payload, ok := claims["payload"].(map[string]interface{})
if !ok {
// Fallback: try to use claims directly if no payload wrapper
payload = map[string]interface{}(claims)
}
// Convert to OnlyOfficeCallback struct with safe type assertions
callback := &OnlyOfficeCallback{}
// Extract key with validation
if key, ok := payload["key"].(string); ok && key != "" {
callback.Key = key
} else {
logger.Warningf("OnlyOffice callback: missing or empty key in JWT payload")
}
// Extract status with validation
if status, ok := payload["status"].(float64); ok {
callback.Status = int(status)
} else {
logger.Warningf("OnlyOffice callback: missing or invalid status in JWT payload")
callback.Status = 0 // Default to unknown status
}
// Extract users with safe array handling
if users, ok := payload["users"].([]interface{}); ok {
callback.Users = make([]string, 0, len(users))
for _, user := range users {
if userStr, ok := user.(string); ok && userStr != "" {
callback.Users = append(callback.Users, userStr)
}
}
}
// Validate essential fields
if callback.Key == "" {
return nil, fmt.Errorf("missing document key in JWT payload")
}
return callback, nil
}
+6 -10
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
@@ -97,8 +98,8 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if !(previewSize == "large" || previewSize == "original") {
previewSize = "small"
}
if d.fileInfo.Type == "directory" && !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this folder does not have a preview")
if !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
if d.fileInfo.Type == "directory" {
// get extended file info of first previewable item in directory
@@ -132,16 +133,11 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
setContentDisposition(w, r, d.fileInfo.Name)
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
if config.Server.DisableResize && isImage {
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
if (!resizable || config.Server.DisableResize) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
if !d.fileInfo.HasPreview {
if isImage {
return rawFileHandler(w, r, d.fileInfo)
}
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", d.fileInfo.Type)
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -160,7 +161,7 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: 0755,
Mode: fileutils.PermDir,
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
+3 -3
View File
@@ -284,12 +284,12 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Use a temporary file in the cache directory for chunks.
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New() //nolint:gosec
hasher := md5.New()
hasher.Write([]byte(realPath))
uploadID := hex.EncodeToString(hasher.Sum(nil))
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
if err = os.MkdirAll(filepath.Dir(tempFilePath), 0755); err != nil {
if err = os.MkdirAll(filepath.Dir(tempFilePath), fileutils.PermDir); err != nil {
logger.Debugf("could not create temp dir: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
}
@@ -326,7 +326,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("could not move temp file to destination: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not move temp file to destination: %v", err)
}
go files.RefreshIndex(source, realPath, false) //nolint:errcheck
go files.RefreshIndex(source, realPath, false, false) //nolint:errcheck
}
return http.StatusOK, nil
+3 -3
View File
@@ -176,8 +176,8 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
var expire int64 = 0
if body.Expires != "" {
//nolint:govet
num, err := strconv.Atoi(body.Expires)
var num int
num, err = strconv.Atoi(body.Expires)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -204,7 +204,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash := ""
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 24) //nolint:gomnd
tokenBuffer := make([]byte, 24)
if _, err = rand.Read(tokenBuffer); err != nil {
return http.StatusInternalServerError, err
}
+7
View File
@@ -92,6 +92,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
shareProps["hash"] = d.share.Hash
if d.shareValid {
shareProps["singleFileShare"] = d.share.IsSingleFileShare()
disableNavButtons = disableNavButtons || d.share.HideNavButtons
shareProps["viewMode"] = d.share.ViewMode
shareProps["banner"] = d.share.Banner
@@ -109,6 +110,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
shareProps["viewMode"] = d.share.ViewMode
shareProps["enableOnlyOffice"] = d.share.EnableOnlyOffice
shareProps["enableOnlyOfficeEditing"] = d.share.EnableOnlyOfficeEditing
// Additional computed properties from extended.go
shareProps["hasPassword"] = d.share.HasPassword()
shareProps["isPermanent"] = d.share.IsPermanent()
shareProps["fileExtension"] = d.share.GetFileExtension()
shareProps["fileName"] = d.share.GetFileName()
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
+83 -42
View File
@@ -20,6 +20,20 @@ import (
var RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
// actionConfig holds all configuration options for indexing operations
type actionConfig struct {
Quick bool // whether to perform a quick scan (skip unchanged directories)
Recursive bool // whether to recursively index subdirectories
}
// NewactionConfig creates a new actionConfig with common presets
func NewactionConfig() *actionConfig {
return &actionConfig{
Quick: false,
Recursive: true,
}
}
// reduced index is json exposed to the client
type ReducedIndex struct {
IdxName string `json:"name"`
@@ -100,8 +114,13 @@ func Initialize(source settings.Source, mock bool) {
}
}
// indexDirectoryWithOptions wraps indexDirectory with actionConfig
func (idx *Index) indexDirectoryWithOptions(adjustedPath string, config *actionConfig) error {
return idx.indexDirectory(adjustedPath, config)
}
// Define a function to recursively index files and directories
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
func (idx *Index) indexDirectory(adjustedPath string, config *actionConfig) error {
realPath := strings.TrimRight(idx.Path, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
@@ -123,7 +142,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
}
// if indexing, mark the directory as valid and indexed.
if recursive {
if config.Recursive {
// Prevent race conditions if scanning becomes concurrent in the future.
idx.mu.Lock()
idx.DirectoriesLedger[adjustedPath] = struct{}{}
@@ -146,14 +165,18 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
// If the directory has not been modified since the last index, skip expensive readdir
// recursively check cached dirs for mod time changes as well
if recursive {
if config.Recursive {
if modChange {
idx.mu.Lock()
idx.FilesChangedDuringIndexing = true
idx.mu.Unlock()
} else if quick {
} else if config.Quick {
for _, item := range cacheDirItems {
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
subConfig := &actionConfig{
Quick: config.Quick,
Recursive: true,
}
err = idx.indexDirectory(combinedPath+item.Name, subConfig)
if err != nil && err != errors.ErrNotIndexed {
logger.Errorf("error indexing directory %v : %v", combinedPath+item.Name, err)
}
@@ -161,7 +184,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
return nil
}
}
dirFileInfo, err2 := idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, quick, recursive, true)
dirFileInfo, err2 := idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, config)
if err2 != nil {
return err2
}
@@ -205,7 +228,10 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
combinedPath = "/"
}
var response *iteminfo.FileInfo
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, false, false, false)
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, &actionConfig{
Quick: false,
Recursive: false,
})
if err != nil {
return nil, err
}
@@ -232,7 +258,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
}
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, quick, recursive, checkSkip bool) (*iteminfo.FileInfo, error) {
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, config *actionConfig) (*iteminfo.FileInfo, error) {
// Read directory contents
files, err := dirInfo.Readdir(-1)
if err != nil {
@@ -242,7 +268,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
fileInfos := []iteminfo.ItemInfo{}
dirInfos := []iteminfo.ItemInfo{}
hasPreview := false
if !recursive {
if !config.Recursive {
realDirInfo, exists := idx.GetMetadataInfo(adjustedPath, true)
if exists {
hasPreview = realDirInfo.HasPreview
@@ -260,7 +286,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
continue
}
}
if checkSkip && idx.shouldSkip(isDir, hidden, fullCombined, baseName) {
if idx.shouldSkip(isDir, hidden, fullCombined, baseName) {
continue
}
itemInfo := &iteminfo.ItemInfo{
@@ -271,7 +297,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if isDir {
dirPath := combinedPath + file.Name()
if idx.hasIndex && recursive && len(idx.Config.NeverWatchPaths) > 0 {
if idx.hasIndex && config.Recursive && len(idx.Config.NeverWatchPaths) > 0 {
if slices.Contains(idx.Config.NeverWatchPaths, fullCombined) {
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
if exists {
@@ -285,11 +311,11 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
continue
}
if recursive {
if config.Recursive {
// clear for garbage collection
file = nil
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, quick, recursive)
err = idx.indexDirectory(dirPath, config)
if err != nil {
logger.Errorf("Failed to index directory %s: %v", dirPath, err)
continue
@@ -303,7 +329,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
totalSize += itemInfo.Size
itemInfo.Type = "directory"
dirInfos = append(dirInfos, *itemInfo)
if recursive {
if config.Recursive {
idx.NumDirs++
}
} else {
@@ -311,14 +337,22 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
itemInfo.DetectType(realPath+"/"+file.Name(), false)
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
if recursive && !quick && idx.Config.IndexAlbumArt {
itemInfo.HasPreview = iteminfo.HasAlbumArt(realPath+"/"+file.Name(), filepath.Ext(file.Name()))
// Check if this file is new or changed by comparing with previous metadata
shouldCheckAlbumArt := false
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
if !exists {
// File is new - check album art
shouldCheckAlbumArt = true
} else if previousInfo.ModTime != file.ModTime() {
// File has been modified - check album art
shouldCheckAlbumArt = true
} else {
// File unchanged - use cached album art info
itemInfo.HasPreview = previousInfo.HasPreview
}
if !recursive && !itemInfo.HasPreview {
info, exists := idx.GetReducedMetadata(fullCombined, false)
if exists {
itemInfo.HasPreview = info.HasPreview
}
if shouldCheckAlbumArt {
itemInfo.HasPreview = iteminfo.HasAlbumArt(realPath+"/"+file.Name(), filepath.Ext(file.Name()))
}
}
ext := strings.ToLower(filepath.Ext(file.Name()))
@@ -338,16 +372,8 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
itemInfo.Size = int64(size)
fileInfos = append(fileInfos, *itemInfo)
if shouldCountSize {
totalSize += itemInfo.Size
}
if recursive {
idx.NumFiles++
}
if itemInfo.HasPreview {
hasPreview = true
}
// Set HasPreview before appending to fileInfos
// these don't create preview for parent folders
if settings.Config.Integrations.OnlyOffice.Secret != "" && iteminfo.IsOnlyOffice(file.Name()) {
itemInfo.HasPreview = true
@@ -355,6 +381,17 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if iteminfo.HasDocConvertableExtension(itemInfo.Name, itemInfo.Type) {
itemInfo.HasPreview = true
}
fileInfos = append(fileInfos, *itemInfo)
if shouldCountSize {
totalSize += itemInfo.Size
}
if config.Recursive {
idx.NumFiles++
}
if itemInfo.HasPreview {
hasPreview = true
}
}
}
@@ -382,6 +419,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
HasPreview: hasPreview,
}
dirFileInfo.SortItems()
return dirFileInfo, nil
}
@@ -441,29 +479,32 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
}
func (idx *Index) RefreshFileInfo(opts iteminfo.FileOptions) error {
refreshOptions := iteminfo.FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
config := &actionConfig{
Quick: false,
Recursive: opts.Recursive,
}
if !refreshOptions.IsDir {
refreshOptions.Path = idx.MakeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
targetPath := opts.Path
if !opts.IsDir {
targetPath = idx.MakeIndexPath(filepath.Dir(targetPath))
}
err := idx.indexDirectory(refreshOptions.Path, false, false)
// Use the recursive flag from options - false for quick scan, true for recursive
err := idx.indexDirectoryWithOptions(targetPath, config)
if err != nil {
return err
}
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
file, exists := idx.GetMetadataInfo(targetPath, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
return fmt.Errorf("file/folder does not exist in metadata: %s", targetPath)
}
current, firstExisted := idx.GetMetadataInfo(refreshOptions.Path, true)
current, firstExisted := idx.GetMetadataInfo(targetPath, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := idx.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
return fmt.Errorf("file/folder does not exist in metadata: %s", targetPath)
}
if !exists {
return nil
+6 -1
View File
@@ -131,6 +131,7 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
return
}
idx.PreScan()
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
@@ -147,7 +148,11 @@ func (idx *Index) RunIndexing(origin string, quick bool) {
startTime := time.Now()
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
config := &actionConfig{
Quick: quick,
Recursive: true,
}
err := idx.indexDirectory("/", config)
if err != nil {
logger.Errorf("Error during indexing: %v", err)
}
+138 -18
View File
@@ -1,6 +1,10 @@
package iteminfo
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"mime"
"net/http"
"os"
@@ -9,7 +13,6 @@ import (
"strconv"
"strings"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -50,6 +53,15 @@ var MuPdfConvertable = map[string]bool{
".md": true, // Markdown
}
var ResizableImageTypes = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".bmp": true,
".other": false,
}
// Known bundle-style extensions that are technically directories but treated as files
var BundleExtensions = []string{
".app", // macOS application bundle
@@ -320,31 +332,139 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
}
}
// hasAlbumArt efficiently checks if an audio file contains embedded album art.
// It returns true if album art is found, and false otherwise.
// hasAlbumArtLowLevel efficiently checks for album art in audio files.
// It handles ID3v2 tags (MP3) and Vorbis comments (FLAC, OGG) with low-level parsing.
func hasAlbumArtLowLevel(filePath string, extension string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Handle different audio formats
switch extension {
case ".mp3":
return hasAlbumArtMP3(file)
case ".flac", ".ogg":
return hasAlbumArtVorbis(file)
case ".m4a", ".mp4":
return hasAlbumArtM4A(file)
default:
// For other formats, return false (no album art detection)
return false, nil
}
}
// hasAlbumArtMP3 checks for APIC frames in ID3v2 tags
func hasAlbumArtMP3(file *os.File) (bool, error) {
// 1. Read the 10-byte ID3v2 header.
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return false, nil // No ID3v2 tag found
}
// 2. Check for the "ID3" identifier.
if string(header[0:3]) != "ID3" {
return false, nil // No ID3v2 tag found
}
// 3. Decode the tag size. It's a "synchsafe" integer.
tagSize := (int(header[6]) << 21) | (int(header[7]) << 14) | (int(header[8]) << 7) | int(header[9])
// 4. Read the entire tag content into a buffer.
tagData := make([]byte, tagSize)
if _, err := io.ReadFull(file, tagData); err != nil {
return false, fmt.Errorf("failed to read tag data: %w", err)
}
position := 0
// 5. Loop through the frames within the tag data.
for position < tagSize {
if position+10 > tagSize {
break
}
frameHeader := tagData[position : position+10]
frameID := string(frameHeader[0:4])
// We found it! The APIC frame contains the album art.
if frameID == "APIC" {
return true, nil
}
// If it's not APIC, find the frame's size to skip over it.
var frameSize uint32
if err := binary.Read(bytes.NewReader(frameHeader[4:8]), binary.BigEndian, &frameSize); err != nil {
return false, fmt.Errorf("corrupt frame header: %w", err)
}
// Move position to the next frame header.
position += 10 + int(frameSize)
// Break if the next frame would start with padding (null bytes).
if position >= tagSize || tagData[position] == 0 {
break
}
}
return false, nil
}
// hasAlbumArtVorbis checks for METADATA_BLOCK_PICTURE in Vorbis comments (FLAC, OGG)
func hasAlbumArtVorbis(file *os.File) (bool, error) {
// For FLAC files, look for METADATA_BLOCK_PICTURE in Vorbis comments
// This is a simplified check - in practice, you'd need to parse the FLAC structure
// For now, we'll do a basic search for common picture-related strings
buffer := make([]byte, 8192) // Read first 8KB
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Look for METADATA_BLOCK_PICTURE or COVERART in the buffer
content := string(buffer[:n])
if strings.Contains(content, "METADATA_BLOCK_PICTURE") ||
strings.Contains(content, "COVERART") ||
strings.Contains(content, "COVER_ART") {
return true, nil
}
return false, nil
}
// hasAlbumArtM4A checks for embedded artwork in M4A/MP4 files
func hasAlbumArtM4A(file *os.File) (bool, error) {
// For M4A files, look for 'covr' atom which contains artwork
// This is a simplified check - in practice, you'd need to parse the MP4 structure
buffer := make([]byte, 8192) // Read first 8KB
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Look for 'covr' atom in the buffer
content := string(buffer[:n])
if strings.Contains(content, "covr") {
return true, nil
}
return false, nil
}
func HasAlbumArt(filePath string, extension string) bool {
if !CouldHaveAlbumArt(extension) {
return false
}
file, err := os.Open(filePath)
if err != nil {
return false
}
defer file.Close()
// Read metadata from the file.
m, err := tag.ReadFrom(file)
// Use low-level detection for all audio formats
hasArt, err := hasAlbumArtLowLevel(filePath, extension)
if err != nil {
// If the error is simply that no tags were found, it's not a processing error.
// It just means there's no album art.
if err == tag.ErrNoTagsFound {
return false
}
// For any other error (e.g., corrupted file), return the error.
// If there's an error with low-level detection, return false
// This is safer than potentially crashing the indexing process
return false
}
// m.Picture() returns the first picture found. If it's not nil, album art exists.
return m.Picture() != nil
return hasArt
}
// DetectTypeByHeader detects the MIME type of a file based on its header.
+1
View File
@@ -64,6 +64,7 @@ type FileOptions struct {
Expand bool
ReadHeader bool
Content bool
Recursive bool // whether to recursively index directories
}
func (f FileOptions) Components() (string, string) {
+61
View File
@@ -18,6 +18,67 @@ func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
return true
}
// DeleteMetadata removes the specified path from the index.
// If recursive is true and the path is a directory, it will also remove all subdirectories.
func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
indexPath := idx.MakeIndexPath(path)
if !isDir {
// For files, remove from parent directory's Files slice
parentPath := idx.MakeIndexPath(filepath.Dir(path))
if parentDir, exists := idx.Directories[parentPath]; exists {
fileName := filepath.Base(path)
for i, file := range parentDir.Files {
if file.Name == fileName {
// Remove file from slice
parentDir.Files = append(parentDir.Files[:i], parentDir.Files[i+1:]...)
break
}
}
}
return true
}
// For directories
if recursive {
// Remove all subdirectories that start with this path
toDelete := []string{}
for dirPath := range idx.Directories {
if dirPath == indexPath || (len(dirPath) > len(indexPath) &&
dirPath[:len(indexPath)] == indexPath &&
(indexPath == "/" || dirPath[len(indexPath)] == '/')) {
toDelete = append(toDelete, dirPath)
}
}
for _, dirPath := range toDelete {
delete(idx.Directories, dirPath)
}
} else {
// Just remove this specific directory
delete(idx.Directories, indexPath)
}
// Remove from parent directory's Folders slice
if indexPath != "/" {
parentPath := idx.MakeIndexPath(filepath.Dir(path))
if parentDir, exists := idx.Directories[parentPath]; exists {
dirName := filepath.Base(path)
for i, folder := range parentDir.Folders {
if folder.Name == dirName {
// Remove folder from slice
parentDir.Folders = append(parentDir.Folders[:i], parentDir.Folders[i+1:]...)
break
}
}
}
}
return true
}
// GetMetadataInfo retrieves the FileInfo from the specified file or directory in the index.
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*iteminfo.FileInfo, bool) {
idx.mu.RLock()
+13 -7
View File
@@ -16,6 +16,7 @@ import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -53,16 +54,16 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
// Create directories recursively with 0755 permissions
err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), 0755)
// Create directories recursively
err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos"), 0755)
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "heic"), 0755)
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "heic"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
@@ -102,7 +103,7 @@ func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string,
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
// md5 is based on album art
// md5 file.AlbumArt
hasher := md5.New() //nolint:gosec
hasher := md5.New()
_, _ = hasher.Write([]byte(file.AudioMeta.AlbumArt))
thisMd5 = hex.EncodeToString(hasher.Sum(nil))
file.Checksums = make(map[string]string)
@@ -113,6 +114,11 @@ func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string,
if err != nil {
return nil, fmt.Errorf("failed to get checksum: %w", err)
}
// Ensure the file.Checksums map is initialized and MD5 is set
if file.Checksums == nil {
file.Checksums = make(map[string]string)
}
file.Checksums["md5"] = thisMd5
}
cacheKey := CacheKey(thisMd5, previewSize, seekPercentage)
if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil {
@@ -131,7 +137,7 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
)
// Generate thumbnail image from video
hasher := md5.New() //nolint:gosec
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(file.Checksums["md5"], previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Generate an image from office document
@@ -205,7 +211,7 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
} else {
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
+1 -1
View File
@@ -3195,7 +3195,7 @@ const docTemplate = `{
]
},
"indexAlbumArt": {
"description": "check for album art in audio files and remember it for folder previews. Can slow down indexing.",
"description": "deprecated: always enabled since 0.8.6",
"type": "boolean"
},
"indexingIntervalMinutes": {
+1 -1
View File
@@ -3184,7 +3184,7 @@
]
},
"indexAlbumArt": {
"description": "check for album art in audio files and remember it for folder previews. Can slow down indexing.",
"description": "deprecated: always enabled since 0.8.6",
"type": "boolean"
},
"indexingIntervalMinutes": {
+1 -2
View File
@@ -584,8 +584,7 @@ definitions:
- $ref: '#/definitions/settings.IncludeIndexFilter'
description: include files and folders from indexing, if exclude is not set
indexAlbumArt:
description: check for album art in audio files and remember it for folder
previews. Can slow down indexing.
description: 'deprecated: always enabled since 0.8.6'
type: boolean
indexingIntervalMinutes:
description: optional manual overide interval in minutes to re-index the source
+1 -2
View File
@@ -47,7 +47,6 @@ server:
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
indexAlbumArt: false # check for album art in audio files and remember it for folder previews. Can slow down indexing.
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
@@ -168,4 +167,4 @@ integrations:
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
convert: # config for ffmpeg conversion settings
imagePreview: # supported image preview formats. default is heic
heic: true
heic: false
+6 -3
View File
@@ -87,7 +87,7 @@
v-if="!showCreate && selectedCount == 1 && isSearchActive"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
@action="openParentFolder"
/>
<action
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
@@ -134,7 +134,7 @@
>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('buttons.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview" icon="file_download" :label="$t('buttons.download')" @action="startDownload" />
<action v-if="hasDownload" icon="file_download" :label="$t('buttons.download')" @action="startDownload" />
<action v-if="showEdit" icon="edit" :label="$t('buttons.edit')" @action="edit()" />
<action v-if="showSave" icon="save" :label="$t('buttons.save')" @action="save()" />
<action v-if="showDelete" icon="delete" :label="$t('buttons.delete')" show="delete" />
@@ -189,7 +189,7 @@ export default {
return false
},
hasOverflowItems() {
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw;
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw || this.hasDownload;
},
showGoToRaw() {
const cv = getters.currentView();
@@ -212,6 +212,9 @@ export default {
const showDelete = cv != "settings" && !this.showCreate && this.selectedCount > 0 && !this.isSearchActive;
return showDelete;
},
hasDownload() {
return this.selectedCount > 0;
},
isPreview() {
const cv = getters.currentView();
return (
+22 -13
View File
@@ -10,21 +10,25 @@
<div v-if="shareInfo.description" class="share__box__element">
<p>{{ shareInfo.description }}</p>
</div>
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<div class="share__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
<div class="share__box__element" :title="modTime">
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element">
<strong>{{ $t("prompts.size") }}:</strong> {{ humanSize }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element share__box__center">
<button class="button button--flat clickable" @click="goToLink()"> {{ $t("buttons.download") }} </button>
</div>
<div v-if="accessible">
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<div class="share__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
<div class="share__box__element" :title="modTime">
<strong>{{ $t("prompts.lastModified", {suffix: ":"}) }}</strong> {{ humanTime }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element">
<strong>{{ $t("prompts.size", {suffix: ":"}) }}</strong> {{ humanSize }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element share__box__center">
<button class="button button--flat clickable" @click="goToLink()"> {{ $t("buttons.download") }} </button>
</div>
</div>
<div v-if="req.type" class="share__box__element share__box__center">
<qrcode-vue class="qrcode" :value="getLink(false)" size="200" level="M"></qrcode-vue>
</div>
@@ -60,6 +64,9 @@ export default {
},
},
computed: {
accessible() {
return shareInfo.isPasswordProtected && state.share.passwordValid;
},
getShareBanner() {
if (this.shareInfo.banner.startsWith("http")) {
return this.shareInfo.banner;
@@ -122,6 +129,7 @@ export default {
.banner img {
width: 100%;
}
.share {
display: flex;
}
@@ -146,6 +154,7 @@ export default {
.share__box__element canvas {
border-style: solid;
}
.share__box__center {
text-align: center;
}
+8 -1
View File
@@ -562,7 +562,14 @@ export default {
* @param {Share} share
*/
buildDownloadLink(share) {
if (share.downloadURL && globalVars.externalUrl == "") {
if (share.downloadURL) {
// Only fix the URL if it doesn't already have the correct external domain
if (globalVars.externalUrl) {
// URL already has the correct external domain, use as-is
return share.downloadURL;
}
// URL needs fixing (internal domain or no externalUrl set)
return this.fixDownloadURL(share.downloadURL);
}
return publicApi.getDownloadURL(share, [this.item.name]);
+350 -34
View File
@@ -1,42 +1,228 @@
<template>
<errors v-if="error" :errorCode="error.status" />
<form @submit.prevent="save" v-if="loaded">
<div class="card-title">
<h2 v-if="isNew">{{ $t("settings.newUser") }}</h2>
<h2 v-else-if="actor.id == user.id">
{{ $t("settings.modifyCurrentUser") }} {{ user.username }}
</h2>
<h2 v-else>{{ $t("settings.modifyOtherUser") }} {{ user.username }}</h2>
<div class="card-title">
<h2 v-if="isNew">{{ $t("settings.newUser") }}</h2>
<h2 v-else-if="actor.id == user.id">
{{ $t("settings.modifyCurrentUser") }} {{ user.username }}
</h2>
<h2 v-else>{{ $t("settings.modifyOtherUser") }} {{ user.username }}</h2>
</div>
<div class="card-content">
<errors v-if="error" :errorCode="error.status" />
<h2
class="message"
v-if="user.loginMethod != 'password' && !stateUser.permissions.admin"
>
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</h2>
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && !isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
aria-label="Password2"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPasswordAgain')"
v-model="user.password"
id="password"
/>
<button
v-if="!isNew"
type="button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
</button>
</div>
<div
style="display: flex; flex-direction: column"
>
<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" @click="newOTP" aria-label="Generate Code">
{{ $t("buttons.generateNewOtp") }}
</button>
</div>
<hr />
</div>
<div class="card-content minimal-card">
<user-form v-model:user="user" :createUserDir="createUserDir" :isNew="isNew"
@update:createUserDir="(updatedDir) => (createUserDir = updatedDir)" />
</div>
<div v-if="actor.permissions.admin" class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
<div v-if="stateUser.permissions.admin">
<p v-if="isNew">
<label for="username">{{ $t("settings.username") }}</label>
<input
class="input"
type="text"
v-model="user.username"
id="username"
@input="emitUpdate"
/>
</p>
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
type="password"
:placeholder="$t('settings.enterPasswordAgain')"
aria-label="Password2"
v-model="user.password"
autocomplete="new-password"
id="password"
/>
<button
v-if="!isNew"
type="button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
</button>
</div>
</div>
<div
v-if="user.loginMethod == 'password' && globalVars.passwordAvailable"
class="settings-items"
>
<ToggleSwitch
v-if="user.loginMethod === 'password' && stateUser.permissions?.admin"
class="item"
:modelValue="user.lockPassword"
@update:modelValue="(val) => updateUserField('lockPassword', val)"
:name="$t('settings.lockPassword')"
/>
</div>
<div style="padding-bottom: 1em" v-if="stateUser.permissions.admin">
<label for="scopes">{{ $t("settings.scopes") }}</label>
<div
class="scope-list"
:class="{ 'form-invalid': duplicateSources.includes(source.name) }"
v-for="(source, index) in selectedSources"
:key="index"
>
<select
@change="handleSourceChange(source, $event, source.name)"
class="input flat-right"
v-model="source.name"
>
<option v-for="s in sourceList" :key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
<input
class="input flat-left scope-input"
placeholder="scope eg. '/subfolder', leave blank for default path"
@input="updateParent({ source: source, input: $event })"
:value="source.scope"
:class="{ 'flat-right': selectedSources.length > 1 }"
/>
<button
v-if="selectedSources.length > 1"
class="button flat-left no-height"
@click="removeScope(index)"
>
<i class="material-icons material-size">delete</i>
</button>
</div>
</div>
<button v-if="hasMoreSources" @click="addNewScopeSource" class="button no-height">
<i class="material-icons material-size">add</i>
</button>
<button v-if="!isNew" @click.prevent="deletePrompt" type="button" class="button button--flat button--red"
aria-label="Delete User" :title="$t('buttons.delete')">
{{ $t("buttons.delete") }}
</button>
<input aria-label="Save User" class="save-button button button--flat" type="submit" :value="$t('buttons.save')" />
<div class="settings-items">
<ToggleSwitch
v-if="displayHomeDirectoryCheckbox"
class="item"
v-model="createUserDir"
:name="$t('settings.createUserHomeDirectory')"
/>
</div>
<p v-if="stateUser.username !== user.username">
<label for="locale">{{ $t("settings.language") }}</label>
<languages
class="input"
id="locale"
v-model:locale="user.locale"
@input="emitUpdate"
></languages>
</p>
<div v-if="stateUser.permissions.admin">
<label for="loginMethod">{{ $t("settings.loginMethodDescription") }}</label>
<select v-model="user.loginMethod" class="input" 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 -->
</select>
</div>
<permissions v-if="stateUser.permissions.admin" :permissions="user.permissions" />
</div>
</form>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button v-if="!isNew" @click.prevent="deletePrompt" type="button" class="button button--flat button--red"
aria-label="Delete User" :title="$t('buttons.delete')">
{{ $t("buttons.delete") }}
</button>
<button @click="save" class="button button--flat" :aria-label="$t('buttons.save')" :title="$t('buttons.save')">
{{ $t("buttons.save") }}
</button>
</div>
</template>
<script>
import { mutations, state } from "@/store";
import { usersApi, settingsApi } from "@/api";
import UserForm from "@/components/settings/UserForm.vue";
import Languages from "@/components/settings/Languages.vue";
import Permissions from "@/components/settings/Permissions.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import Errors from "@/views/Errors.vue";
import { notify } from "@/notify";
import { globalVars } from "@/utils/constants";
export default {
name: "user-edit",
components: {
UserForm,
Languages,
Permissions,
ToggleSwitch,
Errors,
},
props: {
@@ -59,10 +245,16 @@ export default {
showDelete: false,
createUserDir: false,
loaded: false,
originalUserScope: ".",
sourceList: [],
availableSources: [],
selectedSources: [],
passwordRef: "",
};
},
created() {
this.fetchData();
async created() {
await this.fetchData();
await this.initializeForm();
},
computed: {
actor() {
@@ -74,6 +266,39 @@ export default {
isNew() {
return !this.userId;
},
stateUser() {
return state.user;
},
invalidPassword() {
const matching =
this.user.password != this.passwordRef && this.user.password.length > 0;
return matching;
},
passwordAvailable: () => globalVars.passwordAvailable,
globalVars: () => globalVars,
duplicateSources() {
const names = this.selectedSources.map((s) => s.name);
return names.filter((name, idx) => names.indexOf(name) !== idx);
},
hasMoreSources() {
return this.selectedSources.length < this.sourceList.length;
},
passwordPlaceholder() {
return this.isNew ? "" : this.$t("settings.avoidChanges");
},
displayHomeDirectoryCheckbox() {
return this.isNew && this.createUserDir;
},
},
watch: {
createUserDir(newVal) {
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
this.emitUserUpdate();
},
stateUser() {
this.user.otpEnabled = state.user.otpEnabled;
this.emitUserUpdate();
},
},
methods: {
closeHovers() {
@@ -102,6 +327,27 @@ export default {
this.loaded = true;
}
},
async initializeForm() {
if (!this.stateUser.permissions.admin) {
this.sourceList = this.user.scopes || [];
} else {
this.sourceList = await settingsApi.get("sources");
}
this.user.password = this.user.password || "";
this.selectedSources = this.user.scopes || [];
this.availableSources = this.sourceList.filter(
(s) => !this.selectedSources.some((sel) => sel.name === s.name)
);
if (this.isNew && this.availableSources.length) {
const newSource = this.availableSources.shift();
if (newSource) {
this.selectedSources.push(newSource);
this.emitUserUpdate();
}
}
},
deletePrompt() {
mutations.showHover({ name: "deleteUser", props: { user: this.user } });
},
@@ -114,10 +360,10 @@ export default {
return;
}
if (this.isNew) {
await usersApi.create(this.user); // Use the computed property
await usersApi.create({ ...this.user, scopes: this.selectedSources });
this.$router.push({ path: "/settings", hash: "#users-main" });
} else {
await usersApi.update(this.user, fields);
await usersApi.update({ ...this.user, scopes: this.selectedSources }, fields);
notify.showSuccess(this.$t("settings.userUpdated"));
}
window.location.reload();
@@ -125,18 +371,88 @@ export default {
notify.showError(e);
}
},
newOTP() {
mutations.showHover({
name: "totp",
props: {
generate: true,
},
});
},
async submitUpdatePassword() {
event.preventDefault();
if (this.invalidPassword) {
notify.showError(this.$t("settings.passwordsDoNotMatch"));
return;
}
try {
await usersApi.update(this.user, ["password"]);
notify.showSuccess(this.$t("settings.userUpdated"));
} catch (e) {
notify.showError(e);
}
},
emitUserUpdate() {
// Update the user object with current scopes
this.user = { ...this.user, scopes: this.selectedSources };
},
emitUpdate() {
// Update the user object
this.user = { ...this.user };
},
setUpdatePassword() {
// This method is kept for compatibility but not used in the new structure
},
updateParent(input) {
const updatedScopes = this.selectedSources.map((source) =>
source.name === input.source.name
? { ...source, scope: input.input.target.value }
: source
);
this.selectedSources = updatedScopes;
this.emitUserUpdate();
},
addNewScopeSource(event) {
event.preventDefault();
if (this.hasMoreSources) {
this.selectedSources.push({ name: "", scope: "" });
this.emitUserUpdate();
}
},
removeScope(index) {
const removed = this.selectedSources.splice(index, 1)[0];
this.availableSources.push({ name: removed.name });
this.emitUserUpdate();
},
handleSourceChange(source, event, oldName) {
const newName = event.target.value;
this.availableSources = this.availableSources.filter((s) => s.name !== newName);
if (oldName && !this.availableSources.find((s) => s.name === oldName)) {
this.availableSources.push({ name: oldName });
}
source.name = newName;
this.emitUserUpdate();
},
updateUserField(field, value) {
this.user[field] = value;
this.emitUserUpdate();
},
},
};
</script>
<style scoped>
.minimal-card {
/* margin-bottom: 16px; */
padding-top: 0 !important;
padding-bottom: 0 !important;
.scope-list {
display: flex;
}
.save-button {
width: 33%;
.scope-input {
width: 100%;
}
.no-height {
height: unset;
}
.material-size {
font-size: 1em !important;
}
</style>
@@ -1,351 +0,0 @@
<template>
<h2
class="message"
v-if="user.loginMethod != 'password' && !stateUser.permissions.admin"
>
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</h2>
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && !isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
aria-label="Password2"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPasswordAgain')"
v-model="user.password"
id="password"
/>
<button
v-if="!isNew"
type="button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
</button>
</div>
<div
style="display: flex; flex-direction: column"
>
<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" aria-label="Generate Code">
{{ $t("buttons.generateNewOtp") }}
</button>
</div>
<hr />
</div>
<div v-if="stateUser.permissions.admin">
<p v-if="isNew">
<label for="username">{{ $t("settings.username") }}</label>
<input
class="input"
type="text"
v-model="user.username"
id="username"
@input="emitUpdate"
/>
</p>
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-flex-group">
<input
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
type="password"
:placeholder="$t('settings.enterPasswordAgain')"
aria-label="Password2"
v-model="user.password"
autocomplete="new-password"
id="password"
/>
<button
v-if="!isNew"
type="button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
</button>
</div>
</div>
<div
v-if="user.loginMethod == 'password' && globalVars.passwordAvailable"
class="settings-items"
>
<ToggleSwitch
v-if="user.loginMethod === 'password' && stateUser.permissions?.admin"
class="item"
:modelValue="user.lockPassword"
@update:modelValue="(val) => updateUserField('lockPassword', val)"
:name="$t('settings.lockPassword')"
/>
</div>
<div style="padding-bottom: 1em" v-if="stateUser.permissions.admin">
<label for="scopes">{{ $t("settings.scopes") }}</label>
<div
class="scope-list"
:class="{ 'form-invalid': duplicateSources.includes(source.name) }"
v-for="(source, index) in selectedSources"
:key="index"
>
<select
@change="handleSourceChange(source, $event, source.name)"
class="input flat-right"
v-model="source.name"
>
<option v-for="s in sourceList" :key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
<input
class="input flat-left scope-input"
placeholder="scope eg. '/subfolder', leave blank for default path"
@input="updateParent({ source: source, input: $event })"
:value="source.scope"
:class="{ 'flat-right': selectedSources.length > 1 }"
/>
<button
v-if="selectedSources.length > 1"
class="button flat-left no-height"
@click="removeScope(index)"
>
<i class="material-icons material-size">delete</i>
</button>
</div>
</div>
<button v-if="hasMoreSources" @click="addNewScopeSource" class="button no-height">
<i class="material-icons material-size">add</i>
</button>
<div class="settings-items">
<ToggleSwitch
v-if="displayHomeDirectoryCheckbox"
class="item"
v-model="createUserDir"
:name="$t('settings.createUserHomeDirectory')"
/>
</div>
<p v-if="stateUser.username !== user.username">
<label for="locale">{{ $t("settings.language") }}</label>
<languages
class="input"
id="locale"
v-model:locale="user.locale"
@input="emitUpdate"
></languages>
</p>
<div v-if="stateUser.permissions.admin">
<label for="loginMethod">{{ $t("settings.loginMethodDescription") }}</label>
<select v-model="user.loginMethod" class="input" 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 -->
</select>
</div>
<permissions v-if="stateUser.permissions.admin" :permissions="user.permissions" />
</div>
</template>
<script>
import Languages from "./Languages.vue";
import Permissions from "./Permissions.vue";
import { mutations, state } from "@/store";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { notify } from "@/notify";
import { usersApi, settingsApi } from "@/api";
import { globalVars } from "@/utils/constants";
export default {
name: "UserForm",
components: {
Permissions,
Languages,
ToggleSwitch,
},
props: {
user: Object,
isNew: Boolean,
},
data() {
return {
createUserDir: false,
originalUserScope: ".",
sourceList: [],
availableSources: [],
selectedSources: [],
passwordRef: "",
};
},
async mounted() {
if (!this.stateUser.permissions.admin) {
this.sourceList = this.user.scopes || [];
} else {
this.sourceList = await settingsApi.get("sources");
}
this.user.password = this.user.password || "";
this.selectedSources = this.user.scopes || [];
this.availableSources = this.sourceList.filter(
(s) => !this.selectedSources.some((sel) => sel.name === s.name)
);
if (this.isNew && this.availableSources.length) {
const newSource = this.availableSources.shift();
if (newSource) {
this.selectedSources.push(newSource);
this.emitUserUpdate();
}
}
},
watch: {
createUserDir(newVal) {
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
this.emitUserUpdate();
},
stateUser() {
this.user.otpEnabled = state.user.otpEnabled;
this.emitUserUpdate();
},
},
computed: {
invalidPassword() {
const matching =
this.user.password != this.passwordRef && this.user.password.length > 0;
return matching;
},
passwordAvailable: () => globalVars.passwordAvailable,
globalVars: () => globalVars,
duplicateSources() {
const names = this.selectedSources.map((s) => s.name);
return names.filter((name, idx) => names.indexOf(name) !== idx);
},
hasMoreSources() {
return this.selectedSources.length < this.sourceList.length;
},
stateUser() {
return state.user;
},
passwordPlaceholder() {
return this.isNew ? "" : this.$t("settings.avoidChanges");
},
displayHomeDirectoryCheckbox() {
return this.isNew && this.createUserDir;
},
},
methods: {
newOTP() {
mutations.showHover({
name: "totp",
props: {
generate: true,
},
});
},
async submitUpdatePassword() {
event.preventDefault();
if (this.invalidPassword) {
notify.showError(this.$t("settings.passwordsDoNotMatch"));
return;
}
try {
await usersApi.update(this.user, ["password"]);
notify.showSuccess(this.$t("settings.userUpdated"));
} catch (e) {
notify.showError(e);
}
},
emitUserUpdate() {
this.$emit("update:user", { ...this.user, scopes: this.selectedSources });
},
emitUpdate() {
this.$emit("update:user", { ...this.user });
},
setUpdatePassword() {
this.$emit("update:updatePassword", true);
},
updateParent(input) {
const updatedScopes = this.selectedSources.map((source) =>
source.name === input.source.name
? { ...source, scope: input.input.target.value }
: source
);
this.selectedSources = updatedScopes;
this.emitUserUpdate();
},
addNewScopeSource(event) {
event.preventDefault();
if (this.hasMoreSources) {
this.selectedSources.push({ name: "", scope: "" });
this.emitUserUpdate();
}
},
removeScope(index) {
const removed = this.selectedSources.splice(index, 1)[0];
this.availableSources.push({ name: removed.name });
this.emitUserUpdate();
},
handleSourceChange(source, event, oldName) {
const newName = event.target.value;
this.availableSources = this.availableSources.filter((s) => s.name !== newName);
if (oldName && !this.availableSources.find((s) => s.name === oldName)) {
this.availableSources.push({ name: oldName });
}
source.name = newName;
this.emitUserUpdate();
},
updateUserField(field, value) {
this.user[field] = value;
this.emitUserUpdate();
},
},
};
</script>
<style>
.scope-list {
display: flex;
}
.scope-input {
width: 100%;
}
.no-height {
height: unset;
}
.material-size {
font-size: 1em !important;
}
</style>
+1 -1
View File
@@ -186,7 +186,7 @@ export default {
if (!getters.isLoggedIn()) {
return;
}
if (!this.isStickySidebar) {
if (!this.isStickySidebar && !shareInfo.singleFileShare) {
mutations.closeSidebar();
}
},
+11 -8
View File
@@ -101,7 +101,17 @@ export default {
transition: 0.5s ease;
top: 4em;
padding-bottom: 4em;
background-color: #dddddd;
background-color: rgb(37 49 55 / 5%) !important;
}
/* sidebar with backdrop-filter support */
@supports (backdrop-filter: none) {
#sidebar {
backdrop-filter: blur(16px) invert(0.1);
}
#sidebar.dark-mode {
background-color: rgb(37 49 55 / 33%) !important;
}
}
#sidebar.behind-overlay {
@@ -112,13 +122,6 @@ export default {
z-index: 3;
}
@supports (backdrop-filter: none) {
#sidebar {
background-color: rgba(237, 237, 237, 0.33) !important;
backdrop-filter: blur(16px) invert(0.1);
}
}
body.rtl nav {
left: unset;
right: -17em;
-1
View File
@@ -199,7 +199,6 @@ input.sizeInput:disabled {
backdrop-filter: invert(.1);
height: 2em;
width: 5em;
}
.roundedInputLeft {
-15
View File
@@ -235,18 +235,3 @@
background-color: #283136;
}
/* Header with backdrop-filter support */
@supports (backdrop-filter: none) {
.dark-mode-header {
background-color: rgb(37 49 55 / 33%) !important;
backdrop-filter: blur(16px) invert(0.1);
}
#sidebar.dark-mode {
background-color: rgb(37 49 55 / 33%) !important;
backdrop-filter: blur(16px) invert(0.1);
}
}
#sidebar.dark-mode {
background-color: var(--alt-background);
}
-14
View File
@@ -12,13 +12,6 @@ header {
padding: 0.5em;
}
@supports (backdrop-filter: none) {
header {
background-color: rgba(237, 237, 237, 0.33) !important;
backdrop-filter: blur(16px) invert(0.1);
}
}
header>* {
flex: 0 0 auto;
}
@@ -51,10 +44,3 @@ header .action span {
display: none;
}
/* Header with backdrop-filter support */
@supports (backdrop-filter: none) {
header {
background-color: rgba(237, 237, 237, 0.33) !important;
backdrop-filter: blur(16px) invert(0.1);
}
}
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "جدولة",
"scheduleMessage": "أختر الوقت والتاريخ لجدولة نشر هذا المقال.",
"show": "عرض",
"size": "الحجم",
"upload": "التحميل",
"deleteUserMessage": "هل أنت متأكد من رغبتك في حذف هذا المستخدم؟",
"filesSelected": "الملفات المحددة",
@@ -187,7 +186,6 @@
"source": "المصدر{suffix}",
"deleteMessageMultiple": "هل تريد بالتأكيد حذف الملف (الملفات) {count} ؟",
"deleteMessageShare": "هل أنت متأكد أنك تريد حذف هذه المشاركة؟ {path} ?",
"lastModified": "آخر تعديل{suffix}",
"numberDirs": "عدد الدلائل{suffix}",
"numberFiles": "عدد الملفات{suffix}",
"renameMessageConflict": "عنصر باسم \"{filename}\" موجود بالفعل!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "الحد الأقصى للتحميلات المتزامنة: {maxConcurrentUpload} ، تم تعطيل التحميل المقطّع",
"quickJump": "الانتقال بسرعة إلى ملفات أخرى",
"moveSuccess": "تم نقل الملفات بنجاح!",
"copySuccess": "تم نسخ الملفات بنجاح!"
"copySuccess": "تم نسخ الملفات بنجاح!",
"lastModified": "آخر تعديل{suffix}",
"size": "الحجم{suffix}"
},
"search": {
"images": "الصور",
+3 -3
View File
@@ -210,7 +210,6 @@
"schedule": "Naplánovat",
"scheduleMessage": "Vyberte datum a čas naplánování publikace tohoto příspěvku.",
"show": "Zobrazit",
"size": "Velikost",
"upload": "Nahrát",
"optionalPassword": "Volitelné heslo",
"typeName": "Typ:",
@@ -235,7 +234,6 @@
"source": "Zdroj:{suffix}",
"deleteMessageMultiple": "Opravdu chcete odstranit soubor(y) {count}?",
"deleteMessageShare": "Jste si jisti, že chcete tuto sdílenou složku odstranit: {path} ?",
"lastModified": "Poslední úprava{suffix}",
"numberDirs": "Počet adresářů{suffix}",
"numberFiles": "Počet souborů{suffix}",
"renameMessageConflict": "Položka s názvem \"{filename}\" již existuje!",
@@ -243,7 +241,9 @@
"uploadSettingsNoChunk": "Maximální souběžné nahrávání: {maxConcurrentUpload}, vypnuto hromadné nahrávání",
"quickJump": "Rychlý přechod na jiné soubory",
"moveSuccess": "Soubory byly úspěšně přesunuty!",
"copySuccess": "Soubory byly úspěšně zkopírovány!"
"copySuccess": "Soubory byly úspěšně zkopírovány!",
"lastModified": "Poslední úprava{suffix}",
"size": "Velikost{suffix}"
},
"search": {
"images": "Obrázky",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Zeitplan",
"scheduleMessage": "Wählen Sie ein Datum und eine Zeit für die Veröffentlichung dieses Beitrags.",
"show": "Anzeigen",
"size": "Größe",
"upload": "Hochladen",
"optionalPassword": "Optionales Kennwort",
"deleteUserMessage": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?",
@@ -187,7 +186,6 @@
"source": "Quelle{suffix}",
"deleteMessageMultiple": "Sind Sie sicher, dass Sie die Datei(en) {count} löschen möchten?",
"deleteMessageShare": "Sind Sie sicher, dass Sie diese Freigabe löschen möchten: {path} ?",
"lastModified": "Zuletzt modifiziert{suffix}",
"numberDirs": "Anzahl der Verzeichnisse{suffix}",
"numberFiles": "Anzahl der Dateien{suffix}",
"renameMessageConflict": "Ein Artikel mit dem Namen \"{filename}\" existiert bereits!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Max. gleichzeitige Uploads: {maxConcurrentUpload}, Chunked Upload deaktiviert",
"quickJump": "Schnelles Springen zu anderen Dateien",
"moveSuccess": "Dateien erfolgreich verschoben!",
"copySuccess": "Dateien erfolgreich kopiert!"
"copySuccess": "Dateien erfolgreich kopiert!",
"lastModified": "Zuletzt modifiziert{suffix}",
"size": "Größe{suffix}"
},
"search": {
"images": "Bilder",
+3 -3
View File
@@ -162,7 +162,6 @@
"schedule": "Προγραμματισμός",
"scheduleMessage": "Επιλέξτε μια ημερομηνία και ώρα για τον προγραμματισμό της δημοσίευσης αυτής της ανάρτησης.",
"show": "Εμφάνιση",
"size": "Μέγεθος",
"upload": "Μεταφόρτωση",
"optionalPassword": "Προαιρετικός κωδικός πρόσβασης",
"deleteUserMessage": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον χρήστη;",
@@ -190,7 +189,6 @@
"source": "Πηγή{suffix}",
"deleteMessageMultiple": "Είστε σίγουροι ότι θέλετε να διαγράψετε το/τα αρχείο/α {count};",
"deleteMessageShare": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το μερίδιο: {path} ?",
"lastModified": "Τελευταία τροποποίηση{suffix}",
"numberDirs": "Αριθμός καταλόγων{suffix}",
"numberFiles": "Αριθμός αρχείων{suffix}",
"renameMessageConflict": "Ένα στοιχείο με όνομα \"{filename}\" υπάρχει ήδη!",
@@ -198,7 +196,9 @@
"uploadSettingsNoChunk": "Μέγιστες ταυτόχρονες μεταφορτώσεις: {maxConcurrentUpload}, chunked upload disabled",
"quickJump": "Γρήγορη μετάβαση σε άλλα αρχεία",
"moveSuccess": "Τα αρχεία μετακινήθηκαν με επιτυχία!",
"copySuccess": "Τα αρχεία αντιγράφηκαν με επιτυχία!"
"copySuccess": "Τα αρχεία αντιγράφηκαν με επιτυχία!",
"lastModified": "Τελευταία τροποποίηση{suffix}",
"size": "Μέγεθος{suffix}"
},
"search": {
"images": "Εικόνες",
+2 -2
View File
@@ -76,7 +76,7 @@
"clear": "Clear",
"closePreview": "Close preview",
"home": "Home",
"lastModified": "Last modified",
"lastModified": "Last modified{suffix}",
"loading": "Loading...",
"lonely": "It feels lonely here...",
"metadata": "Metadata",
@@ -235,7 +235,7 @@
"schedule": "Schedule",
"scheduleMessage": "Pick a date and time to schedule the publication of this post.",
"show": "Show",
"size": "Size",
"size": "Size{suffix}",
"upload": "Upload",
"uploadSettingsChunked": "Max concurrent uploads: {maxConcurrentUpload}, chunk size: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max concurrent uploads: {maxConcurrentUpload}, chunked upload disabled",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Programar",
"scheduleMessage": "Selecciona una fecha y hora para la publicacion:",
"show": "Mostrar",
"size": "Tamaño",
"upload": "Subir",
"optionalPassword": "Contraseña (opcional)",
"deleteUserMessage": "¿Estas seguro que quieres eliminar este usuario?",
@@ -187,7 +186,6 @@
"source": "Fuente{suffix}",
"deleteMessageMultiple": "¿Está seguro de que desea eliminar los archivos de {count}?",
"deleteMessageShare": "¿Estás seguro de que quieres borrar esta acción? {path} ?",
"lastModified": "Última modificación{suffix}",
"numberDirs": "Número de directorios{suffix}",
"numberFiles": "Número de ficheros{suffix}",
"renameMessageConflict": "Ya existe un elemento llamado \"{filename}\".",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Carga máxima simultánea: {maxConcurrentUpload} Carga por trozos desactivada",
"quickJump": "Saltar rápidamente a otros archivos",
"moveSuccess": "¡Archivos movidos con éxito!",
"copySuccess": "¡Archivos copiados correctamente!"
"copySuccess": "¡Archivos copiados correctamente!",
"lastModified": "Última modificación{suffix}",
"size": "Talla{suffix}"
},
"search": {
"images": "Imágenes",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Fixer la date",
"scheduleMessage": "Choisissez une date pour planifier la publication de ce post",
"show": "Montrer",
"size": "Taille",
"upload": "Importer",
"optionalPassword": "Mot de passe optionnel",
"deleteUserMessage": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?",
@@ -187,7 +186,6 @@
"source": "Source{suffix}",
"deleteMessageMultiple": "Êtes-vous sûr de vouloir supprimer le(s) fichier(s) {count}?",
"deleteMessageShare": "Êtes-vous sûr de vouloir supprimer ce partage : {path} ?",
"lastModified": "Dernière modification{suffix}",
"numberDirs": "Nombre de répertoires{suffix}",
"numberFiles": "Nombre de fichiers{suffix}",
"renameMessageConflict": "Un article nommé \"{filename}\" existe déjà !",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Nombre maximum de téléchargements simultanés : {maxConcurrentUpload}, chunked upload désactivé",
"quickJump": "Passer rapidement à d'autres fichiers",
"moveSuccess": "Les fichiers ont été déplacés avec succès !",
"copySuccess": "Les fichiers ont été copiés avec succès !"
"copySuccess": "Les fichiers ont été copiés avec succès !",
"lastModified": "Dernière modification{suffix}",
"size": "Taille{suffix}"
},
"search": {
"images": "Images",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "תזמון",
"scheduleMessage": "בחר תאריך ושעה לתזמון הפרסום של פוסט זה.",
"show": "הצג",
"size": "גודל",
"upload": "העלאה",
"optionalPassword": "סיסמא אופציונלית",
"deleteUserMessage": "האם אתה בטוח שברצונך למחוק משתמש זה?",
@@ -187,7 +186,6 @@
"source": "מקור{suffix}",
"deleteMessageMultiple": "האם אתה בטוח שברצונך למחוק את הקובץ/ים {count}?",
"deleteMessageShare": "האם אתה בטוח שברצונך למחוק שיתוף זה: {path}?",
"lastModified": "שינוי אחרון{suffix}",
"numberDirs": "מספר הספריות{suffix}",
"numberFiles": "מספר הקבצים{suffix}",
"renameMessageConflict": "פריט בשם \"{filename}\" כבר קיים!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "מספר העלאות מקסימלי בו-זמנית: {maxConcurrentUpload}, העלאה מקוטעת מושבתת",
"quickJump": "קפיצה מהירה לקבצים אחרים",
"moveSuccess": "הקבצים הועברו בהצלחה!",
"copySuccess": "הקבצים הועתקו בהצלחה!"
"copySuccess": "הקבצים הועתקו בהצלחה!",
"lastModified": "שינוי אחרון{suffix}",
"size": "גודל{suffix}"
},
"search": {
"images": "תמונות",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Ütemezés",
"scheduleMessage": "Válasszon egy dátumot és időpontot a bejegyzés közzétételének ütemezéséhez.",
"show": "Megjelenítés",
"size": "Méret",
"upload": "Feltöltés",
"optionalPassword": "Választható jelszó",
"deleteUserMessage": "Biztos, hogy törölni szeretné ezt a felhasználót?",
@@ -187,7 +186,6 @@
"source": "Forrás{suffix}",
"deleteMessageMultiple": "Biztos, hogy törölni szeretné a {count} fájl(ok)at?",
"deleteMessageShare": "Biztos, hogy törölni szeretné ezt a megosztást: {path} ?",
"lastModified": "Utoljára módosítva{suffix}",
"numberDirs": "A könyvtárak száma{suffix}",
"numberFiles": "Fájlok száma{suffix}",
"renameMessageConflict": "A \"{filename}\" nevű elem már létezik!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Maximális egyidejű feltöltés: {maxConcurrentUpload}, chunked upload disabled",
"quickJump": "Gyors ugrás más fájlokra",
"moveSuccess": "Fájlok sikeresen áthelyezve!",
"copySuccess": "A fájlok másolása sikeres volt!"
"copySuccess": "A fájlok másolása sikeres volt!",
"lastModified": "Utoljára módosítva{suffix}",
"size": "Méret{suffix}"
},
"search": {
"images": "Képek",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Pianifica",
"scheduleMessage": "Seleziona data e ora per programmare la pubbilicazione di questo post",
"show": "Mostra",
"size": "Dimensione",
"upload": "Carica",
"deleteUserMessage": "Siete sicuri di voler eliminare questo utente?",
"filesSelected": "file selezionati",
@@ -187,7 +186,6 @@
"source": "Fonte{suffix}",
"deleteMessageMultiple": "Siete sicuri di voler eliminare i file {count}?",
"deleteMessageShare": "Sei sicuro di voler eliminare questa condivisione: {path} ?",
"lastModified": "Ultima modifica{suffix}",
"numberDirs": "Numero di directory{suffix}",
"numberFiles": "Numero di file{suffix}",
"renameMessageConflict": "Un elemento chiamato \"{filename}\" esiste già!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Carichi massimi contemporanei: {maxConcurrentUpload}, caricamento chunked disabilitato",
"quickJump": "Salto rapido ad altri file",
"moveSuccess": "I file sono stati spostati con successo!",
"copySuccess": "I file sono stati copiati con successo!"
"copySuccess": "I file sono stati copiati con successo!",
"lastModified": "Ultima modifica{suffix}",
"size": "Dimensione{suffix}"
},
"search": {
"images": "Immagini",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "スケジュール",
"scheduleMessage": "この投稿の公開日時をスケジュールしてください。",
"show": "表示",
"size": "サイズ",
"upload": "アップロード",
"deleteUserMessage": "本当にこのユーザーを削除しますか?",
"filesSelected": "選択されたファイル",
@@ -187,7 +186,6 @@
"source": "送信元{suffix}",
"deleteMessageMultiple": "本当に{count}個のファイルを削除しますか?",
"deleteMessageShare": "この共有を削除してもよろしいですか? {path}",
"lastModified": "最終更新日時{suffix}",
"numberDirs": "ディレクトリ数{suffix}",
"numberFiles": "ファイル数{suffix}",
"quickJump": "他のファイルに素早くジャンプ",
@@ -195,7 +193,9 @@
"copySuccess": "ファイルは正常にコピーされました!",
"renameMessageConflict": "\"{filename}\"という名前のアイテムがすでに存在します!",
"uploadSettingsChunked": "最大同時アップロード数:{maxConcurrentUpload}、チャンクサイズ:{uploadChunkSizeMb}MB",
"uploadSettingsNoChunk": "最大同時アップロード数:{maxConcurrentUpload}、チャンクアップロード無効"
"uploadSettingsNoChunk": "最大同時アップロード数:{maxConcurrentUpload}、チャンクアップロード無効",
"lastModified": "最終更新日{suffix}",
"size": "サイズ{suffix}"
},
"search": {
"images": "画像",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "일정",
"scheduleMessage": "이 글을 공개할 시간을 알려주세요.",
"show": "보기",
"size": "크기",
"upload": "업로드",
"deleteUserMessage": "이 사용자를 삭제하시겠습니까?",
"filesSelected": "선택한 파일",
@@ -187,7 +186,6 @@
"source": "출처{suffix}",
"deleteMessageMultiple": "{count} 파일을 정말 삭제하시겠습니까?",
"deleteMessageShare": "이 공유를 삭제하시겠습니까? {path} ?",
"lastModified": "마지막 수정{suffix}",
"numberDirs": "디렉터리 수{suffix}",
"numberFiles": "파일 수{suffix}",
"renameMessageConflict": "\"{filename}\"라는 이름의 항목이 이미 존재합니다!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "최대 동시 업로드: {maxConcurrentUpload}, 청크 업로드 비활성화",
"quickJump": "다른 파일로 빠르게 이동",
"moveSuccess": "파일이 성공적으로 이동했습니다!",
"copySuccess": "파일이 성공적으로 복사되었습니다!"
"copySuccess": "파일이 성공적으로 복사되었습니다!",
"lastModified": "마지막 수정{suffix}",
"size": "크기{suffix}"
},
"search": {
"images": "이미지",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Plannen",
"scheduleMessage": "Kies een datum en tijd om de publicatie van dit bericht in te plannen.",
"show": "Tonen",
"size": "Grootte",
"upload": "Uploaden",
"deleteUserMessage": "Weet je zeker dat je deze gebruiker wilt verwijderen?",
"selected": "geselecteerd",
@@ -187,7 +186,6 @@
"source": "Bron{suffix}",
"deleteMessageMultiple": "Weet je zeker dat je {count} bestand(en) wilt verwijderen?",
"deleteMessageShare": "Weet je zeker dat je deze share wilt verwijderen: {path} ?",
"lastModified": "Laatst gewijzigd{suffix}",
"numberDirs": "Aantal mappen{suffix}",
"numberFiles": "Aantal bestanden{suffix}",
"renameMessageConflict": "Een item met de naam \"{filename}\" bestaat al!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Max gelijktijdige uploads: {maxConcurrentUpload}, chunked upload uitgeschakeld",
"quickJump": "Snel naar andere bestanden springen",
"moveSuccess": "Bestanden succesvol verplaatst!",
"copySuccess": "Bestanden succesvol gekopieerd!"
"copySuccess": "Bestanden succesvol gekopieerd!",
"lastModified": "Laatst gewijzigd{suffix}",
"size": "Maat{suffix}"
},
"search": {
"images": "Afbeeldingen",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Grafi",
"scheduleMessage": "Wybierz datę i czas dla publikacji tego wpisu.",
"show": "Pokaż",
"size": "Rozmiar",
"upload": "Prześlij",
"deleteUserMessage": "Czy na pewno chcesz usunąć tego użytkownika?",
"filesSelected": "wybrane pliki",
@@ -187,7 +186,6 @@
"source": "Źródło{suffix}",
"deleteMessageMultiple": "Czy na pewno chcesz usunąć plik(i) {count}?",
"deleteMessageShare": "Czy na pewno chcesz usunąć ten udział: {path} ?",
"lastModified": "Ostatnia modyfikacja{suffix}",
"numberDirs": "Liczba katalogów{suffix}",
"numberFiles": "Liczba plików{suffix}",
"renameMessageConflict": "Element o nazwie \"{filename}\" już istnieje!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Maksymalny jednoczesny upload: {maxConcurrentUpload}, przesyłanie fragmentaryczne wyłączone",
"quickJump": "Szybkie przechodzenie do innych plików",
"moveSuccess": "Pliki zostały przeniesione pomyślnie!",
"copySuccess": "Pliki zostały skopiowane pomyślnie!"
"copySuccess": "Pliki zostały skopiowane pomyślnie!",
"lastModified": "Ostatnia modyfikacja{suffix}",
"size": "Rozmiar{suffix}"
},
"search": {
"images": "Zdjęcia",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Agendar",
"scheduleMessage": "Escolha uma data para agendar a publicação deste post.",
"show": "Mostrar",
"size": "Tamanho",
"upload": "Enviar",
"optionalPassword": "Senha opcional",
"deleteUserMessage": "Tem certeza de que deseja excluir esse usuário?",
@@ -187,7 +186,6 @@
"source": "Fonte{suffix}",
"deleteMessageMultiple": "Tem certeza de que deseja excluir o(s) arquivo(s) {count}?",
"deleteMessageShare": "Tem certeza de que deseja excluir esse compartilhamento: {path} ?",
"lastModified": "Última modificação{suffix}",
"numberDirs": "Número de diretórios{suffix}",
"numberFiles": "Número de arquivos{suffix}",
"renameMessageConflict": "Já existe um item chamado \"{filename}\"!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Máximo de uploads simultâneos: {maxConcurrentUpload}, upload em pedaços desativado",
"quickJump": "Pular rapidamente para outros arquivos",
"moveSuccess": "Os arquivos foram movidos com sucesso!",
"copySuccess": "Os arquivos foram copiados com sucesso!"
"copySuccess": "Os arquivos foram copiados com sucesso!",
"lastModified": "Última modificação{suffix}",
"size": "Tamanho{suffix}"
},
"search": {
"images": "Imagens",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Agendar",
"scheduleMessage": "Escolha uma data para publicar este post.",
"show": "Mostrar",
"size": "Tamanho",
"upload": "Carregar",
"deleteUserMessage": "Tem a certeza de que pretende apagar este utilizador?",
"selected": "selecionado",
@@ -187,7 +186,6 @@
"source": "Fonte{suffix}",
"deleteMessageMultiple": "Tem a certeza de que pretende apagar o(s) ficheiro(s) {count}?",
"deleteMessageShare": "Tem a certeza de que pretende apagar esta partilha: {path} ?",
"lastModified": "Última modificação{suffix}",
"numberDirs": "Número de diretórios{suffix}",
"numberFiles": "Número de ficheiros{suffix}",
"renameMessageConflict": "Já existe um item com o nome \"{filename}\"!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Máximo de carregamentos simultâneos: {maxConcurrentUpload}, carregamento em pedaços desativado",
"quickJump": "Saltar rapidamente para outros ficheiros",
"moveSuccess": "Ficheiros movidos com sucesso!",
"copySuccess": "Ficheiros copiados com sucesso!"
"copySuccess": "Ficheiros copiados com sucesso!",
"lastModified": "Última modificação{suffix}",
"size": "Tamanho{suffix}"
},
"search": {
"images": "Imagens",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Programare",
"scheduleMessage": "Alege data si ora pentru a programa publicarea acestei postări.",
"show": "Arată",
"size": "Dimensiune",
"upload": "Încărcare",
"deleteUserMessage": "Sunteți sigur că doriți să ștergeți acest utilizator?",
"filesSelected": "fișiere selectate",
@@ -187,7 +186,6 @@
"source": "Sursa{suffix}",
"deleteMessageMultiple": "Sunteți sigur că doriți să eliminați fișierul (fișierele) {count}?",
"deleteMessageShare": "Sunteți sigur că doriți să ștergeți această partajare: {path} ?",
"lastModified": "Ultima modificare{suffix}",
"numberDirs": "Număr de directoare{suffix}",
"numberFiles": "Număr de fișiere{suffix}",
"renameMessageConflict": "Un element numit \"{filename}\" există deja!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Încărcări simultane maxime: {maxConcurrentUpload}, încărcare fragmentată dezactivată",
"quickJump": "Salt rapid la alte fișiere",
"moveSuccess": "Fișiere mutate cu succes!",
"copySuccess": "Fișiere copiate cu succes!"
"copySuccess": "Fișiere copiate cu succes!",
"lastModified": "Ultima modificare{suffix}",
"size": "Mărime{suffix}"
},
"search": {
"images": "Imagini",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Планировка",
"scheduleMessage": "Запланировать дату и время публикации.",
"show": "Показать",
"size": "Размер",
"upload": "Загрузить",
"optionalPassword": "Необязательный пароль",
"deleteUserMessage": "Вы уверены, что хотите удалить этого пользователя?",
@@ -187,7 +186,6 @@
"source": "Источник{suffix}",
"deleteMessageMultiple": "Вы уверены, что хотите удалить файл(ы) {count}?",
"deleteMessageShare": "Вы уверены, что хотите удалить этот ресурс: {path} ?",
"lastModified": "Последнее изменение{suffix}",
"numberDirs": "Количество каталогов{suffix}",
"numberFiles": "Количество файлов{suffix}",
"renameMessageConflict": "Элемент с именем \"{filename}\" уже существует!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Максимальное количество одновременных загрузок: {maxConcurrentUpload}, загрузка в куски отключена",
"quickJump": "Быстрый переход к другим файлам",
"moveSuccess": "Файлы успешно перемещены!",
"copySuccess": "Файлы успешно скопированы!"
"copySuccess": "Файлы успешно скопированы!",
"lastModified": "Последнее изменение{suffix}",
"size": "Размер{suffix}"
},
"search": {
"images": "Изображения",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Naplánovať",
"scheduleMessage": "Pick a date and time to schedule the publication of this post.",
"show": "Zobraziť",
"size": "Veľkosť",
"upload": "Nahrať",
"optionalPassword": "Voliteľné heslo",
"deleteUserMessage": "Ste si istí, že chcete odstrániť tohto používateľa?",
@@ -187,7 +186,6 @@
"source": "Zdroj{suffix}",
"deleteMessageMultiple": "Ste si istí, že chcete odstrániť súbor(y) {count}?",
"deleteMessageShare": "Ste si istí, že chcete odstrániť toto zdieľanie: {path} ?",
"lastModified": "Posledná úprava{suffix}",
"numberDirs": "Počet adresárov{suffix}",
"numberFiles": "Počet súborov{suffix}",
"renameMessageConflict": "Položka s názvom \"{filename}\" už existuje!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Maximálne súbežné nahrávanie: {maxConcurrentUpload}, chunked upload vypnuté",
"quickJump": "Rýchly prechod na iné súbory",
"moveSuccess": "Súbory boli úspešne presunuté!",
"copySuccess": "Súbory boli úspešne skopírované!"
"copySuccess": "Súbory boli úspešne skopírované!",
"lastModified": "Posledná úprava{suffix}",
"size": "Veľkosť{suffix}"
},
"search": {
"images": "Obrázky",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Schema",
"scheduleMessage": "Pick a date and time to schedule the publication of this post.",
"show": "Visa",
"size": "Storlek",
"upload": "Ladda upp",
"deleteUserMessage": "Är du säker på att du vill ta bort den här användaren?",
"selected": "utvalda",
@@ -187,7 +186,6 @@
"source": "Källa{suffix}",
"deleteMessageMultiple": "Är du säker att du vill ta bort {count} fil(er)?",
"deleteMessageShare": "Är du säker på att du vill ta bort den här aktien: {path} ?",
"lastModified": "Senast ändrad{suffix}",
"numberDirs": "Antal kataloger{suffix}",
"numberFiles": "Antal filer{suffix}",
"renameMessageConflict": "Ett objekt med namnet \"{filename}\" finns redan!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Max samtidiga uppladdningar: {maxConcurrentUpload}, chunked uppladdning inaktiverad",
"quickJump": "Hoppa snabbt till andra filer",
"moveSuccess": "Filerna flyttades framgångsrikt!",
"copySuccess": "Filerna kopierades framgångsrikt!"
"copySuccess": "Filerna kopierades framgångsrikt!",
"lastModified": "Senast ändrad{suffix}",
"size": "Storlek{suffix}"
},
"search": {
"images": "Bilder",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Planla",
"scheduleMessage": "Bu paylaşımın yayınlanmasını planlamak için bir tarih ve saat seçin.",
"show": "Göster",
"size": "Boyut",
"upload": "Gönder",
"optionalPassword": "İsteğe bağlı şifre",
"deleteUserMessage": "Bu kullanıcıyı silmek istediğinizden emin misiniz?",
@@ -187,7 +186,6 @@
"source": "Kaynak{suffix}",
"deleteMessageMultiple": "{count} dosya(lar)ını silmek istediğinizden emin misiniz?",
"deleteMessageShare": "Bu paylaşımı silmek istediğinizden emin misiniz? {path} ?",
"lastModified": "Son Değişiklik{suffix}",
"numberDirs": "Dizin sayısı{suffix}",
"numberFiles": "Dosya sayısı{suffix}",
"renameMessageConflict": "\"{filename}\" adında bir öğe zaten var!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığınlanmış yükleme devre dışı",
"quickJump": "Diğer dosyalara hızlıca atlayın",
"moveSuccess": "Dosyalar başarıyla taşındı!",
"copySuccess": "Dosyalar başarıyla kopyalandı!"
"copySuccess": "Dosyalar başarıyla kopyalandı!",
"lastModified": "Son Değişiklik{suffix}",
"size": "Boyut{suffix}"
},
"search": {
"images": "Görseller",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "Планування",
"scheduleMessage": "Запланувати дату та час публікації.",
"show": "Показати",
"size": "Розмір",
"upload": "Завантажити",
"optionalPassword": "Необов'язковий пароль",
"deleteUserMessage": "Ви впевнені, що хочете видалити цього користувача?",
@@ -187,7 +186,6 @@
"source": "Джерело{suffix}",
"deleteMessageMultiple": "Ви дійсно хочете видалити файл(и) {count}?",
"deleteMessageShare": "Ви впевнені, що хочете видалити цей ресурс? {path} ?",
"lastModified": "Останній раз змінено{suffix}",
"numberDirs": "Кількість каталогів{suffix}",
"numberFiles": "Кількість файлів{suffix}",
"renameMessageConflict": "Елемент з назвою \"{filename}\" вже існує!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "Максимальне одночасне завантаження: {maxConcurrentUpload}, chunked upload вимкнено",
"quickJump": "Швидкий перехід до інших файлів",
"moveSuccess": "Файли успішно перенесено!",
"copySuccess": "Файли успішно скопійовано!"
"copySuccess": "Файли успішно скопійовано!",
"lastModified": "Останній раз змінено{suffix}",
"size": "Розмір{suffix}"
},
"search": {
"images": "Зображення",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "计划",
"scheduleMessage": "请选择发布这篇帖子的日期与时间。",
"show": "点击以显示",
"size": "大小",
"upload": "上传",
"optionalPassword": "密码(选填,不填即无密码)",
"deleteUserMessage": "您确定要删除该用户吗?",
@@ -187,7 +186,6 @@
"source": "资料来源{suffix}",
"deleteMessageMultiple": "您确定要删除{count} 文件吗?",
"deleteMessageShare": "您确定要删除此共享? {path} ?",
"lastModified": "最后修改{suffix}",
"numberDirs": "目录数量{suffix}",
"numberFiles": "文件数量{suffix}",
"renameMessageConflict": "名为 \"{filename}\" 的项目已经存在!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "最大并发上传:{maxConcurrentUpload}已禁用分块上传",
"quickJump": "快速跳转到其他文件",
"moveSuccess": "文件移动成功!",
"copySuccess": "文件复制成功!"
"copySuccess": "文件复制成功!",
"lastModified": "最后修改{suffix}",
"size": "尺寸{suffix}"
},
"search": {
"images": "图像",
+3 -3
View File
@@ -159,7 +159,6 @@
"schedule": "計畫",
"scheduleMessage": "請選擇發佈這篇貼文的日期。",
"show": "顯示",
"size": "大小",
"upload": "上傳",
"destinationSource": "目的地來源:",
"deleteUserMessage": "您確定要刪除此使用者嗎?",
@@ -187,7 +186,6 @@
"source": "來源{suffix}",
"deleteMessageMultiple": "您確定要刪除{count} 檔案嗎?",
"deleteMessageShare": "您確定要刪除此共用嗎? {path} ?",
"lastModified": "最後修改{suffix}",
"numberDirs": "目錄數量{suffix}",
"numberFiles": "檔案數量{suffix}",
"renameMessageConflict": "名為 \"{filename}\" 的項目已經存在!",
@@ -195,7 +193,9 @@
"uploadSettingsNoChunk": "最大同時上傳數量:{maxConcurrentUpload}, 已停用分塊上傳",
"quickJump": "快速跳到其他檔案",
"moveSuccess": "檔案移動成功!",
"copySuccess": "檔案已成功複製!"
"copySuccess": "檔案已成功複製!",
"lastModified": "最後修改{suffix}",
"size": "尺寸{suffix}"
},
"search": {
"images": "影像",
+8 -1
View File
@@ -178,6 +178,9 @@ export const getters = {
if (previewViews.includes(cv) && !state.user.preview?.disableHideSidebar) {
visible = false
}
if (shareInfo.singleFileShare) {
visible = state.showSidebar
}
return visible
},
isStickySidebar: () => {
@@ -423,14 +426,18 @@ export const getters = {
}
return "close";
}
if (cv == "listingView") {
if (cv == "listingView" || shareInfo.singleFileShare) {
if (state.user.stickySidebar) {
return "menu";
}
return "back";
}
return "close";
}
if (shareInfo.singleFileShare) {
return "menu";
}
if (cv == "settings") {
if (state.isMobile) {
return "menu";
+5
View File
@@ -180,6 +180,10 @@ export const mutations = {
state.showSidebar = false;
emitStateChanged();
},
setSidebarVisible(value) {
state.showSidebar = value;
emitStateChanged();
},
setUpload(value) {
state.upload = value;
emitStateChanged();
@@ -284,6 +288,7 @@ export const mutations = {
hash: null,
token: "",
subPath: "",
passwordValid: false,
};
emitStateChanged();
},
+1
View File
@@ -35,6 +35,7 @@ export const state = reactive({
hash: null,
token: "",
subPath: "",
passwordValid: false,
},
sources: {
current: "",
+4 -3
View File
@@ -183,8 +183,9 @@ export function doubleEncode(str) {
}
/**
* Fixes download URLs by replacing everything before /public/api
* Fixes download URLs by replacing everything before /public/api
* with the current window.location.origin + globalVars.baseURL
* This is only needed when the backend didn't use externalUrl
* @param {string} downloadUrl - The original download URL from backend
* @returns {string} - The corrected URL using current client origin
*/
@@ -198,10 +199,10 @@ export function fixDownloadURL(downloadUrl) {
// If /public/api is not found, return the original URL
return downloadUrl;
}
// Extract the part from /public/api onwards
const publicApiPath = downloadUrl.substring(publicApiIndex);
// Build the corrected URL using current client origin and globalVars.baseURL
const correctedBaseURL = removeTrailingSlash(globalVars.baseURL);
return `${window.location.origin}${correctedBaseURL}${publicApiPath}`;
+10
View File
@@ -199,6 +199,8 @@ export default {
} else if (e.status === 401 && this.isShare) {
// Handle share password requirement
this.attemptedPasswordLogin = this.sharePassword !== "";
// Reset password validation state on wrong password
mutations.setShareData({ passwordValid: false });
this.showPasswordPrompt();
} else {
router.push({ name: "error" });
@@ -234,6 +236,13 @@ export default {
mutations.setMultiple(false);
mutations.closeHovers();
if (shareInfo.singleFileShare) {
mutations.setSidebarVisible(true);
}
// Initialize password validation state for password-protected shares
if (shareInfo.isPasswordProtected) {
mutations.setShareData({ passwordValid: false });
}
// Fetch share data
let file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword);
file.hash = this.shareHash;
@@ -243,6 +252,7 @@ export default {
hash: this.shareHash,
token: this.shareToken,
subPath: this.shareSubPath,
passwordValid: true,
});
// If not a directory, fetch content for preview components
if (file.type != "directory") {
+13 -1
View File
@@ -165,7 +165,7 @@ export default {
},
multiAction() {
const listingView = getters.currentView();
if (listingView == "listingView") {
if (listingView == "listingView" || shareInfo.singleFileShare) {
mutations.toggleSidebar();
} else if (listingView == "settings" && state.isMobile) {
mutations.toggleSidebar();
@@ -193,4 +193,16 @@ header button:hover {
box-shadow: unset !important;
-webkit-box-shadow: unset !important;
}
header {
background-color: rgb(37 49 55 / 5%) !important;
}
/* Header with backdrop-filter support */
@supports (backdrop-filter: none) {
header {
backdrop-filter: blur(16px) invert(0.1);
}
.dark-mode-header {
background-color: rgb(37 49 55 / 33%) !important;
}
}
</style>
+312 -13
View File
@@ -59,6 +59,7 @@ export default {
if (state.user.debugOffice) {
this.debugMode = true;
this.showDebugInfo();
this.monitorCallbackIssues();
}
// Perform the setup and update the config with simplified API parameters
@@ -370,13 +371,16 @@ export default {
}
});
// Monitor for successful completion
// Monitor for successful completion and server-to-server communication
setTimeout(() => {
console.error = originalConsoleError; // Restore original console.error
if (!this.hasErrors && !detectedNetworkError) {
this.updateDebugStatus("✅ Document Download - Complete");
this.updateDebugStatus("🎉 Editor Initialization - Success! All steps completed.");
// Start monitoring for server-to-server communication issues
this.monitorServerToServerCommunication();
}
}, 4000);
@@ -389,26 +393,84 @@ export default {
this.hasErrors = true;
let errorMsg = "❌ OnlyOffice Component Load Error<br/>";
let suggestions = [];
if (errorInfo === -2) {
errorMsg += "<strong>Error Code -2:</strong> OnlyOffice server connection failed<br/>";
errorMsg += "• Verify OnlyOffice server is running<br/>";
errorMsg += `• Test URL manually: ${this.onlyOfficeUrl}<br/>`;
errorMsg += "• Check network connectivity<br/>";
if (errorInfo === -1) {
errorMsg += "<strong>Error Code -1:</strong> Unknown error loading OnlyOffice component<br/>";
suggestions = [
"Check browser console for detailed error messages",
"Verify OnlyOffice server is accessible",
"Check if OnlyOffice server is properly configured",
"Try refreshing the page"
];
} else if (errorInfo === -2) {
errorMsg += "<strong>Error Code -2:</strong> Failed to load DocsAPI from OnlyOffice server<br/>";
suggestions = [
"Verify OnlyOffice server is running and accessible",
`Test OnlyOffice server URL manually: ${this.onlyOfficeUrl}`,
"Check network connectivity between browser and OnlyOffice server",
"Verify OnlyOffice server is not behind authentication",
"Check if OnlyOffice server is fully initialized (wait a few minutes after startup)"
];
} else if (errorInfo === -3) {
errorMsg += "<strong>Error Code -3:</strong> Document loading failed<br/>";
errorMsg += "• File may be corrupted or unsupported<br/>";
errorMsg += "• Check file permissions<br/>";
errorMsg += "<strong>Error Code -3:</strong> DocsAPI is not defined (OnlyOffice server not responding)<br/>";
suggestions = [
"OnlyOffice server is not responding or not properly configured",
"Check OnlyOffice server logs for startup errors",
"Verify OnlyOffice server URL is correct and accessible",
"Ensure OnlyOffice server has completed initialization",
"Check if OnlyOffice server is behind a reverse proxy with incorrect configuration"
];
} else if (errorInfo === -4) {
errorMsg += "<strong>Error Code -4:</strong> Document download failed<br/>";
errorMsg += "• Authentication may have expired<br/>";
errorMsg += "• Check download URL accessibility<br/>";
suggestions = [
"Authentication may have expired - try refreshing",
"Check download URL accessibility",
"Verify OnlyOffice server can reach FileBrowser",
"Check network connectivity between OnlyOffice and FileBrowser"
];
if (!this.getInternalUrlInfo().isSet) {
errorMsg += "• <strong>Suggestion:</strong> consider adding `server.internalUrl` if your OnlyOffice service is on the same network as FileBrowser<br/>";
suggestions.push("Consider adding `server.internalUrl` if OnlyOffice is on same network");
}
} else if (errorInfo === -5) {
errorMsg += "<strong>Error Code -5:</strong> Document security error<br/>";
suggestions = [
"Check if document is password protected",
"Verify OnlyOffice server security settings",
"Check OnlyOffice server logs for security errors",
"Verify document format is supported"
];
} else if (errorInfo === -6) {
errorMsg += "<strong>Error Code -6:</strong> Document access denied<br/>";
suggestions = [
"Check user permissions in FileBrowser",
"Verify OnlyOffice server has proper authentication",
"Check if file path contains special characters",
"Verify OnlyOffice server configuration"
];
} else {
errorMsg += `<strong>Error Code:</strong> ${errorInfo || 'Unknown error'}<br/>`;
errorMsg += "• Check browser console for detailed error messages<br/>";
suggestions = [
"Check browser console for detailed error messages",
"Try refreshing the page",
"Check OnlyOffice server logs"
];
}
// Add suggestions as bullet points
errorMsg += "<br/><strong>Suggestions:</strong><br/>";
suggestions.forEach(suggestion => {
errorMsg += `• ${suggestion}<br/>`;
});
// Add configuration check
const configIssues = this.checkConfigurationIssues();
if (configIssues.length > 0) {
errorMsg += "<br/><strong>Configuration Issues Detected:</strong><br/>";
configIssues.forEach(issue => {
errorMsg += `• ${issue}<br/>`;
});
}
errorMsg += `<br/><a href="${wikiLink}" target="_blank" style="color: #1976d2;">${wikiLinkText}</a>`;
@@ -416,6 +478,243 @@ export default {
this.updateDebugStatus(errorMsg);
}
},
checkConfigurationIssues() {
const issues = [];
// Check OnlyOffice URL
if (!this.onlyOfficeUrl || this.onlyOfficeUrl === '') {
issues.push("OnlyOffice URL is not configured");
} else if (!this.onlyOfficeUrl.startsWith('http')) {
issues.push("OnlyOffice URL should start with http:// or https://");
}
// Check internal URL configuration
const internalUrlInfo = this.getInternalUrlInfo();
if (internalUrlInfo.isSet && internalUrlInfo.message.includes('Error')) {
issues.push("Internal URL configuration has parsing errors");
}
// Check document URL accessibility
if (this.clientConfig && this.clientConfig.document && this.clientConfig.document.url) {
try {
const docUrl = new URL(this.clientConfig.document.url);
if (docUrl.protocol !== window.location.protocol) {
issues.push("Document URL protocol mismatch with current page");
}
} catch (e) {
issues.push("Document URL is malformed");
}
}
// Check callback URL
if (this.clientConfig && this.clientConfig.editorConfig && this.clientConfig.editorConfig.callbackUrl) {
try {
const callbackUrl = new URL(this.clientConfig.editorConfig.callbackUrl);
if (callbackUrl.protocol !== window.location.protocol) {
issues.push("Callback URL protocol mismatch with current page");
}
} catch (e) {
issues.push("Callback URL is malformed");
}
}
return issues;
},
// Monitor for callback-related issues and server-to-server communication problems
monitorCallbackIssues() {
if (!this.debugMode) return;
// Monitor for callback errors in the network tab
const originalFetch = window.fetch;
window.fetch = (...args) => {
return originalFetch.apply(window, args).then(response => {
if (response.url.includes('onlyoffice/callback')) {
if (response.status === 405) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Method Not Allowed (405)<br/>
<strong>Issue:</strong> OnlyOffice server is sending ${args[1]?.method || 'GET'} requests to callback URL<br/>
<strong>Expected:</strong> POST requests<br/>
<strong>Root Cause:</strong> OnlyOffice server configuration issue<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server callback configuration<br/>
• Verify callback URL is correctly set in OnlyOffice server<br/>
• Ensure OnlyOffice server is using correct HTTP method<br/>
• Check OnlyOffice server logs for callback configuration errors<br/>
`);
} else if (response.status === 400) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Bad Request (400)<br/>
<strong>Issue:</strong> OnlyOffice server sent invalid callback data<br/>
<strong>Root Cause:</strong> OnlyOffice server → FileBrowser API communication problem<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server logs for callback generation errors<br/>
• Verify JWT secret matches between OnlyOffice and FileBrowser<br/>
• Check OnlyOffice server callback URL configuration<br/>
• Ensure OnlyOffice server can reach FileBrowser internal URL<br/>
`);
} else if (response.status === 500) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Internal Server Error (500)<br/>
<strong>Issue:</strong> FileBrowser API error processing OnlyOffice callback<br/>
<strong>Root Cause:</strong> FileBrowser API configuration or processing issue<br/>
<strong>Solution:</strong><br/>
• Check FileBrowser server logs for callback processing errors<br/>
• Verify FileBrowser API configuration<br/>
• Check if OnlyOffice server is sending valid callback data<br/>
• Ensure FileBrowser has proper permissions to save files<br/>
`);
}
}
return response;
});
};
// Monitor for JWT parsing errors and other callback issues
const originalConsoleError = console.error;
console.error = (...args) => {
originalConsoleError.apply(console, args);
const errorStr = args.join(' ');
if (errorStr.includes('JWT') && errorStr.includes('callback')) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ JWT Parsing Error in Callback<br/>
<strong>Issue:</strong> OnlyOffice callback JWT token cannot be parsed<br/>
<strong>Root Cause:</strong> OnlyOffice server → FileBrowser API JWT communication problem<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server JWT configuration<br/>
• Verify JWT secret matches between OnlyOffice and FileBrowser<br/>
• Check OnlyOffice server logs for JWT generation errors<br/>
• Ensure OnlyOffice server is using correct JWT signing method<br/>
`);
} else if (errorStr.includes('callback') && (errorStr.includes('network') || errorStr.includes('fetch'))) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Callback Network Error<br/>
<strong>Issue:</strong> OnlyOffice server cannot reach FileBrowser callback URL<br/>
<strong>Root Cause:</strong> Network connectivity between OnlyOffice server and FileBrowser API<br/>
<strong>Solution:</strong><br/>
• Check if OnlyOffice server can reach FileBrowser internal URL<br/>
• Verify callback URL is accessible from OnlyOffice server<br/>
• Check network configuration between services<br/>
• Ensure FileBrowser internal URL is correctly configured<br/>
`);
}
};
// Monitor for document save failures (indicates server-to-server communication issues)
this.monitorDocumentSaveFailures();
// Restore original functions after 15 seconds
setTimeout(() => {
window.fetch = originalFetch;
console.error = originalConsoleError;
}, 15000);
},
// Monitor for document save failures which indicate server-to-server communication issues
monitorDocumentSaveFailures() {
if (!this.debugMode) return;
// Track if document was opened but never saved
let documentOpened = false;
let saveAttempts = 0;
let lastSaveAttempt = 0;
// Monitor for document ready event
const checkDocumentReady = () => {
if (this.clientConfig && this.clientConfig.document) {
documentOpened = true;
console.log("OnlyOffice debug: Document opened successfully");
}
};
// Monitor for save attempts
const checkSaveAttempts = () => {
if (documentOpened) {
saveAttempts++;
lastSaveAttempt = Date.now();
console.log(`OnlyOffice debug: Save attempt #${saveAttempts}`);
}
};
// Check for save failures after 30 seconds
setTimeout(() => {
if (documentOpened && saveAttempts === 0) {
this.hasErrors = true;
this.updateDebugStatus(`
❌ Document Save Issues Detected<br/>
<strong>Issue:</strong> Document opened but no save attempts detected<br/>
<strong>Root Cause:</strong> OnlyOffice server → FileBrowser API communication problem<br/>
<strong>Possible Causes:</strong><br/>
• OnlyOffice server cannot reach FileBrowser callback URL<br/>
• Callback URL is incorrect or inaccessible<br/>
• JWT secret mismatch between OnlyOffice and FileBrowser<br/>
• Network connectivity issues between services<br/>
<strong>Solution:</strong><br/>
• Check OnlyOffice server logs for callback errors<br/>
• Verify callback URL is accessible from OnlyOffice server<br/>
• Test callback URL manually from OnlyOffice server<br/>
• Check FileBrowser internal URL configuration<br/>
`);
}
}, 30000);
// Expose monitoring functions globally for debugging
window.onlyOfficeDebug = {
checkDocumentReady,
checkSaveAttempts,
getStats: () => ({ documentOpened, saveAttempts, lastSaveAttempt })
};
},
// Monitor for server-to-server communication issues (OnlyOffice server → FileBrowser API)
monitorServerToServerCommunication() {
if (!this.debugMode) return;
// Instead of testing the download URL (which fails due to hostname resolution),
// we'll monitor for actual callback success/failure patterns
this.updateDebugStatus("🔄 Monitoring OnlyOffice server → FileBrowser API communication...");
// Set up a timer to check if callbacks are working
let callbackCount = 0;
// Monitor for successful callbacks in the network tab
const originalFetch = window.fetch;
window.fetch = (...args) => {
return originalFetch.apply(window, args).then(response => {
if (response.url.includes('onlyoffice/callback') && response.status === 200) {
callbackCount++;
this.updateDebugStatus(`✅ OnlyOffice callback #${callbackCount} received successfully`);
}
return response;
});
};
// Check after 10 seconds if we've seen any callbacks
setTimeout(() => {
window.fetch = originalFetch; // Restore original fetch
if (callbackCount > 0) {
this.updateDebugStatus(`✅ OnlyOffice server → FileBrowser API communication confirmed (${callbackCount} callbacks received)`);
} else {
// Only show warning if we haven't seen any callbacks and document was opened
if (this.clientConfig && this.clientConfig.document) {
this.updateDebugStatus(`
⚠️ No OnlyOffice callbacks detected yet<br/>
<strong>Note:</strong> This is normal if you haven't made changes to the document<br/>
<strong>Expected:</strong> Callbacks will appear when you edit and save the document<br/>
<strong>If editing fails:</strong> Check OnlyOffice server logs for callback errors<br/>
`);
}
}
}, 10000);
},
},
};
</script>
+63 -16
View File
@@ -1,11 +1,19 @@
<template>
<div class="plyr-viewer">
<div class="plyr-viewer" :key="`${previewType}-${raw}`">
<!-- Audio with plyr -->
<div v-if="previewType == 'audio' && !useDefaultMediaPlayer" class="audio-player-container">
<div class="audio-player-content">
<!-- Album art with a generic icon if no image/metadata -->
<div class="album-art-container" :class="{ 'no-artwork': !albumArtUrl }">
<div class="album-art-container"
:class="{ 'no-artwork': !albumArtUrl }"
:style="{
maxHeight: albumArtSize + 'em',
maxWidth: albumArtSize + 'em'
}"
@mouseenter="onAlbumArtHover"
@mouseleave="onAlbumArtLeave"
@wheel="onAlbumArtScroll">
<img v-if="albumArtUrl" :src="albumArtUrl" :alt="audioMetadata.album || 'Album art'"
class="album-art" />
<div v-else class="album-art-fallback">
@@ -31,7 +39,7 @@
</div>
<div class="audio-controls-container">
<div class="audio-controls-container" :class="{ 'dark-mode': darkMode }">
<vue-plyr ref="audioPlayer" :options="plyrOptions">
<audio :src="raw" :autoplay="autoPlayEnabled" @play="handlePlay"></audio>
</vue-plyr>
@@ -78,6 +86,7 @@
</template>
<script>
import { state } from '@/store/state';
export default {
name: "plyrViewer",
props: {
@@ -116,6 +125,8 @@ export default {
albumArtUrl: null,
albumArt: null,
metadataId: 0,
albumArtSize: 25, // Default size in em
isHovering: false, // Track hover state
// Plyr options
plyrOptions: {
controls: [
@@ -157,6 +168,11 @@ export default {
this.updateMedia();
},
},
computed: {
darkMode() {
return state.user.darkMode;
},
},
mounted() {
this.updateMedia();
},
@@ -227,12 +243,13 @@ export default {
: this.$refs.defaultAudioPlayer;
if (playerRef) {
// Ensure player is not muted before attempting autoplay
playerRef.muted = false;
const playPromise = playerRef.play();
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.log("autoplay failed", error);
playerRef.muted = true;
playerRef.play();
// Don't force muted playback - let user manually start
});
}
}
@@ -244,12 +261,13 @@ export default {
: this.$refs.audioPlayer;
if (playerRef && playerRef.player) {
// Ensure player is not muted before attempting autoplay
playerRef.player.muted = false;
const playPromise = playerRef.player.play();
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.log("autoplay failed", error);
playerRef.player.muted = true;
playerRef.player.play();
// Don't force muted playback - let user manually start
});
}
}
@@ -270,6 +288,24 @@ export default {
this.loadAudioMetadata();
}
},
// Album art hover and scroll handlers
onAlbumArtHover() {
this.isHovering = true;
},
onAlbumArtLeave() {
this.isHovering = false;
},
onAlbumArtScroll(event) {
if (!this.isHovering) return;
event.preventDefault();
const scrollDelta = event.deltaY > 0 ? -5 : 5; // Scroll down decreases, scroll up increases
const newSize = this.albumArtSize + scrollDelta;
// Apply size constraints (minimum 10em, maximum 50em)
this.albumArtSize = Math.max(10, Math.min(50, newSize));
},
// Load metadata from the backend response
async loadAudioMetadata() {
if (this.previewType !== "audio") {
@@ -373,11 +409,22 @@ button:hover,
--plyr-control-padding: 6px;
--plyr-tooltip-background: rgba(0, 0, 0, 0.8);
--plyr-tooltip-color: #ffffff;
--plyr-audio-controls-background: transparent;
--plyr-video-controls-background: linear-gradient(transparent,
rgba(0, 0, 0, 0.7));
border-radius: 12px;
overflow: visible;
background-color: rgb(216 216 216);
}
.audio-controls-container.dark-mode .plyr {
background-color: rgb(37 49 55 / 33%);
color: white;
}
/* sidebar with backdrop-filter support */
@supports (backdrop-filter: none) {
.plyr {
backdrop-filter: blur(16px) invert(0.1);
}
}
/* Position/space of the buttons */
@@ -385,8 +432,12 @@ button:hover,
display: flex;
flex-direction: row;
gap: 8px;
background-color: transparent;
color: black;
}
.audio-controls-container.dark-mode .plyr .plyr__controls {
color: white;
}
.plyr .plyr__controls__items {
display: flex;
justify-content: space-between;
@@ -462,11 +513,6 @@ button:hover,
*** AUDIO ***
************/
/* Style for audio player */
.plyr.plyr--audio {
background: rgba(40, 40, 55, 1);
border-radius: 1em;
}
/* Hide some unnesary buttons on the audio player */
.plyr--audio .plyr__control--overlaid,
@@ -557,11 +603,11 @@ button:hover,
.album-art-container {
height: 100%;
width: 100%;
max-height: 25em;
max-width: 25em;
border-radius: 1em;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
transition: max-height 0.3s ease, max-width 0.3s ease;
cursor: pointer;
}
.album-art {
@@ -628,6 +674,7 @@ button:hover,
.audio-controls-container {
width: 100%;
border-radius: 1em;
}
/* For small tablets and phones with big screen */
+7
View File
@@ -77,6 +77,7 @@ import Errors from "@/views/Errors.vue";
import { fromNow } from '@/utils/moment';
import { eventBus } from "@/store/eventBus";
import { fixDownloadURL } from "@/utils/url";
import { globalVars } from "@/utils/constants";
export default {
name: "shares",
@@ -187,6 +188,12 @@ export default {
return publicApi.getShareURL(share);
},
fixDownloadURL(downloadUrl) {
// Only fix the URL if it doesn't already have the correct external domain
if (globalVars.externalUrl) {
// URL already has the correct external domain, use as-is
return downloadUrl;
}
// URL needs fixing (internal domain or no externalUrl set)
return fixDownloadURL(downloadUrl);
},
},
@@ -19,7 +19,7 @@ test('create, check settings, and delete testuser2', async ({
await page.locator('input[aria-label="Password2"]').fill('testpassword')
// Just create the user first
await page.locator('input[aria-label="Save User"]').click();
await page.locator('button[aria-label="Save"]').click();
await page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 201)
// We should be back on the settings page
@@ -48,7 +48,7 @@ test('create, check settings, and delete testuser2', async ({
}
// Save the updated settings
await modal.locator('input[aria-label="Save User"]').click();
await modal.locator('button[aria-label="Save"]').click();
await expect(modal).not.toBeVisible();
// Re-open the modal to check the settings
@@ -122,7 +122,7 @@ test.describe("User Settings Persistence", () => {
// --- Toggle ON and save ---
const toggleSwitch = modal.locator(".toggle-container", { hasText: settingName }).locator("label.switch");
await toggleSwitch.click();
await modal.locator('input[aria-label="Save User"]').click();
await modal.locator('button[aria-label="Save"]').click();
await expect(modal).not.toBeVisible();
// --- Re-open and check persisted state (should be ON) ---
@@ -134,7 +134,7 @@ test.describe("User Settings Persistence", () => {
// --- Toggle OFF to restore state and save ---
const toggleSwitchOn = modal.locator(".toggle-container", { hasText: settingName }).locator("label.switch");
await toggleSwitchOn.click();
await modal.locator('input[aria-label="Save User"]').click();
await modal.locator('button[aria-label="Save"]').click();
await expect(modal).not.toBeVisible();
// --- Re-open and check state is restored (should be OFF) ---
@@ -181,7 +181,7 @@ test.describe("User Settings Persistence", () => {
await expect(loginMethodSelector).toHaveValue("password");
await loginMethodSelector.selectOption({ label: "Proxy" });
await modal.locator('input[aria-label="Save User"]').click();
await modal.locator('button[aria-label="Save"]').click();
await expect(modal).not.toBeVisible();
await userRow.getByLabel('Edit User').click();
@@ -192,7 +192,7 @@ test.describe("User Settings Persistence", () => {
// Revert change
const loginMethodSelector2 = modal.locator("#loginMethod");
await loginMethodSelector2.selectOption({ label: "Password" });
await modal.locator('input[aria-label="Save User"]').click();
await modal.locator('button[aria-label="Save"]').click();
await expect(modal).not.toBeVisible();
});
});