Compare commits

...
9 Commits
44 changed files with 1107 additions and 737 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ backend/**.db
backend/tmp
backend/dist
backend/backend*
backend/http/dist/**
backend/http/embed/*()
backend/vendor/**
frontend/dist/**
frontend/node_modules/**
+19
View File
@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.8.10-beta
**New Features**:
- Add marquee selection to listing view -- thanks @Kurami32 #1388
**Notes**:
- More changes to login auth flow
- respects `FILEBROWSER_DISABLE_AUTOMATIC_BACKUP='true'` env var #1398
- Deprecating `conditionals.hidden` instead use `conditionals.ignoreHidden`
**BugFixes**:
- Fix material outline styling
- "feels lonely here" when clicking at source tab #1387
- Anonymous upload bug 0.8.9 #1383
- changing previews between documents in onlyoffice #1410
- onlyoffice should display user language #1061
- Filebrowser will hard fail when OIDC provider is not found #733
- some API endpoints always return 404 #849
## v0.8.9-beta
> [!CAUTION]
+3 -1
View File
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"os"
"reflect"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -44,7 +45,8 @@ func validateUserInfo() {
changePass = true
}
if updateUser {
if len(createBackup) == 1 {
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true"
if len(createBackup) == 1 && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
+19 -1
View File
@@ -65,6 +65,18 @@ func setupFs() {
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
// try writing to cache dir
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
if err != nil {
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
}
cacheDir := filepath.Join(absCacheDir, "init_test")
err = os.MkdirAll(cacheDir, fileutils.PermDir)
if err != nil {
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
}
}
func setupFrontend(generate bool) {
@@ -712,8 +724,14 @@ func loadCustomFavicon() {
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
func setConditionals(config *Source) {
rules := config.Config.Conditionals.ItemRules
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
config.Config.Conditionals.IgnoreHidden = true
}
rules := config.Config.Conditionals.ItemRules
// Initialize the maps structure (only exact match maps for Names)
resolved := &ResolvedConditionalsConfig{
FileNames: make(map[string]ConditionalIndexConfig),
+2 -1
View File
@@ -174,7 +174,8 @@ type SourceConfig struct {
}
type ConditionalFilter struct {
Hidden bool `json:"hidden"` // exclude hidden files and folders.
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead to exclude hidden files and folders.
IgnoreHidden bool `json:"ignoreHidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
ItemRules []ConditionalIndexConfig `json:"rules"` // list of item rules to apply to specific paths
}
+2 -2
View File
@@ -108,7 +108,7 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @Success 200 {string} string "JWT token for authentication"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
// @Router /api/auth/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
@@ -176,7 +176,7 @@ type signupBody struct {
// @Failure 405 {object} map[string]string "Method not allowed - signup is disabled"
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
// @Router /api/auth/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
+1 -1
View File
@@ -385,7 +385,7 @@ func withUserHelper(fn handleFunc) handleFunc {
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
}
// Check if the token is about to expire and send a header to renew it
if tk.Expires < time.Now().Add(time.Hour).Unix() {
if tk.Expires < time.Now().Add(time.Minute*30).Unix() {
w.Header().Add("X-Renew-Token", "true")
} // Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(tk.BelongsTo)
+12 -8
View File
@@ -329,8 +329,9 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if err = store.Access.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync oidc user %s groups: %v", username, err)
}
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
// Generate a signed token for the user
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
if err2 != nil {
// Handle potential errors during token generation
if strings.Contains(err2.Error(), "key already exists with same name") {
@@ -339,15 +340,18 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// add 30 minutes so expired token doesn't get automatically deleted by the browser and the backend can see who's making a new request
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
Expires: expiresTime, // Set cookie expiration
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
+8 -3
View File
@@ -16,6 +16,7 @@ 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/database/access"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
@@ -109,7 +110,7 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
// getDirectoryPreview finds a valid preview file within a directory.
// It iterates through files, checking if they should bubble up to folder previews,
// and returns the first valid, non-corrupted file suitable for preview.
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
var lastErr error
for _, item := range d.fileInfo.Files {
@@ -138,7 +139,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
Path: path,
Source: source,
Metadata: true,
}, store.Access)
}, accessStore)
if err != nil {
lastErr = err
continue // Try next file if this one fails
@@ -177,9 +178,13 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
accessStore := store.Access
if d.share != nil {
accessStore = nil
}
if d.fileInfo.Type == "directory" {
// Get extended file info of first previewable item in directory
fileInfo, err := getDirectoryPreview(r, d)
fileInfo, err := getDirectoryPreview(r, d, accessStore)
if err != nil {
return http.StatusInternalServerError, err
}
+6 -4
View File
@@ -169,16 +169,18 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
fullPath := filepath.Join(d.share.Path, r.URL.Query().Get("targetPath"))
path, err := url.QueryUnescape(r.URL.Query().Get("targetPath"))
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
logger.Infof("public upload handler: fullPath: '%v' for share: '%v' with source: '%v'", fullPath, d.share.Source, source)
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", fullPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
logger.Errorf("public upload handler: error uploading with error %v", err)
+39 -46
View File
@@ -208,35 +208,44 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
unescapedPath := path
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
accessStore = nil
} else {
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
unescapedPath, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
userscope := ""
// Determine if this is a directory or file based on trailing slash
// Strip trailing slash from userscope to prevent double slashes
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
path = utils.JoinPathAsUnix(userscope, unescapedPath)
}
path, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Determine if this is a directory or file based on trailing slash
isDir := strings.HasSuffix(path, "/")
// Strip trailing slash from userscope to prevent double slashes
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
isDir := strings.HasSuffix(unescapedPath, "/")
fileOpts := utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Path: path,
Source: source,
Expand: false,
}
@@ -245,10 +254,10 @@ 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(userscope, path)
realPath, _, _ := idx.GetRealPath(path)
// Check access control for the target path
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -260,6 +269,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// If type mismatch (file vs folder or folder vs file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
return http.StatusConflict, nil
}
}
@@ -306,11 +316,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access)
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
logger.Debugf("Resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
@@ -368,30 +377,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusOK, nil
}
// Check for file/folder conflicts for non-chunked uploads
if stat, statErr := os.Stat(realPath); statErr == nil {
existingIsDir := stat.IsDir()
requestingDir := false // Files are never directories
// If type mismatch (existing dir vs requesting file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
return http.StatusConflict, nil
}
}
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access)
if err == nil {
if r.URL.Query().Get("override") != "true" {
return http.StatusConflict, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore)
if err != nil {
preview.DelThumbs(r.Context(), *fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
return errToStatus(err), err
}
return http.StatusOK, nil
}
@@ -477,7 +470,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
action := r.URL.Query().Get("action")
if !d.user.Permissions.Modify {
if !d.user.Permissions.Modify && d.share == nil {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
+1 -1
View File
@@ -328,7 +328,7 @@ type DirectDownloadResponse struct {
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
// @Failure 403 {object} map[string]string "Forbidden - access denied"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/share/direct [get]
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
encodedPath := r.URL.Query().Get("path")
+1 -1
View File
@@ -494,7 +494,7 @@ func TestShouldSkip_ViewableStillSkips(t *testing.T) {
func TestShouldSkip_HiddenFiles(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{
Hidden: true, // Skip hidden files
IgnoreHidden: true, // Skip hidden files
})
tests := []struct {
+1 -1
View File
@@ -698,7 +698,7 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
}
if idx.Config.Conditionals.Hidden && isHidden {
if idx.Config.Conditionals.IgnoreHidden && isHidden {
return true
}
+195 -195
View File
@@ -394,6 +394,47 @@ const docTemplate = `{
}
}
},
"/api/auth/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"description": "Returns a logout URL for the frontend to redirect to.",
@@ -583,6 +624,76 @@ const docTemplate = `{
]
}
},
"/api/auth/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/token": {
"put": {
"description": "Create an API key with specified name, duration, and permissions.",
@@ -813,47 +924,6 @@ const docTemplate = `{
}
}
},
"/api/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
@@ -1745,6 +1815,90 @@ const docTemplate = `{
}
}
},
"/api/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/http.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/shares": {
"get": {
"description": "Returns a list of share links for the current user, or all links if the user is an admin.",
@@ -1825,76 +1979,6 @@ const docTemplate = `{
}
}
},
"/api/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/users": {
"get": {
"description": "Returns a user's details based on their ID, or all users if no id is provided.",
@@ -2592,90 +2676,6 @@ const docTemplate = `{
}
}
}
},
"/public/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/http.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
+195 -195
View File
@@ -383,6 +383,47 @@
}
}
},
"/api/auth/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"description": "Returns a logout URL for the frontend to redirect to.",
@@ -572,6 +613,76 @@
]
}
},
"/api/auth/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/token": {
"put": {
"description": "Create an API key with specified name, duration, and permissions.",
@@ -802,47 +913,6 @@
}
}
},
"/api/login": {
"post": {
"description": "Authenticate a user with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User login",
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden - authentication failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
@@ -1734,6 +1804,90 @@
}
}
},
"/api/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/http.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/shares": {
"get": {
"description": "Returns a list of share links for the current user, or all links if the user is an admin.",
@@ -1814,76 +1968,6 @@
}
}
},
"/api/signup": {
"post": {
"description": "Register a new user account with a username and password.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "User signup",
"parameters": [
{
"description": "User signup details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.signupBody"
}
}
],
"responses": {
"201": {
"description": "User created successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request - invalid input",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"405": {
"description": "Method not allowed - signup is disabled",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict - user already exists",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/users": {
"get": {
"description": "Returns a user's details based on their ID, or all users if no id is provided.",
@@ -2581,90 +2665,6 @@
}
}
}
},
"/public/share/direct": {
"get": {
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/http.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
+131 -131
View File
@@ -1398,6 +1398,33 @@ paths:
summary: Get all groups or groups for a user
tags:
- Access
/api/auth/login:
post:
consumes:
- application/json
description: Authenticate a user with a username and password.
produces:
- application/json
responses:
"200":
description: JWT token for authentication
schema:
type: string
"403":
description: Forbidden - authentication failed
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User login
tags:
- Auth
/api/auth/logout:
post:
description: Returns a logout URL for the frontend to redirect to.
@@ -1519,6 +1546,52 @@ paths:
summary: Verify OTP
tags:
- OTP
/api/auth/signup:
post:
consumes:
- application/json
description: Register a new user account with a username and password.
parameters:
- description: User signup details
in: body
name: body
required: true
schema:
$ref: '#/definitions/http.signupBody'
produces:
- application/json
responses:
"201":
description: User created successfully
schema:
type: string
"400":
description: Bad request - invalid input
schema:
additionalProperties:
type: string
type: object
"405":
description: Method not allowed - signup is disabled
schema:
additionalProperties:
type: string
type: object
"409":
description: Conflict - user already exists
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User signup
tags:
- Auth
/api/auth/token:
delete:
consumes:
@@ -1673,33 +1746,6 @@ paths:
summary: Get jobs info
tags:
- Jobs
/api/login:
post:
consumes:
- application/json
description: Authenticate a user with a username and password.
produces:
- application/json
responses:
"200":
description: JWT token for authentication
schema:
type: string
"403":
description: Forbidden - authentication failed
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User login
tags:
- Auth
/api/media/subtitles:
get:
consumes:
@@ -2311,6 +2357,64 @@ paths:
summary: Create a share link
tags:
- Shares
/api/share/direct:
get:
consumes:
- application/json
description: Creates a direct download link for a specific file with configurable
duration, download count, and speed limits. If a share already exists with
matching parameters, the existing share will be reused.
parameters:
- description: File path to create download link for
in: query
name: path
required: true
type: string
- description: Source name for the file
in: query
name: source
required: true
type: string
- description: 'Duration in minutes for link validity (default: 60)'
in: query
name: duration
type: string
- description: 'Maximum number of downloads allowed (default: unlimited)'
in: query
name: count
type: string
- description: 'Download speed limit in kbps (default: unlimited)'
in: query
name: speed
type: string
produces:
- application/json
responses:
"201":
description: Direct download link created
schema:
$ref: '#/definitions/http.DirectDownloadResponse'
"400":
description: Bad request - invalid parameters or path is not a file
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden - access denied
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create direct download link
tags:
- Shares
/api/shares:
delete:
consumes:
@@ -2365,52 +2469,6 @@ paths:
summary: List share links
tags:
- Shares
/api/signup:
post:
consumes:
- application/json
description: Register a new user account with a username and password.
parameters:
- description: User signup details
in: body
name: body
required: true
schema:
$ref: '#/definitions/http.signupBody'
produces:
- application/json
responses:
"201":
description: User created successfully
schema:
type: string
"400":
description: Bad request - invalid input
schema:
additionalProperties:
type: string
type: object
"405":
description: Method not allowed - signup is disabled
schema:
additionalProperties:
type: string
type: object
"409":
description: Conflict - user already exists
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: User signup
tags:
- Auth
/api/users:
delete:
consumes:
@@ -2890,62 +2948,4 @@ paths:
summary: Get file/directory information from a public share
tags:
- Public Shares
/public/share/direct:
get:
consumes:
- application/json
description: Creates a direct download link for a specific file with configurable
duration, download count, and speed limits. If a share already exists with
matching parameters, the existing share will be reused.
parameters:
- description: File path to create download link for
in: query
name: path
required: true
type: string
- description: Source name for the file
in: query
name: source
required: true
type: string
- description: 'Duration in minutes for link validity (default: 60)'
in: query
name: duration
type: string
- description: 'Maximum number of downloads allowed (default: unlimited)'
in: query
name: count
type: string
- description: 'Download speed limit in kbps (default: unlimited)'
in: query
name: speed
type: string
produces:
- application/json
responses:
"201":
description: Direct download link created
schema:
$ref: '#/definitions/http.DirectDownloadResponse'
"400":
description: Bad request - invalid parameters or path is not a file
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden - access denied
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Create direct download link
tags:
- Shares
swagger: "2.0"
+1
View File
@@ -14,6 +14,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-icons.woff2" as="font" type="font/woff2" crossorigin>
<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">
+1
View File
@@ -64,6 +64,7 @@ export async function login(username, password, recaptcha, otp) {
let apiPath = getApiPath('api/auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin', // Ensure cookies can be set during login
headers: {
'X-Password': password,
'X-Secret': otp,
+1
View File
@@ -11,6 +11,7 @@ export async function fetchURL(url, opts, auth = true) {
let res;
try {
res = await fetch(url, {
credentials: 'same-origin', // Ensure cookies are sent with all API requests
headers: {
"sessionId": state.sessionId,
...headers,
+3 -6
View File
@@ -152,7 +152,7 @@
import downloadFiles from "@/utils/download";
import { state, getters, mutations } from "@/store";
import Action from "@/components/Action.vue";
import { globalVars } from "@/utils/constants.js";
import { globalVars, shareInfo } from "@/utils/constants.js";
import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
@@ -237,11 +237,8 @@ export default {
);
},
showSave() {
if (getters.isShare()) {
// TODO: add support for saving shared files
return false;
}
return getters.currentView() == "editor" && this.permissions.modify;
const allowEdit = this.permissions.modify || (getters.isShare() && shareInfo.allowEdit);
return getters.currentView() == "editor" && allowEdit;
},
showOverflow() {
return getters.currentPromptName() == "OverflowMenu";
+33 -5
View File
@@ -19,6 +19,7 @@
:data-dir="isDir"
:data-type="type"
:data-name="name"
:data-index="index"
:aria-label="name"
:aria-selected="isSelected"
@contextmenu="onRightClick"
@@ -149,10 +150,11 @@ export default {
},
isDraggable() {
// @ts-ignore
return this.readOnly == undefined && state.user.permissions?.modify;
return this.readOnly == undefined && state.user.permissions?.modify || shareInfo.allowCreate;
},
canDrop() {
if (!this.isDir || this.readOnly !== undefined) return false;
for (const i of this.selected) {
if (
// @ts-ignore
@@ -162,6 +164,12 @@ export default {
) {
return false;
}
// Also check if we're trying to drop an item onto itself
// @ts-ignore
if (state.req.items[i].index === this.index) {
return false;
}
}
return true;
},
@@ -280,8 +288,15 @@ export default {
// @ts-ignore
return getters.getTime(this.modified);
},
dragLeave() {
this.isDraggedOver = false;
/** @param {DragEvent} event */
dragLeave(event) {
// Only reset visual state for internal drags
const isInternal = Array.from(event.dataTransfer.types).includes(
"application/x-filebrowser-internal-drag"
);
if (isInternal) {
this.isDraggedOver = false;
}
},
/** @param {DragEvent} event */
dragStart(event) {
@@ -300,6 +315,14 @@ export default {
/** @param {DragEvent} event */
dragOver(event) {
if (!this.canDrop) return;
// Only allow internal drags (from filebrowser items), not external files from desktop
const isInternal = Array.from(event.dataTransfer.types).includes(
"application/x-filebrowser-internal-drag"
);
if (!isInternal) return;
event.preventDefault();
this.isDraggedOver = true;
},
@@ -307,8 +330,13 @@ export default {
async drop(event) {
this.isDraggedOver = false;
if (!this.canDrop) {
// Don't prevent default or stop propagation - let the parent ListingView handle it
// Only allow internal drags (from filebrowser items), not external files from desktop
const isInternal = Array.from(event.dataTransfer.types).includes(
"application/x-filebrowser-internal-drag"
);
if (!isInternal) {
// Don't handle external drags - let the parent ListingView handle them
return;
}
+41 -43
View File
@@ -152,7 +152,7 @@ export default {
const mode = state.playbackQueue?.mode || 'single';
const queueLength = state.playbackQueue?.queue?.length || 0;
const hasQueue = queueLength > 1;
// Use media queue when in media view, NOT in single/loop-single mode, and have a queue
return isMediaView && mode !== 'single' && mode !== 'loop-single' && hasQueue;
}
@@ -335,11 +335,9 @@ export default {
prev() {
if (this.hasPrevious) {
this.hoverNav = false;
// Set transitioning state - keeps old req visible until new one loads
// Editor and other components check isTransitioning to prevent saves
mutations.setNavigationTransitioning(true);
if (this.isMediaQueueMode) {
this.navigateMediaPrevious();
} else {
@@ -350,11 +348,11 @@ export default {
next() {
if (this.hasNext) {
this.hoverNav = false;
// Set transitioning state - keeps old req visible until new one loads
// Editor and other components check isTransitioning to prevent saves
mutations.setNavigationTransitioning(true);
if (this.isMediaQueueMode) {
this.navigateMediaNext();
} else {
@@ -366,12 +364,12 @@ export default {
const queue = state.playbackQueue?.queue || [];
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
const mode = state.playbackQueue?.mode || 'single';
if (queue.length <= 1 || currentIndex < 0) return false;
// For sequential mode, no previous if at start
if (mode === 'sequential' && currentIndex === 0) return false;
// For loop-all and shuffle, always have previous (wraps around)
return true;
},
@@ -379,12 +377,12 @@ export default {
const queue = state.playbackQueue?.queue || [];
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
const mode = state.playbackQueue?.mode || 'single';
if (queue.length <= 1 || currentIndex < 0) return false;
// For sequential mode, no next if at end
if (mode === 'sequential' && currentIndex >= queue.length - 1) return false;
// For loop-all and shuffle, always have next (wraps around)
return true;
},
@@ -392,13 +390,13 @@ export default {
const queue = state.playbackQueue?.queue || [];
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
const mode = state.playbackQueue?.mode || 'single';
if (queue.length === 0 || currentIndex < 0) {
return;
}
let prevIndex = currentIndex - 1;
// Handle wrapping
if (prevIndex < 0) {
if (mode === 'loop-all' || mode === 'shuffle') {
@@ -407,19 +405,19 @@ export default {
return;
}
}
const prevItem = queue[prevIndex];
if (!prevItem) {
return;
}
// Update queue index
mutations.setPlaybackQueue({
queue: queue,
currentIndex: prevIndex,
mode: mode
});
// Navigate
const prevItemUrl = url.buildItemUrl(prevItem.source || state.req.source, prevItem.path);
mutations.replaceRequest(prevItem);
@@ -433,13 +431,13 @@ export default {
const queue = state.playbackQueue?.queue || [];
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
const mode = state.playbackQueue?.mode || 'single';
if (queue.length === 0 || currentIndex < 0) {
return;
}
let nextIndex = currentIndex + 1;
// Handle wrapping
if (nextIndex >= queue.length) {
if (mode === 'loop-all' || mode === 'shuffle') {
@@ -448,19 +446,19 @@ export default {
return;
}
}
const nextItem = queue[nextIndex];
if (!nextItem) {
return;
}
// Update queue index
mutations.setPlaybackQueue({
queue: queue,
currentIndex: nextIndex,
mode: mode
});
// Navigate
const nextItemUrl = url.buildItemUrl(nextItem.source || state.req.source, nextItem.path);
mutations.replaceRequest(nextItem);
@@ -479,14 +477,14 @@ export default {
// Check if any media element is currently playing
const mediaElements = document.querySelectorAll('audio, video');
let mediaActive = false;
mediaElements.forEach(media => {
if (!media.paused ||
if (!media.paused ||
document.activeElement === media) {
mediaActive = true;
}
});
// If media is playing don't handle arrow keys and let use fastfoward and rewind of the player
if (mediaActive) {
return;
@@ -554,13 +552,13 @@ export default {
isClickInLeftZone(event) {
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? 20 * parseFloat(getComputedStyle(document.documentElement).fontSize) : 0; // 20em in pixels
return event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth);
},
isClickInRightZone(event) {
const viewportWidth = window.innerWidth;
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
return event.clientX >= (viewportWidth - zoneWidth) && event.clientX <= viewportWidth;
},
showNavigation() {
@@ -615,7 +613,7 @@ export default {
tapTimeout: null,
triggered: false
};
// Set a longer timeout to allow for drag intent detection
this.touchState.tapTimeout = setTimeout(() => {
// If we haven't moved significantly and not dragging, treat as tap
@@ -656,13 +654,13 @@ export default {
// Check if user has moved enough to start dragging
if (deltaX > movementThreshold || deltaY > movementThreshold) {
this.touchState.hasMoved = true;
// Cancel tap timeout since user is dragging
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
}
// Initialize drag state if not already dragging
if (!this.dragState.isDragging) {
// Calculate 10em threshold in pixels if not set
@@ -670,7 +668,7 @@ export default {
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
this.dragState.threshold = 10 * emSize;
}
this.dragState = {
isDragging: true,
type: this.touchState.buttonType,
@@ -683,12 +681,12 @@ export default {
triggered: false,
};
}
// Update drag position - implement drag logic directly
if (this.dragState.isDragging) {
let dragDeltaX = touch.clientX - this.dragState.startX;
const maxDrag = this.dragState.threshold; // 10em
// Constrain drag to correct direction and max distance
if (this.dragState.type === 'previous') {
// Left button: only allow rightward drag (positive deltaX)
@@ -699,7 +697,7 @@ export default {
}
this.dragState.deltaX = dragDeltaX;
// Check if we've reached the full extent
const atFullExtent = Math.abs(dragDeltaX) >= maxDrag;
this.dragState.atFullExtent = atFullExtent;
@@ -710,21 +708,21 @@ export default {
// Handle touch end for buttons
if (this.touchState.isButtonTouch) {
const touchDuration = Date.now() - this.touchState.startTime;
// If it was a short touch without movement, and we haven't already navigated, treat as tap
if (!this.touchState.hasMoved && touchDuration < 300 && this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
this.handleButtonTap(this.touchState.buttonType);
}
// Reset touch state
this.resetTouchState();
}
// Reset navigation swipe state
this.isSwipe = false;
// Let endDrag handle the drag cleanup
if (this.dragState.isDragging) {
this.endDrag();
@@ -744,23 +742,23 @@ export default {
if (this.touchState.triggered) {
return;
}
// Clear any pending timeouts
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
}
// Mark as triggered to prevent double navigation
this.touchState.triggered = true;
// Navigate immediately on tap
if (buttonType === 'previous' && this.hasPrevious) {
this.prev();
} else if (buttonType === 'next' && this.hasNext) {
this.next();
}
// Reset touch state
this.resetTouchState();
},
+15 -2
View File
@@ -21,12 +21,12 @@
</div>
</template>
<script>
import { filesApi } from "@/api";
import { filesApi, publicApi } from "@/api";
import { getters, mutations, state } from "@/store"; // Import your custom store
import { notify } from "@/notify";
import { goToItem } from "@/utils/url";
import { url } from "@/utils";
import { shareInfo } from "@/utils/constants";
export default {
name: "new-dir",
props: {
@@ -68,6 +68,12 @@ export default {
async createDirectory(overwrite = false) {
try {
if (getters.isShare()) {
await publicApi.post(shareInfo.hash, url.joinPath(state.req.path, this.name) + "/", "", overwrite);
goToItem(state.req.source, url.joinPath(state.req.path, this.name), {});
mutations.closeHovers();
return;
}
await filesApi.post(state.req.source, url.joinPath(state.req.path, this.name) + "/", "", overwrite);
goToItem(state.req.source, url.joinPath(state.req.path, this.name), {});
mutations.closeHovers();
@@ -89,6 +95,13 @@ export default {
for (let counter = 1; counter <= maxAttempts && !success; counter++) {
try {
const newName = counter === 1 ? `${originalName} (1)` : `${originalName} (${counter})`;
if (getters.isShare()) {
await publicApi.post(shareInfo.hash, url.joinPath(state.req.path, newName) + "/", "", false);
goToItem(state.req.source, url.joinPath(state.req.path, newName), {});
mutations.closeHovers();
success = true;
return;
}
await filesApi.post(state.req.source, joinPath(state.req.path, newName) + "/", "", false);
goToItem(state.req.source, joinPath(state.req.path, newName), {});
mutations.closeHovers();
+15 -2
View File
@@ -22,11 +22,11 @@
</template>
<script>
import { state } from "@/store";
import { filesApi } from "@/api";
import { filesApi, publicApi } from "@/api";
import { getters, mutations } from "@/store"; // Import your custom store
import { notify } from "@/notify";
import { url } from "@/utils";
import { shareInfo } from "@/utils/constants";
export default {
name: "new-file",
data() {
@@ -58,6 +58,12 @@ export default {
async createFile(overwrite = false) {
try {
if (getters.isShare()) {
await publicApi.post(shareInfo.hash, url.joinPath(state.req.path, this.name), "", overwrite);
url.goToItem(state.req.source, url.joinPath(state.req.path, this.name), {});
mutations.closeHovers();
return;
}
await filesApi.post(state.req.source, url.joinPath(state.req.path, this.name), "", overwrite);
url.goToItem(state.req.source, url.joinPath(state.req.path, this.name), {});
mutations.closeHovers();
@@ -79,6 +85,13 @@ export default {
for (let counter = 1; counter <= maxAttempts && !success; counter++) {
try {
const newName = counter === 1 ? `${originalName} (1)` : `${originalName} (${counter})`;
if (getters.isShare()) {
await publicApi.post(shareInfo.hash, url.joinPath(state.req.path, newName), "", false);
url.goToItem(state.req.source, url.joinPath(state.req.path, newName), {});
mutations.closeHovers();
success = true;
return;
}
await filesApi.post(state.req.source, url.joinPath(state.req.path, newName), "", false);
url.goToItem(state.req.source, url.joinPath(state.req.path, newName), {});
mutations.closeHovers();
+4 -4
View File
@@ -98,10 +98,10 @@
</select>
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableDownload" :name="$t('share.disableDownload')" :description="$t('share.disableDownloadDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify" :name="$t('share.allowModify')" :description="$t('share.allowModifyDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete" :name="$t('share.allowDelete')" :description="$t('share.allowDeleteDescription')" />
<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="disableDownload" :name="$t('share.disableDownload')" :description="$t('share.disableDownloadDescription')" aria-label="disable downloading files toggle" />
<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="allowDelete" :name="$t('share.allowDelete')" :description="$t('share.allowDeleteDescription')" aria-label="allow deleting 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="createAllowed" class="item" v-model="allowReplacements" :name="$t('share.allowReplacements')" :description="$t('share.allowReplacementsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableFileViewer" :name="$t('share.disableFileViewer')" />
<ToggleSwitch
@@ -12,7 +12,7 @@
</i>
</div>
<label class="switch">
<input type="checkbox" :checked="modelValue" @change="updateValue" />
<input type="checkbox" :checked="modelValue" @change="updateValue" :aria-label="ariaLabel" />
<span class="slider round"></span>
</label>
</div>
@@ -37,6 +37,11 @@ export default {
required: false,
default: "",
},
ariaLabel: {
type: String,
required: false,
default: "",
},
},
methods: {
updateValue(event) {
+21 -27
View File
@@ -41,7 +41,7 @@
</div>
<div v-if="!disableQuickToggles" class="card-wrapper" @mouseleave="hideTooltip">
<div class="quick-toggles">
<div class="quick-toggles" :class="{ 'extra-padding': !hasCreateOptions }">
<div
class="clickable"
:class="{ active: user?.singleClick }"
@@ -147,7 +147,6 @@ import ProgressBar from "@/components/ProgressBar.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { fromNow } from "@/utils/moment";
import * as url from "@/utils/url";
export default {
name: "SidebarGeneral",
@@ -158,10 +157,18 @@ export default {
return {};
},
computed: {
hasCreateOptions() {
if (getters.isShare()) {
return shareInfo.allowCreate
}
return state.user.permissions.create || state.user.permissions.share || state.user.permissions.admin;
},
shareInfo: () => shareInfo,
disableQuickToggles: () => state.user?.disableQuickToggles,
hasSourceInfo: () => state.sources.hasSourceInfo,
hideSidebarFileActions: () => state.user?.hideSidebarFileActions || getters.isInvalidShare(),
hideSidebarFileActions() {
return state.user?.hideSidebarFileActions || getters.isInvalidShare() || !this.hasCreateOptions;
},
settingsAllowed: () => !state.user?.disableSettings,
isSettings: () => getters.isSettings(),
isStickySidebar: () => getters.isStickySidebar(),
@@ -195,6 +202,7 @@ export default {
},
},
methods: {
openContextMenu() {
mutations.resetSelected();
mutations.showHover({
@@ -224,29 +232,13 @@ export default {
mutations.updateCurrentUser({ stickySidebar: !state.user.stickySidebar });
},
navigateTo(path,hash) {
// Check if this is a file system navigation (starts with /files/)
if (path.startsWith('/files/')) {
// Extract source and path from the /files/ URL
const pathParts = path.replace('/files/', '').split('/');
const source = pathParts[0];
const filePath = '/' + pathParts.slice(1).join('/');
const previousHistoryItem = {
name: state.req.name,
source: state.req.source,
path: state.req.path,
};
url.goToItem(source, filePath, previousHistoryItem);
mutations.closeHovers();
} else {
// For non-file system navigation (like settings), use the original method
mutations.setPreviousHistoryItem({
name: state.req.name,
source: state.req.source,
path: state.req.path,
});
this.$router.push({ path: path, hash: hash });
mutations.closeHovers();
}
mutations.setPreviousHistoryItem({
name: state.req.name,
source: state.req.source,
path: state.req.path,
});
this.$router.push({ path: path, hash: hash });
mutations.closeHovers();
},
navigateToLogin() {
this.$router.push({ path: "/login", query: { redirect: this.$route.path } });
@@ -556,5 +548,7 @@ button.action {
opacity: 0;
}
.extra-padding {
padding-bottom: 0.5em !important;
}
</style>
@@ -53,7 +53,6 @@ export default {
// Add keyboard event listener for Ctrl+B to toggle sidebar
this.handleKeydown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'b') {
console.log("sticky sidebar", state.user.stickySidebar);
event.preventDefault();
if (state.user.stickySidebar) {
mutations.updateCurrentUser({ stickySidebar: false });
+1 -1
View File
@@ -127,7 +127,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/material-icons.woff2) format('woff2');
src: url(/fonts/material-symbols-outlined.woff2) format('woff2');
}
.material-icons {
+3 -2
View File
@@ -51,18 +51,19 @@ async function testEventsEndpoint() {
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'same-origin', // Ensure cookies are sent for SSE authentication
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
}
})
if (response.status === 401) {
console.log('🚫 Events endpoint returned 401, authentication failed')
authenticationFailed = true
return false
}
// Close the test connection immediately
response.body?.cancel()
return true
+3
View File
@@ -169,6 +169,9 @@ router.beforeResolve(async (to, from, next) => {
if (getters.isLoggedIn() || to.matched.some((record) => record.meta.optionalAuth)) {
// do nothing
} else {
// Validation failed - clear state
mutations.setCurrentUser(null);
mutations.setJWT("");
if (globalVars.passwordAvailable) {
next({ path: "/login", query: { redirect: to.fullPath } });
return;
+3
View File
@@ -230,6 +230,9 @@ export const getters = {
return false
},
currentView: () => {
if (state.navigation.isTransitioning) {
return 'loading'
}
let listingView = ''
const pathname = getters.routePath()
if (!state.user || state.user?.username == "") {
+10
View File
@@ -1,6 +1,7 @@
import { mutations, getters,state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
import { getCookie } from "@/utils/cookie.js";
export async function setNewToken(token) {
document.cookie = `auth=${token}; path=/`;
@@ -11,6 +12,7 @@ export async function validateLogin() {
// Use direct fetch to avoid automatic logout on 401
let apiPath = getPublicApiPath('users', { id: 'self' });
const res = await fetch(apiPath, {
credentials: 'same-origin', // Ensure cookies are sent with the request
headers: {
"sessionId": state.sessionId,
}
@@ -35,6 +37,14 @@ export async function validateLogin() {
} else {
throw new Error(body);
}
} else if (state.user.loginMethod == "oidc") {
// For OIDC users, sync the cookie to state
// This ensures that after OIDC callback sets a new cookie,
// the frontend state is updated with the new token
const authCookie = getCookie("auth");
if (authCookie && authCookie !== state.jwt) {
mutations.setJWT(authCookie);
}
}
return
}
+2 -1
View File
@@ -20,7 +20,8 @@ const previewViews = [
'epubViewer',
'docViewer',
'onlyOfficeEditor',
'editor'
'editor',
'loading'
];
export {
-1
View File
@@ -452,7 +452,6 @@ class UploadManager {
}
}
}
// If we had completed uploads and the queue is now empty, trigger reload
if (hadCompleted && this.queue.length === 0) {
mutations.setReload(true);
+7 -11
View File
@@ -93,22 +93,18 @@ export default {
};
},
mounted() {
mutations.setCurrentUser(null);
mutations.setJWT("");
document.cookie = "auth=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
let redirect = state.route.query.redirect;
if (redirect) {
redirect = removeLeadingSlash(redirect);
redirect = globalVars.baseURL + redirect;
this.loginURL += `?redirect=${encodeURIComponent(redirect)}`;
// If password auth is disabled and OIDC is available, auto-redirect
// Only auto-redirect when there's a valid redirect URL (user needs to go somewhere)
if (!globalVars.passwordAvailable && globalVars.oidcAvailable) {
window.location.href = this.loginURL;
return;
}
}
this.loginURL += `?redirect=${encodeURIComponent(redirect)}`;
// If password auth is disabled and OIDC is available, auto-redirect
if (!globalVars.passwordAvailable && globalVars.oidcAvailable) {
window.location.href = this.loginURL;
return;
}
if (!globalVars.recaptcha) return;
window.globalVars.recaptcha.ready(function () {
window.globalVars.recaptcha.render("globalVars.recaptcha", {
+176 -6
View File
@@ -46,9 +46,14 @@
'add-padding': isStickySidebar,
[listingViewMode]: true,
dropping: isDragging,
'rectangle-selecting': isRectangleSelecting
}"
class="file-icons"
>
<!-- Rectangle selection overlay -->
<div class="selection-rectangle"
:style="rectangleStyle"
></div>
<div>
<div class="header card" :class="{ 'dark-mode-item-header': isDarkMode }">
<p
@@ -114,7 +119,6 @@
v-bind:path="item.path"
v-bind:reducedOpacity="item.hidden || isDragging"
v-bind:hash="shareInfo.hash"
:readOnly="isShare ? true : undefined"
v-bind:hasPreview="item.hasPreview"
/>
</div>
@@ -194,6 +198,10 @@ export default {
contextTimeout: null, // added for safari context menu
ctrKeyPressed: false,
clipboard: { items: [] }, // Initialize clipboard to prevent errors
isRectangleSelecting: false,
rectangleStart: { x: 0, y: 0 },
rectangleEnd: { x: 0, y: 0 },
rectangleSelection: [],
};
},
watch: {
@@ -361,6 +369,19 @@ export default {
loading() {
return getters.isLoading();
},
rectangleStyle() {
if (!this.isRectangleSelecting) return { display: 'none' };
const left = Math.min(this.rectangleStart.x, this.rectangleEnd.x);
const top = Math.min(this.rectangleStart.y, this.rectangleEnd.y);
const width = Math.abs(this.rectangleStart.x - this.rectangleEnd.x);
const height = Math.abs(this.rectangleStart.y - this.rectangleEnd.y);
return {
left: left + 'px',
top: top + 'px',
width: width + 'px',
height: height + 'px',
};
},
},
mounted() {
mutations.setSearch(false);
@@ -390,10 +411,15 @@ export default {
}
// if safari , make sure click and hold opens context menu, but not for any other browser
if (!state.user.permissions?.modify) return;
this.$el.addEventListener("dragenter", this.dragEnter);
this.$el.addEventListener("dragleave", this.dragLeave);
this.$el.addEventListener("drop", this.drop);
if (state.user.permissions?.modify || shareInfo.allowCreate) {
this.$el.addEventListener("dragenter", this.dragEnter);
this.$el.addEventListener("dragleave", this.dragLeave);
this.$el.addEventListener("drop", this.drop);
this.$el.addEventListener('mousedown', this.startRectangleSelection);
document.addEventListener('mousemove', this.updateRectangleSelection);
document.addEventListener('mouseup', this.endRectangleSelection);
}
},
beforeUnmount() {
// Remove event listeners before destroying this page.
@@ -415,10 +441,13 @@ export default {
}
// Also clean up drag/drop listeners on the component's root element
if (state.user && state.user?.permissions?.modify) {
if (state.user && state.user?.permissions?.modify || shareInfo.allowCreate) {
this.$el.removeEventListener("dragenter", this.dragEnter);
this.$el.removeEventListener("dragleave", this.dragLeave);
this.$el.removeEventListener("drop", this.drop);
this.$el.removeEventListener('mousedown', this.startRectangleSelection);
document.removeEventListener('mousemove', this.updateRectangleSelection);
document.removeEventListener('mouseup', this.endRectangleSelection);
}
},
methods: {
@@ -1001,6 +1030,127 @@ export default {
});
}
},
startRectangleSelection(event) {
// Start rectangle selection when clicking on empty space
if (event.target.closest('.item') || event.target.closest('.header')) {
return;
}
// Don't start if it's a right click, this for avoid some weird issue with the context menu.
if (event.button !== 0) return;
this.isRectangleSelecting = true;
// Get the position to the listing view container
const listingRect = this.$refs.listingView.getBoundingClientRect();
this.rectangleStart = {
x: event.clientX - listingRect.left,
y: event.clientY - listingRect.top
};
this.rectangleEnd = {
x: event.clientX - listingRect.left,
y: event.clientY - listingRect.top
};
// Store the current selection state when starting rectangle
this.initialSelectionState = [...state.selected];
// Only clear selection when CTRL is not holded
const hasModifier = event.ctrlKey || event.metaKey;
if (!hasModifier) {
mutations.resetSelected();
}
event.preventDefault();
},
updateRectangleSelection(event) {
if (!this.isRectangleSelecting) return;
// Get the position to the listing view container
const listingRect = this.$refs.listingView.getBoundingClientRect();
this.rectangleEnd = {
x: event.clientX - listingRect.left,
y: event.clientY - listingRect.top
};
this.updateSelectedItemsInRectangle(event.ctrlKey || event.metaKey);
},
endRectangleSelection(event) {
if (!this.isRectangleSelecting) return;
this.isRectangleSelecting = false;
this.updateSelectedItemsInRectangle(event.ctrlKey || event.metaKey);
// Clear rectangle after a short delay
setTimeout(() => {
this.rectangleStart = { x: 0, y: 0 };
this.rectangleEnd = { x: 0, y: 0 };
this.initialSelectionState = [];
}, 100);
},
updateSelectedItemsInRectangle(isAdditive) {
if (!this.isRectangleSelecting) return;
const listingRect = this.$refs.listingView.getBoundingClientRect();
const rect = {
left: Math.min(this.rectangleStart.x, this.rectangleEnd.x),
top: Math.min(this.rectangleStart.y, this.rectangleEnd.y),
right: Math.max(this.rectangleStart.x, this.rectangleEnd.x),
bottom: Math.max(this.rectangleStart.y, this.rectangleEnd.y)
};
const rectangleSelectedIndexes = [];
// Get all item elements
const itemElements = this.$el.querySelectorAll('.item');
itemElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
// Convert element position to be relative to listing view, this allows selection while scrolling
const elementRelativeRect = {
left: elementRect.left - listingRect.left,
top: elementRect.top - listingRect.top,
right: elementRect.right - listingRect.left,
bottom: elementRect.bottom - listingRect.top
};
// Check if the item intersects with the rectangle
if (
elementRelativeRect.left < rect.right &&
elementRelativeRect.right > rect.left &&
elementRelativeRect.top < rect.bottom &&
elementRelativeRect.bottom > rect.top
) {
const index = parseInt(element.getAttribute('data-index'));
if (!isNaN(index)) {
rectangleSelectedIndexes.push(index);
}
}
});
// Update selection based on modifier keys (ctrl/meta)
if (isAdditive) {
// only add more items to the current selection without reset selection
const newSelection = [...state.selected];
rectangleSelectedIndexes.forEach(index => {
if (!newSelection.includes(index)) {
newSelection.push(index);
}
});
mutations.resetSelected();
newSelection.forEach(index => mutations.addSelected(index));
} else {
// Select only the items in the rectangle and reset initial selection
// PS: If you don't want that just hold ctrl, the selection will not be reset, allowing multi select.
mutations.resetSelected();
rectangleSelectedIndexes.forEach(index => mutations.addSelected(index));
}
},
},
};
</script>
@@ -1032,6 +1182,7 @@ export default {
#listingView {
min-height: 90vh !important;
position: relative;
}
.folder-items a {
@@ -1046,4 +1197,23 @@ export default {
margin: 0 auto;
}
.selection-rectangle {
position: absolute;
border: 2px solid var(--primaryColor);
background-color: color-mix(in srgb, var(--primaryColor) 25%, transparent);
border-radius: 8px;
pointer-events: none;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
#listingView.rectangle-selecting {
cursor: crosshair;
user-select: none;
}
#listingView.rectangle-selecting .item {
pointer-events: none;
}
</style>
@@ -16,6 +16,7 @@ import { globalVars } from "@/utils/constants";
import { state, getters, mutations } from "@/store";
import { removeLastDir } from "@/utils/url";
import { filesApi } from "@/api";
import { toStandardLocale } from "@/i18n";
const wikiLink = "https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#onlyoffice-integration-troubleshooting-guide"
const wikiLinkText = "📖 View Troubleshooting Guide"
@@ -80,6 +81,11 @@ export default {
this.clientConfig = configData;
console.log("OnlyOffice client config received:", this.clientConfig);
// if language is not en , set it to the current language
if (state.user.locale !== "en") {
this.clientConfig.editorConfig.lang = toStandardLocale(state.user.locale);
}
if (this.debugMode) {
this.updateDebugStatus("✅ OnlyOffice Server Connection - Config Sent");
}
+93 -16
View File
@@ -72,12 +72,24 @@
</video>
</div>
<!-- Mouse detection zone for top-left corner -->
<div
v-if="showQueueButton"
class="queue-zone"
@mousemove="toggleQueueButton"
@mouseover="setHoverQueue(true)"
@mouseleave="setHoverQueue(false)"
></div>
<button
v-if="showQueueButton"
@click="showQueuePrompt"
@mouseover="setHoverQueue(true)"
@mouseleave="setHoverQueue(false)"
class="queue-button floating"
:class="{
'dark-mode': darkMode,
'hidden': !showQueueButtonVisible,
}"
:aria-label="$t('player.QueueButtonHint')"
:title="$t('player.QueueButtonHint')"
@@ -154,6 +166,10 @@ export default {
playbackMenuInitialized: false,
lastAppliedMode: null,
isNavigating: false,
// Queue button visibility state
queueButtonVisible: false,
hoverQueue: false,
queueTimeout: null,
// Plyr options
plyrOptions: {
controls: [
@@ -235,12 +251,15 @@ export default {
darkMode() {
return state.user.darkMode;
},
showQueueButtonVisible() {
return this.queueButtonVisible || this.hoverQueue;
},
shouldAutoPlay() {
// Use the autoPlayEnabled prop from parent
return this.autoPlayEnabled;
},
showQueueButton() {
return state.req && (state.req.type?.startsWith('audio/') || state.req.type?.startsWith('video/')) &&
return state.req && (state.req.type?.startsWith('audio/') || state.req.type?.startsWith('video/')) &&
state.navigation.enabled;
},
queueCount() {
@@ -267,6 +286,10 @@ export default {
this.hookEvents();
this.$nextTick(() => {
this.setupPlaybackQueue();
// Show queue button initially if it should be shown
if (this.showQueueButton) {
this.showQueueButtonMethod();
}
});
document.addEventListener('keydown', this.handleKeydown);
},
@@ -274,6 +297,7 @@ export default {
if (this.toastTimeout) {
clearTimeout(this.toastTimeout);
}
this.clearQueueTimeout();
this.cleanupAlbumArt();
// Clean up media players
if (this.$refs.videoPlayer && this.$refs.videoPlayer.player) {
@@ -288,7 +312,7 @@ export default {
settingsSetup(attempt = 0) {
// Wait for Plyr to re-initialize its UI after source change
const playerRef = this.getCurrentPlayer();
if (playerRef?.player?.elements?.settings) {
console.log('Player settings ready, applying custom settings');
this.applyCustomPlaybackSettings(playerRef.player);
@@ -302,6 +326,32 @@ export default {
name: "PlaybackQueue",
});
},
toggleQueueButton() {
if (!this.showQueueButton) {
return;
}
this.showQueueButtonMethod();
},
showQueueButtonMethod() {
this.queueButtonVisible = true;
this.clearQueueTimeout();
this.queueTimeout = setTimeout(() => {
if (!this.hoverQueue) {
this.queueButtonVisible = false;
}
this.queueTimeout = null;
}, 3000); // Show for 3 seconds
},
setHoverQueue(value) {
this.hoverQueue = value;
},
clearQueueTimeout() {
if (this.queueTimeout) {
clearTimeout(this.queueTimeout);
this.queueTimeout = null;
}
},
togglePlayPause() {
const player = this.getCurrentPlayer();
if (!player) return;
@@ -382,7 +432,7 @@ export default {
});
}
// "Q" key for open the queue prompt
if (event.key.toLowerCase() === 'q' &&
if (event.key.toLowerCase() === 'q' &&
state.prompts.length === 0) { // Only open if no other prompts are open
event.stopPropagation();
event.preventDefault();
@@ -394,7 +444,7 @@ export default {
const modeCycle = ['loop-all', 'shuffle', 'sequential'];
const currentIndex = modeCycle.indexOf(this.playbackMode);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % modeCycle.length;
const newMode = modeCycle[nextIndex];
const newMode = modeCycle[nextIndex];
this.updatePlaybackMode(newMode, newMode === 'shuffle');
},
showToast() {
@@ -409,9 +459,7 @@ export default {
async updateMedia() {
this.cleanupAlbumArt();
this.audioMetadata = null;
await this.handleAutoPlay();
if (this.previewType === "audio") {
this.loadAudioMetadata();
}
@@ -521,7 +569,6 @@ export default {
this.albumArtUrl = null;
this.albumArt = null;
},
hookEvents() {
const player = this.getCurrentPlayer();
if (!player) return;
@@ -554,7 +601,6 @@ export default {
},
setupDefaultPlayerEvents(element) {
if (!element) return;
element.addEventListener('ended', this.handleMediaEnd);
element.addEventListener('play', () => {
mutations.setPlaybackState(true);
@@ -623,10 +669,9 @@ export default {
case 'loop-all': {
// For sequential and loop-all, we'll use alphabetical order without rearranging
// On sequential mode will start playing from the file opened and find its place on the queue by the current index (you can see this on UI queue)
// Loop-all will do the same, but if the queue ends, will restart from the first file of the current folder (alphabetically)
// Loop-all will do the same, but if the queue ends, will restart from the first file of the current folder (alphabetically)
const sortedFiles = [...mediaFiles].sort((a, b) => a.name.localeCompare(b.name));
finalQueue = sortedFiles;
// Find the current file position in the queue
if (currentIndex !== -1) {
const currentFile = mediaFiles[currentIndex];
@@ -647,8 +692,7 @@ export default {
} else {
// Use the existing queue when not forcing reshuffle
finalQueue = this.playbackQueue;
}
}
// Find the current file position in the queue
if (currentIndex !== -1) {
const currentFile = mediaFiles[currentIndex];
@@ -743,7 +787,7 @@ export default {
const nextItemUrl = url.buildItemUrl(nextItem.source || this.req.source, nextItem.path);
// Store the expected path before making changes
const expectedPath = nextItem.path;
const expectedPath = nextItem.path;
// Update state.req with the next item's data FIRST
// This will trigger the watcher on req prop and update the media source
@@ -759,7 +803,7 @@ export default {
});
// Wait for state.req to be updated
await this.waitForReqUpdate(expectedPath);
await this.waitForReqUpdate(expectedPath);
const player = this.getCurrentPlayer();
if (player) {
@@ -769,7 +813,6 @@ export default {
} else if (player.player) {
playPromise = player.player.play();
}
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.log("Auto-play prevented:", error);
@@ -842,7 +885,6 @@ export default {
'shuffle': () => this.playNext(),
'loop-all': () => this.playNext()
};
const action = modeActions[this.playbackMode];
if (action) {
console.log(`Media ended - ${this.playbackMode} mode`);
@@ -1391,6 +1433,18 @@ export default {
*** QUEUE BUTTON ***
*******************/
/* Queue detection zone for top-right corner */
.queue-zone {
position: fixed;
top: 4em; /* Account for header bar */
right: 0;
width: 5em;
height: 5em;
pointer-events: auto;
z-index: 1000;
background: transparent;
}
.queue-button {
position: fixed;
top: 80px;
@@ -1447,6 +1501,29 @@ export default {
font-weight: bold;
}
.queue-button.hidden {
opacity: 0;
transform: translateY(-2px) scale(0.9);
pointer-events: none !important;
z-index: -1;
}
/* Smooth show animation for better UX */
.queue-button:not(.hidden) {
animation: queue-button-show 0.4s ease-out;
}
@keyframes queue-button-show {
0% {
opacity: 0;
transform: translateY(-2px) scale(0.8);
}
100% {
opacity: 1;
transform: translateY(-2px) scale(1);
}
}
/*****************
*** LOOP TOAST ***
*****************/
@@ -63,6 +63,14 @@ async function globalSetup() {
await expect(page.locator('.selected-count-header')).toHaveText('1');
await page.locator('button[aria-label="Share"]').click();
await expect(page.locator('div[aria-label="share-path"]')).toHaveText('Path: /share/');
// Toggle "Allow creating and uploading files and folders" setting
await page.locator('input[aria-label="allow creating and uploading files and folders toggle"]').waitFor({ state: 'attached' });
await page.locator('input[aria-label="allow creating and uploading files and folders toggle"] + .slider').click();
// Toggle "Allow creating and uploading files and folders" setting
await page.locator('input[aria-label="allow editing files toggle"]').waitFor({ state: 'attached' });
await page.locator('input[aria-label="allow editing files toggle"] + .slider').click();
await page.locator('button[aria-label="Share-Confirm"]').click();
await expect(page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th))")).toHaveCount(1);
const shareHashShare = await page.locator("div[aria-label='share-prompt'] .card-content table tbody tr:not(:has(th)) td").first().textContent();
+12 -12
View File
@@ -90,17 +90,17 @@ test("share file creation", async ({ page, checkForErrors, openContextMenu }) =>
await page.goto("public/share/" + shareHashShare);
await expect(page).toHaveTitle("Graham's Filebrowser - Share - share");
await openContextMenu();
// await page.locator('button[aria-label="New file"]').click();
// await page.locator('input[aria-label="FileName Field"]').waitFor({ state: 'visible' });
// await page.locator('input[aria-label="FileName Field"]').fill('dfsaf.txt');
// await page.locator('button[aria-label="Create"]').click();
// await expect(page).toHaveTitle("Graham's Filebrowser - Files - dfsaf.txt");
// await page.locator(".ace_content").click();
// await page.keyboard.type("test content");
// await page.locator(".overflow-menu-button").click();
// await page.locator('button[aria-label="Save"]').click();
// const popup = page.locator('#popup-notification-content');
// await popup.waitFor({ state: 'visible' });
// await expect(popup).toHaveText("dfsaf.txt saved successfully.");
await page.locator('button[aria-label="New file"]').click();
await page.locator('input[aria-label="FileName Field"]').waitFor({ state: 'visible' });
await page.locator('input[aria-label="FileName Field"]').fill('dfsaf.txt');
await page.locator('button[aria-label="Create"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Share - dfsaf.txt");
await page.locator(".ace_content").click();
await page.keyboard.type("test content");
await page.locator(".overflow-menu-button").click();
await page.locator('button[aria-label="Save"]').click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText("dfsaf.txt saved successfully.");
checkForErrors();
});
+5 -5
View File
@@ -26,15 +26,15 @@ export function setupErrorTracking(page: Page) {
if (message.type() === "error") {
const errorText = message.text();
const args = message.args();
// Try to extract more detailed error information
let detailedError = errorText;
if (args.length > 0) {
try {
// Get the first argument which usually contains the error object
const firstArg = await args[0].jsonValue().catch(() => null);
if (firstArg && typeof firstArg === 'object') {
if (firstArg.stack) {
// If we have a stack trace, use it
@@ -61,7 +61,7 @@ export function setupErrorTracking(page: Page) {
}
})
);
const combinedArgs = argsText.join(' ');
if (combinedArgs.trim() && combinedArgs !== errorText) {
detailedError = combinedArgs;
@@ -72,7 +72,7 @@ export function setupErrorTracking(page: Page) {
}
}
}
consoleErrors.push(detailedError);
}
});