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:
74 files changed
+1868
-698
No files matched your search
@@ -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**:
|
||||
|
||||
@@ -19,6 +19,8 @@ frontend:
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
proxy:
|
||||
enabled: true
|
||||
header: "X-Username"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
@@ -186,7 +186,7 @@ export default {
|
||||
if (!getters.isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
if (!this.isStickySidebar) {
|
||||
if (!this.isStickySidebar && !shareInfo.singleFileShare) {
|
||||
mutations.closeSidebar();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -199,7 +199,6 @@ input.sizeInput:disabled {
|
||||
backdrop-filter: invert(.1);
|
||||
height: 2em;
|
||||
width: 5em;
|
||||
|
||||
}
|
||||
|
||||
.roundedInputLeft {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": "الصور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Εικόνες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "תמונות",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "画像",
|
||||
|
||||
@@ -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": "이미지",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Изображения",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Зображення",
|
||||
|
||||
@@ -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": "图像",
|
||||
|
||||
@@ -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": "影像",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export const state = reactive({
|
||||
hash: null,
|
||||
token: "",
|
||||
subPath: "",
|
||||
passwordValid: false,
|
||||
},
|
||||
sources: {
|
||||
current: "",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user