Compare commits

...
2 Commits
44 changed files with 884 additions and 391 deletions

No files matched your search

+7 -1
View File
@@ -89,7 +89,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
// Add OnlyOffice ID if applicable
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
@@ -279,6 +279,12 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
}
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
}
if info.Type == "directory" && opts.ShowPinnedItems {
if source, ok := users.ResolveSourceKey(response.Source); ok {
response.PinnedItems = user.PinnedNamesForDirectory(source.Path, response.Path)
}
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
if info.Type == "directory" && opts.HideFileExt != "" {
+2 -1
View File
@@ -57,6 +57,7 @@ type FileOptions struct {
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
ShowPinnedItems bool // whether to show pinned items
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
@@ -108,4 +109,4 @@ func HideFileByExt(filename, hideExt string) bool {
}
}
return false
}
}
-24
View File
@@ -138,17 +138,8 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
user.SidebarLinks = adjustedLinks
}
if slices.Contains(fields, "PinnedItems") {
adjustedPinnedItems, err := user.GetBackendPinnedItems()
if err != nil {
return err
}
user.PinnedItems = adjustedPinnedItems
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
existingUserFields := reflect.ValueOf(existingUser).Elem()
needsUserSave := false
for _, field := range fields {
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(field)
@@ -171,27 +162,12 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
// If otpEnabled is true, continue with normal field update
}
existingFieldValue := existingUserFields.FieldByName(field)
if existingFieldValue.IsValid() && existingFieldValue.CanSet() {
existingFieldValue.Set(reflect.ValueOf(val))
}
if field == "PinnedItems" {
existingUser.PinnedItems = user.PinnedItems
needsUserSave = true
continue
}
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
if needsUserSave {
if err := st.db.Save(existingUser); err != nil {
return fmt.Errorf("failed to save user with pinned items: %v", err)
}
}
return nil
}
-58
View File
@@ -119,64 +119,6 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
return newLinks, nil
}
// GetBackendPinnedItems converts pinned item source keys from frontend source names to backend source paths.
func (u *User) GetBackendPinnedItems() (PinnedItems, error) {
if len(u.PinnedItems) == 0 {
return PinnedItems{}, nil
}
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newPinnedItems := PinnedItems{}
for sourceKey, directories := range u.PinnedItems {
source, ok := sourceConfig.GetSourceByPath(sourceKey)
if !ok {
source, ok = sourceConfig.GetSourceByName(sourceKey)
}
if !ok {
continue
}
if newPinnedItems[source.Path] == nil {
newPinnedItems[source.Path] = map[string][]string{}
}
for directoryPath, pinnedPaths := range directories {
copiedPaths := append([]string(nil), pinnedPaths...)
newPinnedItems[source.Path][directoryPath] = copiedPaths
}
}
return newPinnedItems, nil
}
// GetFrontendPinnedItems converts pinned item source keys from backend source paths to frontend source names.
func (u *User) GetFrontendPinnedItems() PinnedItems {
if len(u.PinnedItems) == 0 || sourceConfig == nil {
return PinnedItems{}
}
newPinnedItems := PinnedItems{}
for sourceKey, directories := range u.PinnedItems {
source, ok := sourceConfig.GetSourceByPath(sourceKey)
if !ok {
continue
}
if newPinnedItems[source.Name] == nil {
newPinnedItems[source.Name] = map[string][]string{}
}
for directoryPath, pinnedPaths := range directories {
copiedPaths := append([]string(nil), pinnedPaths...)
newPinnedItems[source.Name][directoryPath] = copiedPaths
}
}
return newPinnedItems
}
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
func normalizeScope(scope string) string {
if !strings.HasPrefix(scope, "/") {
+69
View File
@@ -0,0 +1,69 @@
package users
import "slices"
// Add records a pinned item name under sourcePath and indexDirPath.
func (p PinnedItems) Add(sourcePath, indexDirPath, name string) {
if p == nil {
return
}
if p[sourcePath] == nil {
p[sourcePath] = make(map[string][]string)
}
items := p[sourcePath][indexDirPath]
if slices.Contains(items, name) {
return
}
p[sourcePath][indexDirPath] = append(items, name)
}
// Remove deletes a pinned item name and prunes empty map entries.
func (p PinnedItems) Remove(sourcePath, indexDirPath, name string) {
if p == nil {
return
}
byDir, ok := p[sourcePath]
if !ok {
return
}
items := byDir[indexDirPath]
idx := slices.Index(items, name)
if idx < 0 {
return
}
items = append(items[:idx], items[idx+1:]...)
if len(items) == 0 {
delete(byDir, indexDirPath)
} else {
byDir[indexDirPath] = items
}
if len(byDir) == 0 {
delete(p, sourcePath)
}
}
// PinnedNamesForDirectory returns pinned item names for a source/index directory path.
func (u *User) PinnedNamesForDirectory(sourcePath, indexDirPath string) []string {
if u == nil || len(u.PinnedItems) == 0 {
return nil
}
byDir, ok := u.PinnedItems[sourcePath]
if !ok {
return nil
}
names := byDir[indexDirPath]
if len(names) == 0 {
return nil
}
out := make([]string, len(names))
copy(out, names)
return out
}
// EnsurePinnedItems returns a non-nil map for mutation.
func (u *User) EnsurePinnedItems() PinnedItems {
if u.PinnedItems == nil {
u.PinnedItems = make(PinnedItems)
}
return u.PinnedItems
}
@@ -0,0 +1,98 @@
package users
import "testing"
func TestPinnedItemsAddRemove(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const dir = "/photos/"
const name = "vacation.jpg"
p.Add(source, dir, name)
if len(p[source][dir]) != 1 || p[source][dir][0] != name {
t.Fatalf("add failed: %#v", p)
}
p.Add(source, dir, name)
if len(p[source][dir]) != 1 {
t.Fatalf("duplicate add should be ignored: %#v", p)
}
p.Remove(source, dir, name)
if len(p) != 0 {
t.Fatalf("remove should prune empty maps: %#v", p)
}
}
func TestPinnedNamesForDirectory(t *testing.T) {
u := &User{
NonAdminEditable: NonAdminEditable{
PinnedItems: PinnedItems{
"/data/files": {
"/photos/": {"vacation.jpg", "notes.txt"},
},
},
},
}
names := u.PinnedNamesForDirectory("/data/files", "/photos/")
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
t.Fatalf("unexpected names: %#v", names)
}
if got := u.PinnedNamesForDirectory("/missing", "/photos/"); got != nil {
t.Fatalf("expected nil for missing source, got %#v", got)
}
}
func TestPinnedItemsRemoveMissing(t *testing.T) {
p := make(PinnedItems)
p.Remove("/data/files", "/photos/", "vacation.jpg")
if len(p) != 0 {
t.Fatalf("remove on empty map should be no-op: %#v", p)
}
}
func TestPinnedItemsMultipleItems(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const dir = "/photos/"
p.Add(source, dir, "a.jpg")
p.Add(source, dir, "b.jpg")
p.Add(source, dir, "c.jpg")
if len(p[source][dir]) != 3 {
t.Fatalf("expected 3 items, got %d", len(p[source][dir]))
}
p.Remove(source, dir, "b.jpg")
items := p[source][dir]
if len(items) != 2 || items[0] != "a.jpg" || items[1] != "c.jpg" {
t.Fatalf("unexpected items after remove: %#v", items)
}
}
func TestPinnedItemsMultipleDirectories(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const photosDir = "/photos/"
const docsDir = "/docs/"
p.Add(source, photosDir, "a.jpg")
p.Add(source, docsDir, "notes.txt")
if len(p[source][photosDir]) != 1 || p[source][photosDir][0] != "a.jpg" {
t.Fatalf("unexpected photos dir: %#v", p[source][photosDir])
}
if len(p[source][docsDir]) != 1 || p[source][docsDir][0] != "notes.txt" {
t.Fatalf("unexpected docs dir: %#v", p[source][docsDir])
}
p.Remove(source, photosDir, "a.jpg")
if _, ok := p[source][photosDir]; ok {
t.Fatalf("photos dir should be pruned: %#v", p)
}
if len(p[source][docsDir]) != 1 {
t.Fatalf("docs dir should remain: %#v", p[source][docsDir])
}
}
+53 -52
View File
@@ -96,26 +96,27 @@ type Preview struct {
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// source filesystem path -> index directory path -> pinned item names (unique within the directory)
type PinnedItems map[string]map[string][]string
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
@@ -127,44 +128,44 @@ type SourceScope struct {
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
PinnedItems PinnedItems `json:"pinnedItems,omitempty"` // pinned items organized by source and directory path
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
}
type FileLoading struct {
+1
View File
@@ -91,6 +91,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /users", withUser(userGetHandler))
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
api.HandleFunc("PUT /users", withUser(userPutHandler))
api.HandleFunc("PATCH /users/pinnedItems", withUser(userPatchPinnedItemsHandler))
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
+127
View File
@@ -0,0 +1,127 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// pinnedItemPatchRequest is the JSON body for PATCH /api/users/pinnedItems.
type pinnedItemPatchRequest struct {
Source string `json:"source" validate:"required"`
Path string `json:"path" validate:"required"` // scope-relative parent directory
Name string `json:"name" validate:"required"` // item basename within path
}
// scopeRelativeDirToIndexPath maps a scope-relative directory to source and index paths.
func scopeRelativeDirToIndexPath(sourceName, userScope, directoryPath string) (sourcePath, indexDirPath string, err error) {
userScope = strings.TrimRight(userScope, "/")
source, ok := users.ResolveSourceKey(sourceName)
if !ok {
return "", "", fmt.Errorf("source not found: %s", sourceName)
}
idx := indexing.GetIndex(source.Name)
if idx == nil {
return "", "", fmt.Errorf("index not found for source %s", source.Name)
}
cleanDir, err := utils.SanitizeUserPath(directoryPath)
if err != nil {
return "", "", fmt.Errorf("invalid path: %w", err)
}
fullDirPath := utils.JoinPathAsUnix(userScope, cleanDir)
return source.Path, idx.MakeIndexPath(fullDirPath, true), nil
}
// pinnedItemAction returns the patch action query param, defaulting to "add".
func pinnedItemAction(r *http.Request) string {
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
if action == "" {
return "add"
}
return action
}
// userPatchPinnedItemsHandler adds or removes a single pinned item for the authenticated user.
// @Summary Add or remove a pinned item
// @Description Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.
// @Tags Users
// @Accept json
// @Produce json
// @Param action query string false "add (default) or remove"
// @Param body body pinnedItemPatchRequest true "Pinned item"
// @Success 204 "No Content"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users/pinnedItems [patch]
func userPatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var body pinnedItemPatchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
action := pinnedItemAction(r)
if action != "add" && action != "remove" {
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
}
if body.Source == "" || body.Path == "" || body.Name == "" {
return http.StatusBadRequest, fmt.Errorf("source, path, and name are required")
}
cleanPath, err := utils.SanitizeUserPath(body.Path)
if err != nil {
return http.StatusBadRequest, err
}
body.Path = cleanPath
cleanName, err := utils.SanitizeUserPath(body.Name)
if err != nil {
return http.StatusBadRequest, err
}
body.Name = cleanName
source, ok := users.ResolveSourceKey(body.Source)
if !ok {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
}
if !d.user.HasSourceByPath(source.Path) {
return http.StatusForbidden, fmt.Errorf("access denied to source %s", body.Source)
}
userScope, err := d.user.GetScopeForSourcePath(source.Path)
if err != nil {
return http.StatusForbidden, err
}
sourcePath, indexDirPath, err := scopeRelativeDirToIndexPath(body.Source, userScope, body.Path)
if err != nil {
return http.StatusBadRequest, err
}
u, err := store.Users.Get(d.user.ID)
if err != nil {
return http.StatusInternalServerError, err
}
pinned := u.EnsurePinnedItems()
switch action {
case "add":
pinned.Add(sourcePath, indexDirPath, body.Name)
case "remove":
pinned.Remove(sourcePath, indexDirPath, body.Name)
}
if err := store.Users.Update(u, d.user.Permissions.Admin, "PinnedItems"); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusNoContent, nil
}
+1
View File
@@ -103,6 +103,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: skipExtendedAttrs,
ShowSharedAttr: true,
ShowPinnedItems: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
+1 -17
View File
@@ -99,8 +99,8 @@ func prepForFrontend(u *users.User) {
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.PinnedItems = u.GetFrontendPinnedItems()
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
u.PinnedItems = nil
u.Locale = normalizeLocale(u.Locale)
for i := range u.PasskeyCredentials {
u.PasskeyCredentials[i].PublicKey = ""
@@ -356,10 +356,6 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
return true
}
func userPutOnlyPinnedItems(which []string) bool {
return len(which) == 1 && strings.EqualFold(strings.TrimSpace(which[0]), "PinnedItems")
}
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
// password login. Callers should invoke this only when the update requires re-authentication.
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
@@ -458,18 +454,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
}
if userPutOnlyPinnedItems(req.Which) {
adjustedPinnedItems, err2 := req.User.GetBackendPinnedItems()
if err2 != nil {
return http.StatusBadRequest, err2
}
oldUser.PinnedItems = adjustedPinnedItems
if err2 := store.Users.Save(oldUser, false, false); err2 != nil {
return http.StatusBadRequest, err2
}
return http.StatusNoContent, nil
}
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
if err != nil {
return http.StatusBadRequest, err
+1
View File
@@ -67,4 +67,5 @@ type ExtendedFileInfo struct {
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
RealPath string `json:"-"`
PinnedItems []string `json:"pinnedItems,omitempty"` // pinned item names in this directory listing
}
+93 -6
View File
@@ -3600,6 +3600,70 @@ const docTemplate = `{
}
}
},
"/api/users/pinnedItems": {
"patch": {
"description": "Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Add or remove a pinned item",
"parameters": [
{
"type": "string",
"description": "add (default) or remove",
"name": "action",
"in": "query"
},
{
"description": "Pinned item",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.pinnedItemPatchRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/health": {
"get": {
"description": "Returns the health status of the API.",
@@ -5034,6 +5098,27 @@ const docTemplate = `{
}
}
},
"http.pinnedItemPatchRequest": {
"type": "object",
"required": [
"name",
"path",
"source"
],
"properties": {
"name": {
"description": "item basename within path",
"type": "string"
},
"path": {
"description": "scope-relative parent directory",
"type": "string"
},
"source": {
"type": "string"
}
}
},
"http.unarchiveRequest": {
"type": "object",
"properties": {
@@ -5150,6 +5235,13 @@ const docTemplate = `{
"description": "path scoped to the associated index",
"type": "string"
},
"pinnedItems": {
"description": "pinned item names in this directory listing",
"type": "array",
"items": {
"type": "string"
}
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
@@ -7590,12 +7682,7 @@ const docTemplate = `{
"$ref": "#/definitions/users.Permissions"
},
"pinnedItems": {
"description": "pinned items organized by source and directory path",
"allOf": [
{
"$ref": "#/definitions/users.PinnedItems"
}
]
"$ref": "#/definitions/users.PinnedItems"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
+93 -6
View File
@@ -3589,6 +3589,70 @@
}
}
},
"/api/users/pinnedItems": {
"patch": {
"description": "Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Add or remove a pinned item",
"parameters": [
{
"type": "string",
"description": "add (default) or remove",
"name": "action",
"in": "query"
},
{
"description": "Pinned item",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.pinnedItemPatchRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/health": {
"get": {
"description": "Returns the health status of the API.",
@@ -5023,6 +5087,27 @@
}
}
},
"http.pinnedItemPatchRequest": {
"type": "object",
"required": [
"name",
"path",
"source"
],
"properties": {
"name": {
"description": "item basename within path",
"type": "string"
},
"path": {
"description": "scope-relative parent directory",
"type": "string"
},
"source": {
"type": "string"
}
}
},
"http.unarchiveRequest": {
"type": "object",
"properties": {
@@ -5139,6 +5224,13 @@
"description": "path scoped to the associated index",
"type": "string"
},
"pinnedItems": {
"description": "pinned item names in this directory listing",
"type": "array",
"items": {
"type": "string"
}
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
@@ -7579,12 +7671,7 @@
"$ref": "#/definitions/users.Permissions"
},
"pinnedItems": {
"description": "pinned items organized by source and directory path",
"allOf": [
{
"$ref": "#/definitions/users.PinnedItems"
}
]
"$ref": "#/definitions/users.PinnedItems"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
+64 -3
View File
@@ -324,6 +324,21 @@ definitions:
- $ref: '#/definitions/http.fileWatchMetadata'
description: File metadata for non-text files
type: object
http.pinnedItemPatchRequest:
properties:
name:
description: item basename within path
type: string
path:
description: scope-relative parent directory
type: string
source:
type: string
required:
- name
- path
- source
type: object
http.unarchiveRequest:
properties:
deleteAfter:
@@ -410,6 +425,11 @@ definitions:
path:
description: path scoped to the associated index
type: string
pinnedItems:
description: pinned item names in this directory listing
items:
type: string
type: array
size:
description: length in bytes for regular files
type: integer
@@ -2236,9 +2256,7 @@ definitions:
permissions:
$ref: '#/definitions/users.Permissions'
pinnedItems:
allOf:
- $ref: '#/definitions/users.PinnedItems'
description: pinned items organized by source and directory path
$ref: '#/definitions/users.PinnedItems'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer
@@ -4858,6 +4876,49 @@ paths:
summary: Update a user's details
tags:
- Users
/api/users/pinnedItems:
patch:
consumes:
- application/json
description: Patches one pinned item at a time. Defaults to add; pass ?action=remove
to unpin.
parameters:
- description: add (default) or remove
in: query
name: action
type: string
- description: Pinned item
in: body
name: body
required: true
schema:
$ref: '#/definitions/http.pinnedItemPatchRequest'
produces:
- application/json
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Add or remove a pinned item
tags:
- Users
/health:
get:
consumes:
+25 -21
View File
@@ -14,29 +14,33 @@ export async function login(username, password, recaptcha, otp) {
password = ''
}
const params = { username, recaptcha };
const apiPath = getApiPath('auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
});
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
body = { message: bodyText };
}
const params = { username, recaptcha };
const apiPath = getApiPath('auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
});
if (res.status !== 200) {
const msg = body.message || 'Forbidden';
throw new Error(msg);
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
body = { message: bodyText };
}
if (res.status !== 200) {
const msg = body.message || 'Forbidden';
throw new Error(msg);
}
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
}
+10
View File
@@ -166,6 +166,16 @@ export async function update(user, which = ['all'], options = {}) {
}
}
// PATCH /api/users/pinnedItems (add by default; ?action=remove to unpin)
export async function patchPinnedItem({ source, path, name, action = 'add' }) {
const params = { action }
const apiPath = getApiPath('users/pinnedItems', params)
await fetchURL(apiPath, {
method: 'PATCH',
body: JSON.stringify({ source, path, name }),
})
}
// DELETE /api/users (remove user)
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
+3
View File
@@ -52,6 +52,7 @@ export async function fetchJSON(url, opts) {
export function adjustedData(data) {
if (data.type === "directory") {
const pinnedNames = new Set(data.pinnedItems || []);
// Combine folders and files into items
data.items = [...(data.folders || []), ...(data.files || [])];
data.items = data.items.map((item) => {
@@ -59,6 +60,7 @@ export function adjustedData(data) {
if (item.isShared === undefined) {
item.isShared = false;
}
item.pinned = pinnedNames.has(item.name);
if (data.path === "/") {
if (item.type === "directory") {
item.path = `/${item.name}/`
@@ -74,6 +76,7 @@ export function adjustedData(data) {
}
return item;
});
delete data.pinnedItems;
}
if (data.files) {
data.files = []
+27 -4
View File
@@ -54,10 +54,10 @@ describe('adjustedData', () => {
folders: [],
files: [],
items: [
{ isShared: false, name: "folder1", path: "/root/folder1/", source: undefined, type: "directory" },
{ isShared: false, name: "folder2", path: "/root/folder2/", source: undefined, type: "directory" },
{ isShared: false, name: "file1.txt", path: "/root/file1.txt", source: undefined, type: "file" },
{ isShared: false, name: "file2.txt", path: "/root/file2.txt", source: undefined, type: "file" },
{ isShared: false, name: "folder1", path: "/root/folder1/", source: undefined, type: "directory", pinned: false },
{ isShared: false, name: "folder2", path: "/root/folder2/", source: undefined, type: "directory", pinned: false },
{ isShared: false, name: "file1.txt", path: "/root/file1.txt", source: undefined, type: "file", pinned: false },
{ isShared: false, name: "file2.txt", path: "/root/file2.txt", source: undefined, type: "file", pinned: false },
],
path: "/root/",
};
@@ -105,5 +105,28 @@ describe('adjustedData', () => {
expect(adjustedData(input)).toEqual(expected);
});
it('marks items as pinned from directory pinnedItems names', () => {
const input = {
type: "directory",
path: "/",
pinnedItems: ["alpha.txt"],
folders: [{ name: "docs", type: "directory" }],
files: [
{ name: "alpha.txt", type: "file" },
{ name: "beta.txt", type: "file" },
],
source: "Docs",
};
const result = adjustedData(input);
expect(result.pinnedItems).toBeUndefined();
expect(result.items).toEqual([
{ isShared: false, name: "docs", path: "/docs/", source: "Docs", type: "directory", pinned: false },
{ isShared: false, name: "alpha.txt", path: "/alpha.txt", source: "Docs", type: "file", pinned: true },
{ isShared: false, name: "beta.txt", path: "/beta.txt", source: "Docs", type: "file", pinned: false },
]);
});
});
+26 -7
View File
@@ -195,7 +195,7 @@
</template>
<script>
import { resourcesApi } from "@/api";
import { resourcesApi, usersApi } from "@/api";
import Action from "@/components/Action.vue";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store";
@@ -343,10 +343,7 @@ export default {
return this.isPinnedSelection ? this.$t("buttons.unpinItem") : this.$t("buttons.pinItem");
},
isPinnedSelection() {
if (!this.firstSelected) {
return false;
}
return getters.isItemPinned(this.firstSelected);
return !!this.firstSelected?.pinned;
},
showCopy() {
if (this.showLimitedOptions) return false;
@@ -724,9 +721,31 @@ export default {
},
});
},
togglePin() {
async togglePin() {
const item = this.firstSelected;
if (!item?.name || getters.isShare() || !getters.isLoggedIn()) {
return;
}
mutations.closeHovers();
mutations.togglePinnedItem(this.firstSelected);
const source = item.source || state.req?.source || state.sources.current;
if (!source) {
return;
}
const directoryPath = state.req?.type === "directory"
? state.req.path
: url.getParentDir(item.path);
const action = item.pinned ? "remove" : "add";
try {
await usersApi.patchPinnedItem({
source,
path: directoryPath,
name: item.name,
action,
});
mutations.setReload(true);
} catch (e) {
notify.showError(e);
}
},
async copyPathToClipboard() {
const item = this.firstSelected;
+26 -35
View File
@@ -44,6 +44,7 @@
:forceFilesApi="!!browseSource"
:showLimitedOptions="true"
:inlinePin="true"
:pinned="item.pinned"
@click.prevent="(event) => handleItemClick(item, index, event)"
@dblclick.prevent="(event) => handleItemDblClick(item, index, event)"
/>
@@ -292,20 +293,6 @@ export default {
// Fetch files for the share
this.withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(this.fillOptions));
},
// Sort items to make pinned items appear on top always (it was working before, but just for the first load)
// after navigate the pinned was lost, this will also keep our sorting preference in all prompts that use FileList
sortItemsWithPinned(items, source, path) {
if (!items?.length) return items;
const parentEntry = items.find(i => i.name === "..");
const rest = items.filter(i => i.name !== "..");
const dirs = rest.filter(i => i.type === 'directory');
const files = rest.filter(i => i.type !== 'directory');
const pinnedPaths = getters.pinnedPathsFor(source, path);
const sorting = getters.sorting();
const sortedDirs = sortedItems(dirs, sorting.by, sorting.asc, pinnedPaths);
const sortedFiles = sortedItems(files, sorting.by, sorting.asc, pinnedPaths);
return parentEntry ? [parentEntry, ...sortedDirs, ...sortedFiles] : [...sortedDirs, ...sortedFiles];
},
fillOptions(req) {
// Sets the current path and resets the current items.
// Use this.path (the path we're browsing) instead of req.path (which may be relative)
@@ -321,34 +308,38 @@ export default {
isValid: !this.requireFileSelection, // Folder is only valid if file is not required
});
// If the path isn't the root path,
// show a button to navigate to the previous
// directory (unless we are only displaying files).
const entries = [];
if (req.items && Array.isArray(req.items)) {
for (const item of req.items) {
if (!this.showFolders && item.type === "directory") continue;
if (!this.showFiles && item.type !== "directory") continue;
if (item.type !== "directory" && !this.isFileTypeAllowed(item.type)) continue;
entries.push({
name: item.name,
path: item.path,
source: item.source || req.source,
type: item.type,
pinned: !!item.pinned,
originalItem: item,
});
}
const sorting = getters.sorting();
const dirs = entries.filter((item) => item.type === "directory");
const files = entries.filter((item) => item.type !== "directory");
this.items = [
...sortedItems(dirs, sorting.by, sorting.asc),
...sortedItems(files, sorting.by, sorting.asc),
];
}
if (this.path !== "/" && this.showFolders) {
this.items.push({
this.items.unshift({
name: "..",
path: `${url.removeLastDir(this.path)}/`,
source: this.source,
type: "directory",
});
}
// If this folder has no items or items is not an array, finish here.
if (!req.items || !Array.isArray(req.items)) return;
for (const item of req.items) {
if (!this.showFolders && item.type === "directory") continue;
if (!this.showFiles && item.type !== "directory") continue;
// Filter by file type if specified (only for files, not directories)
if (item.type !== "directory" && !this.isFileTypeAllowed(item.type)) continue;
this.items.push({
name: item.name,
path: item.path,
source: item.source || req.source,
type: item.type, // Store type for file selection
originalItem: item, // Store original item for Icon component
});
}
this.items = this.sortItemsWithPinned(this.items, this.source, this.path);
},
next: function (event) {
// Retrieves the URL of the directory the user
+1 -1
View File
@@ -657,7 +657,7 @@ export default {
// Compare node to each candidate (usually only one, but safe to loop)
return candidates.some(selected => {
if (!selected?.path) return false;
if (!selected.path) return false;
if (this.isShare) {
return selected.path === node.path;
} else {
+2 -1
View File
@@ -373,7 +373,8 @@ export default {
// Preload the next frame (cancellable fetch warms HTTP cache)
const nextIndex = (index + 1) % sequence.length;
const frameUrl = sequence[nextIndex];
const frameUrl = sequence.at(nextIndex);
if (!frameUrl) return;
const preloadController = new AbortController();
this.preloadAbortControllers.push(preloadController);
fetchPreviewImage(frameUrl, preloadController.signal)
@@ -217,6 +217,10 @@ export default {
type: Boolean,
default: false,
},
pinned: {
type: Boolean,
default: false,
},
},
computed: {
displayName() {
@@ -321,10 +325,7 @@ export default {
return this.clickable;
},
isPinned() {
return getters.isItemPinned({
path: this.path,
source: this.source,
});
return this.pinned;
},
// Computed properties for display values - Vue caches these automatically!
humanSize() {
+8 -4
View File
@@ -111,6 +111,7 @@ import Unarchive from "./Unarchive.vue";
import OfficeDebug from "./OfficeDebug.vue";
import ThreeJSControls from "./ThreeJSControls.vue";
import { state, getters, mutations } from "@/store";
import { getObjectProperty, omitObjectProperty, setObjectProperty } from "@/utils/object.js";
export default {
name: "Prompts",
@@ -396,7 +397,7 @@ export default {
cleanupDragState(id) {
delete this.dragOffsets[id];
delete this.dragStarts[id];
delete this.touchIds[id];
this.touchIds = omitObjectProperty(this.touchIds, id);
this.draggingIds.delete(id);
delete this.sizes[id];
},
@@ -499,7 +500,10 @@ export default {
this.makeTopPrompt(id);
if (type === "mouse" && e.button !== 0) return;
if (type === "touch") {
this.touchIds[id] = e.changedTouches?.[0]?.identifier;
const touchId = e.changedTouches?.[0]?.identifier;
if (touchId !== undefined) {
this.touchIds = setObjectProperty(this.touchIds, id, touchId);
}
}
const pos = this.getPointerPos(e, type);
@@ -526,7 +530,7 @@ export default {
let pos;
if (type === "touch") {
if (!e.touches) return;
const t = Array.from(e.touches).find((touch) => touch.identifier === this.touchIds[id]);
const t = Array.from(e.touches).find((touch) => touch.identifier === getObjectProperty(this.touchIds, id));
if (!t) return;
e.preventDefault();
pos = { x: t.clientX, y: t.clientY };
@@ -546,7 +550,7 @@ export default {
},
onPointerEnd(_e, id, type, moveHandler, endHandler) {
if (type === "touch") {
delete this.touchIds[id];
this.touchIds = omitObjectProperty(this.touchIds, id);
window.removeEventListener("touchmove", moveHandler);
window.removeEventListener("touchend", endHandler);
window.removeEventListener("touchcancel", endHandler);
+8 -3
View File
@@ -79,6 +79,7 @@
<script>
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { getObjectProperty } from "@/utils/object.js";
function defaultCompare(av, bv) {
if (av === bv) {
@@ -94,13 +95,17 @@ function cellValueLookup(row, k) {
if (!row || k === null) {
return undefined;
}
if (typeof row[k] !== "undefined") {
return row[k];
const direct = getObjectProperty(row, k);
if (direct !== undefined) {
return direct;
}
if (typeof k === "string" && k.indexOf(".") !== -1) {
let cur = row;
for (const segment of k.split(".")) {
cur = cur === null ? cur : cur[segment];
if (cur === null) {
return cur;
}
cur = getObjectProperty(cur, segment);
}
return cur;
}
+8 -23
View File
@@ -5,7 +5,8 @@ import { globalVars, previewViews, tools } from '@/utils/constants';
import { getFileExtension } from '@/utils/files.js';
import { getTypeInfo } from '@/utils/mimetype';
import { fromNow } from '@/utils/moment';
import { buildItemUrl, removeLeadingSlash, removePrefix, getParentDir } from '@/utils/url.js';
import { getNestedProperty, getObjectProperty } from '@/utils/object.js';
import { buildItemUrl, removeLeadingSlash, removePrefix } from '@/utils/url.js';
export const getters = {
displayPreferenceFor: (source, path) => {
@@ -13,7 +14,7 @@ export const getters = {
if (!sourceKey || !path) {
return null;
}
return state.displayPreferences?.[sourceKey]?.[path] || null;
return getNestedProperty(state.displayPreferences, sourceKey, path) || null;
},
eventTheme: () => {
if (getters.isShare()) {
@@ -69,23 +70,6 @@ export const getters = {
return getters.displayPreferenceFor(source, path);
},
pinnedPathsFor: (source, path) => {
if (getters.isShare() || !getters.isLoggedIn()) {
return [];
}
const sourceKey = source || state.sources.current || state.req?.source || "";
const directoryPath = path && path !== "/" ? url.removeTrailingSlash(path) : "/";
const pinnedPaths = state.user?.pinnedItems?.[sourceKey]?.[directoryPath];
return Array.isArray(pinnedPaths) ? pinnedPaths : [];
},
isItemPinned: (item) => {
if (!item?.path || getters.isShare() || !getters.isLoggedIn()) {
return false;
}
const directoryPath = getParentDir(item.path);
const source = item.source || state.req?.source || state.sources.current;
return getters.pinnedPathsFor(source, directoryPath).includes(item.path);
},
viewMode: () => {
if (!state.user || state.user?.username === "") {
return "normal";
@@ -243,7 +227,7 @@ export const getters = {
if (!state.req.items) return { pinned, dirs, files };
for (const item of state.req.items) {
if (getters.isItemPinned(item)) {
if (item.pinned) {
pinned.push(item);
continue;
}
@@ -432,12 +416,13 @@ export const getters = {
const files = []
for (const index in state.upload.uploads) {
const upload = state.upload.uploads[index]
for (const index of Object.keys(state.upload.uploads)) {
const upload = getObjectProperty(state.upload.uploads, index)
if (!upload) continue
const id = upload.id
const type = upload.type
const name = upload.file.name
const size = state.upload.sizes[id] || 0 // Default to 0 if size is undefined
const size = getObjectProperty(state.upload.sizes, id) ?? 0 // Default to 0 if size is undefined
const isDir = upload.file.type === 'directory'
const progress = isDir
? 100
+7 -76
View File
@@ -407,9 +407,6 @@ export const mutations = {
await i18n.setLocale(value.locale);
}
state.user = value;
if (!state.user.pinnedItems || typeof state.user.pinnedItems !== "object") {
state.user.pinnedItems = {};
}
state.user.sorting = {};
state.user.sorting.by = "name";
state.user.sorting.asc = true;
@@ -607,7 +604,6 @@ export const mutations = {
"fileLoading",
"deleteAfterArchive",
"preferEditorForMarkdown",
"pinnedItems",
].includes(key)
);
value.id = state.user.id;
@@ -630,22 +626,16 @@ export const mutations = {
const sorting = getters.sorting();
const sortby = sorting.by;
const asc = sorting.asc;
const pinnedPaths = getters.pinnedPathsFor(value.source, value.path || state.route.path || "/");
// Separate directories and files
const dirs = value.items.filter((item) => item.type === 'directory');
const files = value.items.filter((item) => item.type !== 'directory');
// Sort them separately
const sortedDirs = sortedItems(dirs, sortby, asc, pinnedPaths);
const sortedFiles = sortedItems(files, sortby, asc, pinnedPaths);
// Combine them and assign indices
const sortedDirs = sortedItems(dirs, sortby, asc);
const sortedFiles = sortedItems(files, sortby, asc);
value.items = [...sortedDirs, ...sortedFiles];
value.items.map((item, index) => {
value.items.forEach((item, index) => {
item.index = index;
return item;
})
});
state.req = value;
emitStateChanged();
},
@@ -837,62 +827,6 @@ export const mutations = {
}
emitStateChanged();
},
togglePinnedItem: (item) => {
if (!item?.path || getters.isShare() || !getters.isLoggedIn()) {
return;
}
const directoryPath = url.getParentDir(item.path);
const sourceKey = item.source || state.req?.source || state.sources.current;
if (!sourceKey) {
return;
}
const nextPinnedItems = {
...(state.user?.pinnedItems || {}),
};
const sourcePinnedItems = {
...(nextPinnedItems[sourceKey] || {}),
};
const pinnedPaths = Array.isArray(sourcePinnedItems[directoryPath]) ? [...sourcePinnedItems[directoryPath]] : [];
const existingIndex = pinnedPaths.indexOf(item.path);
if (existingIndex >= 0) {
pinnedPaths.splice(existingIndex, 1);
} else {
pinnedPaths.push(item.path);
}
if (pinnedPaths.length > 0) {
sourcePinnedItems[directoryPath] = pinnedPaths;
nextPinnedItems[sourceKey] = sourcePinnedItems;
} else {
delete sourcePinnedItems[directoryPath];
if (Object.keys(sourcePinnedItems).length > 0) {
nextPinnedItems[sourceKey] = sourcePinnedItems;
} else {
delete nextPinnedItems[sourceKey];
}
}
mutations.updateCurrentUser({
pinnedItems: nextPinnedItems,
});
const currentDirectoryPath = state.req?.type === "directory" ? state.req.path : url.getParentDir(state.req?.path);
const currentSourceKey = state.req?.source || state.sources.current;
if (
getters.currentView() === "listingView" &&
currentDirectoryPath === directoryPath &&
currentSourceKey === sourceKey
) {
mutations.updateListingItems();
return;
}
emitStateChanged();
},
setNavigationEnabled: (enabled) => {
if (state.navigation.enabled === enabled) {
return;
@@ -924,8 +858,7 @@ export const mutations = {
// Sort listing according to sorting preferences
const sorting = getters.sorting();
const pinnedPaths = getters.pinnedPathsFor(currentItem?.source || state.req?.source, directoryPath);
listing = sortedItems(listing, sorting.by, sorting.asc, pinnedPaths);
listing = sortedItems(listing, sorting.by, sorting.asc);
// Find current item index in the listing
for (let i = 0; i < listing.length; i++) {
@@ -941,8 +874,7 @@ export const mutations = {
}
// Find previous item (skip directories)
for (let j = state.navigation.currentIndex - 1; j >= 0; j--) {
const item = listing[j];
for (const item of listing.slice(0, state.navigation.currentIndex).reverse()) {
if (item.type === 'directory') continue;
item.path = url.joinPath(directoryPath, item.name);
@@ -956,8 +888,7 @@ export const mutations = {
}
// Find next item (skip directories)
for (let j = state.navigation.currentIndex + 1; j < listing.length; j++) {
const item = listing[j];
for (const item of listing.slice(state.navigation.currentIndex + 1)) {
if (item.type === 'directory') continue;
item.path = url.joinPath(directoryPath, item.name);
-1
View File
@@ -49,7 +49,6 @@ export const state = reactive({
info: {},
},
user: {
pinnedItems: {},
preview: {
audio: true,
video: true,
-1
View File
@@ -41,7 +41,6 @@ export interface ShareInfoObject {
}
export interface UserObject {
pinnedItems?: Record<string, Record<string, string[]>>;
preview: {
video: boolean;
audio: boolean;
+2 -5
View File
@@ -1,8 +1,5 @@
export default function (name) {
const re = new RegExp(
`(?:(?:^|.*;\\s*)${name}\\s*\\=\\s*([^;]*).*$)|^.*$`
);
return document.cookie.replace(re, "$1");
export default function getCookieDefault(name) {
return getCookie(name);
}
export function getCookie(name) {
+71
View File
@@ -0,0 +1,71 @@
/**
* Safe property access helpers for plain objects (prototype-pollution / injection-sink safe).
*/
function normalizeKey(key) {
if (typeof key === 'string' || typeof key === 'number') {
return String(key);
}
return null;
}
/** @returns {unknown} */
export function getObjectProperty(obj, key) {
const prop = normalizeKey(key);
if (obj === null || obj === undefined || prop === null || !Object.hasOwn(obj, prop)) {
return undefined;
}
return obj[prop];
}
/** @returns {Record<string, unknown>} */
export function setObjectProperty(obj, key, value) {
const prop = normalizeKey(key);
if (prop === null) {
return { ...(obj ?? {}) };
}
const result = { ...(obj ?? {}) };
result[prop] = value;
return result;
}
/** @returns {Record<string, unknown>} */
export function omitObjectProperty(obj, key) {
const prop = normalizeKey(key);
if (obj === null || obj === undefined || prop === null || !Object.hasOwn(obj, prop)) {
return { ...(obj ?? {}) };
}
return Object.fromEntries(
Object.entries(obj).filter(([entryKey]) => entryKey !== prop)
);
}
/** @returns {unknown} */
export function getNestedProperty(obj, outerKey, innerKey) {
const outer = getObjectProperty(obj, outerKey);
return getObjectProperty(outer, innerKey);
}
/**
* @param {Record<string, unknown> | null | undefined} obj
* @param {string | number} outerKey
* @param {string | number} innerKey
* @param {unknown} innerValue
* @param {{ removeInner?: boolean; removeOuterIfEmpty?: boolean }} [options]
* @returns {Record<string, unknown>}
*/
export function updateNestedProperty(obj, outerKey, innerKey, innerValue, options = {}) {
const { removeInner = false, removeOuterIfEmpty = false } = options;
const outerObj = getObjectProperty(obj, outerKey) ?? {};
let newOuter;
if (removeInner) {
newOuter = omitObjectProperty(outerObj, innerKey);
} else {
newOuter = setObjectProperty(outerObj, innerKey, innerValue);
}
if (removeOuterIfEmpty && Object.keys(newOuter).length === 0) {
return omitObjectProperty(obj, outerKey);
}
return setObjectProperty(obj, outerKey, newOuter);
}
+3 -11
View File
@@ -1,15 +1,7 @@
import { removeTrailingSlash } from "@/utils/url.js";
export function sortedItems(items = [], sortby = "name", asc = true, pinnedPaths = []) {
// Normalise pinned paths
const normalisedPinnedPaths = (Array.isArray(pinnedPaths) ? pinnedPaths : []).map(p => removeTrailingSlash(p));
const pinnedSet = new Set(normalisedPinnedPaths);
export function sortedItems(items = [], sortby = "name", asc = true) {
return items.sort((a, b) => {
const aPathNorm = a.path ? removeTrailingSlash(a.path) : '';
const bPathNorm = b.path ? removeTrailingSlash(b.path) : '';
const aPinned = pinnedSet.has(aPathNorm);
const bPinned = pinnedSet.has(bPathNorm);
const aPinned = !!a.pinned;
const bPinned = !!b.pinned;
if (aPinned !== bPinned) {
return aPinned ? -1 : 1;
+5 -5
View File
@@ -141,18 +141,18 @@ describe('testSort', () => {
it('keeps pinned items at the top while preserving sort order among them', () => {
const input = [
{ name: "beta.txt", path: "/beta.txt" },
{ name: "delta.txt", path: "/delta.txt" },
{ name: "alpha.txt", path: "/alpha.txt" },
{ name: "delta.txt", path: "/delta.txt", pinned: true },
{ name: "alpha.txt", path: "/alpha.txt", pinned: true },
{ name: "gamma.txt", path: "/gamma.txt" },
];
const expected = [
{ name: "alpha.txt", path: "/alpha.txt" },
{ name: "delta.txt", path: "/delta.txt" },
{ name: "alpha.txt", path: "/alpha.txt", pinned: true },
{ name: "delta.txt", path: "/delta.txt", pinned: true },
{ name: "beta.txt", path: "/beta.txt" },
{ name: "gamma.txt", path: "/gamma.txt" },
];
expect(sortedItems(input, "name", true, ["/delta.txt", "/alpha.txt"])).toEqual(expected);
expect(sortedItems(input, "name", true)).toEqual(expected);
});
});
+10 -7
View File
@@ -132,13 +132,16 @@ export function convertToVTT (ext, text) {
if (!dialogue) return '' // Skip empty captions
const startFormatted = formatMillisecondsToVTT(startTime)
// Use next start time as the end time if available
const nextSyncMatch = text.match(
new RegExp(`<SYNC Start=${parseInt(startTime, 10) + 1}>`)
)
const endTime = nextSyncMatch
? nextSyncMatch[1]
: parseInt(startTime, 10) + 3000 // Default to +3s
// Use next SYNC start time as the end time if available
const currentTag = `<SYNC Start=${startTime}>`
const tagPos = text.indexOf(currentTag)
let endTime = parseInt(startTime, 10) + 3000 // Default to +3s
if (tagPos >= 0) {
const nextMatch = text.slice(tagPos + currentTag.length).match(/<SYNC Start=(\d+)>/)
if (nextMatch) {
endTime = parseInt(nextMatch[1], 10)
}
}
const endFormatted = formatMillisecondsToVTT(endTime)
return `${startFormatted} --> ${endFormatted}\n${dialogue}`
+1
View File
@@ -28,6 +28,7 @@ import { url } from "@/utils";
import router from "@/router";
import { extractSourceFromPath } from "@/utils/url";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { globalVars } from "@/utils/constants";
function directoryListingHasMediaChildren(req) {
return (
+5 -3
View File
@@ -18,6 +18,7 @@ import { defineComponent, watch } from "vue";
import ePub, { type Book, type Rendition } from "epubjs";
import { state, mutations, getters } from "@/store"; // Assuming your store setup
import { resourcesApi } from "@/api";
import router from "@/router";
import { removeLastDir } from "@/utils/url"; // Assuming your utils setup
/** Hash format: `#epubcfi=<encodeURIComponent(epub-cfi)>` — distinct from listing `#filename` hashes. */
@@ -194,9 +195,10 @@ export default defineComponent({
},
// Close the viewer and navigate away
close() {
const current = window.location.pathname;
const newPath = removeLastDir(current);
window.location.href = `${newPath}#${state.req.name}`;
const filename = state.req.name;
mutations.replaceRequest({});
const uri = `${removeLastDir(state.route.path)}/`;
router.push({ path: uri, hash: `#${filename}` });
},
// Error handler
onLoadComponentError(error: unknown) {
+10 -4
View File
@@ -84,6 +84,7 @@
v-bind:metadata="item.metadata"
v-bind:hasDuration="hasDuration"
v-bind:isShared="item.isShared"
v-bind:pinned="item.pinned"
/>
</div>
@@ -113,6 +114,7 @@
v-bind:hasPreview="item.hasPreview"
v-bind:hasDuration="hasDuration"
v-bind:isShared="item.isShared"
v-bind:pinned="item.pinned"
/>
</div>
@@ -143,6 +145,7 @@
v-bind:metadata="item.metadata"
v-bind:hasDuration="hasDuration"
v-bind:isShared="item.isShared"
v-bind:pinned="item.pinned"
/>
</div>
@@ -238,18 +241,21 @@ export default {
});
// Decide category by checking which section is above
let letter = topItem.getAttribute("data-name")?.[0]?.toUpperCase() || "A";
let letter = "A";
let category = "folders"; // Default category
if (!topItem && this.numPinned > 0) {
if (topItem) {
letter = topItem.getAttribute("data-name")?.[0]?.toUpperCase() || "A";
} else if (this.numPinned > 0) {
const pinnedHeader = this.$el.querySelector(".pinned-items h2");
if (pinnedHeader && pinnedHeader.getBoundingClientRect().top >= 0) {
category = "pinned";
const firstPinned = this.pinnedItems[0];
letter = firstPinned?.name?.[0]?.toUpperCase();
}
}
else if (topItem?.closest('.pinned-items')) {
}
if (topItem?.closest('.pinned-items')) {
category = "pinned";
const firstPinned = this.pinnedItems[0];
letter = firstPinned?.name?.[0]?.toUpperCase();
@@ -13,6 +13,7 @@
<script>
import { DocumentEditor } from "@onlyoffice/document-editor-vue";
import { globalVars } from "@/utils/constants";
import router from "@/router";
import { state, mutations } from "@/store";
import { removeLastDir } from "@/utils/url";
import { officeApi } from "@/api";
@@ -79,17 +80,17 @@ export default {
if (window.DocsAPI) delete window.DocsAPI;
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
if (iframe.src?.includes('onlyoffice')) {
if (iframe.src.includes('onlyoffice')) {
iframe.remove();
}
});
},
methods: {
close() {
const current = window.location.pathname;
const newpath = removeLastDir(current);
mutations.replaceRequest({});
const uri = `${removeLastDir(state.route.path)}/`;
const filename = this.path.split('/').pop() || "";
window.location.href = `${newpath}#${filename}`;
router.push({ path: uri, hash: `#${filename}` });
},
showDebugPrompt() {
+1 -1
View File
@@ -714,7 +714,7 @@ export default {
try {
navigator.mediaSession.setActionHandler(action, handler);
} catch (e) {
console.warn(`The media session action "${action}" is not supported`, e);
console.warn(`The media session action "${String(action)}" is not supported`, e);
}
}
this.updateMediaSessionPlaybackState();
+2 -2
View File
@@ -50,8 +50,8 @@ export default {
const splitted = name.split(where);
name = "";
for (let i = 0; i < splitted.length; i++) {
name += `${splitted[i].charAt(0).toUpperCase() + splitted[i].slice(1)} `;
for (const part of splitted) {
name += `${part.charAt(0).toUpperCase() + part.slice(1)} `;
}
return name.slice(0, -1);
@@ -119,6 +119,7 @@ import { eventBus } from "@/store/eventBus";
import ListingItem from "@/components/files/ListingItem.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import { getTypeInfo } from "@/utils/mimetype";
import { globalVars } from "@/utils/constants";
export default {
name: "DuplicateFinder",
+1
View File
@@ -98,6 +98,7 @@ import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { notify } from "@/notify";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import { globalVars } from "@/utils/constants";
export default {
name: "FileWatcher",
@@ -72,6 +72,7 @@
<script>
import { materialSymbols, getIconClass } from "@/utils/material-symbols";
import { copyToClipboard } from "@/utils/clipboard";
import { globalVars } from "@/utils/constants";
export default {
name: "MaterialIconPicker",