Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 09713b32a5 updated last minute changes (#2103) 2026-03-07 07:02:10 -06:00
Graham Steffaniak 52e3c5c142 updated with oidc and sidebar changes (#2093) 2026-03-05 18:52:00 -06:00
Graham Steffaniak 2af75311bd fix download issue for users with scope 2026-03-01 10:02:48 -06:00
Graham Steffaniak 82c0182c22 updated to use html/template (#2060) 2026-03-01 09:20:58 -06:00
Graham Steffaniak c8f0b5abea updated share link (#2059) 2026-03-01 09:18:03 -06:00
Graham Steffaniak d3d029d1e9 updated share link edit while on share (#2056) 2026-03-01 07:48:34 -06:00
Graham Steffaniak f39002f375 add strict sharing (#2053) 2026-02-28 18:30:55 -06:00
Graham Steffaniak a658f100c0 Fix database path loading (#2055) 2026-02-28 18:28:15 -06:00
Graham Steffaniak c2d255a25e updated deps (#2052) 2026-02-28 14:26:31 -06:00
Graham Steffaniak 2eb352c2d3 fix delete item issue while viewing (#2043) 2026-02-27 16:29:49 -06:00
Graham Steffaniak 43bb4a36b4 stable/v1.2.1 (#2041) 2026-02-27 13:13:18 -06:00
Stephan Paternotte c67abefc7b Update nl.json (#2032) 2026-02-25 12:26:39 -06:00
Graham Steffaniak 2ecb9036aa Beta/v1.2.7 (#2027) 2026-02-24 12:55:04 -06:00
Graham Steffaniak dbcfba993b Beta/v1.2.6 (#2004) 2026-02-21 10:39:04 -06:00
Graham Steffaniak ed22ce9421 Beta/v1.2.5 (#1999) 2026-02-19 16:35:55 -06:00
Graham Steffaniakandzarev 8caa6d6a60 Beta/v1.2.4 (#1978)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:08:20 -06:00
Graham Steffaniak fb1af87048 Beta/v1.2.3 (#1940) 2026-02-06 13:52:38 -06:00
+4 f73ca51f94 Beta/v1.2.2 (#1928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-02-03 18:07:51 -06:00
+4 ad839f92ac Beta/v1.2.1 (#1889)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:13:59 -06:00
107 changed files with 3776 additions and 3005 deletions

No files matched your search

+80
View File
@@ -2,6 +2,85 @@
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).
## v1.2.2-stable
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
**New Features**:
- "Divider" option in sidebar links to add a text or divider between links (#1875)
- shares offer a "go to source Location" sidebar link and button when editing a share.
**Notes**:
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
- OIDC group claims accepted as map (#2084)
**BugFixes**:
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
- Error downloading zipped directory: no such file or directory (users with scope) #2015
## v1.2.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
@@ -35,6 +114,7 @@ All notable changes to this project will be documented in this file. For commit
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
-1
View File
@@ -6,7 +6,6 @@ WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-1
View File
@@ -4,7 +4,6 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
+28 -20
View File
@@ -2,7 +2,6 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
@@ -37,6 +36,18 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
@@ -50,9 +61,6 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
for i := range response.Files {
fileItem := &response.Files[i]
@@ -86,10 +94,10 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
}
// Wait for all goroutines to complete
wg.Wait()
}
}
// Wait for all goroutines to complete
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
@@ -211,17 +219,16 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
// Handle subtitles if requested
if opts.ExtractEmbeddedSubtitles {
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
@@ -236,7 +243,8 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
}
@@ -323,11 +331,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art and encode as base64 with strict size limits
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
if picture := m.Picture(); picture != nil && picture.Data != nil {
// More aggressive size limit to prevent memory issues (max 5MB)
// Size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
item.Metadata.AlbumArt = picture.Data
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
+1 -1
View File
@@ -189,7 +189,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
if totpSecret != "" || !user.OtpEnabled {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
+10 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -181,7 +182,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
@@ -193,14 +194,20 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
} else {
fileutils.InitAssetFS(nil, false)
}
// setup disk cache
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+1 -1
View File
@@ -119,7 +119,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version == 1 {
if user.Version >= 1 {
return false
}
updateUser := true
+1 -25
View File
@@ -278,8 +278,6 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
@@ -636,14 +634,9 @@ func setDefaults(generate bool) Settings {
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
}
s := Settings{
Server: Server{
@@ -788,23 +781,6 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
func loadLoginIcon() {
const imageName = "login icon"
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
+94 -51
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -111,74 +112,93 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
@@ -187,6 +207,29 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
+10 -4
View File
@@ -63,12 +63,18 @@ type FileOptions struct {
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
// Clean resolves . and .. segments
clean := filepath.Clean(userPath)
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
return clean, nil
+43
View File
@@ -0,0 +1,43 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
+23 -4
View File
@@ -1,11 +1,12 @@
package share
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// IsSingleFileShare determines if this share is for a single file (not a directory).
@@ -144,10 +145,28 @@ func (l *Link) HasReachedUserLimit(username string) bool {
return count >= l.DownloadsLimit
}
func (l *Link) GetSourceName() (string, error) {
func (l *Link) GetSourceName() string {
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
if !ok {
return "", fmt.Errorf("source not found")
return ""
}
return sourceInfo.Name, nil
return sourceInfo.Name
}
func (l *Link) UserCanEdit(user *users.User) bool {
return l.UserID == user.ID || user.Permissions.Admin
}
func (l *Link) SourceURL(user *users.User) string {
sourceName := l.GetSourceName()
// get user scope path from share
userScope, err := user.GetScopeForSourceName(sourceName)
if err != nil {
return ""
}
if !strings.HasPrefix(l.Path, userScope) {
return ""
}
scopedPath := strings.TrimPrefix(l.Path, userScope)
return "/" + filepath.Join("files", sourceName, scopedPath)
}
+4 -2
View File
@@ -43,8 +43,10 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEdit bool `json:"canEdit,omitempty"`
}
type CreateBody struct {
CommonShare
-7
View File
@@ -247,7 +247,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -264,14 +263,8 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
+1 -1
View File
@@ -541,7 +541,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
return "", err
}
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
}
return plan.String(), rows.Err()
}
+1 -1
View File
@@ -77,7 +77,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
+1
View File
@@ -18,6 +18,7 @@ import (
var userStore *users.Storage
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
logger.Debugf("Initializing database: %v", path)
exists, err := dbExists(path)
if err != nil {
panic(err)
+4 -3
View File
@@ -3,11 +3,12 @@ package ffmpeg
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+33 -136
View File
@@ -10,20 +10,10 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// SubtitleTrack represents a subtitle track (embedded or external file)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Content string `json:"content,omitempty"` // subtitle content
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
}
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
@@ -36,7 +26,7 @@ type FFProbeOutput struct {
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
@@ -44,7 +34,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return cached
}
var allSubtitles []SubtitleTrack
var allSubtitles []utils.SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
@@ -60,9 +50,25 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
@@ -82,13 +88,13 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
return nil
}
var subtitles []SubtitleTrack
var subtitles []utils.SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
IsFile: false, // This is an embedded subtitle
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
}
// Extract language and title from tags
@@ -117,8 +123,8 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
var subtitles []SubtitleTrack
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
@@ -131,9 +137,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := SubtitleTrack{
Name: filepath.Base(subtitlePath),
IsFile: true, // This is an external file
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
@@ -170,120 +176,11 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
return string(output), nil
}
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(subtitlePath))
if ext == ".vtt" {
// Already WebVTT, just read it
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
if ext == ".srt" {
// Convert SRT to WebVTT using ffmpeg
cmd := exec.Command("ffmpeg",
"-i", subtitlePath,
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
}
return string(output), nil
}
// For other formats, try to read as plain text and hope for the best
// This is a fallback - ideally you'd want format-specific converters
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
// Basic conversion wrapper for non-VTT formats
vttHeader := "WEBVTT\n\n"
return vttHeader + string(content), nil
}
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
// Get all subtitle tracks
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
if trackIndex >= len(allTracks) {
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
}
track := allTracks[trackIndex]
// Load content based on type
if track.IsFile {
// Load external subtitle file
subtitlePath := filepath.Join(parentDir, track.Name)
content, err := LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
}
track.Content = content
} else {
// Extract embedded subtitle content
if track.Index == nil {
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
}
content, err := ExtractSubtitleContent(videoPath, *track.Index)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
track.Content = content
}
return track, nil
}
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
for idx := range subtitles {
subtitle := &subtitles[idx]
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
var content string
var err error
if subtitle.IsFile {
// Load external subtitle file content and convert to WebVTT
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
content, err = LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
continue
}
} else {
// Load embedded subtitle content
if subtitle.Index == nil {
logger.Debug("embedded subtitle has no stream index")
continue
}
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
if err != nil {
logger.Debug("failed to extract embedded subtitle: " + err.Error())
continue
}
}
subtitle.Content = content
// Cache the content for future requests
SubtitleContentCache.Set(contentKey, content)
}
return nil
return string(content), nil
}
+46 -46
View File
@@ -15,22 +15,23 @@ require (
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.33
github.com/kovidgoyal/imaging v1.8.20
github.com/mattn/go-sqlite3 v1.14.34
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.47.0
golang.org/x/image v0.35.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/time v0.14.0
golang.org/x/tools v0.42.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
modernc.org/sqlite v1.46.1
)
require (
@@ -52,16 +53,17 @@ require (
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.64.2 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.1 // indirect
github.com/alexkohler/prealloc v1.0.2 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -71,7 +73,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -83,7 +85,7 @@ require (
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
@@ -97,7 +99,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -105,20 +107,20 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.18 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -128,21 +130,21 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
@@ -164,7 +166,7 @@ require (
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -194,7 +196,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.13.0 // indirect
github.com/mgechev/revive v1.14.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -203,7 +205,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -228,8 +230,8 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/securego/gosec/v2 v2.23.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
@@ -262,23 +264,21 @@ require (
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.3.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.67.7 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.69.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+112 -115
View File
@@ -73,18 +73,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -96,14 +96,16 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
@@ -154,8 +156,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -182,8 +184,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -242,8 +244,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -273,8 +275,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -296,33 +298,33 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -355,8 +357,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -365,8 +367,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -391,8 +393,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a h1:7f/cr+n7Jtd7mzCLrx47sdVIGabpSSxCHrf6fJczDp0=
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -432,12 +434,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
@@ -471,8 +473,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -543,8 +545,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -558,8 +560,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -622,12 +624,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -659,8 +661,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -671,10 +673,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -698,8 +700,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -759,8 +759,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -769,8 +769,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
@@ -802,7 +802,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -853,6 +852,8 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -878,8 +879,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -890,8 +891,6 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -909,8 +908,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -921,16 +920,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -956,8 +955,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1000,16 +999,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1071,7 +1070,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1080,8 +1078,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -1098,8 +1096,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1155,8 +1153,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1262,7 +1260,6 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1272,22 +1269,22 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1296,8 +1293,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-5
View File
@@ -8,7 +8,6 @@ import (
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -398,10 +397,6 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
+6 -9
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -556,7 +555,10 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// First pass uses filename matching for speed, then checksums verify final groups
@@ -565,19 +567,14 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
// Default minimum size: 1MB
minSizeMb := int64(1)
if minSizeMbStr != "" {
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
if _, err = fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
}
}
// Convert MB to bytes
minSize := minSizeMb * 1024 * 1024
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
searchScope := strings.TrimPrefix(scope, ".")
index := indexing.GetIndex(source)
if index == nil {
+12 -1
View File
@@ -137,8 +137,12 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -282,6 +286,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
+4 -1
View File
@@ -6,10 +6,10 @@ import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net"
"net/http"
"text/template"
"time"
_ "net/http/pprof"
@@ -74,6 +74,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
+54 -25
View File
@@ -4,24 +4,26 @@ import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle extraction requests
// @Summary Extract embedded subtitles
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
// subtitlesHandler handles subtitle requests for both external files and embedded streams
// @Summary Get subtitle content
// @Description Returns raw subtitle content from external files or embedded streams
// @Tags Subtitles
// @Accept json
// @Produce text/vtt
// @Produce text/plain
// @Param path query string true "Index path to the video file"
// @Param source query string true "Source name for the desired source"
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
// @Success 200 {string} string "Raw WebVTT subtitle content"
// @Param name query string true "Subtitle track name (filename for external, descriptive name for embedded)"
// @Param embedded query bool false "Whether this is an embedded stream (true) or external file (false), defaults to false"
// @Success 200 {string} string "Raw subtitle content in original format"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -30,10 +32,11 @@ import (
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
indexParam := r.URL.Query().Get("index")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if indexParam == "" {
indexParam = "0" // default to first subtitle stream
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
userscope, err := d.user.GetScopeForSourceName(source)
@@ -45,28 +48,54 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
if !exists {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
}
index, err := strconv.Atoi(indexParam)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
}
parentDir := filepath.Dir(realPath)
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
}
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
// Return raw content with appropriate content type
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
+14 -15
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -26,6 +25,7 @@ import (
type requestContext struct {
user *users.User
shareUser *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
@@ -51,12 +51,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -103,23 +98,27 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
shareCreatedByUser, err := store.Users.Get(link.UserID)
data.shareUser, err = store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
// get user scope path from share
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
// so trim user scope from link.Path
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(userScopedPath, "/") {
userScopedPath = userScopedPath + "/"
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(pathWithoutUserScope, "/") {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
return fn(w, r, data)
}
data.IndexPath = userScopedPath
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: userScopedPath,
Path: pathWithoutUserScope,
Source: source.Name,
Expand: true,
Content: getContent,
@@ -128,7 +127,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, shareCreatedByUser)
}, store.Access, data.shareUser)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
+3
View File
@@ -4,6 +4,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/url"
"slices"
@@ -44,6 +45,8 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
if u.groupsClaim != "" {
if v, ok := raw[u.groupsClaim]; ok {
switch val := v.(type) {
case map[string]interface{}:
u.userInfo.Groups = slices.Collect(maps.Keys(val))
case []interface{}:
groups := make([]string, len(val))
for i, g := range val {
+19 -1
View File
@@ -80,6 +80,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
providedPath = cleanPath
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo *settings.Source
var ok bool
@@ -102,7 +110,8 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Build file info based on whether this is a share or regular request
// Regular user request
logger.Debugf("OnlyOffice user request: request path=%s", path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
@@ -336,6 +345,15 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
}
path = cleanPath
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
+6 -7
View File
@@ -16,7 +16,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -87,10 +86,6 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
fd, err := os.Open(file.RealPath)
if err != nil {
return http.StatusInternalServerError, err
@@ -162,7 +157,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original") {
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
previewSize = "small"
}
if !d.fileInfo.HasPreview {
@@ -183,9 +178,13 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
if (!resizable || config.Server.DisableResize) && isImage {
// If image is under 500KB, serve the original instead of generating a preview
const maxSizeForOriginal = 500 * 1024 // 500KB
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
+61 -52
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -77,9 +76,9 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Get all "file" parameter values (supports repeated params)
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
files := r.URL.Query()["file"]
if len(files) == 0 {
files = []string{"/"}
}
// Get the actual source name from the share's source mapping
@@ -89,16 +88,17 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Decode and process each file path
// Process each file path
fileList := []string{}
for _, encodedFile := range encodedFiles {
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
file, decodeErr := url.PathUnescape(encodedFile)
if decodeErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
for _, file := range files {
// Rule 1: Validate each file path to prevent path traversal
cleanFile, err := utils.SanitizeUserPath(file)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, file)
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
fileList = append(fileList, filePath)
}
@@ -107,8 +107,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
return status, fmt.Errorf("error processing filelist: %v", files)
}
return status, nil
}
@@ -143,7 +143,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param path query string true "path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
@@ -159,18 +159,15 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
}
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
// Go automatically decodes query params
path := r.URL.Query().Get("targetPath")
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", fullPath)
q.Set("path", d.IndexPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
@@ -249,8 +246,8 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("create is not allowed for this share")
}
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -259,8 +256,15 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
// hide the error
if err != nil {
logger.Errorf("public put handler: error updating resource with error %v", err)
@@ -269,36 +273,26 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
// deprecated -- see publicBulkDeleteHandler
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
path := r.URL.Query().Get("path")
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
Expand: false,
ShowHidden: d.share.ShowHidden,
}, store.Access, d.user)
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
return http.StatusOK, nil
}
@@ -320,10 +314,10 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// hide the error
status, err := resourceBulkDeleteHandler(w, r, d)
if err != nil {
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
return status, nil
@@ -350,8 +344,8 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
// Get the source from the share
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -377,9 +371,9 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// Note: Share paths are absolute, so we don't strip user scope here
// resourcePatchHandler will skip adding scope for shares
for i := range req.Items {
req.Items[i].FromSource = source
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].ToSource = source
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
}
d.Data = req
@@ -398,15 +392,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return status, err
}
// getShareImage serves banner or favicon files for shares
// @Summary Get share image (banner or favicon)
// @Description Serves the banner or favicon file for a share
// getShareImage serves banner or favicon files for shares as resizable previews
// @Summary Get share image (banner or favicon) as preview
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
// @Tags Public Shares
// @Produce octet-stream
// @Produce image/jpeg
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
// @Param favicon query bool false "Request favicon file"
// @Success 200 {file} binary "Image file content"
// @Success 200 {file} file "Preview image content (JPEG)"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "Asset not found"
@@ -451,9 +445,24 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
}
// Serve the file
http.ServeFile(w, r, fileInfo.RealPath)
return 0, nil
// Set file info in request context for preview generation
d.fileInfo = *fileInfo
q := r.URL.Query()
if isBanner {
q.Set("size", "xlarge")
} else {
q.Set("size", "small")
}
r.URL.RawQuery = q.Encode()
// Use the preview helper to generate and serve a resized preview
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("error generating preview for share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
return http.StatusNotFound, fmt.Errorf("preview not available for this asset")
}
return status, err
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
+25 -35
View File
@@ -14,7 +14,6 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -113,6 +112,16 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
@@ -133,16 +142,6 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
}
}
// Verify file exists
_, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
if err != nil {
return err
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
@@ -274,7 +273,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
}
if len(fileList) == 0 {
if len(fileList) == 0 && d.share == nil {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
@@ -288,6 +287,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
var documentId string
var logContext *OnlyOfficeLogContext
// modify all filepaths for user scope
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
@@ -309,8 +309,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
}
}
firstFilePath = fileList[0]
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
@@ -495,7 +498,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
_, err = io.Copy(w, reader)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Errorf("Failed to copy archive data to response: %v", err)
logger.Debugf("client likely aborted: %v, failed to copy archive data to response: %v", r.Context().Err(), err)
return http.StatusInternalServerError, err
}
@@ -509,30 +512,17 @@ func computeArchiveSize(source string, fileList []string, d *requestContext) (in
return 0, fmt.Errorf("source %s is not available", source)
}
var userScope string
var err error
if d.share == nil {
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
for _, path := range fileList {
var fullPath string
if d.share == nil {
fullPath = utils.JoinPathAsUnix(userScope, path)
// Check access control for each file in the archive
// Silently skip if access is denied (as if the file doesn't exist)
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
continue // Skip this file and continue with the next one
}
} else {
fullPath = path
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(fullPath)
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -558,11 +548,11 @@ func createZip(d *requestContext, source string, tmpDirPath string, filenames ..
zipWriter := zip.NewWriter(file)
for _, fname := range filenames {
err := addFile(source, fname, d, nil, zipWriter, false)
for _, filepath := range filenames {
err := addFile(source, filepath, d, nil, zipWriter, false)
if err != nil {
// Access control failures return nil, so any error here is a real error
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
return err
}
}
@@ -585,10 +575,10 @@ func createTarGz(d *requestContext, source string, tmpDirPath string, filenames
gzWriter := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gzWriter)
for _, fname := range filenames {
err := addFile(source, fname, d, tarWriter, nil, false)
for _, filepath := range filenames {
err := addFile(source, filepath, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
return err
}
}
+79 -62
View File
@@ -203,6 +203,10 @@ type MoveCopyResponse struct {
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [post]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
// Check permissions - either user delete permission or share delete permission
if d.share == nil {
if !d.user.Permissions.Delete {
@@ -248,24 +252,35 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
Source: sourceName,
ShowHidden: true,
}, store.Access, d.user)
}, store.Access, filePermUser)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// Delete the file/directory
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
response.Failed = append(response.Failed, BulkDeleteItem{
@@ -289,7 +304,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
// Check user scope for this source
_, err := d.user.GetScopeForSourceName(item.Source)
_, err := filePermUser.GetScopeForSourceName(item.Source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -315,7 +330,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, d.user)
}, store.Access, filePermUser)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -354,7 +369,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
@@ -367,22 +382,22 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
accessStore = nil
} else {
// Go automatically decodes query params - no need for QueryUnescape
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Path is now handled by FileInfoFaster which will apply user scope
}
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
isDir := r.URL.Query().Get("isDir") == "true"
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -394,10 +409,21 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(path)
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
// Check access control for the target path
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -416,14 +442,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Directories creation on POST.
if isDir {
// Get user scope to resolve full index path for directory creation
var userScope string
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Create a new FileOptions with the full index path
dirOpts := fileOpts
dirOpts.Path = fullIndexPath
@@ -469,7 +487,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -519,23 +537,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = fileutils.MoveFile(tempFilePath, realPath)
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
if err != nil {
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
}
// Refresh index with user scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr == nil {
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
}
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -545,13 +556,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
preview.DelThumbs(r.Context(), *fileInfo)
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
@@ -578,6 +582,13 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
@@ -645,6 +656,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Process each item
for _, item := range req.Items {
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
@@ -657,26 +669,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
response.Failed = append(response.Failed, item)
continue
}
// Check for root path modifications
if item.ToPath == "/" || item.FromPath == "/" {
item.Message = "cannot modify root directory"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
cleanToPath, err := utils.SanitizeUserPath(item.ToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
userscopeSrc := ""
userscopeDst := ""
if d.share == nil {
var err error
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
@@ -722,6 +734,11 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
continue
}
// Check access control for both source and destination paths
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
+5
View File
@@ -122,6 +122,11 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope = cleanScope
var sources []string
if sourcesParam != "" {
+50 -21
View File
@@ -33,14 +33,10 @@ type ShareResponse struct {
}
// convertToFrontendShareResponse converts shares to response format with usernames
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
responses := make([]*ShareResponse, 0, len(shares))
for _, s := range shares {
user, err := store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
}
username := user.Username
// Get source info to convert path to name for frontend
sourceInfo, ok := config.Server.SourceMap[s.Source]
@@ -65,6 +61,9 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
s.CommonShare.HasPassword = s.HasPassword()
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
if s.UserCanEdit(user) {
s.CommonShare.SourceURL = s.SourceURL(user)
}
// Create response with source name (overrides the embedded Link's source field)
responses = append(responses, &ShareResponse{
Link: s,
@@ -97,7 +96,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -116,13 +115,8 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
@@ -142,7 +136,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
sharesWithUsernames, err := convertToFrontendShareResponse(r, s, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -167,7 +161,16 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, nil
}
err := store.Share.Delete(hash)
// only allow users to delete their own shares
thisShare, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share not found")
}
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to delete this share")
}
err = store.Share.Delete(hash)
if err != nil {
return errToStatus(err), err
}
@@ -201,8 +204,16 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
}
// only allow users to update their own shares
thisShare, err := store.Share.GetByHash(body.Hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share not found")
}
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
// Update the share path
err := store.Share.UpdateSharePath(body.Hash, body.Path)
err = store.Share.UpdateSharePath(body.Hash, body.Path)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -214,7 +225,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
}
// Convert to response format
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare}, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -363,7 +374,14 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusForbidden, err
}
providedPath := body.Path
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -387,7 +405,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s}, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -608,8 +626,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare := shareLink.CommonShare
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, "")
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
@@ -620,6 +637,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare.Source = ""
commonShare.Path = ""
commonShare.DownloadURL = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
@@ -628,6 +646,17 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
}
commonShare.SourceURL = shareLink.SourceURL(d.user)
commonShare.CanEdit = shareLink.UserCanEdit(d.user)
if commonShare.SourceURL != "" {
link := users.SidebarLink{
Name: "sourceLocation",
Category: "custom",
Target: commonShare.SourceURL,
}
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
return renderJSON(w, r, commonShare)
}
+148 -25
View File
@@ -3,12 +3,12 @@ package http
import (
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -16,6 +16,7 @@ import (
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -24,10 +25,18 @@ type TemplateRenderer struct {
// Render renders a template document with headers and data
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
// If in dev mode, reload templates on every render.
templates := t.templates
// If in dev mode, reload templates on every render by creating a fresh instance
if t.devMode {
var err error
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
templates = template.New("").Funcs(template.FuncMap{
"marshal": func(v interface{}) (string, error) {
var a []byte
a, err = json.Marshal(v)
return string(a), err
},
})
templates, err = templates.ParseFS(assetFs, "public/index.html")
if err != nil {
return fmt.Errorf("error reloading template: %w", err)
}
@@ -38,7 +47,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
w.Header().Set("X-Accel-Expires", "0")
w.Header().Set("Transfer-Encoding", "identity")
// Execute the template with the provided data
return t.templates.ExecuteTemplate(w, name, data)
return templates.ExecuteTemplate(w, name, data)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
@@ -78,7 +87,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
favicon := staticURL + "/favicon"
// Determine the correct favicon extension based on type
faviconExt := ".svg" // Default to SVG
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
faviconExt = ".png"
}
favicon := staticURL + "/favicon" + faviconExt
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
@@ -105,7 +119,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(d.share.Banner, "http") {
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
@@ -113,7 +127,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(d.share.Favicon, "http") {
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
@@ -174,8 +188,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
"userSelectedTheme": template.CSS(userSelectedTheme),
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
@@ -186,13 +200,14 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
"banner": banner,
"image": ogImage,
"url": fullURL,
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -231,8 +246,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
}
// Replace with JSON strings for direct template usage
data["globalVars"] = string(globalVarsJSON)
// Mark as JS for html/template to avoid escaping
data["globalVars"] = template.JS(globalVarsJSON)
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
@@ -270,6 +285,16 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest
func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
// staticAssetHandler serves static assets exactly as the frontend build produces them
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
@@ -285,25 +310,30 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "favicon.svg", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
case "pwa-icon.svg":
// Serve custom SVG favicon for PWA if configured
case "site.webmanifest":
manifestHandler(w, r)
return
case "favicon.svg":
// For custom SVG favicons, serve the SVG directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Fall back to default favicon SVG
// Use embedded default SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon-16x16.png", "favicon-32x32.png",
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png", "mstile-256x256.png":
"apple-touch-icon.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
@@ -312,6 +342,15 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
@@ -364,3 +403,87 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+261
View File
@@ -0,0 +1,261 @@
package icons
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
// sourceType represents the type of favicon source
type sourceType int
const (
sourceCustomSVG sourceType = iota
sourceCustomRaster
sourceDefault
)
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
func determineSourceType(faviconPath string) sourceType {
if !settings.Env.FaviconIsCustom {
return sourceDefault
}
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".svg":
return sourceCustomSVG
default:
return sourceCustomRaster
}
}
// loadDefaultFavicon loads the embedded default favicon PNG
func loadDefaultFavicon() ([]byte, error) {
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
return nil, errors.New("asset filesystem not initialized")
}
pngPath := "img/icons/favicon.png"
sourceData, err := fs.ReadFile(assetFs, pngPath)
if err != nil {
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
}
logger.Debug("Loaded default embedded favicon")
return sourceData, nil
}
// findRasterCompanion looks for a raster companion file for an SVG favicon
// Returns the companion data and path, or error if not found
func findRasterCompanion(svgPath string) ([]byte, string, error) {
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
for _, ext := range rasterExts {
companionPath := basePath + ext
if _, err := os.Stat(companionPath); err == nil {
data, readErr := os.ReadFile(companionPath)
if readErr != nil {
continue
}
return data, companionPath, nil
}
}
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
}
// convertRasterToPNG decodes any raster format and re-encodes as PNG
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(rasterData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
}
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
return buf.Bytes(), nil
}
// loadFaviconSource loads and prepares the favicon source data as PNG
// Returns PNG data ready for resizing into icon sizes
func loadFaviconSource() ([]byte, error) {
sourceType := determineSourceType(settings.Env.FaviconPath)
switch sourceType {
case sourceCustomSVG:
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
return handleSVGFavicon(settings.Env.FaviconPath)
case sourceCustomRaster:
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
return handleRasterFavicon(settings.Env.FaviconPath)
case sourceDefault:
logger.Debug("Using default embedded favicon")
return loadDefaultFavicon()
default:
return nil, errors.New("unknown icon source type")
}
}
// handleSVGFavicon processes an SVG favicon by finding its raster companion
func handleSVGFavicon(svgPath string) ([]byte, error) {
logger.Debug("Looking for companion for SVG favicon")
companionData, companionPath, err := findRasterCompanion(svgPath)
if err != nil {
logger.Warningf("SVG favicon has no companion: %v", err)
logger.Warning("Falling back to default embedded favicon")
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
return loadDefaultFavicon()
}
logger.Debugf("Found companion: %s", companionPath)
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
}
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
func handleRasterFavicon(faviconPath string) ([]byte, error) {
sourceData, err := os.ReadFile(faviconPath)
if err != nil {
return nil, fmt.Errorf("failed to read favicon: %w", err)
}
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
}
// getIconSizesToGenerate returns all icon sizes that need to be generated
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
}
}
// generateIconSizes generates all required icon sizes from source PNG data
func generateIconSizes(sourceData []byte) error {
previewService := preview.GetService()
if previewService == nil {
return errors.New("preview service not initialized")
}
iconSizes := getIconSizesToGenerate()
allSuccess := true
generatedCount := 0
for _, icon := range iconSizes {
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
logger.Warningf("Failed to generate %s: %v", icon.name, err)
allSuccess = false
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
return nil
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
// Resize image using the preview service's Resize method
err = previewService.Resize(
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
Width: icon.size,
Height: icon.size,
ResizeMode: preview.ResizeModeFill,
Quality: preview.QualityHigh,
Format: preview.FormatPng,
},
)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("resize failed: %w", err)
}
return nil
}
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Set PWA icons directory in cache
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
}
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
return fmt.Errorf("failed to load favicon source: %w", err)
}
// Generate all icon sizes
if err := generateIconSizes(sourceData); err != nil {
return fmt.Errorf("failed to generate icon sizes: %w", err)
}
return nil
}
+2 -1
View File
@@ -196,7 +196,7 @@ func TestProcessDirectoryItem(t *testing.T) {
},
}
for _, tt := range tests {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
itemInfo, size, shouldCount := idx.processDirectoryItem(
tt.file, tt.combinedPath,
@@ -212,6 +212,7 @@ func TestProcessDirectoryItem(t *testing.T) {
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
return
}
if size != tt.expectSize {
+12 -12
View File
@@ -3,7 +3,7 @@ package iteminfo
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
type ItemInfo struct {
@@ -40,21 +40,21 @@ type MediaMetadata struct {
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []utils.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+46 -21
View File
@@ -8,19 +8,9 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
IsFile bool `json:"isFile"` // true for external files, false for embedded
}
type FFProbeOutput struct {
Streams []struct {
Index int `json:"index"`
@@ -31,20 +21,55 @@ type FFProbeOutput struct {
} `json:"streams"`
}
// detects subtitles for video files.
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
// GetSubtitles detects external subtitle files for video files.
// Embedded subtitles should be detected by ffmpeg and passed as embeddedSubs parameter.
func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + i.Name)
return
}
// Use unified subtitle detection that finds both embedded and external
parentDir := filepath.Dir(i.RealPath)
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
}
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
// Collect external subtitle files
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
}
// Sort external subtitles alphabetically for consistent ordering
sort.Slice(externalSubs, func(i, j int) bool {
return externalSubs[i].Name < externalSubs[j].Name
})
i.Subtitles = append(i.Subtitles, externalSubs...)
// Sort all subtitles for consistent ordering
sort.Slice(i.Subtitles, func(a, b int) bool {
return strings.ToLower(i.Subtitles[a].Name) < strings.ToLower(i.Subtitles[b].Name)
})
// Note: Content is NOT loaded here. Frontend will call /api/media/subtitles for each track
}
func (info *FileInfo) SortItems() {
+4
View File
@@ -174,6 +174,10 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
+8 -3
View File
@@ -489,12 +489,17 @@ func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
data, err := io.ReadAll(source)
require.NoError(t, err)
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
// Get options for small preview
options, err := getPreviewOptions("small")
require.NoError(t, err)
// Create preview from reader
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via CreatePreview (bytes) for parity
out2, err := svc.CreatePreview(data, "small")
// Same via another call for parity
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.Equal(t, out, out2)
}
+72
View File
@@ -0,0 +1,72 @@
package preview
import (
"context"
"sync/atomic"
)
// MemoryTracker wraps the existing FFmpegService to add memory tracking
type MemoryTracker struct {
maxMemoryBytes int64
usedMemoryBytes int64
semaphore chan struct{}
}
// NewMemoryTracker creates a memory-aware concurrency limiter
func NewMemoryTracker(maxConcurrent int, maxMemoryMB int) *MemoryTracker {
if maxConcurrent < 1 {
maxConcurrent = 1
}
if maxMemoryMB < 50 {
maxMemoryMB = 500 // Default 500MB
}
return &MemoryTracker{
maxMemoryBytes: int64(maxMemoryMB) * 1024 * 1024,
semaphore: make(chan struct{}, maxConcurrent),
}
}
// TryAcquire attempts to acquire resources for processing an image
// Returns false if memory limit would be exceeded
func (mt *MemoryTracker) TryAcquire(ctx context.Context, estimatedBytes int64) bool {
// Check if we have memory headroom
currentUsed := atomic.LoadInt64(&mt.usedMemoryBytes)
if currentUsed+estimatedBytes > mt.maxMemoryBytes {
return false
}
// Try to acquire semaphore slot (non-blocking check first)
select {
case mt.semaphore <- struct{}{}:
// Successfully acquired slot, now add memory
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return true
case <-ctx.Done():
return false
default:
return false
}
}
// Acquire waits to acquire resources
func (mt *MemoryTracker) Acquire(ctx context.Context, estimatedBytes int64) error {
// Wait for semaphore slot
select {
case mt.semaphore <- struct{}{}:
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Release frees up resources
func (mt *MemoryTracker) Release(estimatedBytes int64) {
<-mt.semaphore
atomic.AddInt64(&mt.usedMemoryBytes, -estimatedBytes)
}
// GetMemoryUsageMB returns current memory usage in MB
func (mt *MemoryTracker) GetMemoryUsageMB() int64 {
return atomic.LoadInt64(&mt.usedMemoryBytes) / (1024 * 1024)
}
+244 -319
View File
@@ -4,12 +4,10 @@ import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -22,7 +20,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
var (
@@ -164,10 +161,6 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
}
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
// Generate PWA icons after service is initialized
GeneratePWAIcons()
return nil
}
@@ -176,153 +169,6 @@ func GetService() *Service {
return service
}
// GeneratePWAIcons generates all icon sizes from favicon.svg or favicon.png
func GeneratePWAIcons() {
if service == nil {
logger.Warning("Preview service not initialized, skipping icon generation")
return
}
// Determine source icon - prefer custom, fallback to default
var sourceIcon string
var sourceData []byte
var err error
var isSVG bool
if settings.Env.FaviconIsCustom {
sourceIcon = settings.Env.FaviconPath
logger.Debugf("Generating icons from custom favicon: %s", sourceIcon)
isSVG = strings.ToLower(filepath.Ext(sourceIcon)) == ".svg"
// Read the source file
sourceData, err = os.ReadFile(sourceIcon)
if err != nil {
logger.Warningf("Failed to read custom favicon: %v", err)
return
}
// If it's not a raster format that imaging library can decode, handle separately
if isSVG {
// SVG: Set PWA icons to use SVG directly, look for PNG for raster generation
logger.Debug("Source is SVG - will use directly where supported")
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
// Look for PNG in same directory with same base name
basePath := sourceIcon[:len(sourceIcon)-len(filepath.Ext(sourceIcon))]
pngPath := basePath + ".png"
sourceData, err = os.ReadFile(pngPath)
if err != nil {
logger.Warningf("SVG favicon provided without PNG version at %s. For best compatibility across all browsers and platforms, provide a PNG version alongside your SVG.", pngPath)
return
}
logger.Debugf("Using PNG version (%s) for generating raster icons", pngPath)
} else {
// For any raster format (JPEG, PNG, GIF, WebP, etc.), decode and convert to PNG
// This creates a standardized PNG source for all icon generation
img, decodeErr := imaging.Decode(bytes.NewReader(sourceData))
if decodeErr != nil {
logger.Warningf("Failed to decode favicon image: %v", decodeErr)
return
}
// Re-encode as PNG in memory for consistent processing
var buf bytes.Buffer
if encodeErr := imaging.Encode(&buf, img, imaging.PNG); encodeErr != nil {
logger.Warningf("Failed to convert favicon to PNG: %v", encodeErr)
return
}
sourceData = buf.Bytes()
logger.Debugf("Converted %s favicon to PNG for icon generation", filepath.Ext(sourceIcon))
}
} else {
// Use default embedded favicon - we have both SVG and PNG versions
logger.Debug("Generating icons from default favicon")
// For default, use the PNG version for raster generation
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
logger.Warning("Asset filesystem not initialized, skipping default icon generation")
return
}
pngPath := "img/icons/favicon.png"
sourceData, err = fs.ReadFile(assetFs, pngPath)
if err != nil {
logger.Warningf("Failed to read default favicon PNG: %v", err)
return
}
// Set PWA icons to use SVG for modern browsers
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
}
// Define all icon sizes we need to generate
// Format: {size, filename, envVar pointer (optional)}
iconSizes := []struct {
size int
name string
env *string
}{
// Browser favicon
{32, "favicon-32x32.png", nil},
// PWA icons
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256},
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen
{256, "mstile-256x256.png", nil}, // Windows tile
}
allSuccess := true
generatedCount := 0
for _, iconSize := range iconSizes {
outputPath := filepath.Join(settings.Env.PWAIconsDir, iconSize.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
logger.Warningf("Failed to create icon %s: %v", iconSize.name, err)
allSuccess = false
continue
}
// Resize image using the service's Resize method
err = service.Resize(
bytes.NewReader(sourceData),
outFile,
ResizeOptions{
Width: iconSize.size,
Height: iconSize.size,
ResizeMode: ResizeModeFill,
Quality: QualityHigh,
Format: FormatPng,
},
)
outFile.Close()
if err != nil {
logger.Warningf("Failed to generate icon %s: %v", iconSize.name, err)
os.Remove(outputPath)
allSuccess = false
continue
}
// Update environment variable if specified
if iconSize.env != nil {
*iconSize.env = filepath.Join("icons", iconSize.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
}
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
@@ -335,10 +181,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
// Generate fast cache key based on file metadata
var cacheHash string
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
// For audio with album art, hash the album art content
hasher := md5.New()
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
_, _ = hasher.Write(file.Metadata.AlbumArt)
cacheHash = hex.EncodeToString(hasher.Sum(nil))
} else {
// For all other files, use fast metadata-based hash
@@ -357,6 +203,191 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
}
// filePreviewType represents the type of preview generation needed
type filePreviewType int
const (
previewTypeDocument filePreviewType = iota
previewTypeOffice
previewTypeHEIC
previewTypeImage
previewTypeVideo
previewTypeAudio
previewTypeUnsupported
)
// determinePreviewType determines what type of preview generation is needed
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
// Check document conversion first
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
return previewTypeDocument
}
// Check office files
if file.OnlyOfficeId != "" {
return previewTypeOffice
}
// Check HEIC with conversion enabled
if strings.HasPrefix(file.Type, "image/heic") &&
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
return previewTypeHEIC
}
// Check by MIME type prefix
switch {
case strings.HasPrefix(file.Type, "image"):
return previewTypeImage
case strings.HasPrefix(file.Type, "video"):
return previewTypeVideo
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
return previewTypeAudio
default:
return previewTypeUnsupported
}
}
// generateRawPreview generates the initial preview image bytes based on file type
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
previewType := determinePreviewType(file)
switch previewType {
case previewTypeDocument:
return s.generateDocumentPreview(ctx, file, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
case previewTypeHEIC:
return s.generateHEICPreview(ctx, file, previewSize)
case previewTypeImage:
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
return file.Metadata.AlbumArt, nil
}
return nil, nil
case previewTypeUnsupported:
ext := strings.ToLower(filepath.Ext(file.Name))
return nil, fmt.Errorf("unsupported media type: %s", ext)
default:
return nil, errors.New("unknown preview type")
}
}
// generateDocumentPreview generates preview for PDF and document files
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
return imageBytes, nil
}
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
return imageBytes, nil
}
// generateHEICPreview generates preview for HEIC files using FFmpeg
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
return imageBytes, nil
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
return imageBytes, nil
}
// generateImagePreview generates preview for regular image files
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
f, err := os.Open(file.RealPath)
if err != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to open image file: %w", err)
}
defer f.Close()
options, err := getPreviewOptions(previewSize)
if err != nil {
return nil, err
}
imageBytes, err := s.CreatePreview(f, file.Size, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if initial decode failed
if strings.HasPrefix(file.Type, "image/jpeg") {
return handleJPEGFallback(ctx, s, file, previewSize, err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
return imageBytes, nil
}
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
// This is preview-specific logic for user files, not used for icon generation
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
}
if s.ffmpegService == nil {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
}
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
return imageBytes, nil
}
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
// Validate that cache hash is not empty to prevent cache corruption
@@ -374,13 +405,11 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Acquire global image processor semaphore for ALL operations
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
// Large file path - use imageLargeSem
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
// Small file path or fallback - use imageSem
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
@@ -400,122 +429,34 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, errors.New(message)
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
imageBytes []byte
)
// Generate thumbnail image from video
// Generate hash for temp file paths
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
// Generate raw preview based on file type
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, err
}
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
} else if convertHEIC {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
// For HEIC files, we've already done the resize/conversion, cache and return directly
previewType := determinePreviewType(file)
if previewType == previewTypeHEIC {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "image") {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory.
// ResizeWithSize() holds the imaging semaphore and only reads in chunks / decodes one at a time.
f, openErr := os.Open(file.RealPath)
if openErr != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, openErr)
return nil, fmt.Errorf("failed to open image file: %w", openErr)
}
defer f.Close()
imageBytes, err = service.CreatePreviewFromReaderWithSize(f, previewSize, file.Size)
if err != nil {
// Check if this is an unsupported JPEG format and FFmpeg fallback is enabled
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if enableJPEGFallback && service.ffmpegService != nil {
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
imageBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return imageBytes, nil
}
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
}
// For regular images that were already resized, cache and return
if previewType == previewTypeImage {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "video") {
// Check if this video format is enabled for preview generation
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
// Extract album artwork from audio files
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
if err != nil {
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, nil
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
// Check if context was cancelled during processing
@@ -523,62 +464,47 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Validate generated image size
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
// Resize if needed (for videos, audio, documents, office files)
if previewSize != "original" {
// resize image
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
options, err := getPreviewOptions(previewSize)
if err != nil {
// Check if this is an unsupported JPEG format (extended sequential, etc.)
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
if enableJPEGFallback && service.ffmpegService != nil {
// Fall back to FFmpeg for problematic JPEG files
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
resizedBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
// Cache and return the FFmpeg-converted image
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return resizedBytes, nil
}
// If FFmpeg fallback is disabled or not available, return the original error
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image: %w", err)
return nil, err
}
// Cache and return
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if resize failed
if strings.HasPrefix(file.Type, "image/jpeg") {
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("failed to resize preview image: %w", err)
}
}
// Cache and return resized image
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
// Cache and return original size
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
@@ -591,41 +517,40 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReader(bytes.NewReader(data), previewSize)
}
// CreatePreviewFromReader resizes an image from a reader (e.g. *os.File) without loading it fully into memory.
// Used for the image preview path so only concurrencyLimit decodes run at once.
func (s *Service) CreatePreviewFromReader(reader io.Reader, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReaderWithSize(reader, previewSize, 0)
}
// CreatePreviewFromReaderWithSize resizes an image with file size information for semaphore selection.
func (s *Service) CreatePreviewFromReaderWithSize(reader io.Reader, previewSize string, fileSize int64) ([]byte, error) {
var options ResizeOptions
// getPreviewOptions returns resize options for the given preview size
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
switch previewSize {
case "xlarge":
return ResizeOptions{
Width: 1024,
Height: 1024,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
case "large":
options = ResizeOptions{
return ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}
}, nil
case "small":
options = ResizeOptions{
return ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}
}, nil
default:
return nil, ErrUnsupportedFormat
return ResizeOptions{}, ErrUnsupportedFormat
}
}
// CreatePreview resizes an image from a reader with the given options
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
output := &bytes.Buffer{}
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
return nil, err
+50 -16
View File
@@ -1105,17 +1105,17 @@ const docTemplate = `{
},
"/api/media/subtitles": {
"get": {
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"description": "Returns raw subtitle content from external files or embedded streams",
"consumes": [
"application/json"
],
"produces": [
"text/vtt"
"text/plain"
],
"tags": [
"Subtitles"
],
"summary": "Extract embedded subtitles",
"summary": "Get subtitle content",
"parameters": [
{
"type": "string",
@@ -1132,15 +1132,22 @@ const docTemplate = `{
"required": true
},
{
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw WebVTT subtitle content",
"description": "Raw subtitle content in original format",
"schema": {
"type": "string"
}
@@ -1564,7 +1571,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"description": "url encoded destination path where to place the files inside the destination source",
"name": "path",
"in": "query",
"required": true
@@ -3063,8 +3070,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"in": "query",
"required": true
},
@@ -3312,14 +3319,14 @@ const docTemplate = `{
},
"/public/api/share/image": {
"get": {
"description": "Serves the banner or favicon file for a share",
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"produces": [
"application/octet-stream"
"image/jpeg"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon)",
"summary": "Get share image (banner or favicon) as preview",
"parameters": [
{
"type": "string",
@@ -3343,7 +3350,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "Image file content",
"description": "Preview image content (JPEG)",
"schema": {
"type": "file"
}
@@ -3607,6 +3614,9 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -3718,6 +3728,9 @@ const docTemplate = `{
"description": "Override embedded field to show source name",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -4075,8 +4088,11 @@ const docTemplate = `{
"type": "string"
},
"albumArt": {
"description": "base64 encoded album art / video thumbnail",
"type": "string"
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
},
"artist": {
"description": "track artist",
@@ -5052,6 +5068,9 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5148,6 +5167,9 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -5190,6 +5212,9 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5295,6 +5320,9 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -5340,6 +5368,9 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5448,6 +5479,9 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
+50 -16
View File
@@ -1094,17 +1094,17 @@
},
"/api/media/subtitles": {
"get": {
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"description": "Returns raw subtitle content from external files or embedded streams",
"consumes": [
"application/json"
],
"produces": [
"text/vtt"
"text/plain"
],
"tags": [
"Subtitles"
],
"summary": "Extract embedded subtitles",
"summary": "Get subtitle content",
"parameters": [
{
"type": "string",
@@ -1121,15 +1121,22 @@
"required": true
},
{
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw WebVTT subtitle content",
"description": "Raw subtitle content in original format",
"schema": {
"type": "string"
}
@@ -1553,7 +1560,7 @@
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"description": "url encoded destination path where to place the files inside the destination source",
"name": "path",
"in": "query",
"required": true
@@ -3052,8 +3059,8 @@
},
{
"type": "string",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"in": "query",
"required": true
},
@@ -3301,14 +3308,14 @@
},
"/public/api/share/image": {
"get": {
"description": "Serves the banner or favicon file for a share",
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"produces": [
"application/octet-stream"
"image/jpeg"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon)",
"summary": "Get share image (banner or favicon) as preview",
"parameters": [
{
"type": "string",
@@ -3332,7 +3339,7 @@
],
"responses": {
"200": {
"description": "Image file content",
"description": "Preview image content (JPEG)",
"schema": {
"type": "file"
}
@@ -3596,6 +3603,9 @@
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -3707,6 +3717,9 @@
"description": "Override embedded field to show source name",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -4064,8 +4077,11 @@
"type": "string"
},
"albumArt": {
"description": "base64 encoded album art / video thumbnail",
"type": "string"
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
},
"artist": {
"description": "track artist",
@@ -5041,6 +5057,9 @@
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5137,6 +5156,9 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -5179,6 +5201,9 @@
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5284,6 +5309,9 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -5329,6 +5357,9 @@
"bannerUrl": {
"type": "string"
},
"canEdit": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -5437,6 +5468,9 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
+43 -18
View File
@@ -122,6 +122,8 @@ definitions:
type: string
bannerUrl:
type: string
canEdit:
type: boolean
description:
type: string
disableAnonymous:
@@ -199,6 +201,8 @@ definitions:
source:
description: Override embedded field to show source name
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -447,8 +451,10 @@ definitions:
description: album name
type: string
albumArt:
description: base64 encoded album art / video thumbnail
type: string
description: album art image data (automatically base64-encoded in JSON)
items:
type: integer
type: array
artist:
description: track artist
type: string
@@ -1197,6 +1203,8 @@ definitions:
type: string
bannerUrl:
type: string
canEdit:
type: boolean
description:
type: string
disableAnonymous:
@@ -1264,6 +1272,8 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -1294,6 +1304,8 @@ definitions:
type: string
bannerUrl:
type: string
canEdit:
type: boolean
description:
type: string
disableAnonymous:
@@ -1367,6 +1379,8 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -1399,6 +1413,8 @@ definitions:
type: string
bannerUrl:
type: string
canEdit:
type: boolean
description:
type: string
disableAnonymous:
@@ -1474,6 +1490,8 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -2497,8 +2515,7 @@ paths:
get:
consumes:
- application/json
description: Extracts embedded subtitle content from video files by stream index
and returns raw WebVTT content
description: Returns raw subtitle content from external files or embedded streams
parameters:
- description: Index path to the video file
in: query
@@ -2510,15 +2527,22 @@ paths:
name: source
required: true
type: string
- description: Stream index for embedded subtitle extraction, defaults to 0
- description: Subtitle track name (filename for external, descriptive name
for embedded)
in: query
name: index
type: integer
name: name
required: true
type: string
- description: Whether this is an embedded stream (true) or external file (false),
defaults to false
in: query
name: embedded
type: boolean
produces:
- text/vtt
- text/plain
responses:
"200":
description: Raw WebVTT subtitle content
description: Raw subtitle content in original format
schema:
type: string
"400":
@@ -2545,7 +2569,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Extract embedded subtitles
summary: Get subtitle content
tags:
- Subtitles
/api/preview:
@@ -2855,7 +2879,7 @@ paths:
Supports file uploads and directory creation.
parameters:
- description: url encoded destination path where to place the files inside
the destination source, a directory must end in / to create a directory
the destination source
in: query
name: path
required: true
@@ -3839,10 +3863,10 @@ paths:
name: hash
required: true
type: string
- description: Target path within the share to upload to. Must be relative to
share root.
- description: path within the share to upload to. Must be relative to share
root.
in: query
name: targetPath
name: path
required: true
type: string
- description: If true, overwrite existing files/folders. Defaults to false.
@@ -4018,7 +4042,8 @@ paths:
- Public Shares
/public/api/share/image:
get:
description: Serves the banner or favicon file for a share
description: Returns a resizable preview (large size) for the banner or favicon
file of a share
parameters:
- description: Share hash
in: query
@@ -4034,10 +4059,10 @@ paths:
name: favicon
type: boolean
produces:
- application/octet-stream
- image/jpeg
responses:
"200":
description: Image file content
description: Preview image content (JPEG)
schema:
type: file
"400":
@@ -4058,7 +4083,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get share image (banner or favicon)
summary: Get share image (banner or favicon) as preview
tags:
- Public Shares
/public/api/shareinfo:
+391 -1374
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -22,12 +22,11 @@
"dependencies": {
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"axios": "^1.7.9",
"axios": "^1.13.5",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"epubjs": "^0.3.93",
"file-loader": "^6.2.0",
"glob": "^9.3.5",
"highlight.js": "^11.11.1",
"mammoth": "^1.9.1",
@@ -41,6 +40,9 @@
"vue-lazyload": "^3.0.0",
"vue-router": "^4.3.0"
},
"overrides": {
"minimatch": "^10.2.1"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
+2 -36
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>{{ .htmlVars.title }}</title>
<title>"{{ .htmlVars.title }}"</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
@@ -17,7 +17,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
<meta name="theme-color" content="{{ .htmlVars.color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
@@ -52,40 +52,6 @@
<!-- Injected variables come from backend server -->
<script>
window.globalVars = {{ .globalVars }};
var dynamicManifest = {
"name": window.globalVars.name || 'FileBrowser Quantum',
"short_name": window.globalVars.name || 'FileBrowser',
"icons": [
{
"src": "{{ .htmlVars.pwaIcon192 }}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "{{ .htmlVars.pwaIcon256 }}",
"sizes": "256x256",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "{{ .htmlVars.pwaIcon512 }}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "{{ .htmlVars.color }}",
"description": "{{ .htmlVars.description }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
const blob = new Blob([stringManifest], { type: 'application/json' });
const manifestURL = URL.createObjectURL(blob);
document.querySelector('#manifestPlaceholder').setAttribute('href', manifestURL);
</script>
<style>
-12
View File
@@ -1,12 +0,0 @@
import { globalVars } from "@/utils/constants";
const ssl = window.location.protocol === "https:";
const protocol = ssl ? "wss:" : "ws:";
export default function command(url, command, onmessage, onclose) {
url = `${protocol}//${window.location.host}${globalVars.baseURL}api/command${url}`;
let conn = new window.WebSocket(url);
conn.onopen = () => conn.send(command);
conn.onmessage = onmessage;
conn.onclose = onclose;
}
+64 -52
View File
@@ -12,8 +12,8 @@ export async function fetchFiles(source, path, content = false, metadata = false
}
try {
const apiPath = getApiPath('api/resources', {
path: encodeURIComponent(path),
source: encodeURIComponent(source),
path: path,
source: source,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
})
@@ -32,8 +32,6 @@ async function resourceAction(source, path, method, content) {
throw new Error('no source provided')
}
try {
source = encodeURIComponent(source)
path = encodeURIComponent(path)
const apiPath = getApiPath('api/resources', { path, source })
let opts = { method }
if (content) {
@@ -76,15 +74,12 @@ export async function bulkDelete(items) {
},
body: JSON.stringify(items),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
// Both are valid responses that should be returned, not thrown as errors
if (response.status === 200 || response.status === 207) {
return data
}
// For other error status codes, throw an error
const error = new Error(data.message || response.statusText)
error.status = response.status
@@ -110,12 +105,12 @@ export async function download(format, files, shareHash = "") {
// Check if chunked download should be used (single file only)
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
files[0].size >= sizeThreshold
if (useChunkedDownload) {
@@ -127,14 +122,14 @@ export async function download(format, files, shareHash = "") {
if (format !== 'zip') {
format = 'tar.gz'
}
// For non-share downloads, validate single source and build file list
let source = null
let filePaths = []
if (shareHash) {
// For shares, no source parameter needed, just paths
filePaths = files.map(file => encodeURIComponent(file.path))
filePaths = files.map(file => file.path)
} else {
// Validate all files are from the same source
for (let file of files) {
@@ -146,19 +141,19 @@ export async function download(format, files, shareHash = "") {
} else if (source !== file.source) {
throw new Error('All files must be from the same source for downloads')
}
filePaths.push(encodeURIComponent(file.path))
filePaths.push(file.path)
}
}
const params = {
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: encodeURIComponent(source) }),
...(!shareHash && source && { source: source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const url = window.origin + apiPath
@@ -172,7 +167,7 @@ export async function download(format, files, shareHash = "") {
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link)
@@ -181,36 +176,36 @@ export async function download(format, files, shareHash = "") {
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
if (chunkSizeMb === 0) {
throw new Error("Chunked download is disabled (chunk size is 0)")
}
const chunkSize = chunkSizeMb * 1024 * 1024 // Convert MB to bytes
const fileSize = file.size
// Extract filename from path if name is not available
const fileName = file.name || (file.path ? file.path.split('/').pop() : 'download')
// Add to download manager
const downloadId = downloadManager.add(file, shareHash)
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showHover({ name: 'download' })
}
const params = {
file: encodeURIComponent(file.path),
file: file.path,
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
...(!shareHash && file.source && { source: file.source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const baseUrl = window.origin + apiPath
@@ -225,7 +220,7 @@ async function downloadChunked(file, shareHash = "") {
let loaded = 0
while (offset < fileSize) {
const download = downloadManager.findById(downloadId);
if (download && download.status === "cancelled") {
// Silently handle cancellation - don't throw error
@@ -249,7 +244,7 @@ async function downloadChunked(file, shareHash = "") {
// Track progress within the chunk using ReadableStream
const expectedChunkSize = end - offset + 1;
const reader = response.body.getReader();
const chunkParts = [];
let chunkLoaded = 0;
@@ -264,14 +259,14 @@ async function downloadChunked(file, shareHash = "") {
reading = false;
break;
}
chunkParts.push(value);
chunkLoaded += value.length;
// Calculate progress: only count up to expected chunk size to avoid over-counting
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize);
const totalLoaded = offset + chunkProgress;
// Update progress in real-time, but throttle updates for performance
if (chunkLoaded - lastProgressUpdate >= progressUpdateInterval || chunkLoaded >= expectedChunkSize) {
downloadManager.updateProgress(downloadId, totalLoaded, fileSize);
@@ -295,19 +290,19 @@ async function downloadChunked(file, shareHash = "") {
chunk.set(part, position);
position += part.length;
}
// Only use the expected chunk size portion if server returned more (handles Range header issues)
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
: chunk.buffer;
chunks.push(chunkToUse)
// Always use expected chunk size for progress to avoid double-counting
loaded += expectedChunkSize
// Final progress update for this chunk
downloadManager.updateProgress(downloadId, loaded, fileSize)
offset = end + 1
}
@@ -360,8 +355,8 @@ export function post(
}
try {
const apiPath = getApiPath("api/resources", {
path: encodeURIComponent(path),
source: encodeURIComponent(source),
path: path,
source: source,
override: overwrite,
...(isDir && { isDir: 'true' })
});
@@ -453,7 +448,7 @@ export async function moveCopy(
'Content-Type': 'application/json',
'sessionId': state.sessionId
}
const response = await fetch(apiPath, {
method: 'PATCH',
headers: headers,
@@ -496,8 +491,8 @@ export async function checksum(source, path, algo) {
}
try {
const params = {
path: encodeURIComponent(path),
source: encodeURIComponent(source),
path: path,
source: source,
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
@@ -516,8 +511,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
try {
const params = {
source: encodeURIComponent(source),
file: encodeURIComponent(path),
source: source,
file: path,
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
@@ -537,9 +532,9 @@ export function getPreviewURL(source, path, modified) {
}
try {
const params = {
path: encodeURIComponent(path),
path: path,
key: Date.parse(modified), // Use modified date as cache key
source: encodeURIComponent(source),
source: source,
inline: 'true'
}
const apiPath = getApiPath('api/preview', params)
@@ -568,9 +563,9 @@ export async function sources() {
export async function GetOfficeConfig(req) {
const params = {
path: encodeURIComponent(req.path),
...(req.hash && { hash: encodeURIComponent(req.hash) }),
...(req.source && { source: encodeURIComponent(req.source) })
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
}
let apiPath = getApiPath('api/onlyoffice/config', params)
if (req.hash) {
@@ -578,4 +573,21 @@ export async function GetOfficeConfig(req) {
}
const res = await fetchURL(apiPath)
return await res.json()
}
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('api/media/subtitles', {
source: source,
path: path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
+11 -15
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath, encodedPath } from "@/utils/url.js";
import { getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -17,7 +17,6 @@ import { state } from "@/store";
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
path = encodedPath(path);
const params = {
path: path,
hash,
@@ -28,7 +27,7 @@ export async function fetchPub(path, hash, password = "", content = false, metad
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
"X-SHARE-PASSWORD": password || "",
},
});
@@ -61,10 +60,8 @@ export async function fetchPub(path, hash, password = "", content = false, metad
export function getDownloadURL(share, files, inline=false) {
// Handle array of files for repeated 'file' parameters
const fileArray = Array.isArray(files) ? files : [files]
const filePaths = fileArray.map(file => encodeURIComponent(file))
const params = {
file: filePaths, // Array of file paths - getPublicApiPath will create repeated parameters
file: fileArray, // Array of file paths - getPublicApiPath will create repeated parameters
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
@@ -82,7 +79,7 @@ export function getDownloadURL(share, files, inline=false) {
export function getPreviewURL(path, size) {
try {
const params = {
path: encodeURIComponent(path),
path: path,
hash: state.shareInfo.hash,
inline: 'true',
// Only add size parameter if specified and not the default
@@ -115,7 +112,7 @@ export function post(
}
try {
const apiPath = getPublicApiPath("resources", {
targetPath: encodeURIComponent(path),
path: path,
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
@@ -156,13 +153,13 @@ export function post(
// If parsing fails, use responseText or default message
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
// Show notification for upload errors
notify.showError(errorMessage);
reject(error);
}
};
@@ -172,7 +169,7 @@ export function post(
notify.showError("Network error during upload");
reject(error);
};
request.onabort = () => {
const error = new Error("Upload aborted");
notify.showError("Upload was aborted");
@@ -207,7 +204,6 @@ async function resourceAction(hash, path, method, content, token = "") {
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
path = encodeURIComponent(path)
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
@@ -240,12 +236,12 @@ export async function bulkDelete(items) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
const hash = state.shareInfo?.hash;
if (!hash) {
throw new Error('share hash is required')
}
const params = {
hash: hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
+4 -6
View File
@@ -4,14 +4,12 @@ import { getApiPath } from "@/utils/url.js";
export default async function search(base, sources, query, largest = false) {
try {
query = encodeURIComponent(query);
// Ensure sources is an array
const sourcesArray = Array.isArray(sources) ? sources : [sources];
const params = {
query: query,
sources: encodeURIComponent(sourcesArray.join(","))
sources: sourcesArray.join(",")
};
// Only include scope if searching a single source
@@ -20,7 +18,7 @@ export default async function search(base, sources, query, largest = false) {
if (!base.endsWith("/")) {
base += "/";
}
params.scope = encodeURIComponent(base);
params.scope = base;
}
if (largest) {
@@ -44,8 +42,8 @@ export async function findDuplicates(base, source, minSizeMb, useChecksum = fals
base += "/";
}
const params = {
scope: encodeURIComponent(base),
source: encodeURIComponent(source),
scope: base,
source: source,
minSizeMb: minSizeMb.toString()
};
+1 -1
View File
@@ -26,7 +26,7 @@ export async function list() {
*/
export async function get(path, source) {
try {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const params = { path, source };
const apiPath = getApiPath("api/share", params);
let data = await fetchJSON(apiPath);
return adjustedData(data);
+1
View File
@@ -1,6 +1,7 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
export async function getAllUsers() {
try {
const apiPath = getApiPath('api/users')
+4 -1
View File
@@ -148,7 +148,7 @@
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
<action v-if="hasDownload && !req.isDir" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
<action v-if="showEdit" icon="edit" :label="$t('general.edit')" @action="edit()" />
<action v-if="showSave" icon="save" :label="$t('general.save')" @action="save()" />
@@ -187,6 +187,9 @@ export default {
},
},
computed: {
showWatch() {
return this.hasDownload && !this.req.isDir && !this.isShare;
},
showGoToItem() {
return this.isDuplicateFinder && this.selectedCount == 1;
},
+6 -1
View File
@@ -250,7 +250,7 @@ export default {
let index = 0;
const updateThumbnail = () => {
if (state.popupPreviewSourceInfo?.url === "") {
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== this.path) {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
@@ -361,6 +361,11 @@ export default {
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
},
beforeUnmount() {
// Clean up any pending animation timeouts
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
},
};
</script>
@@ -406,6 +406,10 @@ export default {
},
/** @param {MouseEvent} event */
onRightClick(event) {
if (!this.updateGlobalState) {
event.preventDefault();
return;
}
event.preventDefault(); // Prevent default context menu
// If one or fewer items are selected, reset the selection
if (this.updateGlobalState) {
+29 -5
View File
@@ -17,9 +17,9 @@
:name="getItemName(item.path)"
:isDir="item.type === 'directory'"
:source="item.source"
:type="item.type || 'file'"
:size="item.size || 0"
:modified="item.modified || ''"
:type="item.type"
:size="item.size"
:modified="item.modified"
:index="index"
:path="item.path"
:hasPreview="item.hasPreview"
@@ -57,6 +57,7 @@ import { notify } from "@/notify";
import { getTypeInfo } from "@/utils/mimetype";
import ListingItem from "@/components/files/ListingItem.vue";
import { eventBus } from "@/store/eventBus";
import url from "@/utils/url";
export default {
name: "delete",
@@ -107,6 +108,7 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else if (!this.isListing) {
const item = state.req.items[state.selected[0]];
@@ -118,6 +120,7 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else {
for (let index of state.selected) {
@@ -130,6 +133,7 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
}
}
@@ -227,7 +231,7 @@ export default {
const failedCount = response.failed ? response.failed.length : 0;
if (failedCount === 0) {
// All succeeded - close prompt and reload
// All succeeded - close prompt and handle navigation
buttons.success("delete");
notify.showSuccessToast(this.$t('prompts.deleted'));
@@ -242,7 +246,27 @@ export default {
if (!this.items) {
mutations.resetSelected();
}
mutations.setReload(true);
// Smart navigation: if in preview mode with single item deletion, navigate intelligently
if (getters.isPreviewView() && this.itemsToDelete.length === 1) {
// Try next, then previous, then parent directory
if (state.navigation.nextItem) {
// Use goToItem with undefined to use router.replace (no history entry)
url.goToItem(state.navigation.nextItem.source, state.navigation.nextItem.path, undefined);
} else if (state.navigation.previousItem) {
// Use goToItem with undefined to use router.replace (no history entry)
url.goToItem(state.navigation.previousItem.source, state.navigation.previousItem.path, undefined);
} else {
// Navigate to parent directory of deleted item
const deletedItemPath = this.itemsToDelete[0].path;
const parentPath = url.removeLastDir(deletedItemPath);
const deletedItemSource = this.itemsToDelete[0].source;
url.goToItem(deletedItemSource, parentPath, {});
}
} else {
// For listing view or multiple deletions, just reload
mutations.setReload(true);
}
} else if (succeededCount > 0) {
// Partial success - keep prompt open to show errors
buttons.done("delete");
+18 -12
View File
@@ -38,7 +38,7 @@
:modified="item.originalItem?.modified || new Date().toISOString()"
:index="index"
:path="item.path"
:hasPreview="item.originalItem?.hasPreview || false"
:hasPreview="item.originalItem?.hasPreview && item.type !== 'directory' || false"
:metadata="item.originalItem?.metadata"
:hasDuration="item.originalItem?.hasDuration || false"
:updateGlobalState="false"
@@ -412,13 +412,14 @@ export default {
handleItemClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Display mode: single click navigates directly to the file
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
// Create a synthetic event-like object for compatibility with existing methods
// Browse mode: single click selects only, emit selected, do not navigate
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -428,18 +429,17 @@ export default {
preventDefault: () => {},
stopPropagation: () => {},
};
if (state.user.singleClick) {
this.next(syntheticEvent);
} else {
this.select(syntheticEvent);
}
this.select(syntheticEvent);
},
handleItemDblClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Create a synthetic event for double-click
// Double click: navigate (into folder or open file)
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -457,9 +457,12 @@ export default {
if (this.selected === path) {
this.selected = null;
this.selectedSource = null;
this.selectedType = null;
this.$emit("update:selected", {
path: this.current,
source: this.source
source: this.source,
type: 'directory',
isValid: !this.requireFileSelection,
});
return;
}
@@ -468,9 +471,12 @@ export default {
this.selected = path;
let clickedItem = this.items.find(item => item.path === path);
this.selectedSource = clickedItem ? clickedItem.source : this.source;
this.selectedType = clickedItem ? clickedItem.type : null;
this.$emit("update:selected", {
path: this.selected,
source: this.selectedSource
source: this.selectedSource,
type: this.selectedType,
isValid: this.selectedType === 'directory' ? !this.requireFileSelection : true,
});
},
createDir: async function () {
+50 -44
View File
@@ -27,31 +27,20 @@
</div>
<div v-if="isPickingFile">
<file-list
@update:selected="updateTempFilePath"
:browse-source="displaySource"
:browse-path="displayPath"
:show-files="true"
:show-folders="true"
:allowed-file-types="filePickerAllowedTypes"
:require-file-selection="true"
:title="filePickerTitle">
<file-list @update:selected="updateTempFilePath" :browse-source="displaySource" :browse-path="displayPath"
:show-files="true" :show-folders="true" :allowed-file-types="filePickerAllowedTypes"
:require-file-selection="true" :title="filePickerTitle">
</file-list>
<div class="card-action">
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat button--blue" @click="confirmFilePicker" :disabled="!filePickerSelectionValid" :aria-label="$t('general.ok')"
:title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
</div>
</div>
<div v-else-if="!isPickingFile">
<div aria-label="share-path" class="searchContext button"> {{ $t('general.path', { suffix: ':' }) }} {{
<div v-if="isFiles" aria-label="share-path" class="searchContext button"> {{ $t('general.path', { suffix: ':' }) }} {{
displayPath }}</div>
<div v-else>
<a :href="link.sourceURL" class="button button--block">
{{ $t('buttons.goToSource') }}
</a>
</div>
<p> {{ $t('share.notice') }} </p>
<div v-if="listing">
@@ -110,8 +99,8 @@
</i>
</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0" @keyup.enter="submit"
v-model.trim="time" />
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0"
@keyup.enter="submit" v-model.trim="time" />
<select class="flat-left input form-dropdown" v-model="unit" :aria-label="$t('time.unit')">
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
@@ -148,16 +137,19 @@
{{ $t('share.customizeSidebarLinksButton') }}
</button>
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify" :name="$t('share.allowModify')"
:description="$t('share.allowModifyDescription')" aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate" :name="$t('share.allowCreate')"
:description="$t('share.allowCreateDescription')"
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify"
:name="$t('share.allowModify')" :description="$t('share.allowModifyDescription')"
aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate"
:name="$t('share.allowCreate')" :description="$t('share.allowCreateDescription')"
aria-label="allow creating and uploading files and folders toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete" :name="$t('share.allowDelete')"
:description="$t('share.allowDeleteDescription')" aria-label="allow deleting files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete"
:name="$t('share.allowDelete')" :description="$t('share.allowDeleteDescription')"
aria-label="allow deleting files toggle" />
</div>
</div>
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true" :start-collapsed="!showMoreExpanded" @toggle="showMoreExpanded = $event">
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true"
:start-collapsed="!showMoreExpanded" @toggle="showMoreExpanded = $event">
<div class="settings-items">
<p>
{{ $t("prompts.shareTheme") }}
@@ -213,7 +205,8 @@
<p>
{{ $t("share.enforceDarkLightMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))" @mouseleave="hideTooltip">
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))"
@mouseleave="hideTooltip">
help
</i>
</p>
@@ -227,22 +220,22 @@
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableThumbnails"
:name="$t('share.disableThumbnails')" :description="$t('share.disableThumbnailsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="showHidden"
:name="$t('profileSettings.showHiddenFiles')" :description="$t('profileSettings.showHiddenFilesDescription')" />
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons" :name="$t('share.hideNavButtons')"
:description="$t('share.hideNavButtonsDescription')" />
:name="$t('profileSettings.showHiddenFiles')"
:description="$t('profileSettings.showHiddenFilesDescription')" />
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons"
:name="$t('share.hideNavButtons')" :description="$t('share.hideNavButtonsDescription')" />
<ToggleSwitch class="item" v-model="disableShareCard" :name="$t('share.disableShareCard')"
:description="$t('share.disableShareCardDescription')" />
<ToggleSwitch class="item" v-model="disableSidebar"
:name="$t('share.disableSidebar')" :description="$t('share.disableSidebarDescription')" />
<ToggleSwitch class="item" v-model="disableSidebar" :name="$t('share.disableSidebar')"
:description="$t('share.disableSidebarDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="perUserDownloadLimit"
:name="$t('share.perUserDownloadLimit')" :description="$t('share.perUserDownloadLimitDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="extractEmbeddedSubtitles"
:name="$t('share.extractEmbeddedSubtitles')"
:description="$t('share.extractEmbeddedSubtitlesDescription')" />
<ToggleSwitch class="item" v-model="disableLoginOption"
:name="$t('share.disableLoginOption')"
:description="$t('share.disableLoginOptionDescription')" />
</div>
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="extractEmbeddedSubtitles"
:name="$t('share.extractEmbeddedSubtitles')"
:description="$t('share.extractEmbeddedSubtitlesDescription')" />
<ToggleSwitch class="item" v-model="disableLoginOption" :name="$t('share.disableLoginOption')"
:description="$t('share.disableLoginOptionDescription')" />
</div>
<div v-if="shareType === 'normal'">
<p>
@@ -342,6 +335,16 @@
{{ $t("general.share") }}
</button>
</div>
<div v-if="isPickingFile" class="card-action">
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat button--blue" @click="confirmFilePicker" :disabled="!filePickerSelectionValid"
:aria-label="$t('general.ok')" :title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
</div>
</template>
<script>
import { notify } from "@/notify";
@@ -476,6 +479,9 @@ export default {
isListing() {
return getters.isListing(); // Access getter directly from the store
},
isFiles() {
return getters.isFiles(); // Access getter directly from the store
},
url() {
if (state.isSearchActive) {
const file = /** @type {FilebrowserFile} */ (this.selected[0]);
@@ -864,7 +870,7 @@ export default {
icon: "qr_code"
}
];
// Only add Download link for normal shares, not upload shares
if (this.shareType !== 'upload') {
this.sidebarLinks.push({
@@ -964,7 +970,7 @@ export default {
if (this.tempFilePath && this.tempFileSource) {
// Build query params format: source=sourcename&path=indexpath
const queryParams = `source=${encodeURIComponent(this.tempFileSource)}&path=${encodeURIComponent(this.tempFilePath)}`;
if (this.filePickerField === 'banner') {
this.banner = queryParams;
} else if (this.filePickerField === 'favicon') {
@@ -20,7 +20,10 @@
class="link-drag-handle">
<i class="material-icons">drag_indicator</i>
</div>
<div class="link-icon">
<div v-if="link.category === 'divider'" class="link-icon">
<i class="material-icons">horizontal_rule</i>
</div>
<div v-else class="link-icon">
<i :class="getIconClass(link.icon)">{{ link.icon }}</i>
</div>
<div class="link-details">
@@ -66,6 +69,7 @@
<option value="share">{{ $t('general.share') }}</option>
<option v-if="context === 'user'" value="tool">{{ $t('general.tool') }}</option>
<option value="custom">{{ $t('sidebar.customLink') }}</option>
<option value="divider">{{ $t('general.divider') }}</option>
<option v-if="context === 'share'" value="shareInfo">{{ $t('share.shareInfo') }}</option>
<option v-if="context === 'share'" value="download">{{ $t('general.download') }}</option>
</select>
@@ -148,6 +152,13 @@
<p>{{ $t('share.downloadDescription') }}</p>
</div>
<!-- Divider - special visual separator -->
<div v-if="newLink.category === 'divider'" class="form-group">
<p>{{ $t('sidebar.linkName') }}</p>
<input aria-label="Link Name" v-model="newLink.name" type="text" class="input"
:placeholder="$t('sidebar.dividerNamePlaceholder')" />
</div>
<!-- Custom Link Input -->
<div v-if="newLink.category === 'custom'" class="form-group">
<p>{{ $t('sidebar.linkName') }}</p>
@@ -159,8 +170,8 @@
:placeholder="$t('sidebar.linkUrlPlaceholder')" />
</div>
<!-- Icon Selection - Available for ALL link types -->
<div v-if="newLink.category" class="form-group">
<!-- Icon Selection - Available for ALL link types except divider -->
<div v-if="newLink.category && newLink.category !== 'divider'" class="form-group">
<p>{{ $t('sidebar.linkIcon') }}</p>
<div class="icon-input-group">
<input v-model="newLink.icon" type="text" class="input icon-input"
@@ -285,8 +296,10 @@ export default {
isNewLinkValid() {
if (!this.newLink.category) return false;
// Special link types for shares don't need additional validation
if (this.newLink.category === "shareInfo" || this.newLink.category === "download") {
// Special link types that don't need additional validation
if (this.newLink.category === "shareInfo" ||
this.newLink.category === "download" ||
this.newLink.category === "divider") {
return true;
}
@@ -392,10 +405,16 @@ export default {
share: this.$t('general.share'),
shareInfo: this.$t('share.shareInfo'),
download: this.$t('general.download'),
divider: this.$t('general.divider'),
};
return labels[category] || category;
},
getLinkDisplayName(link) {
// For dividers, show the name or a default
if (link.category === 'divider') {
return link.name || this.$t('general.divider');
}
// Check if the name looks like a translation key that needs translating
if (link.category === 'shareInfo' && link.name === 'share.shareInfo') {
return this.$t('share.shareInfo');
@@ -546,6 +565,11 @@ export default {
} else if (this.newLink.category === "download") {
// Download is a special action link
linkData.target = "#";
} else if (this.newLink.category === "divider") {
// Divider is a visual separator with no action
linkData.target = "#";
linkData.name = this.newLink.name || ""; // Keep the custom name if provided
linkData.icon = ""; // Dividers don't need an icon
} else if (this.newLink.category === "custom") {
linkData.target = this.processCustomUrl(this.newLink.target);
} else if (this.newLink.category === "source") {
+2 -26
View File
@@ -8,6 +8,7 @@
<script>
import { defineComponent } from "vue";
import { availableLocales } from "@/i18n/index.ts";
export default defineComponent({
name: "Languages",
@@ -19,32 +20,7 @@ export default defineComponent({
},
data() {
return {
locales: {
he: "he",
hu: "hu",
ar: "ar",
de: "de",
en: "en",
es: "es",
fr: "fr",
is: "is",
it: "it",
ja: "ja",
ko: "ko",
nlBE: "nl-be",
pl: "pl",
ptBR: "pt-br",
pt: "pt",
ro: "ro",
ru: "ru",
sk: "sk",
svSE: "sv-se",
tr: "tr",
ua: "ua",
zhCN: "zh-cn",
zhTW: "zh-tw",
cz: "cz",
},
locales: availableLocales,
};
},
methods: {
+3 -4
View File
@@ -31,7 +31,7 @@
</div>
<div v-if="!disableQuickToggles" class="card-wrapper" @mouseleave="hideTooltip">
<div class="quick-toggles" :class="{ 'extra-padding': !hasCreateOptions }">
<div class="quick-toggles" :class="{ 'extra-padding': hasCreateOptions }">
<div class="clickable" :class="{ active: user?.singleClick }" @click="toggleClick"
@mouseenter="showTooltip($event, $t('index.toggleClick'))" @mouseleave="hideTooltip" v-if="!isInvalidShare">
<i class="material-icons">ads_click</i>
@@ -97,7 +97,7 @@ export default {
},
hasCreateOptions() {
if (getters.isShare()) {
return state.shareInfo?.allowCreate
return state.shareInfo?.allowCreate == true
}
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
},
@@ -128,7 +128,7 @@ export default {
shouldShowLogin() {
if (getters.isShare()) {
// Don't show login until shareInfo is fully loaded
if (state.shareInfo && state.shareInfo.disableLoginOption == undefined) {
if (state.shareInfo && state.shareInfo?.disableLoginOption) {
return false;
}
}
@@ -146,7 +146,6 @@ export default {
},
},
methods: {
openContextMenu() {
mutations.resetSelected();
mutations.showHover({
+64 -26
View File
@@ -7,17 +7,33 @@
</div>
<!-- Links section header -->
<div class="sidebar-links-header" :class="{ 'no-edit-options': isShare, 'with-top-spacing': isShare && !disableShareCard }">
<i v-if="!isShare" @click="goHome()" class="material-icons action">home</i>
<div class="sidebar-links-header"
:class="{ 'with-top-spacing': isShare && !disableShareCard }">
<i :class="{ 'disabled': !isLoggedIn }" @click="goHome()" class="material-icons action">home</i>
<span>{{ $t("general.links") }}</span>
<i v-if="!isShare" @mouseenter="showTooltip($event, $t('sidebar.customizeLinks'))" @mouseleave="hideTooltip"
<i v-if="isShare" aria-label="Edit Share" @mouseenter="showTooltip($event, editShareText())" @mouseleave="hideTooltip"
:class="{ 'disabled': !canEdit }"
@click="showEditShareHover" class="material-icons action">edit</i>
<i v-else @mouseenter="showTooltip($event, $t('sidebar.customizeLinks'))" @mouseleave="hideTooltip"
@click="openSidebarLinksPrompt" class="material-icons action">edit</i>
</div>
<transition-group name="expand" tag="div" class="inner-card">
<template v-for="(link, index) in sidebarLinksToDisplay" :key="`link-${index}-${link.category}`">
<!-- Divider: renders as hr line or text label -->
<div v-if="link.category === 'divider'" class="sidebar-divider-container">
<hr v-if="!link.name || link.name.toLowerCase() === 'divider'" class="sidebar-divider" />
<span v-else class="sidebar-divider-text">{{ link.name }}</span>
</div>
<a v-else-if="link.category == 'custom' && link.name == 'sourceLocation'" :href="link.target"
:aria-label="link.name" class="action button sidebar-link-button" @click.prevent="handleLinkClick(link)">
<div class="link-container">
<i class="material-icons link-icon">open_in_new</i>
<span>{{ $t('buttons.goToSource') }}</span>
</div>
</a>
<!-- Source-type links: styled exactly like original sources -->
<a v-if="link.category === 'source'" :href="getLinkHref(link)"
<a v-else-if="link.category === 'source'" :href="getLinkHref(link)"
class="action button source-button sidebar-link-button" :class="{
active: isLinkActive(link),
disabled: !isLinkAccessible(link)
@@ -46,12 +62,11 @@
</i>
</div>
<div v-if="hasUsageInfo(link)" class="usage-info">
<ProgressBar
<ProgressBar
:key="`progress-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${sourceInfo[link.sourceName]?.total || 0}`"
:val="getProgressBarValue(sourceInfo[link.sourceName] || {})"
:max="(sourceInfo[link.sourceName] || {}).total || 1"
:status="getProgressBarStatus(sourceInfo[link.sourceName] || {})"
unit="bytes">
:val="getProgressBarValue(sourceInfo[link.sourceName] || {})"
:max="(sourceInfo[link.sourceName] || {}).total || 1"
:status="getProgressBarStatus(sourceInfo[link.sourceName] || {})" unit="bytes">
</ProgressBar>
</div>
</a>
@@ -59,22 +74,12 @@
<!-- Non-source links: tool and custom links with simple icon style -->
<a v-else :aria-label="link.name" :href="getLinkHref(link)" class="action button sidebar-link-button"
:class="{ active: isLinkActive(link) }" @click.prevent="handleLinkClick(link)">
<div class="link-container">
<div class="link-container">
<i :class="getIconClass(link.icon) + ' link-icon'">{{ link.icon }}</i>
<span>{{ link.name }}</span>
</div>
</a>
</template>
<!-- Edit Share link - only shown when viewing a share and user has share permissions -->
<a v-if="isShare && canEditShare" :aria-label="$t('general.edit', { suffix: ' ' + $t('general.share') })" href="#"
class="action button sidebar-link-button"
@click.prevent="showEditShareHover">
<div class="link-container">
<i class="material-icons link-icon">edit</i>
<span>{{ $t("general.edit", { suffix: " " + $t("general.share") }) }}</span>
</div>
</a>
</transition-group>
</div>
</transition>
@@ -97,11 +102,17 @@ export default {
ShareInfo,
},
computed: {
canEdit() {
return state.shareInfo?.canEdit || false;
},
isLoggedIn() {
return state.user?.username !== 'anonymous';
},
isShare: () => getters.isShare(),
hasLinks() {
return this.sidebarLinksToDisplay?.length > 0;
},
user: () => (state.user || {username: 'anonymous'}),
user: () => (state.user || { username: 'anonymous' }),
sourceInfo() {
// Access state.sources.info to create reactive dependency
return state.sources.info;
@@ -117,10 +128,6 @@ export default {
// Check if user has customized their links
return this.user?.sidebarLinks && this.user.sidebarLinks.length > 0;
},
canEditShare() {
// Check if user is logged in and has share permissions
return state.user && state.user.permissions && state.user.permissions.share;
},
// Share info card props
disableShareCard() {
return state.shareInfo?.disableShareCard;
@@ -151,6 +158,9 @@ export default {
},
methods: {
getIconClass,
editShareText() {
return this.$t("general.edit") + " " + this.$t("general.share");
},
hasUsageInfo(link) {
// Check if usage info should be displayed for this link
// Returns true when link is accessible and has usage > 0
@@ -185,6 +195,9 @@ export default {
return baseURL + target;
},
goHome() {
if (!this.isLoggedIn) {
return;
}
this.$router.push('/');
},
getDefaultLinks() {
@@ -320,6 +333,9 @@ export default {
mutations.closeHovers();
},
openSidebarLinksPrompt() {
if (!this.isLoggedIn) {
return;
}
mutations.showHover({
name: "SidebarLinks",
});
@@ -407,12 +423,12 @@ export default {
</script>
<style scoped>
.no-edit-options {
justify-content: center !important;
}
.share-info-section {
margin-top: 0 !important;
margin-bottom: 1em;
padding-bottom: 0.5em;
border-bottom: 1px solid var(--borderColor);
@@ -627,6 +643,7 @@ a.sidebar-link-button {
.realtime-pulse.warning>.center {
fill: rgb(255, 157, 0);
}
.vue-simple-progress {
margin-top: 0 !important;
}
@@ -640,4 +657,25 @@ a.sidebar-link-button {
.edit-share-button .link-icon {
color: var(--primaryColor);
}
.sidebar-divider-container {
width: 100%;
margin-top: 0.5em;
}
.sidebar-divider {
width: 50%;
border: none;
border-top: 0.1em solid var(--divider);
}
.sidebar-divider-text {
display: flex;
font-size: .85em;
color: var(--textSecondary);
letter-spacing: .05em;
justify-content: center;
align-content: center;
align-items: center;
}
</style>
+1
View File
@@ -8,6 +8,7 @@
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.05);
transition: .1s ease all;
text-align: center;
}
.button--block {
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "الانتقال إلى العنصر",
"watchFile": "ملف المشاهدة",
"showMore": "إظهار الخيارات المتقدمة",
"showLess": "إخفاء الخيارات المتقدمة"
"showLess": "إخفاء الخيارات المتقدمة",
"goToSource": "الانتقال إلى موقع المصدر"
},
"download": {
"downloadFile": "Download File",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Create an account",
@@ -320,7 +322,6 @@
"adminOptions": "خيارات المستخدم المسؤول",
"searchOptions": "خيارات البحث",
"systemAdmin": "النظام والإدارة",
"configViewer": "عارض التكوين",
"configViewerShowFull": "إظهار التكوين الكامل",
"configViewerShowComments": "إظهار التعليقات",
"configViewerLoadConfig": "تكوين التحميل",
@@ -332,7 +333,8 @@
"proxy": "الوكيل"
},
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي",
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام."
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام.",
"configViewer": "عارض التكوين"
},
"sidebar": {
"siteSettings": "إعدادات الموقع",
@@ -370,7 +372,8 @@
"pickIconDescription": "بحث وتحديد أيقونة من مكتبة الأيقونات المادية",
"shareLinksUpdatedSuccess": "تم تحديث روابط الشريط الجانبي المشاركة بنجاح!",
"shareHome": "مشاركة الصفحة الرئيسية",
"linkIconPlaceholder": "تحديد اسم رمز المادة اختياريًا"
"linkIconPlaceholder": "تحديد اسم رمز المادة اختياريًا",
"dividerNamePlaceholder": "نص المقسم (اتركه فارغًا لإظهار سطر)"
},
"success": {
"linkCopied": "تم نسخ الملف"
@@ -625,7 +628,9 @@
"lines": "الخطوط{suffix}",
"lastUpdate": "آخر تحديث{suffix}",
"latency": "الكمون{suffix}",
"all": "الكل{suffix}"
"all": "الكل{suffix}",
"expiration": "انتهاء الصلاحية{suffix}",
"divider": "مقسم{suffix}"
},
"access": {
"rules": "قواعد الوصول",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Přejít na položku",
"watchFile": "Sledovat soubor",
"showMore": "Zobrazit rozšířené možnosti",
"showLess": "Skrytí pokročilých možností"
"showLess": "Skrytí pokročilých možností",
"goToSource": "Přejít na zdrojové umístění"
},
"download": {
"downloadFile": "Stáhnout soubor",
@@ -117,7 +118,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Vytvořit účet",
@@ -457,7 +459,6 @@
"userUpdated": "Uživatel aktualizován!",
"adminOptions": "Možnosti administrátora",
"systemAdmin": "Systém a správce",
"configViewer": "Prohlížeč konfigurace",
"configViewerShowFull": "Zobrazit úplnou konfiguraci",
"configViewerShowComments": "Zobrazit komentáře",
"configViewerLoadConfig": "Konfigurace načítání",
@@ -469,7 +470,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení",
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému."
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému.",
"configViewer": "Prohlížeč konfigurace"
},
"sidebar": {
"siteSettings": "Nastavení webu",
@@ -507,7 +509,8 @@
"pickIconDescription": "Vyhledejte a vyberte ikonu z knihovny Material Icons.",
"shareLinksUpdatedSuccess": "Odkazy na sdílení postranního panelu byly úspěšně aktualizovány!",
"shareHome": "Sdílet Domů",
"linkIconPlaceholder": "Volitelně zadejte název ikony materiálu"
"linkIconPlaceholder": "Volitelně zadejte název ikony materiálu",
"dividerNamePlaceholder": "Text oddělovače (pro zobrazení řádku nechte prázdný)"
},
"success": {
"linkCopied": "Odkaz zkopírován!"
@@ -705,7 +708,9 @@
"lines": "Řádky{suffix}",
"lastUpdate": "Poslední aktualizace{suffix}",
"latency": "Zpoždění{suffix}",
"all": "Všechny{suffix}"
"all": "Všechny{suffix}",
"expiration": "Expirace{suffix}",
"divider": "Rozdělovač{suffix}"
},
"access": {
"rules": "Pravidla přístupu",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Weiter zu Artikel",
"watchFile": "Datei beobachten",
"showMore": "Erweiterte Optionen anzeigen",
"showLess": "Erweiterte Optionen ausblenden"
"showLess": "Erweiterte Optionen ausblenden",
"goToSource": "Zum Quellort gehen"
},
"download": {
"downloadFile": "Datei herunterladen",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Konto erstellen",
@@ -320,7 +322,6 @@
"adminOptions": "Optionen für Verwaltungsbenutzer",
"searchOptions": "Suchoptionen",
"systemAdmin": "System & Verwaltung",
"configViewer": "Config Viewer",
"configViewerShowFull": "Vollständige Konfiguration anzeigen",
"configViewerShowComments": "Kommentare anzeigen",
"configViewerLoadConfig": "Konfiguration laden",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard",
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen."
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen.",
"configViewer": "Config Viewer"
},
"sidebar": {
"siteSettings": "Seiteneinstellungen",
@@ -370,7 +372,8 @@
"pickIconDescription": "Suchen und wählen Sie ein Symbol aus der Bibliothek der Materialsymbole",
"shareLinksUpdatedSuccess": "Links in der Seitenleiste erfolgreich aktualisiert!",
"shareHome": "Startseite teilen",
"linkIconPlaceholder": "Optional können Sie einen Materialsymbolnamen angeben"
"linkIconPlaceholder": "Optional können Sie einen Materialsymbolnamen angeben",
"dividerNamePlaceholder": "Teilertext (leer lassen, um eine Zeile anzuzeigen)"
},
"success": {
"linkCopied": "Link wurde kopiert!"
@@ -625,7 +628,9 @@
"lines": "Zeilen{suffix}",
"lastUpdate": "Letzte Aktualisierung{suffix}",
"latency": "Latenzzeit{suffix}",
"all": "Alle{suffix}"
"all": "Alle{suffix}",
"expiration": "Verfallsdatum{suffix}",
"divider": "Teiler{suffix}"
},
"access": {
"rules": "Zugangsregeln",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Πηγαίνετε στο στοιχείο",
"watchFile": "Παρακολουθήστε το αρχείο",
"showMore": "Εμφάνιση προηγμένων επιλογών",
"showLess": "Απόκρυψη προηγμένων επιλογών"
"showLess": "Απόκρυψη προηγμένων επιλογών",
"goToSource": "Πηγαίνετε στην τοποθεσία της πηγής"
},
"download": {
"downloadFile": "Λήψη αρχείου",
@@ -117,7 +118,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Δημιουργία λογαριασμού",
@@ -323,7 +325,6 @@
"adminOptions": "Επιλογές χρήστη διαχειριστή",
"searchOptions": "Επιλογές αναζήτησης",
"systemAdmin": "Σύστημα & Διαχείριση",
"configViewer": "Config Viewer",
"configViewerShowFull": "Εμφάνιση πλήρων ρυθμίσεων",
"configViewerShowComments": "Εμφάνιση σχολίων",
"configViewerLoadConfig": "Φόρτωση Config",
@@ -335,7 +336,8 @@
"proxy": "Πληρεξούσιος"
},
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή",
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος."
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος.",
"configViewer": "Config Viewer"
},
"sidebar": {
"siteSettings": "Ρυθμίσεις ιστότοπου",
@@ -373,7 +375,8 @@
"pickIconDescription": "Αναζήτηση και επιλογή ενός εικονιδίου από τη βιβλιοθήκη Material Icons",
"shareLinksUpdatedSuccess": "Οι σύνδεσμοι της πλαϊνής μπάρας κοινής χρήσης ενημερώθηκαν με επιτυχία!",
"shareHome": "Μοιραστείτε το Home",
"linkIconPlaceholder": "Προαιρετικά καθορίστε ένα όνομα εικονιδίου υλικού"
"linkIconPlaceholder": "Προαιρετικά καθορίστε ένα όνομα εικονιδίου υλικού",
"dividerNamePlaceholder": "Κείμενο διαχωριστικού (αφήστε το κενό για να εμφανιστεί μια γραμμή)"
},
"success": {
"linkCopied": "Ο σύνδεσμος αντιγράφηκε!"
@@ -625,7 +628,9 @@
"lines": "Γραμμές{suffix}",
"lastUpdate": "Τελευταία ενημέρωση{suffix}",
"latency": "Καθυστέρηση{suffix}",
"all": "Όλα{suffix}"
"all": "Όλα{suffix}",
"expiration": "Λήξη{suffix}",
"divider": "Διαχωριστικό{suffix}"
},
"access": {
"rules": "Κανόνες πρόσβασης",
+7 -2
View File
@@ -8,6 +8,8 @@
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}."
},
"general": {
"divider": "Divider{suffix}",
"expiration": "Expiration{suffix}",
"all": "All{suffix}",
"name": "Name{suffix}",
"path": "Path{suffix}",
@@ -149,7 +151,8 @@
"saveAndExit": "Save and Exit",
"keepEditing": "Keep Editing",
"closeAnyway": "Close Anyways",
"goToItem": "Go to Item"
"goToItem": "Go to Item",
"goToSource": "Go to Source Location"
},
"download": {
"downloadFile": "Download File",
@@ -235,7 +238,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Create an account",
@@ -590,6 +594,7 @@
"adminOptions": "Admin user options"
},
"sidebar": {
"dividerNamePlaceholder": "Divider text (leave blank to show a line)",
"siteSettings": "Site Settings",
"updateIsAvailable": "An update is available!",
"fileActions": "File actions",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Ir al elemento",
"watchFile": "Ver archivo",
"showMore": "Mostrar opciones avanzadas",
"showLess": "Ocultar opciones avanzadas"
"showLess": "Ocultar opciones avanzadas",
"goToSource": "Ir a la ubicación de origen"
},
"download": {
"downloadFile": "Descargar archivo",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Crear cuenta",
@@ -326,13 +328,13 @@
"adminOptions": "Opciones de administrador",
"searchOptions": "Opciones de busqueda",
"systemAdmin": "Sistema y administración",
"configViewer": "Visor de configuración",
"configViewerShowFull": "Mostrar configuración completa",
"configViewerShowComments": "Mostrar comentarios",
"configViewerLoadConfig": "Cargar configuración",
"brandingHelp": "Puedes personalizar la apariencia de FileBrowser Quantum cambiando el nombre, el logo, puedes añadir tus propios estilos personalizados, desactivar los enlaces externos a GitHub, etc. \nSi quieres saber más, visita {0}.",
"commandRunnerHelp": "Aquí puede establecer los comandos que se ejecutan en los eventos nombrados. Debe escribir uno por línea. Las variables de entorno {0} y {1} estarán disponibles, siendo {0} relativa a {1}. Para más información sobre esta característica y las variables de entorno disponibles, por favor lea el {2}.",
"permissionsHelp": "Los permisos de usuario controlan el acceso al tipo de acciones que el usuario puede realizar. El acceso de administrador proporciona un control total para actualizar y cambiar la configuración de cualquier usuario, así como muchos ajustes del sistema."
"permissionsHelp": "Los permisos de usuario controlan el acceso al tipo de acciones que el usuario puede realizar. El acceso de administrador proporciona un control total para actualizar y cambiar la configuración de cualquier usuario, así como muchos ajustes del sistema.",
"configViewer": "Visor de configuración"
},
"sidebar": {
"siteSettings": "Ajustes del sitio",
@@ -370,7 +372,8 @@
"pickIconDescription": "Aquí puedes explorar los iconos más populates de la biblioteca \"Material Icons\"",
"shareLinksUpdatedSuccess": "¡Se actualizaron los accesos directos del recurso compartido!",
"shareHome": "Recurso compartido",
"linkIconPlaceholder": "Opcionalmente, puedes elegir un Material Icon que más te guste"
"linkIconPlaceholder": "Opcionalmente, puedes elegir un Material Icon que más te guste",
"dividerNamePlaceholder": "Texto divisor (déjelo en blanco para mostrar una línea)"
},
"success": {
"linkCopied": "¡Enlace copiado!"
@@ -625,7 +628,9 @@
"lines": "Líneas{suffix}",
"lastUpdate": "Última actualización{suffix}",
"latency": "Latencia{suffix}",
"all": "Todos{suffix}"
"all": "Todos{suffix}",
"expiration": "Caducidad{suffix}",
"divider": "Divisor{suffix}"
},
"access": {
"rules": "Reglas de acceso",
+24 -19
View File
@@ -31,7 +31,8 @@
"goToItem": "Aller à l'article",
"watchFile": "Regarder le dossier",
"showMore": "Afficher les options avancées",
"showLess": "Masquer les options avancées"
"showLess": "Masquer les options avancées",
"goToSource": "Aller à l'emplacement de la source"
},
"download": {
"downloadFile": "Télécharger le fichier",
@@ -58,9 +59,9 @@
"itemSelected": "élément sélectionné",
"itemsSelected": "éléments sélectionnés",
"myFiles": "Mes dossiers{suffix}",
"newFile": "Nouveau Fichier{suffix}",
"newFile": "Nouveau fichier{suffix}",
"newFolder": "Nouveau dossier{suffix}",
"duration": "Durée de l'accord{suffix}",
"duration": "Durée{suffix}",
"sortByDuration": "Trier par durée",
"sameFolder": "Impossible de déplacer vers le même dossier",
"cannotAccesDir": "Impossible d'accéder au répertoire",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Créer un compte",
@@ -309,7 +311,7 @@
"userHomeBasePath": "Chemin de base pour les répertoires personnels des utilisateurs",
"userScopeGenerationPlaceholder": "Le champ d'application sera généré automatiquement",
"createUserHomeDirectory": "Créer le répertoire personnel de l'utilisateur",
"api": "Clés API",
"api": "API",
"loginMethodDescription": "méthode de connexion autorisée pour l'utilisateur.",
"userNotAdmin": "L'utilisateur n'est pas un administrateur",
"enterPassword": "Saisir le mot de passe",
@@ -320,7 +322,7 @@
"adminOptions": "Options de l'utilisateur administrateur",
"searchOptions": "Options de recherche",
"systemAdmin": "Système et administration",
"configViewer": "Config Viewer",
"configViewer": "Visualiseur de configuration",
"configViewerShowFull": "Afficher la configuration complète",
"configViewerShowComments": "Afficher les commentaires",
"configViewerLoadConfig": "Configuration de la charge",
@@ -370,7 +372,8 @@
"pickIconDescription": "Rechercher et sélectionner une icône dans la bibliothèque des icônes matérielles",
"shareLinksUpdatedSuccess": "Les liens de la barre latérale de partage ont été mis à jour avec succès !",
"shareHome": "Partager l'accueil",
"linkIconPlaceholder": "Spécifier éventuellement un nom d'icône de matériau"
"linkIconPlaceholder": "Spécifier éventuellement un nom d'icône de matériau",
"dividerNamePlaceholder": "Texte du séparateur (laisser en blanc pour montrer une ligne)"
},
"success": {
"linkCopied": "Lien copié !"
@@ -476,7 +479,6 @@
"description": "Les clés API sont basées sur l'utilisateur qui les a créées. Voir les",
"swaggerLinkText": "page swagger",
"created": "Créée",
"expires": "Expiration",
"actions": "Actions",
"enabled": "Activée",
"disabled": "Désactivée",
@@ -484,7 +486,8 @@
"tokenType": "Type de jeton :",
"clickToCopyKey": "Cliquez pour copier la clé (cachée)",
"minimalDescription": "Jeton minimal",
"minimalInfo": "Exclut les personnalisations du jeton afin de réduire la longueur du jeton. Les autorisations correspondent exactement à celles de l'utilisateur associé."
"minimalInfo": "Exclut les personnalisations du jeton afin de réduire la longueur du jeton. Les autorisations correspondent exactement à celles de l'utilisateur associé.",
"expires": "Expiration"
},
"index": {
"assessment": "Évaluation",
@@ -494,7 +497,7 @@
"settingsHover": "Paramètres pour l'utilisateur",
"toggleClick": "Activation du mode simple clic",
"toggleDark": "Mode sombre/clair",
"toggleSticky": "Activer/Désactiver le mode persistent",
"toggleSticky": "Activer/Désactiver le mode persistant",
"noSources": "Aucune source n'est disponible pour cet utilisateur. Ajoutez une source pour cet utilisateur ou configurez une source avec defaultEnabled : true",
"unknown": "Inconnu",
"simple": "Simple",
@@ -525,11 +528,11 @@
"signup": "S'inscrire",
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"files": "Dossiers",
"files": "Fichiers",
"folders": "Dossiers",
"exit": "Sortie",
"permanent": "Permanent",
"hash": "Hachures",
"hash": "Hachage",
"of": "de",
"source": "Source{suffix}",
"percentage": "{percentage}%",
@@ -569,7 +572,7 @@
"documentation": "la documentation{suffix}",
"examples": "Exemples{suffix}",
"dark": "Sombre{suffix}",
"light": "Lumière{suffix}",
"light": "Clair{suffix}",
"title": "Thème{suffix}",
"expires": "Expiration{suffix}",
"scope": "Champ d'application{suffix}",
@@ -604,12 +607,12 @@
"select": "Sélectionner{suffix}",
"share": "Partager{suffix}",
"update": "Mise à jour{suffix}",
"upload": "Télécharger{suffix}",
"upload": "Importer{suffix}",
"openFile": "Ouvrir un fichier{suffix}",
"continue": "Continuer{suffix}",
"edit": "Editer{suffix}",
"edit": "Éditer{suffix}",
"pause": "Pause{suffix}",
"resume": "Curriculum vitae{suffix}",
"resume": "Reprendre{suffix}",
"retry": "Réessayer{suffix}",
"disable": "Désactiver{suffix}",
"confirm": "Confirmer{suffix}",
@@ -625,7 +628,9 @@
"lines": "Lignes{suffix}",
"lastUpdate": "Dernière mise à jour{suffix}",
"latency": "Temps de latence{suffix}",
"all": "Tous{suffix}"
"all": "Tous{suffix}",
"expiration": "Expiration{suffix}",
"divider": "Diviseur{suffix}"
},
"access": {
"rules": "Règles d'accès",
@@ -770,11 +775,11 @@
"video": "Vidéo",
"image": "Image",
"audio": "Audio",
"archive": "Archives",
"archive": "Archive",
"document": "Document",
"binary": "Binaire",
"directory": "Annuaire",
"other": "Autres"
"other": "Autre"
},
"tools": {
"description": "Utilitaires et outils d'analyse pour vos fichiers",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "עבור לפריט",
"watchFile": "צפה בקובץ",
"showMore": "הצג אפשרויות מתקדמות",
"showLess": "הסתר אפשרויות מתקדמות"
"showLess": "הסתר אפשרויות מתקדמות",
"goToSource": "עבור למיקום המקור"
},
"download": {
"downloadFile": "הורד קובץ",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "צור חשבון",
@@ -320,7 +322,6 @@
"adminOptions": "אפשרויות משתמש מנהל",
"searchOptions": "אפשרויות חיפוש",
"systemAdmin": "מערכת וניהול",
"configViewer": "מציג תצורה",
"configViewerShowFull": "הצג את התצורה המלאה",
"configViewerShowComments": "הצג תגובות",
"configViewerLoadConfig": "טען תצורה",
@@ -332,7 +333,8 @@
"proxy": "פרוקסי"
},
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל",
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת."
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת.",
"configViewer": "מציג תצורה"
},
"sidebar": {
"siteSettings": "הגדרות",
@@ -370,7 +372,8 @@
"pickIconDescription": "חפש ובחר סמל מספריית Material Icons.",
"shareLinksUpdatedSuccess": "קישורי הסרגל הצדדי שותפו בהצלחה!",
"shareHome": "שתף בית",
"linkIconPlaceholder": "אפשר לציין שם סמל חומר"
"linkIconPlaceholder": "אפשר לציין שם סמל חומר",
"dividerNamePlaceholder": "טקסט מפריד (השאר ריק כדי להציג קו)"
},
"success": {
"linkCopied": "הקישור הועתק!"
@@ -625,7 +628,9 @@
"lines": "קווים{suffix}",
"lastUpdate": "עדכון אחרון{suffix}",
"latency": "חביון{suffix}",
"all": "כל{suffix}"
"all": "כל{suffix}",
"expiration": "תוקף{suffix}",
"divider": "מפריד{suffix}"
},
"access": {
"rules": "כללי גישה",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Tovább a tételhez",
"watchFile": "Nézze meg a fájlt",
"showMore": "Speciális beállítások megjelenítése",
"showLess": "Speciális beállítások elrejtése"
"showLess": "Speciális beállítások elrejtése",
"goToSource": "Menj a forráshelyre"
},
"download": {
"downloadFile": "Fájl letöltése",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Fiók létrehozása",
@@ -320,7 +322,6 @@
"adminOptions": "Admin felhasználói beállítások",
"searchOptions": "Keresési lehetőségek",
"systemAdmin": "Rendszer és adminisztráció",
"configViewer": "Konfiguráció megtekintő",
"configViewerShowFull": "Teljes konfiguráció megjelenítése",
"configViewerShowComments": "Hozzászólások megjelenítése",
"configViewerLoadConfig": "Konfiguráció betöltése",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez",
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához."
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához.",
"configViewer": "Konfiguráció megtekintő"
},
"sidebar": {
"siteSettings": "Oldalbeállítások",
@@ -370,7 +372,8 @@
"pickIconDescription": "Keressen és válasszon ki egy ikont az Anyagi ikonok könyvtárból.",
"shareLinksUpdatedSuccess": "Megosztás oldalsáv linkek sikeresen frissítve!",
"shareHome": "Megosztás",
"linkIconPlaceholder": "Opcionálisan megadhatja az anyag ikon nevét"
"linkIconPlaceholder": "Opcionálisan megadhatja az anyag ikon nevét",
"dividerNamePlaceholder": "Választószöveg (üresen hagyva egy sor megjelenítéséhez)"
},
"success": {
"linkCopied": "Link másolva!"
@@ -625,7 +628,9 @@
"lines": "Vonalak{suffix}",
"lastUpdate": "Utolsó frissítés{suffix}",
"latency": "Késleltetés{suffix}",
"all": "Minden{suffix}"
"all": "Minden{suffix}",
"expiration": "Lejárat{suffix}",
"divider": "Választó{suffix}"
},
"access": {
"rules": "Hozzáférési szabályok",
+52 -59
View File
@@ -1,19 +1,21 @@
// i18n.js
import { createI18n } from 'vue-i18n';
// Import translations
import he from './he.json';
import hu from './hu.json';
// Import translations (alphabetical order)
import ar from './ar.json';
import cz from './cz.json';
import de from './de.json';
import el from './el.json';
import en from './en.json';
import es from './es.json';
import fr from './fr.json';
import he from './he.json';
import hu from './hu.json';
import is from './is.json';
import it from './it.json';
import ja from './ja.json';
import ko from './ko.json';
import nl from './nl.json';
import nlBE from './nl-be.json';
import pl from './pl.json';
import pt from './pt.json';
@@ -21,14 +23,35 @@ import ptBR from './pt-br.json';
import ro from './ro.json';
import ru from './ru.json';
import sk from './sk.json';
import ua from './ua.json';
import svSE from './sv-se.json';
import ua from './ua.json';
import zhCN from './zh-cn.json';
import zhTW from './zh-tw.json';
import cz from './cz.json';
type LocaleMap = { [key: string]: string };
// Map of all imported translation modules (alphabetical order)
// This is the single source for all translations
const translationModules: { [key: string]: any } = {
ar, cz, de, el, en, es, fr, he, hu, is, it, ja, ko, nl, nlBE, pl, pt, ptBR, ro, ru, sk, svSE, ua, zhCN, zhTW
};
// Shared list of available locales for the application
// Auto-generated from translation modules
export const availableLocales: LocaleMap = Object.keys(translationModules).reduce((acc, key) => {
// Convert internal keys to their display format
const displayMap: { [key: string]: string } = {
nlBE: 'nl-be',
ptBR: 'pt-br',
svSE: 'sv-se',
zhCN: 'zh-cn',
zhTW: 'zh-tw',
};
acc[key] = displayMap[key] || key;
return acc;
}, {} as LocaleMap);
// Maps internal locale names to standard BCP 47 codes (for navigator.language)
export const internalToStandardLocaleMap: { [key: string]: string } = {
nlBE: 'nl-be',
ptBR: 'pt-br',
@@ -44,43 +67,38 @@ export function toStandardLocale(locale: string): string {
}
export function detectLocale(): string {
const locale = navigator.language.toLowerCase();
const localeMap: LocaleMap = {
'he': 'he',
'hu': 'hu',
'ar': 'ar',
'el': 'el',
'es': 'es',
'en': 'en',
'is': 'is',
'it': 'it',
'fr': 'fr',
const browserLocale = navigator.language.toLowerCase();
// Map of browser locale codes to internal locale keys
const browserToInternalMap: LocaleMap = {
'pt-br': 'ptBR',
'pt': 'pt',
'ja': 'ja',
'zh-tw': 'zhTW',
'zh-cn': 'zhCN',
'zh': 'zhCN',
'de': 'de',
'ro': 'ro',
'ru': 'ru',
'pl': 'pl',
'ko': 'ko',
'cz': 'cz',
'sk': 'sk',
'tr': 'tr',
'uk': 'uk',
'sv-se': 'svSE',
'sv': 'svSE',
'nl-be': 'nlBE',
};
for (const key in localeMap) {
if (locale.startsWith(key)) {
return localeMap[key];
}
// Check for exact matches first (including variants like pt-br)
if (browserToInternalMap[browserLocale]) {
return browserToInternalMap[browserLocale];
}
return 'en-us'; // Default fallback
// Check for language prefix matches (e.g., 'en-US' -> 'en')
const languagePrefix = browserLocale.split('-')[0];
// If the language prefix exists in our translations, use it
if (translationModules[languagePrefix]) {
return languagePrefix;
}
// Try browser-specific mappings for the prefix
if (browserToInternalMap[languagePrefix]) {
return browserToInternalMap[languagePrefix];
}
return 'en'; // Default fallback
}
// List of RTL languages
@@ -100,38 +118,13 @@ export function setLocale(locale: string) {
}
// Create i18n instance
// Create i18n instance with auto-generated messages from imported modules
const i18n = createI18n({
locale: detectLocale(),
fallbackLocale: 'en',
// expose i18n.global for outside components
legacy: true,
messages: {
he,
hu,
ar,
de,
el,
en,
es,
fr,
is,
it,
ja,
ko,
nlBE,
pl,
ptBR,
pt,
ru,
ro,
sk,
svSE,
ua,
zhCN,
zhTW,
cz,
},
messages: translationModules,
});
export default i18n;
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Vai all'articolo",
"watchFile": "Guarda il file",
"showMore": "Mostra opzioni avanzate",
"showLess": "Nascondere le opzioni avanzate"
"showLess": "Nascondere le opzioni avanzate",
"goToSource": "Vai alla posizione della fonte"
},
"download": {
"downloadFile": "Scarica file",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Crea un account",
@@ -320,7 +322,6 @@
"adminOptions": "Opzioni dell'utente amministratore",
"searchOptions": "Opzioni di ricerca",
"systemAdmin": "Sistema e amministrazione",
"configViewer": "Visualizzatore di configurazione",
"configViewerShowFull": "Mostra la configurazione completa",
"configViewerShowComments": "Mostra i commenti",
"configViewerLoadConfig": "Configurazione del carico",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "es. '/sottocartella' - vuoto per l'impostazione predefinita",
"permissionsHelp": "I permessi utente controllano l'accesso al tipo di azioni che l'utente può eseguire. L'accesso dell'amministratore consente di aggiornare e modificare le impostazioni di qualsiasi utente e molte impostazioni del sistema."
"permissionsHelp": "I permessi utente controllano l'accesso al tipo di azioni che l'utente può eseguire. L'accesso dell'amministratore consente di aggiornare e modificare le impostazioni di qualsiasi utente e molte impostazioni del sistema.",
"configViewer": "Visualizzatore di configurazione"
},
"sidebar": {
"siteSettings": "Impostazioni del sito",
@@ -370,7 +372,8 @@
"pickIconDescription": "Cercare e selezionare un'icona dalla libreria Material Icons.",
"shareLinksUpdatedSuccess": "I link della barra laterale di condivisione sono stati aggiornati con successo!",
"shareHome": "Condividi Casa",
"linkIconPlaceholder": "Specificare facoltativamente il nome dell'icona del materiale"
"linkIconPlaceholder": "Specificare facoltativamente il nome dell'icona del materiale",
"dividerNamePlaceholder": "Testo del divisore (lasciare vuoto per mostrare una riga)"
},
"success": {
"linkCopied": "Link copiato!"
@@ -625,7 +628,9 @@
"lines": "Linee{suffix}",
"lastUpdate": "Ultimo aggiornamento{suffix}",
"latency": "Latenza{suffix}",
"all": "Tutti{suffix}"
"all": "Tutti{suffix}",
"expiration": "Scadenza{suffix}",
"divider": "Divisore{suffix}"
},
"access": {
"rules": "Regole di accesso",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "アイテムへ",
"watchFile": "ウォッチファイル",
"showMore": "詳細オプションを表示",
"showLess": "詳細オプションを隠す"
"showLess": "詳細オプションを隠す",
"goToSource": "ソース・ロケーションへ"
},
"download": {
"downloadFile": "ファイルをダウンロード",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "アカウントを作成",
@@ -320,7 +322,6 @@
"adminOptions": "管理者ユーザーオプション",
"searchOptions": "検索オプション",
"systemAdmin": "システム&管理",
"configViewer": "設定ビューア",
"configViewerShowFull": "全設定を表示",
"configViewerShowComments": "コメントを表示",
"configViewerLoadConfig": "設定を読み込み",
@@ -332,7 +333,8 @@
"proxy": "プロキシ"
},
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白",
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。"
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。",
"configViewer": "コンフィグビューア"
},
"sidebar": {
"siteSettings": "サイト設定",
@@ -370,7 +372,8 @@
"pickIconDescription": "マテリアル・アイコン・ライブラリからアイコンを検索して選択します。",
"shareLinksUpdatedSuccess": "サイドバーの共有リンクが正常に更新されました!",
"shareHome": "シェア・ホーム",
"linkIconPlaceholder": "オプションで素材アイコン名を指定"
"linkIconPlaceholder": "オプションで素材アイコン名を指定",
"dividerNamePlaceholder": "分割テキスト(行を表示するには空白にする)"
},
"success": {
"linkCopied": "リンクがコピーされました!"
@@ -625,7 +628,9 @@
"lines": "ライン{suffix}",
"lastUpdate": "最終更新{suffix}",
"latency": "レイテンシー{suffix}",
"all": "すべて{suffix}"
"all": "すべて{suffix}",
"expiration": "有効期限{suffix}",
"divider": "ディバイダー{suffix}"
},
"access": {
"rules": "アクセスルール",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "항목으로 이동",
"watchFile": "파일 보기",
"showMore": "고급 옵션 표시",
"showLess": "고급 옵션 숨기기"
"showLess": "고급 옵션 숨기기",
"goToSource": "소스 위치로 이동"
},
"download": {
"downloadFile": "파일 다운로드",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "계정 생성",
@@ -320,7 +322,6 @@
"adminOptions": "관리자 사용자 옵션",
"searchOptions": "검색 옵션",
"systemAdmin": "시스템 및 관리자",
"configViewer": "구성 뷰어",
"configViewerShowFull": "전체 구성 표시",
"configViewerShowComments": "댓글 표시",
"configViewerLoadConfig": "로드 구성",
@@ -332,7 +333,8 @@
"proxy": "프록시"
},
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다.",
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다."
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다.",
"configViewer": "구성 뷰어"
},
"sidebar": {
"siteSettings": "사이트 설정",
@@ -370,7 +372,8 @@
"pickIconDescription": "머티리얼 아이콘 라이브러리에서 아이콘을 검색하고 선택합니다.",
"shareLinksUpdatedSuccess": "공유 사이드바 링크가 성공적으로 업데이트되었습니다!",
"shareHome": "홈 공유",
"linkIconPlaceholder": "선택적으로 머티리얼 아이콘 이름을 지정합니다."
"linkIconPlaceholder": "선택적으로 머티리얼 아이콘 이름을 지정합니다.",
"dividerNamePlaceholder": "디바이더 텍스트(줄을 표시하려면 비워둡니다)"
},
"success": {
"linkCopied": "링크가 복사되었습니다!"
@@ -625,7 +628,9 @@
"lines": "라인{suffix}",
"lastUpdate": "최근 업데이트{suffix}",
"latency": "지연 시간{suffix}",
"all": "모두{suffix}"
"all": "모두{suffix}",
"expiration": "만료{suffix}",
"divider": "디바이더{suffix}"
},
"access": {
"rules": "액세스 규칙",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Ga naar item",
"watchFile": "Bestand bekijken",
"showMore": "Geavanceerde opties weergeven",
"showLess": "Geavanceerde opties verbergen"
"showLess": "Geavanceerde opties verbergen",
"goToSource": "Ga naar de bronlocatie"
},
"download": {
"downloadFile": "Bestand downloaden",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Account aanmaken",
@@ -320,7 +322,6 @@
"adminOptions": "Opties voor beheerders",
"searchOptions": "Zoekopties",
"systemAdmin": "Systeem & Beheer",
"configViewer": "Configuratiescherm",
"configViewerShowFull": "Volledige configuratie weergeven",
"configViewerShowComments": "Opmerkingen tonen",
"configViewerLoadConfig": "Configuratie laden",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen."
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen.",
"configViewer": "Configuratiescherm"
},
"sidebar": {
"siteSettings": "Site-instellingen",
@@ -370,7 +372,8 @@
"pickIconDescription": "Zoek en selecteer een pictogram uit de bibliotheek met materiaalpictogrammen",
"shareLinksUpdatedSuccess": "Zijbalk links delen succesvol bijgewerkt!",
"shareHome": "Thuis delen",
"linkIconPlaceholder": "Geef optioneel een materiaalpictogramnaam op"
"linkIconPlaceholder": "Geef optioneel een materiaalpictogramnaam op",
"dividerNamePlaceholder": "Scheidingstekst (leeg laten om een lijn weer te geven)"
},
"success": {
"linkCopied": "Link gekopieerd!"
@@ -705,7 +708,9 @@
"lines": "Lijnen{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Latency{suffix}",
"all": "Alle{suffix}"
"all": "Alle{suffix}",
"expiration": "Vervaldatum{suffix}",
"divider": "Verdeler{suffix}"
},
"access": {
"rules": "Toegangsregels",
+856
View File
@@ -0,0 +1,856 @@
{
"messages": {
"pathNotFound": "Pad bestaat niet op bestandssysteem",
"pathNotFoundMessage": "Het pad hiervoor bestaat niet meer op het bestandssysteem. Het kan verplaatst of verwijderd zijn.",
"reassignPath": "Pad opnieuw toewijzen",
"pathReassigned": "Pad succesvol bijgewerkt!",
"pathReassignFailed": "Het bijwerken van het pad is mislukt. Probeer het opnieuw.",
"incompleteResultsDetails": "Max resultaten ({max}). Probeer de zoekopdracht aan te passen voor meer resultaten onder {max}."
},
"general": {
"all": "Alle{suffix}",
"name": "Naam{suffix}",
"path": "Pad{suffix}",
"user": "Gebruiker{suffix}",
"group": "Groep{suffix}",
"signup": "Aanmelden{suffix}",
"password": "Wachtwoord{suffix}",
"username": "Gebruikersnaam{suffix}",
"files": "Bestanden{suffix}",
"folders": "Mappen{suffix}",
"exit": "Sluiten{suffix}",
"permanent": "Permanent{suffix}",
"hash": "Hash{suffix}",
"of": "van{suffix}",
"source": "Bron{suffix}",
"percentage": "{percentage}%",
"type": "Type{suffix}",
"size": "Grootte{suffix}",
"body": "Inhoud{suffix}",
"home": "Thuis{suffix}",
"clear": "Wissen{suffix}",
"help": "Hulp{suffix}",
"login": "Inloggen{suffix}",
"logout": "Afmelden{suffix}",
"preview": "Voorbeeld{suffix}",
"settings": "Instellingen{suffix}",
"sources": "Bronnen{suffix}",
"links": "Links{suffix}",
"tool": "Gereedschap{suffix}",
"used": "gebruikt{suffix}",
"status": "Status{suffix}",
"analyze": "Analyseren{suffix}",
"results": "Resultaten{suffix}",
"users": "Gebruikers{suffix}",
"scopes": "Bereiken{suffix}",
"commands": "Opdrachten{suffix}",
"photos": "Foto's",
"audio": "Audio{suffix}",
"videos": "Video's{suffix}",
"documents": "Documenten{suffix}",
"archives": "Archieven{suffix}",
"images": "Afbeeldingen{suffix}",
"music": "Muziek{suffix}",
"pdf": "PDF{suffix}",
"search": "Zoeken{suffix}",
"number": "Aantal{suffix}",
"types": "Typen{suffix}",
"video": "Video{suffix}",
"admin": "Admin{suffix}",
"branding": "Branding{suffix}",
"language": "Taal{suffix}",
"documentation": "documentatie{suffix}",
"examples": "Voorbeelden{suffix}",
"dark": "Donker{suffix}",
"light": "Licht{suffix}",
"title": "Thema{suffix}",
"expires": "Verloopt op{suffix}",
"scope": "Bereik{suffix}",
"rules": "Regels{suffix}",
"downloads": "Downloads{suffix}",
"submit": "Indienen{suffix}",
"navigation": "Navigatie{suffix}",
"metadata": "Metagegevens{suffix}",
"loading": "Laden{suffix}",
"add": "Toevoegen{suffix}",
"stay": "Blijven{suffix}",
"cancel": "Annuleren{suffix}",
"change": "Wijzigen{suffix}",
"verify": "Controleren{suffix}",
"close": "Sluiten{suffix}",
"copy": "Kopiëren{suffix}",
"create": "Aanmaken{suffix}",
"delete": "Verwijderen{suffix}",
"download": "Downloaden{suffix}",
"file": "Bestand{suffix}",
"folder": "Map{suffix}",
"info": "Info{suffix}",
"more": "Meer{suffix}",
"move": "Verplaatsen{suffix}",
"new": "Nieuw{suffix}",
"next": "Volgende{suffix}",
"ok": "OK{suffix}",
"previous": "Vorige{suffix}",
"publish": "Publiceren{suffix}",
"rename": "Hernoemen{suffix}",
"replace": "Vervangen{suffix}",
"save": "Opslaan{suffix}",
"schedule": "Schema{suffix}",
"select": "Selecteren{suffix}",
"share": "Delen{suffix}",
"update": "Bijwerken{suffix}",
"upload": "Uploaden{suffix}",
"openFile": "Bestand openen{suffix}",
"continue": "Doorgaan{suffix}",
"edit": "Bewerken{suffix}",
"pause": "Pauzeren{suffix}",
"resume": "Hervatten{suffix}",
"retry": "Opnieuw proberen{suffix}",
"disable": "Uitschakelen{suffix}",
"confirm": "Bevestigen{suffix}",
"uploading": "Uploaden{suffix}",
"paused": "Gepauzeerd{suffix}",
"conflict": "Conflict{suffix}",
"output": "Uitvoer{suffix}",
"watching": "Bekijken{suffix}",
"lines": "Regels{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Vertraging{suffix}",
"expiration": "Vervaldatum{suffix}",
"divider": "Verdeler{suffix}"
},
"buttons": {
"generateNewOtp": "Herstellen en nieuwe twee-factorcode genereren",
"copyFile": "Bestand kopiëren",
"copyToClipboard": "Kopiëren naar klembord",
"copyDownloadLinkToClipboard": "Downloadlink kopiëren naar klembord",
"moveFile": "Bestand verplaatsen",
"permalink": "Permanente link aanmaken",
"reportIssue": "Probleem melden",
"selectMultiple": "Meerdervoudige selectie",
"shell": "Opdrachtvenster schakelen",
"switchView": "Weergave wisselen",
"toggleSidebar": "Zijbalk schakelen",
"pauseAll": "Alles onderbreken",
"resumeAll": "Alles hervatten",
"clearCompleted": "Wissen voltooid",
"showMore": "Geavanceerde opties weergeven",
"showLess": "Geavanceerde opties verbergen",
"openParentFolder": "Bovenliggende map openen",
"watchFile": "Bestand bekijken",
"listView": "Lijstweergave",
"compactView": "Compacte weergave",
"normalView": "Normale weergave",
"galleryView": "Galerie",
"selectedCount": "Aantal items geselecteerd om een actie uit te voeren",
"selectAll": "Alles selecteren",
"uploadFiles": "Bestanden uploaden",
"uploadFolder": "Map uploaden",
"openUploadViewer": "Uploadmanager openen",
"discardAndExit": "Weggooien en afsluiten",
"saveAndExit": "Opslaan en afsluiten",
"keepEditing": "Blijven bewerken",
"closeAnyway": "Sluiten",
"goToItem": "Ga naar item",
"goToSource": "Ga naar de bronlocatie"
},
"download": {
"downloadFile": "Bestand downloaden",
"downloadFolder": "Map downloaden",
"downloadSelected": "Selectie downloaden"
},
"upload": {
"abortUpload": "Weet u zeker dat u wilt afbreken?"
},
"errors": {
"forbidden": "U hebt hier geen toegangsrechten.",
"internal": "Er ging iets niet goed.",
"notFound": "Geen toegang tot deze locatie.",
"connection": "Geen toegang tot de server.",
"shareNotFound": "Ongeldige of verlopen gedeelde map.",
"failedToConnectToServer": "Kan geen verbinding maken met de server, draait deze nog?"
},
"files": {
"closePreview": "Voorbeeld sluiten",
"lastModified": "Laatst bewerkt{suffix}",
"duration": "Duur{suffix}",
"lonely": "Hier is niets te zien...",
"multipleSelectionEnabled": "Meervoudige selectie ingeschakeld",
"sortByLastModified": "Sorteren op laatst bewerkt",
"sortByName": "Sorteren op naam",
"sortBySize": "Sorteren op grootte",
"sortByDuration": "Sorteren op duur",
"noPreview": "Voorbeeld is niet beschikbaar voor dit bestand.",
"noDownloadAccess": "Download is niet beschikbaar.",
"itemSelected": "item geselecteerd",
"itemsSelected": "items geselecteerd",
"myFiles": "Mijn bestanden{suffix}",
"newFile": "Nieuw bestand{suffix}",
"newFolder": "Nieuwe map{suffix}",
"sameFolder": "Kan niet verplaatsen naar dezelfde map",
"cannotAccesDir": "Geen toegang tot map"
},
"help": {
"functionKeys": "Functietoetsen",
"mouseActions": "Muisacties",
"fileOperations": "Bestandsbewerkingen",
"click": "selecteer bestand of map",
"marqueeSelect": "selecteer meerdere bestanden of mappen door over lege ruimte te slepen",
"ctrl": {
"click": "meerdere bestanden of mappen selecteren",
"c": "geselecteerde items kopiëren",
"v": "gekopieerde/geknipte items plakken",
"x": "geselecteerde items knippen",
"d": "geselecteerde items downloaden",
"b": "zijbalk schakelen",
"comma": "ga naar instellingen"
},
"del": "geselecteerde items verwijderen",
"esc": "selectie wissen en/of opdrachtvenster sluiten",
"f2": "bestand herbenoemen",
"f1": "hulp-venster tonen",
"slash": "Zoeken inschakelen",
"description": "U kunt de basisnavigatieopties hieronder bekijken. Ga voor aanvullende informatie naar",
"wiki": "FileBrowser Quantum Officiële documenten"
},
"languages": {
"he": "עברית",
"hu": "Magyar",
"ar": "العربية",
"de": "Deutsch",
"el": "Ελληνικά",
"en": "English",
"es": "Español",
"fr": "Français",
"is": "Icelandic",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
"ro": "Romanian",
"ru": "Русский",
"sk": "Slovenčina",
"svSE": "Swedish (Sweden)",
"tr": "Türkçe",
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Account aanmaken",
"loginInstead": "Ik heb al een account",
"passwordConfirm": "Wachtwoord bevestigen",
"passwordsDontMatch": "Wachtwoorden komen niet overeen",
"usernameTaken": "Gebruikersnaam al in gebruik",
"wrongCredentials": "Verkeerde inloggegevens",
"or": "OF",
"inProgress": "Inloggen...",
"failedLogin": "Inloggen is mislukt",
"invalidCredentials": "Ongeldige inloggegevens verstrekt"
},
"share": {
"notice": "Opmerking: wanneer een gedeelde map wordt aangemaakt, wordt deze meer blootgesteld aan het publiek en gelden er andere toegangsregels. Als uw gedeelde map niet veilig is geconfigureerd, kan iedereen er toegang tot krijgen.",
"disableAnonymous": "Anonieme toegang uitschakelen",
"disableThumbnails": "Miniaturen uitschakelen",
"enableAllowedUsernames": "Alleen delen met bepaalde gebruikers",
"allowedUsernamesPlaceholder": "Voer gebruikersnamen in, door komma's gescheiden",
"disableAnonymousDescription": "Alleen geauthenticeerde gebruikers krijgen toegang tot de gedeelde map. De gebruiker moet toegang hebben tot de bron van de gedeelde map.",
"disableThumbnailsDescription": "Er worden geen voorbeeldminiaturen weergegeven in de gedeelde map.",
"enableAllowedUsernamesDescription": "Alleen de opgegeven gebruikers krijgen toegang tot de gedeelde map. De gebruiker moet toegang hebben tot de bron van de gedeelde map.",
"downloadsLimitDescription": "Het maximum aantal keren dat een bestand/map van de gedeelde map kan worden gedownload. Leeg laten voor onbeperkt.",
"maxBandwidthDescription": "De maximale downloadbandbreedte in kilobytes per seconde. Leeg laten voor onbeperkt.",
"shareThemeDescription": "Het thema dat moet worden gebruikt voor de link delen.",
"keepAfterExpiration": "Gedeelde map niet verwijderen nadat deze is verlopen",
"keepAfterExpirationDescription": "De gedeelde map wordt niet verwijderd nadat deze verlopen is. Dit is handig als u de vervaldatum van een gedeelde map wilt verlengen of het verder wilt bewerken nadat het verlopen is.",
"saveDisabled": "Kan wijzigingen in gedeelde bestanden niet opslaan.",
"shareDurationDescription": "Duur voordat de gedeelde map verloopt. Leeg laten voor een permanente gedeelde map.",
"passwordDescription": "Optioneel wachtwoord dat vereist is om toegang te krijgen tot deze gedeelde map, inclusief geauthenticeerde gebruikers.",
"allowedUsernamesListDescription": "Door komma's gescheiden gebruikersnamen die toegang hebben tot de gedeelde map (indien ingeschakeld).",
"shareThemeColorDescription": "CSS kleurwaarde toegepast op het thema van de gedeelde map (bijv. rood, #0ea5e9, rgb(14,165,233)).",
"shareTitleDescription": "Aangepaste paginatitel die wordt weergegeven op de deelpagina.",
"shareDescriptionHelp": "Korte beschrijving die wordt weergegeven op de pagina van de gedeelde map (kan worden gebruikt in metatags).",
"shareLogoDescription": "URL van logoafbeelding of pad dat toegankelijk is voor de client -- zowel abosolute als index path worden geaccepteerd: bijv. 'https://domain.com/logo.png' of '/path/to/logo.png'.",
"shareBannerDescription": "URL van bannerafbeelding of pad dat toegankelijk is voor de client -- zowel abosolute als indexpath worden geaccepteerd: bijv. 'https://domain.com/banner.png' of '/path/to/banner.png'.",
"shareFaviconDescription": "Favicon URL of pad toegankelijk door de client -- zowel abosolute als index path worden geaccepteerd: bijv. 'https://domain.com/favicon.png' of '/path/to/favicon.png'.",
"hideNavButtons": "Navigatieknoppen verbergen",
"hideNavButtonsDescription": "Verberg de navigatieknoppen op de navigatiebalk in de gedeelde map om een minimalistische look te creëren.",
"viewModeDescription": "Standaard lay-out voor de gedeelde pagina.",
"titleDefault": "Gedeelde bestanden - {title}",
"descriptionDefault": "Er is een gedeelde map naar u verzonden om te bekijken of te downloaden.",
"descriptionUploadDefault": "U hebt een gedeelde map ontvangen om bestanden te uploaden.",
"disableShareCard": "Gedeelde map-kaart uitschakelen",
"disableShareCardDescription": "De gedeelde map-kaart uitschakelen op de gedeelde pagina in de zijbalk of bovenaan de pagina op mobiel.",
"disableSidebar": "Zijbalk uitschakelen",
"disableFileViewer": "Bestandsverkenner uitschakelen",
"disableSidebarDescription": "De zijbalk op de gedeelde pagina uitschakelen.",
"enforceDarkLightMode": "Themamodus afdwingen",
"enforceDarkLightModeDescription": "Een specifieke themamodus (donker of licht) afdwingen voor deze gedeelde map, waarbij gebruikersvoorkeuren worden overschreven.",
"defaultViewMode": "Standaard weergavemodus",
"defaultViewModeDescription": "Stel de standaard weergavemodus in voor anonieme gebruikers die deze gedeelde map bekijken. Aangemelde gebruikers kunnen nog steeds hun weergavevoorkeur wijzigen.",
"default": "Gebruikersvoorkeuren toepassen",
"dark": "Donker",
"light": "Licht",
"enableOnlyOffice": "OnlyOffice Viewer inschakelen",
"enableOnlyOfficeDescription": "Het bekijken van Office-bestanden toestaan met OnlyOffice in deze gedeelde map.",
"shareType": "Type gedeelde map",
"shareTypeDescription": "Het type gedeelde map dat moet worden aangemaakt. Normale gedeelde mappen staan het bekijken en downloaden van bestanden toe. Upload gedeelde mappen staan het uploaden van bestanden naar de gedeelde map toe.",
"normalShare": "Normaal",
"uploadShare": "Alleen uploaden",
"perUserDownloadLimit": "Downloadlimiet op elke gebruiker toepassen",
"perUserDownloadLimitDescription": "Indien ingeschakeld, wordt de downloadlimiet toegepast op elke gebruiker. Anonieme gebruikers kunnen geen bestanden downloaden van deze gedeelde map.",
"extractEmbeddedSubtitles": "Ingesloten ondertitels uitpakken",
"extractEmbeddedSubtitlesDescription": "Ingesloten ondertitels uit mediabestanden halen tijdens het laden -- dit kan traag zijn voor grote bestanden.",
"disableLoginOption": "Aanmeldingsoptie verbergen",
"disableLoginOptionDescription": "De aanmeldknop in de zijbalk verbergen voor anonieme gebruikers. Als deze optie is ingeschakeld, zien anonieme gebruikers geen optie om in te loggen.",
"customizeSidebarLinks": "Koppelingen in de zijbalk aanpassen",
"customizeSidebarLinksButton": "Koppelingen aanpassen",
"browseFiles": "Bladeren door bestanden",
"selectBannerImage": "Banner-afbeelding selecteren",
"selectFaviconImage": "Favicon-afbeelding selecteren",
"selectImage": "Afbeelding selecteren",
"disableDownload": "Het downloaden van bestanden uitschakelen",
"disableDownloadDescription": "Niemand kan bestanden downloaden of de inhoud van een bestand bekijken.",
"allowModify": "Bewerken van bestanden toestaan",
"allowModifyDescription": "Iedereen kan bestanden bewerken in deze gedeelde map.",
"allowDelete": "Bestanden verwijderen toestaan",
"allowDeleteDescription": "Iedereen kan bestanden en mappen van deze gedeelde map verwijderen.",
"allowCreate": "Bestanden en mappen aanmaken en uploaden",
"allowCreateDescription": "Iedereen kan nieuwe bestanden en mappen aanmaken en uploaden in deze gedeelde map.",
"allowReplacements": "Vervanging van bestanden toestaan",
"allowReplacementsDescription": "Iedereen kan bestaande bestanden vervangen door bestanden met dezelfde naam te uploaden.",
"shareInfo": "QR-code en info delen",
"shareInfoDescription": "Deze koppeling toont de informatie van de gedeelde map, inclusief titel, beschrijving, banner en QR-code in een popup.",
"downloadDescription": "Met deze koppeling kunt u de volledige inhoud van de gedeelde map downloaden als een gecomprimeerd bestand."
},
"prompts": {
"operationInProgress": "Werk in uitvoering, even geduld...",
"quickJump": "Snel navigeren naar andere bestanden",
"downloadsLimit": "Max. aantal downloads",
"maxBandwidth": "Max. downloadbandbreedte (kbps)",
"shareTheme": "Thema delen",
"viewMode": "Weergavemodus",
"shareThemeColor": "Themakleur delen (CSS-waarde)",
"shareTitle": "Titel delen",
"shareDescription": "Beschrijving delen",
"shareLogo": "URL van logo delen",
"shareBanner": "URL van banner delen",
"shareFavicon": "URL van favicon delen",
"destinationSource": "Bestemmingsbron:",
"selectPath": "Pad kiezen",
"copy": "Kopiëren",
"copyMessage": "Kies een locatie om uw bestanden te kopiëren: ",
"deleteMessageMultiple": "Weet u zeker dat u {count} bestand(en) wilt verwijderen?",
"deleteMessageSingle": "Weet u zeker dat u dit bestand/deze map wil verwijderen?",
"deleteMessageShare": "Weet u zeker dat u deze gedeelde map wilt verwijderen: {path} ?",
"deleteUserMessage": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"deleteTitle": "Bestanden verwijderen",
"displayName": "Weergavenaam: ",
"download": "Bestanden downloaden",
"downloadMessage": "Kies het formaat dat u wilt downloaden. ",
"dragAndDrop": "Sleep bestanden hier naartoe",
"error": "Er ging iets mis",
"completed": "Voltooid",
"fileInfo": "Bestandsinformatie",
"selected": "geselecteerd",
"filesSelected": "bestanden geselecteerd",
"lastModified": "Laatst gewijzigd{suffix}",
"move": "Verplaatsen",
"moveSuccess": "Bestanden succesvol verplaatst!",
"newFileSuccess": "Bestand succesvol aangemaakt!",
"newDirSuccess": "Map succesvol aangemaakt!",
"copySuccess": "Bestanden succesvol gekopieerd!",
"moveMessage": "Kies een nieuwe locatie voor de bestand(en)/map(pen)",
"newArchetype": "Maak een nieuw bericht op basis van een archetype. Uw bestand wordt aangemaakt in de inhoudsmap.",
"newDir": "Nieuwe map",
"newDirMessage": "Voer de naam van de nieuwe map in",
"newFile": "Nieuw bestand",
"newFileMessage": "Voer de naam van het nieuwe bestand in",
"numberDirs": "Aantal mappen{suffix}",
"numberFiles": "Aantal bestanden{suffix}",
"rename": "Hernoemen",
"renameSuccess": "Item met succes hernoemd!",
"renameMessageConflict": "Een item met de naam \"{filename}\" bestaat al!",
"renameMessage": "Voer een nieuwe naam in:",
"renameMessageInvalid": "Een bestand kan geen \"/\" of \"\\\" bevatten.",
"renameFailed": "Hernoemen van item mislukt",
"replace": "Vervangen",
"replaceMessage": "Een van de bestanden die u probeert te uploaden geeft conflicten i.v.m. de naamgeving. Wilt u de bestaande bestanden vervangen?\n",
"saveBeforeExit": "Niet-opgeslagen wijzigingen",
"saveBeforeExitMessage": "U hebt niet-opgeslagen wijzigingen in de editor. Wat wilt u doen?",
"schedule": "Plannen",
"scheduleMessage": "Kies een datum en tijd om de publicatie van dit bericht in te plannen.",
"show": "Tonen",
"size": "Grootte{suffix}",
"upload": "Uploaden",
"uploadSettingsChunked": "Max. aantal gelijktijdige uploads: {maxConcurrentUpload}, brokgrootte: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max. aantal gelijktijdige uploads: {maxConcurrentUpload}, upload in brokken uitgeschakeld",
"optionalPassword": "Optioneel wachtwoord",
"optionalPasswordHelp": "Indien ingesteld, moeten gebruikers dit wachtwoord invoeren om de gedeelde inhoud te bekijken.",
"typeName": "Type:",
"deleted": "Met succes verwijderd!",
"conflictsDetected": "Conflicten gedetecteerd",
"copyPasteConfirm": "Bevestiging kopiëren/plakken",
"copyItemQuestion": "Dit item kopiëren naar de nieuwe locatie?",
"moveItemQuestion": "Dit item verplaatsen naar de nieuwe locatie?",
"sourcePath": "Bron:",
"destinationPath": "Bestemming:",
"closeWithActiveUploads": "Actieve uploads",
"closeWithActiveUploadsMessage": "Waarschuwing: U hebt actieve uploads en het sluiten van het uploadvenster kan resulteren in onvoltooide uploads. Wat wilt u doen?",
"basicInfo": "Basisinformatie",
"directoryInfo": "Mapinformatie",
"additionalInfo": "Aanvullende informatie",
"hidden": "Verborgen",
"hasPreview": "Voorbeeld",
"checksums": "Controlesommen",
"hashAlgorithm": "Hash-algoritme",
"hashValue": "Hashwaarde",
"selectHashAlgorithm": "Selecteer een hash-algoritme",
"generate": "Genereren",
"errorGeneratingHash": "Fout bij het genereren van hash",
"hashCopied": "Hash gekopieerd naar klembord",
"copyToClipboardFailed": "Kan niet naar klembord kopiëren",
"errorCopyingHash": "Fout bij het kopiëren van hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice-ID",
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat u verwachtte, controleer dan het configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/database.db` of `./database.db` zijn.",
"halloweenTitle": "Happy Halloween!",
"halloweenBody": "Het Halloween-thema is toegepast. U kunt het uitschakelen als u dit niet prettig vindt, of permanent uitschakelen in de profielinstellingen door een ander thema te selecteren.",
"moveFailed": "Verplaatsen mislukt",
"operationFailed": "Bewerking is mislukt",
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt."
},
"search": {
"searchContext": "Zoekcontext: {context}",
"onlyFolders": "Alleen mappen",
"onlyFiles": "Alleen bestanden",
"showOptions": "Opties weergeven",
"notEnoughCharacters": "Niet genoeg tekens om te zoeken (min {minSearchLength})",
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken.",
"noResults": "Geen resultaten gevonden in geïndexeerde zoekopdrachten.",
"smallerThan": "Kleiner dan:",
"largerThan": "Groter dan:",
"showPreviewImages": "Voorbeeldafbeeldingen tonen",
"showPreviewImagesDescription": "Toon, indien beschikbaar, miniatuurvoorbeelden voor zoekresultaten en toon grotere pictogrammen voor alle resultaten.",
"helpText1": "Er wordt gezocht op elk teken dat u typt (minimaal 3 tekens voor zoektermen).",
"helpText2": "De index: Zoeken maakt gebruik van de index die automatisch wordt bijgewerkt op het ingestelde interval (standaard: 5 minuten). Zoeken als het programma net is gestart, kan onvolledige resultaten opleveren."
},
"profileSettings": {
"previewFolder": "Toon miniaturen voor mappen indien beschikbaar",
"previewFolderDescription": "Toon miniatuurafbeeldingen voor mappen met het eerste item met miniatuurafbeelding als omslag.",
"debugOfficeEditor": "OnlyOffice foutopsporingsmodus inschakelen",
"debugOfficeEditorDescription": "Een foutopsporingspop-up weergeven met aanvullende informatie in de OnlyOffice-editor.",
"defaultMediaPlayer": "De ingebouwde mediaspeler van uw browser gebruiken",
"defaultMediaPlayerDescription": "Gebruik de eigen mediaspeler van uw browser voor video- en audiobestanden in plaats van de meegeleverde 'vue-plyr' mediaspeler van FileBrowser.",
"showSelectMultiple": "Meervoudige selectie tonen in contextmenu op bureaublad",
"showSelectMultipleDescription": "Standaard heeft het contextmenu op de desktop geen optie \"meerdere selecteren\" zoals mobiel. Deze optie wordt altijd weergegeven in het contextmenu.",
"customTheme": "Kies uw thema",
"defaultThemeDescription": "standaard - Het standaardthema",
"setDateFormat": "Exact datumformaat instellen",
"showHiddenFiles": "Verborgen bestanden weergeven",
"showHiddenFilesDescription": "Toon verborgen bestanden in de lijst (bijv. .thumbnails, ProgramData), maar ze zijn nog steeds handmatig toegankelijk als ze zijn geconfigureerd om te worden geïndexeerd.",
"showQuickDownload": "Downloadpictogram weergeven voor snelle toegang",
"showQuickDownloadDescription": "Toon een downloadpictogram voor snelle toegang tot elk bestandsitem waarop kan worden geklikt om het meteen te downloaden.",
"previewImages": "Miniaturen van afbeeldingen weergeven",
"previewImagesDescription": "Toon miniatuurvoorbeelden voor afbeeldingen, toont generiek pictogram indien uitgeschakeld.",
"previewVideos": "Miniaturen van video's weergeven",
"previewVideosDescription": "Toon miniatuurvoorbeelden voor video's, toont generiek pictogram indien uitgeschakeld.",
"previewMotionVideos": "Bewegingsvoorbeeld voor video's inschakelen",
"previewMotionVideosDescription": "Bewegingsvoorbeeld is het geanimeerde popup-voorbeeld dat door de frames van de video loopt.",
"highQualityPreview": "Miniaturen van hoge kwaliteit",
"highQualityPreviewDescription": "Heeft alleen invloed op de galerieweergave. Het laden kan langer duren bij een langzame verbinding.",
"editOffice": "Office-bestanden bekijken en bewerken met OnlyOffice",
"editOfficeDescription": "Als alleen office is ingeschakeld, is de bestandsviewer/editor uitgeschakeld voor alle bestandsextensies die gerelateerd zijn aan Office-bestanden.",
"popupPreview": "Pop-upvoorbeeld inschakelen wanneer u met de muis over bestanden beweegt",
"popupPreviewDescription": "Er verschijnt een overlay pop-upvenster over de UI wanneer u met de muis over bestanden beweegt die een groot voorbeeld ondersteunen.",
"filePreviewOptions": "Opties voor bestandsvoorbeeld",
"disableUpdateNotifications": "Het tonen van meldingen update uitschakelen",
"disableUpdateNotificationsDescription": "Schakel de banner met updatemeldingen uit die wordt weergegeven voor beheerders in de zijbalk.",
"disableHideSidebar": "Houd de zijbalk open bij het bekijken of bewerken van bestanden",
"disableHideSidebarDescription": "Als deze optie is ingeschakeld, wordt de zijbalk niet automatisch verborgen bij het bekijken of bewerken van bestanden.",
"autoplayMedia": "Mediabestanden automatisch afspelen bij het bekijken",
"autoplayMediaDescription": "Mediabestanden automatisch afspelen bij het bekijken. Dit werkt alleen als het bestand een mediabestand is.",
"sidebarOptions": "Zijbalk-opties",
"disableQuickToggles": "Snel wisselen uitschakelen",
"disableFileExtensions": "Voer bestandsextensies in om uit te schakelen",
"disableQuickTogglesDescription": "Schakel de sneltoetsen uit die op de zijbalk worden weergegeven.",
"disableSearchOptions": "Zoekopties uitschakelen",
"disableSearchOptionsDescription": "Schakel de zoekopties in de zijbalk uit.",
"hideSidebarFileActions": "Verberg de knop Bestandsacties in de zijbalk",
"deleteWithoutConfirming": "Bestanden verwijderen zonder bevestigingsvenster",
"deleteWithoutConfirmingDescription": "Bestanden verwijderen zonder bevestigingsvenster. Dit werkt alleen als het bestand een mediabestand is.",
"editorViewerOptions": "Opties voor editor en viewer",
"editorQuickSave": "Snel opslaan in editor",
"editorQuickSaveDescription": "De teksteditor toont altijd het pictogram voor opslaan en verbergt alle andere acties",
"previewOffice": "Miniaturen weergeven voor Office-bestanden",
"previewOfficeDescription": "Toon miniaturen voor kantoorbestandstypen in de listingweergave",
"disableThumbnailPreviews": "Miniaturen uitschakelen voor bepaalde bestandsextensies",
"disableThumbnailPreviewsDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor miniatuurafbeeldingen uitgeschakeld moeten worden (bijv. '.txt .html').",
"disableViewingFiles": "Bestandsviewer/editor uitschakelen voor bepaalde bestandsextensies",
"disableViewingFilesDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor de bestandsviewer/editor uitgeschakeld moet worden (bijv. '.txt .pdf').",
"disablePreviewExt": "Voorbeelden van Office-bestanden uitschakelen voor bepaalde bestandsextensies",
"disablePreviewExtDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor Office-bestandsvoorvertoningen moeten worden uitgeschakeld (bijv. '.docx .pptx').",
"disableOfficeEditorDescription": "Een door spaties gescheiden lijst van bestandsextensies waarvoor de OnlyOffice editor en viewer uitgeschakeld moeten worden - of '*' om alle extensies uit te schakelen. (bijv. '.txt .html .pdf').",
"disableOfficeEditor": "Office Viewer uitschakelen voor bepaalde bestandsextensies",
"fileViewerOptions": "Opties voor bestandsviewer",
"themeAndLanguage": "Thema & Taal"
},
"settings": {
"systemAdmin": "Systeem & Beheer",
"configViewer": "Configuratiescherm",
"configViewerShowFull": "Volledige configuratie weergeven",
"configViewerShowComments": "Opmerkingen tonen",
"configViewerLoadConfig": "Configuratie laden",
"enterPassword": "Wachtwoord invoeren",
"enterPasswordAgain": "Wachtwoord opnieuw invoeren",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"loginMethod": "Aanmeldingsmethode",
"loginMethods": {
"password": "Wachtwoord",
"oidc": "OIDC",
"proxy": "Proxy"
},
"loginMethodDescription": "toegestane aanmeldingsmethode voor de gebruiker.",
"modifyOtherUser": "Gebruiker wijzigen:",
"modifyCurrentUser": "Huidige gebruiker wijzigen",
"themeColor": "Themakleur",
"listingOptions": "Lijstopties",
"searchOptions": "Zoekopties",
"emptyGlobal": "Op dit moment zijn er geen globale configuratieopties. U kunt de configuratie yaml bewerken om systeeminstellingen aan te passen.",
"scopes": "Toegestane bronnen en het gebruikersbereik daarbinnen definiëren",
"UserManagement": "Gebruikersbeheer",
"userNotAdmin": "Gebruiker is geen beheerder",
"administrator": "Beheerder",
"allowCommands": "Opdrachten uitvoeren",
"allowEdit": "Bestanden of mappen bewerken, hernoemen of verwijderen",
"allowNew": "Nieuwe bestanden of mappen aanmaken",
"allowPublish": "Nieuwe berichten en pagina's publiceren",
"allowSignup": "Gebruikers toestaan om zich te registreren",
"avoidChanges": "(Leeg laten om wijzigingen te voorkomen)",
"brandingDirectoryPath": "Pad naar map met Branding",
"brandingHelp": "U kunt aanpassen hoe deze FileBrowser eruit ziet en aanvoelt door de naam te veranderen, het logo te vervangen, aangepaste stijlen toe te voegen en zelfs externe links naar GitHub uit te schakelen.\nMeer informatie over het aanpassen van de branding, kunt u lezen op {0}.",
"changePassword": "Wachtwoord wijzigen",
"commandRunner": "Opdrachten uitvoeren",
"commandRunnerHelp": "Hier kunt u opdrachten instellen die worden uitgevoerd in de genoemde gebeurtenissen. U moet er één per regel schrijven. De omgevingsvariabelen {0} en {1} zijn beschikbaar, waarbij {0} relatief is ten opzichte van {1}. Lees de {2} voor meer informatie over deze functie en de beschikbare omgevingsvariabelen.",
"commandsUpdated": "Opdrachten bijgewerkt!",
"createUserDir": "Maak automatisch een thuismap aan wanneer een nieuwe gebruiker wordt aangemaakt",
"tusUploads": "Verzamelde uploads",
"tusUploadsHelp": "FileBrowser ondersteunt bestandsuploads in brokken, waardoor efficiënte, betrouwbare, hervattbare bestandsuploads mogelijk zijn, zelfs op onbetrouwbare netwerken.",
"tusUploadsChunkSize": "Geeft de maximale grootte van een verzoek aan (directe uploads worden gebruikt voor kleinere uploads). U kunt een gewoon geheel getal invoeren dat staat voor een bytes-invoer of een tekenreeks zoals 10MB, 1GB enz.",
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een brok mislukt.",
"userHomeBasePath": "Basispad voor thuismappen van gebruikers",
"userScopeGenerationPlaceholder": "Het bereik wordt automatisch gegenereerd",
"createUserHomeDirectory": "Thuismap voor gebruiker aanmaken",
"customStylesheet": "Aangepast Stylesheet",
"defaultUserDescription": "Dit zijn de standaardinstellingen voor nieuwe gebruikers.",
"disableExternalLinks": "Externe links uitschakelen (behalve documentatie)",
"disableUsedDiskPercentage": "Grafiek voor gebruikt schijfpercentage uitschakelen",
"executeOnShell": "Uitvoeren in het opdrachtvenster",
"executeOnShellDescription": "FileBrowser voert de opdrachten standaard uit door hun binaire bestanden rechtstreeks op te roepen. Als u ze in plaats daarvan wilt uitvoeren op een opdrachtvenster (zoals Bash of PowerShell), kunt u dit hier definiëren met de vereiste argumenten en vlaggen. Indien ingesteld, wordt de opdracht die u uitvoert, toegevoegd als een argument. Dit is van toepassing op zowel gebruikersopdrachten als event hooks.",
"globalRules": "Dit is een algemene reeks regels die al dan niet zijn toegestaan. Ze zijn van toepassing op elke gebruiker. U kunt specifieke regels voor de instellingen van elke gebruiker definiëren om deze te overschrijven.",
"globalSettings": "Algemene instellingen",
"shareSettings": "Instellingen voor Delen",
"api": "API-sleutels",
"insertPath": "Een pad invoeren",
"insertRegex": "Regex expressie invoeren",
"instanceName": "Naam van de instantie",
"lockPassword": "Voorkom dat de gebruiker het wachtwoord wijzigt",
"newPassword": "Uw nieuwe wachtwoord",
"newPasswordConfirm": "Uw nieuwe wachtwoord bevestigen",
"newUser": "Nieuwe gebruiker",
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"passwordUpdated": "Wachtwoord bijgewerkt!",
"permissions": {
"admin": "Beheerder",
"modify": "Bestanden bewerken",
"share": "Bestanden delen",
"api": "Langlevende API-sleutels aanmaken en beheren",
"realtime": "Real-time verbindingen en updates mogelijk maken",
"create": "Bestanden aanmaken",
"delete": "Bestanden verwijderen",
"download": "Bestanden downloaden"
},
"permissions-name": "Machtigingen",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Toegang als beheerder geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen.",
"profileSettings": "Profielinstellingen",
"ruleExample1": "voorkomt de toegang tot elk puntbestand (zoals .git, .gitignore) in elke map.\n",
"ruleExample2": "blokkeert de toegang tot het bestand met de naam Caddyfile in de hoofdmap van het bereik.",
"rulesHelp": "Hier kunt u een reeks regels voor toestaan en niet toestaan voor deze specifieke gebruiker definiëren. De geblokkeerde bestanden verschijnen niet in de lijsten en zijn niet toegankelijk voor de gebruiker. We ondersteunen regex en paden relatief ten opzichte van het bereik van gebruikers. \n",
"settingsUpdated": "Instellingen bijgewerkt!",
"shareManagement": "Beheer van Gedeelde mappen",
"shareDeleted": "Gedeelde map verwijderd!",
"singleClick": "Gebruik één klik om bestanden en mappen te openen",
"userCommandsHelp": "Een lijst met beschikbare opdrachten voor de gebruiker gescheiden door spaties. Voorbeeld: \n",
"userCreated": "Gebruiker aangemaakt!",
"userDefaults": "Standaardinstellingen gebruiker",
"userDeleted": "Gebruiker verwijderd!",
"userManagement": "Gebruikersbeheer",
"userUpdated": "Gebruiker bijgewerkt!",
"adminOptions": "Opties voor beheerders"
},
"sidebar": {
"siteSettings": "Site-instellingen",
"updateIsAvailable": "Er is een update beschikbaar!",
"fileActions": "Bestandsacties",
"customizeLinks": "Zijbalk koppelingen aanpassen",
"customizeLinksDescription": "Pas de koppelingen in de zijbalk aan. Gebruik slepen en neerzetten om te ordenen.",
"customizeShareLinks": "Koppelingen in Zijbalk Delen aanpassen",
"customizeShareLinksDescription": "Pas de koppelingen aan die worden weergegeven in de zijbalk voor deze gedeelde map. Slepen en neerzetten om te herschikken.",
"currentLinks": "Huidige koppelingen",
"addNewLink": "Nieuwe koppeling toevoegen",
"editLink": "Koppeling bewerken",
"linkType": "Type koppeling",
"selectLinkType": "Selecteer een type koppeling",
"customLink": "Aangepaste koppeling",
"selectSource": "Bron selecteren",
"chooseSource": "Kies een bron",
"selectShare": "Selecteer gedeelde map",
"chooseShare": "Kies een gedeelde map",
"selectSharePath": "Pad binnen gedeelde map (optioneel)",
"sharePathPlaceholder": "bijv. /submap of leeg laten voor root",
"selectTool": "Selecteer gereedschap",
"chooseTool": "Kies een hulpmiddel",
"linkName": "Link Naam",
"linkNamePlaceholder": "Naam koppeling invoeren",
"linkUrl": "URL van koppeling",
"linkUrlPlaceholder": "Voer een URL of pad in (bijv. /files/path of https://example.com)",
"linkIcon": "Pictogram van koppeling",
"linkIconPlaceholder": "Geef optioneel een materiaalpictogramnaam op",
"browseIcons": "Bladeren door pictogrammen",
"pickIcon": "Kies een pictogram",
"pickIconDescription": "Zoek en selecteer een pictogram uit de bibliotheek met materiaalpictogrammen",
"linksUpdatedSuccess": "Koppelingen in zijbalk succesvol bijgewerkt!",
"shareLinksUpdatedSuccess": "Koppelingen in zijbalk Delen succesvol bijgewerkt!",
"linksUpdateFailed": "Bijwerken van koppelingen in zijbalk is mislukt",
"sourceNotAccessible": "Deze bron is niet langer toegankelijk voor jou",
"noLinksYet": "Er zijn nog geen links geconfigureerd. Voeg hieronder uw eerste link toe.",
"shareHome": "Thuis delen",
"dividerNamePlaceholder": "Scheidingstekst (leeg laten om een lijn weer te geven)"
},
"success": {
"linkCopied": "Koppeling gekopieerd!"
},
"time": {
"days": "Dagen",
"hours": "Uren",
"minutes": "Minuten",
"seconds": "Seconden",
"unit": "Tijdseenheid"
},
"api": {
"title": "API-sleutels",
"createdAt": "Aangemaakt op:",
"expiresAt": "Verloopt op:",
"permissions": "Machtigingen:",
"createTitle": "API-sleutel aanmaken",
"keyNamePlaceholder": "Voer een unieke naam voor de API-sleutel in:",
"tokenDuration": "Duur van token:",
"durationNumberPlaceholder": "Aantal",
"days": "Dagen",
"months": "Maanden",
"permissionNote": "Kies minstens één machtiging voor de sleutel. Uw gebruiker moet deze rechten ook hebben.",
"createKeyFailed": "API-sleutel is niet aangemaakt.",
"createKeySuccess": "API-sleutel met succes aangemaakt.",
"apiKeyDeleted": "API-sleutel verwijderd!",
"description": "API-sleutels zijn gebaseerd op de gebruiker die ze aanmaakt. Zie",
"swaggerLinkText": "branie-pagina",
"created": "Aangemaakt op",
"expires": "Verloopt op",
"actions": "Acties",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"tokenType": "Type token:",
"minimalDescription": "Minimaal token",
"minimalInfo": "Sluit tokenaanpassingen uit om een kleinere tokenlengte te creëren. Exacte overeenkomst met de rechten van de bijbehorende gebruiker.",
"clickToCopyKey": "Klik om sleutel te kopiëren (verborgen)"
},
"index": {
"unknown": "Onbekend",
"simple": "Eenvoudig",
"normal": "Normaal",
"complex": "Complex",
"highlyComplex": "Zeer complex",
"assessment": "Beoordeling",
"ready": "Klaar",
"indexing": "Indexering",
"unavailable": "Niet beschikbaar",
"error": "Fout",
"lastScanned": "Laatst gescand",
"quickScan": "Seconden voor Snelle scan",
"fullScan": "Seconden voor Volledige scan",
"settingsHover": "Instellingen voor gebruiker",
"toggleClick": "Enkele klik schakelen",
"toggleDark": "Donkere modus schakelen",
"toggleSticky": "Kleefmodus schakelen",
"noSources": "Geen bronnen beschikbaar voor deze gebruiker. Voeg een bron toe voor deze gebruiker of configureer een bron met defaultEnabled: true"
},
"otp": {
"name": "Twee-factorenauthenticatie",
"verifyInstructions": "Voer de code van uw authenticator-app in",
"generate": "Scan de QR-code met uw authenticator-app",
"codeInputPlaceholder": "6-cijferige code",
"invalidCodeType": "De verificatiecode moet uit 6 engelse cijfers bestaan",
"enabledSuccessfully": "OTP succesvol ingeschakeld",
"verificationFailed": "Verificatie is mislukt",
"verificationSucceed": "Verificatie is geslaagd",
"generationFailed": "Aanmaken is mislukt"
},
"access": {
"all": "Alles weigeren",
"rules": "Toegangsregels",
"deny": "Weigeren",
"deniedUsers": "Geweigerde gebruikers",
"deniedGroups": "Geweigerde groepen",
"allowedUsers": "Toegestane gebruikers",
"allowedGroups": "Toegestane groepen",
"totalDenied": "Geweigerd",
"totalAllowed": "Toegestaan",
"allow": "Toestaan",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"enterName": "Voer de naam van de gebruiker of groep in",
"deleted": "Toegangsregel verwijderd!",
"deletedCascade": "Toegangsregels verwijderd van pad en alle subpaden!",
"added": "Toegangsregel toegevoegd!",
"accessManagement": "Toegangsbeheer",
"defaultBehavior": "Standaardactie{suffix}",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"cascadeDelete": "Recursief verwijderen",
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kunt u een gebruiker of groep volledig verwijderen uit de hele mappenstructuur."
},
"fileLoading": {
"title": "Uploaden & Downloads",
"maxConcurrentUpload": "Max. gelijktijdige uploads",
"maxConcurrentUploadHelp": "Het maximum aantal gelijktijdige uploads. Hogere waarden presteren meestal beter, maar dit hangt van veel factoren af.",
"uploadChunkSizeMb": "Upload brokgrootte in MB",
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt uploads in brokken uit.",
"downloadChunkSizeMb": "Grootte downloadbrok in MB",
"downloadChunkSizeMbHelp": "Brok-grootte voor downloads in brokken. Bestanden die groter zijn dan deze grootte worden in brokken gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt downloads in brokken uit.",
"clearAll": "Wissen voltooid moet fout, pauze en conflict verhelpen",
"clearAllDescription": "De knop Wissen voltooid wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen.",
"uploadSettings": "Instellingen uploaden"
},
"colors": {
"red": "rood",
"green": "groen",
"blue": "blauw",
"yellow": "geel",
"violet": "paars",
"orange": "oranje"
},
"editor": {
"uninitialized": "Het is niet gelukt de editor te initialiseren. Opnieuw laden.",
"saveFailed": "Het is niet gelukt om het bestand op te slaan. Probeer het opnieuw.",
"saveAborted": "Opslaan afgebroken. De bestandsnaam komt niet overeen met het actieve bestand.",
"saveAbortedMessage": "Opslaan afgebroken. Het actieve bestand van de toepassing ({activeFile}) komt niet overeen met het bestand dat u probeert op te slaan ({tryingToSave}).",
"syncFailed": "Na 5 pogingen is het niet gelukt om de status te synchroniseren met de route voor \"{filename}\". Installatie van editor afgebroken om gegevensbeschadiging te voorkomen.",
"fileSaved": "Bestand opgeslagen!",
"errorSavingFile": "Fout bij het opslaan van bestand: {error}"
},
"player": {
"LoopEnabled": "Herhaling ingeschakeld",
"LoopDisabled": "Herhaling uitgeschakeld",
"PlayAllOncePlayback": "Alles eenmaal afspelen",
"ShuffleAllPlayback": "Alles willekeurig afspelen",
"PlayAllLoopedPlayback": "Alles herhaald afspelen",
"QueueButtonHint": "Afspeellijst weergeven",
"QueuePlayback": "Afspeellijst",
"changePlaybackMode": "Afspeelmodus wijzigen",
"changePlaybackModeHint": "Wijzig afspeelmodus om meer bestanden te zien",
"emptyQueue": "Wachtrij is leeg"
},
"fileSizeAnalyzer": {
"title": "Bestandsgrootte analyseren",
"searchLocation": "Zoeklocatie",
"selectPath": "Zoeklocatie selecteren",
"largerThan": "Groter dan (MB)",
"includeFolders": "Mappen opnemen",
"includeFoldersDescription": "mappen meenemen in de analyse",
"totalFiles": "Totaal aantal bestanden{suffix}",
"totalSize": "Totale grootte{suffix}",
"emptyState": "Voer zoekcriteria in en klik op Analyseren om bestandsgroottes te visualiseren",
"completeResults": "Volledige resultaten getoond!",
"incompleteResults": "Resultaten kunnen onvolledig zijn",
"relativePercentage": "Relatief percentage{suffix}",
"percentageOfResults": "{percentage}% resultaten"
},
"fileTypes": {
"video": "Video",
"image": "Afbeelding",
"audio": "Audio",
"archive": "Archief",
"document": "Document",
"binary": "Binair",
"directory": "Map",
"other": "Overig"
},
"tools": {
"title": "Gereedschap",
"description": "Hulpprogramma's en analysetools voor uw bestanden",
"sizeAnalyzer": {
"name": "Bestandsgrootte analyseren",
"description": "Visualiseer grote bestanden en mappen met een interactieve treemap"
},
"duplicateFinder": {
"name": "Duplicaatbestanden zoeken",
"description": "Dubbele bestanden vinden en beheren om opslagruimte terug te winnen"
},
"materialIconPicker": {
"name": "Material pictogrammenkiezer",
"description": "Bladeren en zoeken in Material pictogrammen met live voorbeeld",
"searchPlaceholder": "Pictogrammen zoeken...",
"browseFullLibrary": "Blader door de volledige pictogrammenbibliotheek",
"iconLibraryUrl": "fonts.google.com/icons",
"popularIcons": "{count} populaire pictogrammen weergeven",
"showingResults": "{count} resultaten weergeven",
"noResults": "Geen pictogrammen gevonden",
"tryDifferentSearch": "Probeer een andere zoekterm",
"copiedToClipboard": "'{name}' is gekopieerd naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"custom": "Aangepast",
"useCustomIcon": "Gebruik een aangepast pictogram: {name}"
},
"fileWatcher": {
"name": "Bestandenbewaker",
"description": "Bestanden in realtime bewaken en volgen met latentiebewaking",
"file": "Bestand{suffix}",
"interval": "Update-interval",
"stopWatching": "Stoppen met bewaken",
"emptyState": "Kies een bestand en klik op afspelen om te beginnen met het controleren van bestandswijzigingen",
"chooseFile": "Kies een bestand"
},
"toolNotFound": "Gereedschap niet gevonden",
"backToTools": "Terug naar Gereedschap"
},
"notifications": {
"title": "Meldingen",
"description": "Alle meldingen van uw huidige sessie bekijken",
"empty": "Nog geen meldingen",
"clearAll": "Alles wissen",
"clearConfirm": "Weet u zeker dat u de gehele meldingsgeschiedenis wilt wissen?",
"total": "{count} meldingen"
},
"duplicateFinder": {
"title": "Duplicaatbestanden zoeken",
"minSize": "Minimale grootte (MB){suffix}",
"minSizeHint": "Alleen bestanden analyseren die groter zijn dan deze grootte (in megabytes)",
"useChecksums": "Bestandsinhoud vergelijken",
"useChecksumsDescription": "Neem ook de inhoud van bestanden op in de vergelijking. Zonder dit worden bestanden vergeleken op basis van grootte en bestandsnaam overeenkomst.",
"findDuplicates": "Duplicaten zoeken",
"groupsFound": "Dubbele groepen gevonden{suffix}",
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
"wasted": "Verspild{suffix}",
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren"
},
"events": {
"serverShutdown": "De server is uitgeschakeld.",
"authenticationFailed": "Authenticatie is mislukt. Ververs de pagina om opnieuw in te loggen.",
"reconnected": "Opnieuw verbinding gemaakt met server.",
"connectionLost": "De verbinding met de server is verbroken. Probeer opnieuw verbinding te maken..."
}
}
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Przejdź do pozycji",
"watchFile": "Oglądaj plik",
"showMore": "Pokaż opcje zaawansowane",
"showLess": "Ukryj opcje zaawansowane"
"showLess": "Ukryj opcje zaawansowane",
"goToSource": "Przejdź do lokalizacji źródłowej"
},
"download": {
"downloadFile": "Pobierz plik",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Utwórz konto",
@@ -320,7 +322,6 @@
"adminOptions": "Opcje użytkownika administratora",
"searchOptions": "Opcje wyszukiwania",
"systemAdmin": "System i administrator",
"configViewer": "Przeglądarka konfiguracji",
"configViewerShowFull": "Pokaż pełną konfigurację",
"configViewerShowComments": "Pokaż komentarze",
"configViewerLoadConfig": "Konfiguracja ładowania",
@@ -332,7 +333,8 @@
"proxy": "Pełnomocnik"
},
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste",
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych."
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych.",
"configViewer": "Przeglądarka konfiguracji"
},
"sidebar": {
"siteSettings": "Ustawienia Strony",
@@ -370,7 +372,8 @@
"pickIconDescription": "Wyszukaj i wybierz ikonę z biblioteki Material Icons",
"shareLinksUpdatedSuccess": "Linki do paska bocznego zostały pomyślnie zaktualizowane!",
"shareHome": "Share Home",
"linkIconPlaceholder": "Opcjonalnie można określić nazwę ikony materiału"
"linkIconPlaceholder": "Opcjonalnie można określić nazwę ikony materiału",
"dividerNamePlaceholder": "Tekst podziału (pozostaw pusty, aby wyświetlić linię)"
},
"success": {
"linkCopied": "Link Skopiowany!"
@@ -625,7 +628,9 @@
"lines": "Linie{suffix}",
"lastUpdate": "Ostatnia aktualizacja{suffix}",
"latency": "Opóźnienie{suffix}",
"all": "Wszystkie{suffix}"
"all": "Wszystkie{suffix}",
"expiration": "Wygaśnięcie{suffix}",
"divider": "Dzielnik{suffix}"
},
"access": {
"rules": "Zasady dostępu",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Ir para o item",
"watchFile": "Assista ao arquivo",
"showMore": "Mostrar opções avançadas",
"showLess": "Ocultar opções avançadas"
"showLess": "Ocultar opções avançadas",
"goToSource": "Ir para o local de origem"
},
"download": {
"downloadFile": "Baixar arquivo",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Criar uma conta",
@@ -320,7 +322,6 @@
"adminOptions": "Opções do usuário administrador",
"searchOptions": "Opções de pesquisa",
"systemAdmin": "Sistema e administração",
"configViewer": "Visualizador de configuração",
"configViewerShowFull": "Mostrar configuração completa",
"configViewerShowComments": "Mostrar comentários",
"configViewerLoadConfig": "Configuração de carga",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão",
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema."
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema.",
"configViewer": "Visualizador de configuração"
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -370,7 +372,8 @@
"pickIconDescription": "Pesquise e selecione um ícone na biblioteca de ícones de materiais",
"shareLinksUpdatedSuccess": "Os links da barra lateral de compartilhamento foram atualizados com sucesso!",
"shareHome": "Compartilhar casa",
"linkIconPlaceholder": "Opcionalmente, especifique um nome de ícone de material"
"linkIconPlaceholder": "Opcionalmente, especifique um nome de ícone de material",
"dividerNamePlaceholder": "Texto do divisor (deixe em branco para mostrar uma linha)"
},
"success": {
"linkCopied": "Link copiado!"
@@ -625,7 +628,9 @@
"lines": "Linhas{suffix}",
"lastUpdate": "Última atualização{suffix}",
"latency": "Latência{suffix}",
"all": "Todos{suffix}"
"all": "Todos{suffix}",
"expiration": "Vencimento{suffix}",
"divider": "Divisor{suffix}"
},
"access": {
"rules": "Regras de acesso",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Ir para o item",
"watchFile": "Ver ficheiro",
"showMore": "Mostrar opções avançadas",
"showLess": "Ocultar opções avançadas"
"showLess": "Ocultar opções avançadas",
"goToSource": "Ir para o local de origem"
},
"download": {
"downloadFile": "Descarregar ficheiro",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Criar uma conta",
@@ -320,7 +322,6 @@
"adminOptions": "Opções do utilizador administrador",
"searchOptions": "Opções de pesquisa",
"systemAdmin": "Sistema e administração",
"configViewer": "Visualizador de configuração",
"configViewerShowFull": "Mostrar configuração completa",
"configViewerShowComments": "Mostrar comentários",
"configViewerLoadConfig": "Configuração de carga",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito",
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema."
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema.",
"configViewer": "Visualizador de configuração"
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -370,7 +372,8 @@
"pickIconDescription": "Procurar e selecionar um ícone na biblioteca de ícones de materiais",
"shareLinksUpdatedSuccess": "As ligações da barra lateral de partilha foram actualizadas com êxito!",
"shareHome": "Partilhar casa",
"linkIconPlaceholder": "Especificar opcionalmente um nome de ícone de material"
"linkIconPlaceholder": "Especificar opcionalmente um nome de ícone de material",
"dividerNamePlaceholder": "Texto do separador (deixar em branco para mostrar uma linha)"
},
"success": {
"linkCopied": "Link copiado!"
@@ -705,7 +708,9 @@
"lines": "Linhas{suffix}",
"lastUpdate": "Última atualização{suffix}",
"latency": "Latência{suffix}",
"all": "Todos{suffix}"
"all": "Todos{suffix}",
"expiration": "Prazo de validade{suffix}",
"divider": "Divisor{suffix}"
},
"access": {
"rules": "Regras de acesso",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Du-te la articol",
"watchFile": "Fișier de vizionare",
"showMore": "Afișare opțiuni avansate",
"showLess": "Ascundeți opțiunile avansate"
"showLess": "Ascundeți opțiunile avansate",
"goToSource": "Du-te la locația sursei"
},
"download": {
"downloadFile": "Descarcă fișier",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Crează cont",
@@ -320,7 +322,6 @@
"adminOptions": "Opțiuni utilizator administrator",
"searchOptions": "Opțiuni de căutare",
"systemAdmin": "Sistem și administrare",
"configViewer": "Vizualizator de configurare",
"configViewerShowFull": "Afișați configurația completă",
"configViewerShowComments": "Afișați comentariile",
"configViewerLoadConfig": "Configurare încărcare",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit",
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem."
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem.",
"configViewer": "Vizualizator de configurare"
},
"sidebar": {
"siteSettings": "Setări site",
@@ -370,7 +372,8 @@
"pickIconDescription": "Căutați și selectați o pictogramă din biblioteca Material Icons",
"shareLinksUpdatedSuccess": "Link-urile din bara laterală de partajare au fost actualizate cu succes!",
"shareHome": "Share Home",
"linkIconPlaceholder": "Opțional, specificați un nume de pictogramă de material"
"linkIconPlaceholder": "Opțional, specificați un nume de pictogramă de material",
"dividerNamePlaceholder": "Textul separatorului (lăsați gol pentru a afișa o linie)"
},
"success": {
"linkCopied": "Legătură copiată!"
@@ -625,7 +628,9 @@
"lines": "Linii{suffix}",
"lastUpdate": "Ultima actualizare{suffix}",
"latency": "Latență{suffix}",
"all": "Toate{suffix}"
"all": "Toate{suffix}",
"expiration": "Expirare{suffix}",
"divider": "Divizor{suffix}"
},
"access": {
"rules": "Reguli de acces",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Перейти к пункту",
"watchFile": "Смотреть файл",
"showMore": "Показать дополнительные параметры",
"showLess": "Скрыть дополнительные параметры"
"showLess": "Скрыть дополнительные параметры",
"goToSource": "Перейти к исходному местоположению"
},
"download": {
"downloadFile": "Скачать файл",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Создать аккаунт",
@@ -320,7 +322,6 @@
"adminOptions": "Параметры пользователя администратора",
"searchOptions": "Параметры поиска",
"systemAdmin": "Система и администрирование",
"configViewer": "Программа просмотра конфигурации",
"configViewerShowFull": "Показать полную конфигурацию",
"configViewerShowComments": "Показать комментарии",
"configViewerLoadConfig": "Загрузить конфигурацию",
@@ -332,7 +333,8 @@
"proxy": "Прокси-сервер"
},
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию",
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек."
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек.",
"configViewer": "Программа просмотра конфигурации"
},
"sidebar": {
"siteSettings": "Настройки сайта",
@@ -370,7 +372,8 @@
"pickIconDescription": "Найдите и выберите значок в библиотеке Material Icons.",
"shareLinksUpdatedSuccess": "Ссылки на боковой панели успешно обновлены!",
"shareHome": "Поделиться Домой",
"linkIconPlaceholder": "Опционально укажите имя значка материала"
"linkIconPlaceholder": "Опционально укажите имя значка материала",
"dividerNamePlaceholder": "Текст разделителя (оставьте пустым, чтобы показать строку)"
},
"success": {
"linkCopied": "Ссылка скопирована!"
@@ -625,7 +628,9 @@
"lines": "Линии{suffix}",
"lastUpdate": "Последнее обновление{suffix}",
"latency": "Латентность{suffix}",
"all": "Все{suffix}"
"all": "Все{suffix}",
"expiration": "Срок действия{suffix}",
"divider": "Разделитель{suffix}"
},
"access": {
"rules": "Правила доступа",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Prejsť na položku",
"watchFile": "Sledovať súbor",
"showMore": "Zobraziť rozšírené možnosti",
"showLess": "Skryť rozšírené možnosti"
"showLess": "Skryť rozšírené možnosti",
"goToSource": "Prejsť na zdrojové miesto"
},
"download": {
"downloadFile": "Stiahnuť súbor",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Vytvoriť účet",
@@ -320,7 +322,6 @@
"adminOptions": "Možnosti používateľa správcu",
"searchOptions": "Možnosti vyhľadávania",
"systemAdmin": "Systém a administrátor",
"configViewer": "Prehliadač konfigurácie",
"configViewerShowFull": "Zobraziť úplnú konfiguráciu",
"configViewerShowComments": "Zobraziť komentáre",
"configViewerLoadConfig": "Konfigurácia načítania",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie",
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami."
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami.",
"configViewer": "Prehliadač konfigurácie"
},
"sidebar": {
"siteSettings": "Nastavenia stránky",
@@ -370,7 +372,8 @@
"pickIconDescription": "Vyhľadajte a vyberte ikonu z knižnice Material Icons",
"shareLinksUpdatedSuccess": "Zdieľanie bočného panela odkazy úspešne aktualizované!",
"shareHome": "Zdieľať Domov",
"linkIconPlaceholder": "Voliteľne zadajte názov ikony materiálu"
"linkIconPlaceholder": "Voliteľne zadajte názov ikony materiálu",
"dividerNamePlaceholder": "Text oddeľovača (nechajte prázdny, aby sa zobrazil riadok)"
},
"success": {
"linkCopied": "Odkaz skopírovaný!"
@@ -625,7 +628,9 @@
"lines": "Riadky{suffix}",
"lastUpdate": "Posledná aktualizácia{suffix}",
"latency": "Latencia{suffix}",
"all": "Všetky{suffix}"
"all": "Všetky{suffix}",
"expiration": "Uplynutie platnosti{suffix}",
"divider": "Rozdeľovač{suffix}"
},
"access": {
"rules": "Pravidlá prístupu",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Gå till artikel",
"watchFile": "Titta på filen",
"showMore": "Visa avancerade alternativ",
"showLess": "Dölj avancerade alternativ"
"showLess": "Dölj avancerade alternativ",
"goToSource": "Gå till källplatsen"
},
"download": {
"downloadFile": "Ladda ner fil",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Skapa ett konto",
@@ -320,7 +322,6 @@
"adminOptions": "Alternativ för administratörsanvändare",
"searchOptions": "Sökalternativ",
"systemAdmin": "System & Administration",
"configViewer": "Konfig-visare",
"configViewerShowFull": "Visa fullständig konfiguration",
"configViewerShowComments": "Visa kommentarer",
"configViewerLoadConfig": "Ladda konfig",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard",
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar."
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar.",
"configViewer": "Konfig-visare"
},
"sidebar": {
"siteSettings": "System inställningar",
@@ -370,7 +372,8 @@
"pickIconDescription": "Sök och välj en ikon från Material Icons-biblioteket",
"shareLinksUpdatedSuccess": "Dela sidofältets länkar uppdaterades framgångsrikt!",
"shareHome": "Dela hem",
"linkIconPlaceholder": "Ange eventuellt ett namn på materialikonen"
"linkIconPlaceholder": "Ange eventuellt ett namn på materialikonen",
"dividerNamePlaceholder": "Delningstext (lämna tom för att visa en rad)"
},
"success": {
"linkCopied": "Länk kopierad"
@@ -705,7 +708,9 @@
"lines": "Linjer{suffix}",
"lastUpdate": "Senaste uppdatering{suffix}",
"latency": "Fördröjning{suffix}",
"all": "Alla{suffix}"
"all": "Alla{suffix}",
"expiration": "Förfallodag{suffix}",
"divider": "Uppdelare{suffix}"
},
"access": {
"rules": "Regler för åtkomst",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Öğeye git",
"watchFile": "Dosyayı izle",
"showMore": "Gelişmiş Seçenekleri Göster",
"showLess": "Gelişmiş Seçenekleri Gizle"
"showLess": "Gelişmiş Seçenekleri Gizle",
"goToSource": "Kaynak Konuma Git"
},
"download": {
"downloadFile": "Dosyayı indir",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Bir hesap oluşturun",
@@ -320,7 +322,6 @@
"adminOptions": "Yönetici kullanıcı seçenekleri",
"searchOptions": "Arama seçenekleri",
"systemAdmin": "Sistem ve Yönetici",
"configViewer": "Konfigürasyon Görüntüleyici",
"configViewerShowFull": "Tam yapılandırmayı göster",
"configViewerShowComments": "Yorumları göster",
"configViewerLoadConfig": "Yük Konfigürasyonu",
@@ -332,7 +333,8 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş",
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar."
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar.",
"configViewer": "Yapılandırma Görüntüleyici"
},
"sidebar": {
"siteSettings": "Site ayarları!",
@@ -370,7 +372,8 @@
"pickIconDescription": "Materyal Simgeleri kitaplığından bir simge arayın ve seçin",
"shareLinksUpdatedSuccess": "Kenar çubuğu bağlantılarını paylaşma başarıyla güncellendi!",
"shareHome": "Ev Paylaşımı",
"linkIconPlaceholder": "İsteğe bağlı olarak bir malzeme simgesi adı belirtin"
"linkIconPlaceholder": "İsteğe bağlı olarak bir malzeme simgesi adı belirtin",
"dividerNamePlaceholder": "Bölücü metin (bir çizgi göstermek için boş bırakın)"
},
"success": {
"linkCopied": "Link kopyalandı!"
@@ -625,7 +628,9 @@
"lines": "Çizgiler{suffix}",
"lastUpdate": "Son Güncelleme{suffix}",
"latency": "Gecikme{suffix}",
"all": "Tümü{suffix}"
"all": "Tümü{suffix}",
"expiration": "Son kullanma tarihi{suffix}",
"divider": "Bölücü{suffix}"
},
"access": {
"rules": "Erişim kuralları",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "Перейти до пункту",
"watchFile": "Переглянути файл",
"showMore": "Показати додаткові параметри",
"showLess": "Приховати додаткові параметри"
"showLess": "Приховати додаткові параметри",
"goToSource": "Перейдіть до Розташування джерела"
},
"download": {
"downloadFile": "Завантажити файл",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Створити обліковий запис",
@@ -320,7 +322,6 @@
"adminOptions": "Параметри користувача адміністрування",
"searchOptions": "Параметри пошуку",
"systemAdmin": "Система та адміністрування",
"configViewer": "Переглядач конфігурацій",
"configViewerShowFull": "Показати повну конфігурацію",
"configViewerShowComments": "Показати коментарі",
"configViewerLoadConfig": "Завантажити конфігурацію",
@@ -332,7 +333,8 @@
"proxy": "Довірена особа"
},
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням",
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань."
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань.",
"configViewer": "Переглядач конфігурацій"
},
"sidebar": {
"siteSettings": "Налаштування сайту",
@@ -370,7 +372,8 @@
"pickIconDescription": "Знайдіть і виберіть піктограму з бібліотеки піктограм матеріалів",
"shareLinksUpdatedSuccess": "Посилання на бічну панель обміну успішно оновлено!",
"shareHome": "Поділитися Додому",
"linkIconPlaceholder": "За бажанням вкажіть назву іконки матеріалу"
"linkIconPlaceholder": "За бажанням вкажіть назву іконки матеріалу",
"dividerNamePlaceholder": "Текст роздільника (залиште порожнім, щоб показати лінію)"
},
"success": {
"linkCopied": "Посилання скопійоване!"
@@ -705,7 +708,9 @@
"lines": "Лінії{suffix}",
"lastUpdate": "Останнє оновлення{suffix}",
"latency": "Затримка{suffix}",
"all": "Усе.{suffix}"
"all": "Усе.{suffix}",
"expiration": "Закінчення терміну дії{suffix}",
"divider": "Перегородка{suffix}"
},
"access": {
"rules": "Правила доступу",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "转到项目",
"watchFile": "观看文件",
"showMore": "显示高级选项",
"showLess": "隐藏高级选项"
"showLess": "隐藏高级选项",
"goToSource": "转到源位置"
},
"download": {
"downloadFile": "下载文件",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "创建用户",
@@ -320,7 +322,6 @@
"adminOptions": "管理员用户选项",
"searchOptions": "搜索选项",
"systemAdmin": "系统与管理",
"configViewer": "配置查看器",
"configViewerShowFull": "显示完整配置",
"configViewerShowComments": "显示评论",
"configViewerLoadConfig": "加载配置",
@@ -332,7 +333,8 @@
"proxy": "代理"
},
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空",
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。"
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。",
"configViewer": "配置查看器"
},
"sidebar": {
"siteSettings": "网站设置",
@@ -370,7 +372,8 @@
"pickIconDescription": "从材质图标库中搜索并选择图标",
"shareLinksUpdatedSuccess": "共享侧边栏链接更新成功!",
"shareHome": "共享主页",
"linkIconPlaceholder": "可选择指定材料图标名称"
"linkIconPlaceholder": "可选择指定材料图标名称",
"dividerNamePlaceholder": "分隔线文本(留空以显示一行)"
},
"success": {
"linkCopied": "链接已复制!"
@@ -705,7 +708,9 @@
"lines": "线路{suffix}",
"lastUpdate": "最新更新{suffix}",
"latency": "延迟{suffix}",
"all": "全部{suffix}"
"all": "全部{suffix}",
"expiration": "到期{suffix}",
"divider": "分割器{suffix}"
},
"access": {
"rules": "访问规则",
+11 -6
View File
@@ -31,7 +31,8 @@
"goToItem": "前往項目",
"watchFile": "觀看檔案",
"showMore": "顯示進階選項",
"showLess": "隱藏進階選項"
"showLess": "隱藏進階選項",
"goToSource": "前往來源位置"
},
"download": {
"downloadFile": "下載檔案",
@@ -114,7 +115,8 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština"
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "新建賬戶",
@@ -320,7 +322,6 @@
"shareDeleted": "分享已刪除!",
"adminOptions": "管理員使用者選項",
"systemAdmin": "系統與管理",
"configViewer": "組態檢視器",
"configViewerShowFull": "顯示完整設定",
"configViewerShowComments": "顯示評論",
"configViewerLoadConfig": "載入組態",
@@ -332,7 +333,8 @@
"proxy": "代理"
},
"newUserHintSubFolder": "例如 '/subfolder' - 預設為空白",
"permissionsHelp": "使用者權限可控制使用者可執行的動作類型。管理員存取權可完全控制更新和變更任何使用者的設定以及許多系統設定。"
"permissionsHelp": "使用者權限可控制使用者可執行的動作類型。管理員存取權可完全控制更新和變更任何使用者的設定以及許多系統設定。",
"configViewer": "組態檢視器"
},
"sidebar": {
"siteSettings": "網站設定",
@@ -370,7 +372,8 @@
"pickIconDescription": "從 Material Icons 圖示庫搜尋並選取圖示",
"shareLinksUpdatedSuccess": "分享側邊欄連結更新成功!",
"shareHome": "分享首頁",
"linkIconPlaceholder": "可選擇指定材質圖示名稱"
"linkIconPlaceholder": "可選擇指定材質圖示名稱",
"dividerNamePlaceholder": "分割線文字(留空顯示一行)"
},
"success": {
"linkCopied": "連結已複製!"
@@ -705,7 +708,9 @@
"lines": "線路{suffix}",
"lastUpdate": "最後更新{suffix}",
"latency": "延遲{suffix}",
"all": "全部{suffix}"
"all": "全部{suffix}",
"expiration": "到期日{suffix}",
"divider": "分割器{suffix}"
},
"access": {
"rules": "存取規則",
+6 -2
View File
@@ -11,10 +11,10 @@ import { mutations } from "@/store";
import { validateLogin } from "@/utils/auth";
import i18n from "@/i18n";
const titles = {
Login: i18n.global.t("general.login"),
Share: i18n.global.t("general.share"),
PublicShare: i18n.global.t("general.share"),
Files: i18n.global.t("general.files"),
Tools: i18n.global.t("general.tool"),
Settings: i18n.global.t("general.settings"),
@@ -169,6 +169,9 @@ router.beforeResolve(async (to, from, next) => {
return next(false);
}
// Clear any popup previews when navigating
mutations.setPreviewSource("");
const title = titles[to.name as keyof typeof titles] || String(to.name || '');
document.title = globalVars.name + " - " + title;
mutations.setRoute(to);
@@ -177,11 +180,12 @@ router.beforeResolve(async (to, from, next) => {
to.matched.some((record) => record.meta.requiresAuth) ||
to.matched.some((record) => record.meta.optionalAuth)
) {
const isPublicRoute = to.path.startsWith("/public");
if (state?.user?.username) {
// do nothing, user is already set
} else {
try {
await validateLogin();
await validateLogin(isPublicRoute);
} catch (error) {
mutations.setCurrentUser(getters.anonymous());
}
+3 -2
View File
@@ -270,8 +270,9 @@ export const getters = {
if (urlPath == "/" || urlPath == "") {
return "";
}
// remove hash from path
urlPath = urlPath.split('/').slice(2).join('/')
// remove hash from path and decode each part
let parts = urlPath.split('/').slice(2);
urlPath = parts.map(part => decodeURIComponent(part)).join('/')
if (subPath != "") {
urlPath = url.joinPath(urlPath, removeLeadingSlash(subPath))
}
-1
View File
@@ -76,7 +76,6 @@ export interface UserObject {
rules?: any[];
lockPassword?: boolean;
hideDotfiles?: boolean;
commands?: string[];
sorting?: {
by: string;
asc: boolean;
+5 -2
View File
@@ -2,9 +2,12 @@ import { mutations, getters,state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
export async function validateLogin() {
export async function validateLogin(isPublicRoute = false) {
// Use direct fetch to avoid automatic logout on 401
let apiPath = getPublicApiPath('users', { id: 'self' });
// Public routes (e.g. /public/share/...) use the public API base path
const apiPath = isPublicRoute
? getPublicApiPath('users', { id: 'self' })
: getApiPath('/api/users', { id: 'self' });
const res = await fetch(apiPath, {
credentials: 'same-origin', // Ensure cookies are sent with the request
headers: {
+13 -1
View File
@@ -1,3 +1,15 @@
/**
* Strips ASS/SSA override blocks from dialogue text (e.g. {\fad(100,100)\blur3}).
* Used only when converting .ass / .ssa to VTT. Other formats keep their styling.
*/
export function stripAssOverrideTags (text) {
if (typeof text !== 'string') return text
return text
.replace(/\{[^}]*\}/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function convertToVTT (ext, text) {
if (ext == '.vtt') {
return text
@@ -73,7 +85,7 @@ export function convertToVTT (ext, text) {
let parts = line.split(',')
let startTime = formatAssTime(parts[1].trim())
let endTime = formatAssTime(parts[2].trim())
let dialogue = parts.slice(9).join(',').trim()
let dialogue = stripAssOverrideTags(parts.slice(9).join(',').trim())
return `${startTime} --> ${endTime}\n ${dialogue}` // Add leading space before dialogue
})
.join('\n\n')
+7 -9
View File
@@ -79,12 +79,12 @@ export function removePrefix(path, prefix = "") {
// get path with parameters
// Supports array values for repeated parameters (e.g., file=a&file=b&file=c)
export function getApiPath(path, params = {}, encode = false) {
export function getApiPath(path, params = {}, encode = true) {
if (path.startsWith("/")) {
path = path.slice(1);
}
path = `${globalVars.baseURL}${path}`;
const paramKeys = Object.keys(params);
if (paramKeys.length > 0) {
if (encode) {
@@ -92,7 +92,7 @@ export function getApiPath(path, params = {}, encode = false) {
for (const key of paramKeys) {
const value = params[key];
if (value === undefined) continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
@@ -110,7 +110,7 @@ export function getApiPath(path, params = {}, encode = false) {
for (const key in params) {
const value = params[key];
if (value === undefined) continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
@@ -125,14 +125,13 @@ export function getApiPath(path, params = {}, encode = false) {
}
}
}
return path;
}
// get path with parameters
// relative path so it can be used behind proxy
export function getPublicApiPath(path, params = {}) {
return getApiPath(`/public/api/${path}`, params);
export function getPublicApiPath(path, params = {}, encode = true) {
return getApiPath(`/public/api/${path}`, params, encode);
}
export function removeTrailingSlash(str) {
@@ -211,7 +210,6 @@ export function goToItem(source, path, previousHistoryItem, newTab = false) {
if (source == state.sources.current && path == state.req.path) {
return;
}
mutations.setPreviewSource("");
let newPath = encodedPath(path);
let fullPath = `/files/${encodeURIComponent(source)}${newPath}`;
if (newTab) {
@@ -230,7 +228,7 @@ export function goToItem(source, path, previousHistoryItem, newTab = false) {
router.push({ path: fullPath });
return;
}
if (previousHistoryItem === undefined) {
// When undefined will not create browser history
router.replace({ path: fullPath });
+5 -4
View File
@@ -191,7 +191,9 @@ export default {
// Parse share route to get subPath
let urlPath = getters.routePath('public/share')
let parts = urlPath.split("/");
shareInfo.subPath = "/" + parts.slice(2).join("/");
// Decode each part since URL paths are encoded
let decodedParts = parts.slice(2).map(part => decodeURIComponent(part));
shareInfo.subPath = "/" + decodedParts.join("/");
// Set shareInfo in state
mutations.setShareInfo(shareInfo);
@@ -354,8 +356,7 @@ export default {
// Display initial data immediately
mutations.replaceRequest(file);
document.title = `${document.title} - ${file.name}`;
document.title = globalVars.name + " - " + this.$t("general.share") + " - " + file.name;
// Second pass: If directory has metadata available, fetch again with metadata IN THE BACKGROUND
if (file.type === "directory" && file.hasMetadata) {
this.loadingProgress = 90;
@@ -474,7 +475,7 @@ export default {
if (state.sources.count > 1) {
mutations.setCurrentSource(data.source);
}
document.title = `${document.title} - ${res.name}`;
document.title = globalVars.name + " - " + this.$t("general.files") + " - " + res.name;
// Display initial data immediately and clear loading spinner
mutations.replaceRequest(data);
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more