mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
9
Commits
main
...
v0.8.10-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4e455dfe | ||
|
|
6d101ee8ee | ||
|
|
6285664b62 | ||
|
|
9f6e84cb02 | ||
|
|
3c45d94761 | ||
|
|
6b826621d0 | ||
|
|
5397961503 | ||
|
|
2f72a85a83 | ||
|
|
d76a7832b1 |
No files matched your search
@@ -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/**
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 == "") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ const previewViews = [
|
||||
'epubViewer',
|
||||
'docViewer',
|
||||
'onlyOfficeEditor',
|
||||
'editor'
|
||||
'editor',
|
||||
'loading'
|
||||
];
|
||||
|
||||
export {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user