mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
11
Commits
main
...
v2.0.6-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bbda72dd | ||
|
|
8493fe582b | ||
|
|
a615ba7814 | ||
|
|
a2fb87a79c | ||
|
|
b9679d03a3 | ||
|
|
4b9f03ad35 | ||
|
|
4b465f5855 | ||
|
|
0d68ad5d7c | ||
|
|
d417da2b83 | ||
|
|
ba04611565 | ||
|
|
0a2265980e |
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
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).
|
||||
|
||||
## v2.0.6
|
||||
|
||||
**Security**:
|
||||
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
|
||||
|
||||
**Notes**:
|
||||
- renaming sources updates sidebar links (#2878)
|
||||
- disabling/deleting a user source removes that source from sidebar links (#2942)
|
||||
|
||||
**Bugfixes**:
|
||||
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
|
||||
- Support non-ASCII share passwords (#2933)
|
||||
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
|
||||
- Preserve deleted sidebar links across restarts (#2935)
|
||||
|
||||
## v2.0.5
|
||||
|
||||
**New Features**:
|
||||
|
||||
+10
-20
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ func validateUserInfo(newDB bool) {
|
||||
changedFields := make([]string, 0, 8)
|
||||
changePass := false
|
||||
|
||||
if updateUserScopes(user) {
|
||||
scopesChanged := updateUserScopes(user)
|
||||
if scopesChanged {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
}
|
||||
if updatePermissions(user) {
|
||||
@@ -45,7 +46,7 @@ func validateUserInfo(newDB bool) {
|
||||
if updateShowFirstLogin(user) {
|
||||
changedFields = append(changedFields, "showFirstLogin")
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
if updateSidebarLinks(user, scopesChanged) {
|
||||
changedFields = append(changedFields, "sidebarLinks")
|
||||
}
|
||||
if updateTokens(user) {
|
||||
@@ -232,28 +233,17 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
|
||||
|
||||
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
|
||||
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
|
||||
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
|
||||
if scopesChanged || user.SidebarLinks == nil {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
user.SidebarLinks = links
|
||||
|
||||
if needsEnsure {
|
||||
if validBefore == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if validBefore == 0 {
|
||||
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
|
||||
} else {
|
||||
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
|
||||
|
||||
updateUserScopes(graham)
|
||||
updateSourcePermissions(graham)
|
||||
updateSidebarLinks(graham)
|
||||
updateSidebarLinks(graham, true)
|
||||
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -77,3 +77,37 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected default source names to be blanked on normalize")
|
||||
}
|
||||
if len(user.SidebarLinks) != 2 {
|
||||
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
|
||||
}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -126,8 +128,7 @@ func copySingleFile(source, dest string) error {
|
||||
defer dst.Close()
|
||||
|
||||
// Copy the contents of the file.
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
if err = copyFileContents(dst, src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -146,6 +147,18 @@ func copySingleFile(source, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFileContents(dst io.Writer, src io.Reader) error {
|
||||
_, err := io.Copy(dst, src)
|
||||
if !errors.Is(err, syscall.EBADF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
|
||||
// methods so io.Copy retries from the current offsets with buffered I/O.
|
||||
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
|
||||
return err
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
srcInfo, err := os.Stat(source)
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type copyFileRangeRejectingWriter struct {
|
||||
bytes.Buffer
|
||||
readFromCalls int
|
||||
}
|
||||
|
||||
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
w.readFromCalls++
|
||||
buf := make([]byte, 4)
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
_, _ = w.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
return int64(n), err
|
||||
}
|
||||
return int64(n), syscall.EBADF
|
||||
}
|
||||
|
||||
func TestUnixModeToFileMode_setgid(t *testing.T) {
|
||||
m := unixModeToFileMode(0o2770)
|
||||
if got := m.Perm(); got != 0o770 {
|
||||
@@ -117,3 +138,19 @@ func TestCopyFilePreservesModTime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
|
||||
const content = "complete file contents"
|
||||
src := struct{ io.Reader }{bytes.NewBufferString(content)}
|
||||
dst := ©FileRangeRejectingWriter{}
|
||||
|
||||
if err := copyFileContents(dst, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dst.String(); got != content {
|
||||
t.Fatalf("copied contents = %q, want %q", got, content)
|
||||
}
|
||||
if dst.readFromCalls != 1 {
|
||||
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package share
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -62,31 +61,34 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
|
||||
}
|
||||
out.SingleFileShare = snap.IsSingleFileShare()
|
||||
if r != nil && publicHost != "" {
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
|
||||
tokenParam := ""
|
||||
if token != "" && isDirectDownload {
|
||||
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
|
||||
if isDirectDownload && hasPassword {
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
return fmt.Sprintf("%s%spublic/share/%s?download=true",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s%spublic/share/%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, true)
|
||||
want := "https://example.com/files/public/share/abc123?download=true"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, false)
|
||||
want := "https://example.com/files/public/api/resources/download?hash=abc123"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,6 @@ type Share struct {
|
||||
ShareColumns
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
UserID uint64 `json:"userID,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserDownloads map[string]int `json:"userDownloads,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
|
||||
@@ -5,9 +5,8 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
|
||||
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
|
||||
const currentSchemaVersion = 1
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase.
|
||||
const currentSchemaVersion = 2
|
||||
|
||||
// Schema creates all tables for the SQLite database
|
||||
func createSchema(db *sql.DB) error {
|
||||
@@ -39,7 +38,6 @@ func createSchema(db *sql.DB) error {
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
@@ -183,6 +181,10 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
switch v {
|
||||
case 1:
|
||||
// Canonical schema is createSchema + Bolt import; no step migrations.
|
||||
case 2:
|
||||
if err := normalizeLegacyShareTokens(db); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown schema version: %d", v)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ func scanShareUserID(s string, dest *uint64) error {
|
||||
|
||||
// GetShareByHash retrieves a share by hash
|
||||
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
|
||||
|
||||
var link share.Share
|
||||
var userIDStr string
|
||||
@@ -56,7 +54,6 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -87,9 +84,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
|
||||
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
|
||||
@@ -103,9 +98,7 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
|
||||
// GetSharesBySourcePath retrieves shares for a specific source and path
|
||||
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path)
|
||||
if err != nil {
|
||||
@@ -119,9 +112,7 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
|
||||
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
|
||||
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
AND (expire = 0 OR expire > ?) ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
|
||||
@@ -135,9 +126,7 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
|
||||
|
||||
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
|
||||
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
LIMIT 1`
|
||||
|
||||
var link share.Share
|
||||
@@ -152,7 +141,6 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -183,9 +171,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
// ListAllShares retrieves all non-expired shares
|
||||
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, now)
|
||||
if err != nil {
|
||||
@@ -207,24 +193,59 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
|
||||
return fmt.Errorf("failed to marshal share settings: %w", err)
|
||||
}
|
||||
|
||||
query := `INSERT OR REPLACE INTO shares
|
||||
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect shares schema: %w", err)
|
||||
}
|
||||
|
||||
legacyToken := ""
|
||||
if hasLegacyToken {
|
||||
legacyToken, err = s.legacyShareToken(link.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if hasLegacyToken {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
token, user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
legacyToken,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
} else {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(query,
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
link.Token,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
)
|
||||
_, err = s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save share: %w", err)
|
||||
}
|
||||
@@ -299,7 +320,6 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
|
||||
password_hash, user_downloads, share_settings, version`
|
||||
|
||||
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(shares)`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read shares schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
colType string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, fmt.Errorf("scan shares schema: %w", err)
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("iterate shares schema: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
|
||||
// scan shares.token into a Go string (NULL is unsupported there).
|
||||
func normalizeLegacyShareTokens(db *sql.DB) error {
|
||||
hasToken, err := sharesTableHasColumn(db, "token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasToken {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
|
||||
return fmt.Errorf("normalize legacy share tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
|
||||
return sharesTableHasColumn(s.db, "token")
|
||||
}
|
||||
|
||||
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
|
||||
hasToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasToken {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var token sql.NullString
|
||||
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read legacy share token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return token.String, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
)
|
||||
|
||||
func createLegacySharesDB(t *testing.T) *SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
|
||||
store, _, err := NewSQLStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLStore: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.db.Exec(`DROP TABLE shares`)
|
||||
if err != nil {
|
||||
t.Fatalf("drop shares: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
CREATE TABLE shares (
|
||||
hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy shares table: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO shares (
|
||||
hash, user_id, source, path, expire, downloads, password_hash, token,
|
||||
user_downloads, share_settings, version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"legacy-null-token",
|
||||
"1",
|
||||
"/data",
|
||||
"/docs/file.txt",
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
nil,
|
||||
"{}",
|
||||
`{"shareType":"normal"}`,
|
||||
1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert legacy share: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
|
||||
t.Helper()
|
||||
var token string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`, hash).Scan(
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(int64),
|
||||
new(int),
|
||||
new(string),
|
||||
&token,
|
||||
new([]byte),
|
||||
new([]byte),
|
||||
new(int),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scan legacy share row: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
|
||||
if err := runMigrations(store.db, 1); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string after migration", token)
|
||||
}
|
||||
|
||||
links, err := store.ListAllShares()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllShares: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
|
||||
t.Fatalf("unexpected shares loaded: %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("seed legacy token: %v", err)
|
||||
}
|
||||
|
||||
link, err := store.GetShareByHash("legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetShareByHash: %v", err)
|
||||
}
|
||||
link.Downloads = 2
|
||||
|
||||
if err := store.SaveShare(link); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "keep-me" {
|
||||
t.Fatalf("token = %q, want keep-me", token)
|
||||
}
|
||||
|
||||
var tokenSQL sql.NullString
|
||||
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
|
||||
t.Fatalf("select token: %v", err)
|
||||
}
|
||||
if !tokenSQL.Valid {
|
||||
t.Fatal("expected legacy token column to remain non-null after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
newShare := &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "brand-new-share",
|
||||
Path: "/new/file.txt",
|
||||
},
|
||||
SourcePath: "/data",
|
||||
UserID: 1,
|
||||
Version: 1,
|
||||
}
|
||||
if err := store.SaveShare(newShare); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "brand-new-share")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string for new share", token)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
|
||||
func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
@@ -127,8 +127,8 @@ func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
|
||||
t.Fatalf("expected stale include link kept, got %d links: %#v", count, loaded.SidebarLinks)
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 1 {
|
||||
t.Fatalf("expected revoked include link pruned, got %d links: %#v", count, loaded.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
|
||||
}
|
||||
}
|
||||
link.SourceName = source.Name
|
||||
if link.Name == "" {
|
||||
link.Name = source.Name
|
||||
}
|
||||
} else if link.Category == "tool" && link.Target == "/tools" {
|
||||
hasTools = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestFrontendLinks_populatesEmptySourceName(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "My Vault", Category: string(users.SidebarLinkSourceMinimal), Icon: "folder", SourceName: "../frontend/tests/playwright-files", Target: "/docs"},
|
||||
}
|
||||
|
||||
out := FrontendLinks(in, false)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" || out[0].SourceName != "docker" {
|
||||
t.Fatalf("default source link = %#v", out[0])
|
||||
}
|
||||
if out[1].Name != "My Vault" || out[1].SourceName != "playwright + files" {
|
||||
t.Fatalf("custom source link = %#v", out[1])
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
|
||||
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
|
||||
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
|
||||
// Empty Name means "use live source display name"; names matching the current source display name
|
||||
// are blanked so renames propagate without overwriting custom labels.
|
||||
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
|
||||
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
|
||||
// pass through; duplicate Tools links are deduped.
|
||||
@@ -36,8 +38,8 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
normalized := link
|
||||
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
|
||||
normalized.SourceName = source.Path
|
||||
if strings.TrimSpace(normalized.Name) == "" {
|
||||
normalized.Name = source.Name
|
||||
if strings.TrimSpace(normalized.Name) == source.Name {
|
||||
normalized.Name = ""
|
||||
}
|
||||
normalized.Target = normalizeSidebarTarget(normalized.Target)
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ func TestNormalizeSidebarLinks_dedupesLinuxAndDockerPaths(t *testing.T) {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
want := []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
{Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Category: "source", SourceName: ".", Target: "/"},
|
||||
{Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("got %#v, want %#v", out, want)
|
||||
@@ -95,8 +95,24 @@ func TestNormalizeSidebarLinks_nameFallbackRemapsStalePath(t *testing.T) {
|
||||
if out[0].SourceName != "../frontend/tests/playwright-files" {
|
||||
t.Fatalf("SourceName = %q", out[0].SourceName)
|
||||
}
|
||||
if out[0].Name != "playwright + files" {
|
||||
t.Fatalf("Name = %q", out[0].Name)
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("Name = %q, want blanked default name", out[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_blanksDefaultSourceName(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("Name = %q, want blanked when matching source display name", out[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +126,20 @@ func TestNormalizeSidebarLinks_idempotentOnCanonicalLinks(t *testing.T) {
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false for canonical links")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when blanking default names")
|
||||
}
|
||||
if !reflect.DeepEqual(out, in) {
|
||||
t.Fatalf("got %#v, want %#v", out, in)
|
||||
for _, link := range out {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
|
||||
out2, changed2 := NormalizeSidebarLinks(out)
|
||||
if changed2 {
|
||||
t.Fatal("expected second pass unchanged")
|
||||
}
|
||||
if !reflect.DeepEqual(out2, in) {
|
||||
if !reflect.DeepEqual(out2, out) {
|
||||
t.Fatalf("second pass got %#v", out2)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +159,8 @@ func TestNormalizeSidebarLinks_dropsUnresolvableSourceLink(t *testing.T) {
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" {
|
||||
t.Fatalf("remaining link = %#v", out[0])
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("remaining link should have blanked default name: %#v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +193,15 @@ func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *t
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected canonical folder shortcuts unchanged")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when blanking default root source name")
|
||||
}
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("len(out) = %d, want 4", len(out))
|
||||
}
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("root source link should have blank default name: %#v", out[0])
|
||||
}
|
||||
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
|
||||
t.Fatalf("photos link = %#v", out[1])
|
||||
}
|
||||
@@ -204,8 +225,8 @@ func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" {
|
||||
t.Fatalf("first link wins: %#v", out[0])
|
||||
if out[0].Name != "" {
|
||||
t.Fatalf("first link wins with blanked default name: %#v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
|
||||
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
|
||||
// PrepareSidebarLinksForPersist normalizes sidebar links, prunes links for revoked scopes,
|
||||
// and adds missing scoped source entries. Sources absent from config are dropped by NormalizeSidebarLinks.
|
||||
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
updated := false
|
||||
|
||||
@@ -14,6 +14,11 @@ func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.Bac
|
||||
updated = true
|
||||
}
|
||||
|
||||
if pruned, changed := PruneSidebarLinksForScopes(links, scopes); changed {
|
||||
links = pruned
|
||||
updated = true
|
||||
}
|
||||
|
||||
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
|
||||
if changed {
|
||||
links = merged
|
||||
|
||||
@@ -28,12 +28,12 @@ func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *t
|
||||
if out[1].Name != "Photos" || out[1].Icon != "photo" {
|
||||
t.Fatalf("folder shortcut lost: %#v", out[1])
|
||||
}
|
||||
if out[2].Name != "playwright + files" {
|
||||
t.Fatalf("added scope link = %#v", out[2])
|
||||
if out[2].Name != "" {
|
||||
t.Fatalf("added scope link should have empty default name, got %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T) {
|
||||
func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
@@ -46,10 +46,10 @@ func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T)
|
||||
}
|
||||
|
||||
out, changed := PrepareSidebarLinksForPersist(links, scopes)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false when only pruning is not performed")
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true when revoked scope link is pruned")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2 (revoked scope link removed)", len(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// PruneSidebarLinksForScopes removes source-type links whose source is not in the user's scopes.
|
||||
// Non-source links (tool, custom, divider) are preserved.
|
||||
func PruneSidebarLinksForScopes(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
if !users.SourceConfigLoaded() || len(scopes) == 0 {
|
||||
return links, false
|
||||
}
|
||||
|
||||
allowed := make(map[string]struct{}, len(scopes))
|
||||
for _, path := range uniqueScopedSourcePaths(scopes) {
|
||||
allowed[path] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]users.SidebarLink, 0, len(links))
|
||||
changed := false
|
||||
for _, link := range links {
|
||||
if users.IsSourceSidebarCategory(link.Category) {
|
||||
source, ok := resolveSourceLink(link)
|
||||
if !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[source.Path]; !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
|
||||
return out, changed
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestPruneSidebarLinksForScopes_removesRevokedSourceAndFolderShortcut(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "Docs", Category: string(users.SidebarLinkCustom), Target: "https://example.com", Icon: "link"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
}
|
||||
|
||||
out, changed := PruneSidebarLinksForScopes(links, scopes)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (playwright link removed, docker + folder + custom kept)", len(out))
|
||||
}
|
||||
if out[2].Category != string(users.SidebarLinkCustom) {
|
||||
t.Fatalf("custom link lost: %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSidebarLinksForScopes_noChangeWhenAllScoped(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
}
|
||||
|
||||
out, changed := PruneSidebarLinksForScopes(links, scopes)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ func EnsureSidebarLinksFromScopes(links []users.SidebarLink, scopes []users.Back
|
||||
continue
|
||||
}
|
||||
out = append(out, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: string(users.SidebarLinkSource),
|
||||
Target: "/",
|
||||
SourceName: source.Path,
|
||||
|
||||
@@ -67,8 +67,8 @@ func TestEnsureSidebarLinksFromScopes_addsMissingScopedSources(t *testing.T) {
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
if out[2].Name != "playwright + files" {
|
||||
t.Fatalf("added link = %#v", out[2])
|
||||
if out[2].Name != "" {
|
||||
t.Fatalf("added link should have empty default name, got %#v", out[2])
|
||||
}
|
||||
if out[1].Category != string(users.SidebarLinkCustom) {
|
||||
t.Fatalf("custom link lost: %#v", out[1])
|
||||
|
||||
@@ -14,4 +14,5 @@ var (
|
||||
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
|
||||
ViewGrantsCache = cache.NewCache[ViewGrant](15 * time.Minute)
|
||||
ViewGrantIndex = cache.NewCache[string](15 * time.Minute)
|
||||
ShareAccessGrantsCache = cache.NewCache[ShareAccessGrant](24 * time.Hour)
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -56,6 +58,37 @@ func HashSHA256(data string) string {
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
// SignHMACSHA256Base64URL returns base64url(payload).base64url(hmac-sha256 signature).
|
||||
func SignHMACSHA256Base64URL(key, payload []byte) string {
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(payloadB64))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return payloadB64 + "." + signature
|
||||
}
|
||||
|
||||
// VerifyHMACSHA256Base64URL validates token form and returns the decoded payload.
|
||||
func VerifyHMACSHA256Base64URL(key []byte, token string) ([]byte, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
payloadB64, signature := parts[0], parts[1]
|
||||
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(payloadB64))
|
||||
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// RandomUint64ID returns a non-zero cryptographically random uint64 (e.g. new user rows after migration).
|
||||
// Small ids 1, 2, 3, … remain valid for migrated Bolt-era users.
|
||||
func RandomUint64ID() (uint64, error) {
|
||||
|
||||
@@ -6,3 +6,8 @@ type ViewGrant struct {
|
||||
Source string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
// ShareAccessGrant tracks remaining uses for count-limited share download tokens.
|
||||
type ShareAccessGrant struct {
|
||||
RemainingUses int
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -367,40 +365,23 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
// AuthenticateShareRequest validates access to a password-protected share.
|
||||
// UI sessions are minted only after successful X-SHARE-PASSWORD auth, never from download tokens.
|
||||
func AuthenticateShareRequest(w http.ResponseWriter, r *http.Request, l share.Share) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
if validateShareUISessionCookie(r, l.Hash) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
tokenParam := r.URL.Query().Get("token")
|
||||
if tokenParam != "" {
|
||||
// Verify the token signature if it's in the new signed format
|
||||
if strings.Contains(tokenParam, ".") {
|
||||
parts := strings.Split(tokenParam, ".")
|
||||
if len(parts) == 2 {
|
||||
payload := parts[0]
|
||||
signature := parts[1]
|
||||
|
||||
// Verify HMAC signature
|
||||
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
|
||||
mac.Write([]byte(payload))
|
||||
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
// Token signature is valid, now check if it matches stored token
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy token format (plain base64) - direct comparison
|
||||
if tokenParam == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
if shareRequestAllowsDownloadToken(r) && authorizeShareDownloadAccessToken(tokenParam, l.Hash) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
logger.Debugf("share auth failed: hash=%s reason=invalid_token", l.Hash)
|
||||
logger.Debugf("share auth failed: hash=%s reason=invalid_or_disallowed_token", l.Hash)
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
@@ -408,17 +389,80 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
logger.Debugf("share auth failed: hash=%s reason=missing_password", l.Hash)
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
logger.Debugf("share auth failed: hash=%s reason=wrong_password", l.Hash)
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
return 401, err
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
return 200, nil
|
||||
if w != nil {
|
||||
if cookieErr := SetShareUISessionCookie(w, r, l.Hash); cookieErr != nil {
|
||||
logger.Debugf("share session cookie: hash=%s err=%v", l.Hash, cookieErr)
|
||||
}
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
const sessionCookieName = "filebrowser_quantum_jwt"
|
||||
const shareUISessionCookieName = "filebrowser_share_session"
|
||||
|
||||
// validateShareUISessionCookie checks the HttpOnly share UI session cookie for a hash.
|
||||
func validateShareUISessionCookie(r *http.Request, shareHash string) bool {
|
||||
if r == nil || shareHash == "" {
|
||||
return false
|
||||
}
|
||||
cookie, err := r.Cookie(shareUISessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
return validateShareUISessionToken(cookie.Value, shareHash)
|
||||
}
|
||||
|
||||
// SetShareUISessionCookie stores a short-lived session after successful X-SHARE-PASSWORD auth.
|
||||
// Enables native browser streaming downloads without putting tokens in download URLs.
|
||||
func SetShareUISessionCookie(w http.ResponseWriter, r *http.Request, shareHash string) error {
|
||||
token, expiresAt, err := mintShareUISessionToken(shareHash, maxShareUISessionTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresTime := time.Unix(expiresAt, 0)
|
||||
http.SetCookie(w, shareUISessionCookie(r, token, expiresTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxShareUISessionTTL = 24 * time.Hour
|
||||
|
||||
// shareUISessionCookiePath scopes the share session cookie to the configured HTTP base URL path.
|
||||
func shareUISessionCookiePath() string {
|
||||
base := settings.Config.Http.BaseURL
|
||||
if base == "" {
|
||||
return "/"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// shareUISessionCookie builds the HttpOnly share UI session cookie for a validated password entry.
|
||||
func shareUISessionCookie(r *http.Request, token string, expiresTime time.Time) *http.Cookie {
|
||||
maxAge := int(time.Until(expiresTime).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: shareUISessionCookieName,
|
||||
Value: token,
|
||||
Path: shareUISessionCookiePath(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
Secure: requestScheme(r) == "https",
|
||||
Expires: expiresTime,
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
|
||||
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
|
||||
|
||||
@@ -83,7 +83,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
// Authenticate the share request if needed
|
||||
var status int
|
||||
if link.Hash != "" {
|
||||
status, err = AuthenticateShareRequest(r, link)
|
||||
status, err = AuthenticateShareRequest(w, r, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return status, fmt.Errorf("could not authenticate share request")
|
||||
}
|
||||
@@ -149,7 +149,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return ErrToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
}
|
||||
file.Token = link.Token
|
||||
file.Source = link.Hash
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
|
||||
|
||||
@@ -322,6 +322,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
|
||||
const accentedPasswordBcrypt = "$2b$04$JaVUkztbmWKgV8GsSvRorettpNHrc0BvYV3/oARpepfoanKbqt5zi"
|
||||
|
||||
// Create and save a dummy user (shares reference owner by UserID)
|
||||
dummyUser := &users.User{
|
||||
@@ -363,7 +364,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
},
|
||||
{
|
||||
name: "Private share, valid password when token exists",
|
||||
name: "Private share, valid password",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
@@ -372,7 +373,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "some_random_token",
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "password",
|
||||
@@ -389,12 +389,11 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, valid token",
|
||||
name: "Private share, download token rejected on listing route",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
@@ -403,10 +402,9 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
token: "123",
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
token: "download-token-placeholder",
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, invalid password",
|
||||
@@ -418,30 +416,49 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: passwordBcrypt,
|
||||
Token: "123",
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "wrong-password",
|
||||
},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Private share, valid encoded non-ASCII password",
|
||||
share: &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
ShareLimits: share.ShareLimits{SourceName: "srv"},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{Hash: "accented_password_hash", Path: "/"},
|
||||
SourcePath: "/srv",
|
||||
UserID: 1,
|
||||
PasswordHash: accentedPasswordBcrypt,
|
||||
},
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "pass%C3%A9",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Save the share in the state
|
||||
if err := state.CreateShare(tc.share); err != nil {
|
||||
t.Fatal("failed to save share:", err)
|
||||
}
|
||||
|
||||
// Create a response recorder to capture handler output
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Wrap the handler with authentication middleware
|
||||
handler := withHashFile(publicGetResourceHandler)
|
||||
|
||||
// Prepare the request with query parameters and optional headers
|
||||
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
|
||||
token := tc.token
|
||||
if tc.name == "Private share, download token rejected on listing route" {
|
||||
minted, _, err := mintShareDownloadAccessToken(tc.share.Hash, time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
token = minted
|
||||
}
|
||||
|
||||
req := newTestRequest(t, tc.share.Hash, token, tc.password, tc.extraHeaders)
|
||||
|
||||
// Serve the request
|
||||
handler(recorder, req)
|
||||
|
||||
@@ -298,13 +298,17 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
|
||||
return base + "/" + path
|
||||
}
|
||||
|
||||
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
|
||||
// Only password-protected shares have a token; callers must already have passed share auth.
|
||||
// shareTokenForOnlyOffice mints a short-lived download token for OnlyOffice server URLs.
|
||||
func shareTokenForOnlyOffice(d *Context) string {
|
||||
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
|
||||
if d.Share.Hash == "" || d.Share.PasswordHash == "" {
|
||||
return ""
|
||||
}
|
||||
return d.Share.Token
|
||||
token, _, err := mintShareDownloadAccessToken(d.Share.Hash, 2*time.Hour, 0)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to mint OnlyOffice share download token: hash=%s error=%v", d.Share.Hash, err)
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
|
||||
|
||||
@@ -83,7 +83,7 @@ func requestPageFullURL(r *http.Request) string {
|
||||
}
|
||||
|
||||
// ShareURLFromRequest builds a public share or direct-download URL from the request and server config.
|
||||
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, token string) string {
|
||||
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, hasPassword bool) string {
|
||||
host, scheme := shareURLParams(r)
|
||||
return share.PublicShareURL(host, scheme, hash, isDirectDownload, token)
|
||||
return share.PublicShareURL(host, scheme, hash, isDirectDownload, hasPassword)
|
||||
}
|
||||
+84
-168
@@ -1,9 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -260,19 +258,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return status, err2
|
||||
}
|
||||
stringHash := ""
|
||||
var token string
|
||||
if len(hash) > 0 {
|
||||
payloadBuffer := make([]byte, 24)
|
||||
if _, err = rand.Read(payloadBuffer); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
|
||||
mac.Write([]byte(payload))
|
||||
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
token = payload + "." + signature
|
||||
stringHash = string(hash)
|
||||
}
|
||||
if req.Hash != "" {
|
||||
@@ -295,7 +281,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
shouldResetCounts := link.DownloadsLimit != req.DownloadsLimit ||
|
||||
link.PerUserDownloadLimit != req.PerUserDownloadLimit
|
||||
|
||||
if err = applySharePasswordUpdate(link, req.Password, stringHash, token); err != nil {
|
||||
if err = applySharePasswordUpdate(link, req.Password, stringHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -392,7 +378,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
SourcePath: source.Path,
|
||||
UserID: d.User.ID,
|
||||
PasswordHash: stringHash,
|
||||
Token: token,
|
||||
Version: 1,
|
||||
}
|
||||
s.DownloadURL = ""
|
||||
@@ -421,174 +406,107 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return RenderJSON(w, r, prepared)
|
||||
}
|
||||
|
||||
// DirectDownloadResponse represents the response for direct download endpoint
|
||||
// DirectDownloadResponse represents the response for direct download endpoint.
|
||||
type DirectDownloadResponse struct {
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
DownloadURL string `json:"url"`
|
||||
ShareURL string `json:"shareUrl"`
|
||||
Hash string `json:"hash"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// shareDirectDownloadHandler creates a direct download link for files only.
|
||||
// @Summary Create direct download link
|
||||
// @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.
|
||||
// parseShareDownloadTokenParams reads duration, unit, and optional use-count from the direct-download API query.
|
||||
func parseShareDownloadTokenParams(r *http.Request) (time.Duration, int, error) {
|
||||
durationStr := r.URL.Query().Get("duration")
|
||||
if durationStr == "" {
|
||||
durationStr = "60"
|
||||
}
|
||||
durationNum, err := strconv.Atoi(durationStr)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
unit := r.URL.Query().Get("unit")
|
||||
if unit == "" {
|
||||
unit = "minutes"
|
||||
}
|
||||
ttl, err := parseShareDownloadAccessDuration(durationNum, unit)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
maxUses := 0
|
||||
if countStr := r.URL.Query().Get("count"); countStr != "" {
|
||||
maxUses, err = strconv.Atoi(countStr)
|
||||
if err != nil || maxUses <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid count: %v", err)
|
||||
}
|
||||
}
|
||||
return ttl, maxUses, nil
|
||||
}
|
||||
|
||||
// mintShareDownloadTokenResponse builds the JSON payload for GET /api/share/direct.
|
||||
func mintShareDownloadTokenResponse(r *http.Request, hash string, ttl time.Duration, maxUses int) (DirectDownloadResponse, error) {
|
||||
token, expiresAt, err := mintShareDownloadAccessToken(hash, ttl, maxUses)
|
||||
if err != nil {
|
||||
return DirectDownloadResponse{}, err
|
||||
}
|
||||
host, scheme := shareURLParams(r)
|
||||
return DirectDownloadResponse{
|
||||
Hash: hash,
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
URL: directDownloadURL(host, scheme, hash, token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// shareDirectDownloadHandler mints a download-only ephemeral token for a password-protected share.
|
||||
// @Summary Get direct download link
|
||||
// @Description Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "File path to create download link for"
|
||||
// @Param source query string true "Source name for the file"
|
||||
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
|
||||
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
|
||||
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
|
||||
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
|
||||
// @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"
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param duration query string false "Duration value (default: 60)"
|
||||
// @Param unit query string false "Duration unit: minutes or hours (default: minutes)"
|
||||
// @Param count query string false "Maximum number of downloads allowed with this token (default: unlimited within TTL)"
|
||||
// @Param X-SHARE-PASSWORD header string true "Share password"
|
||||
// @Success 200 {object} DirectDownloadResponse "Ephemeral download link"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Invalid or missing share password"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Share not found"
|
||||
// @Router /api/share/direct [get]
|
||||
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
// Extract query parameters
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
duration := r.URL.Query().Get("duration")
|
||||
downloadCountStr := r.URL.Query().Get("count")
|
||||
downloadSpeedStr := r.URL.Query().Get("speed")
|
||||
|
||||
// Validate required parameters
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("hash is required")
|
||||
}
|
||||
|
||||
cleanPath, err := utils.SanitizePath(path)
|
||||
link, err := state.GetShare(hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
|
||||
return http.StatusNotFound, fmt.Errorf("share not found")
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Validate source exists
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
|
||||
if !link.HasPassword() {
|
||||
return http.StatusBadRequest, fmt.Errorf("share is not password protected")
|
||||
}
|
||||
if !link.UserCanEdit(d.User) {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to mint tokens for this share")
|
||||
}
|
||||
|
||||
// Get user scope for this source
|
||||
userscope, err := d.User.GetScopeForSourceName(source)
|
||||
status, err := AuthenticateShareRequest(w, r, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid share password")
|
||||
}
|
||||
|
||||
ttl, maxUses, err := parseShareDownloadTokenParams(r)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Validate the path exists and is a file (not a folder)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
|
||||
}
|
||||
|
||||
metadata, exists := idx.GetReducedMetadata(path, false)
|
||||
if !exists {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
|
||||
}
|
||||
|
||||
// Check if it's a file (not a directory)
|
||||
if metadata.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
|
||||
}
|
||||
|
||||
// Set default duration to 60 minutes if not provided
|
||||
if duration == "" {
|
||||
duration = "60"
|
||||
}
|
||||
|
||||
// Parse download count
|
||||
var downloadCount int
|
||||
if downloadCountStr != "" {
|
||||
downloadCount, err = strconv.Atoi(downloadCountStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse download speed (in bytes per second)
|
||||
var downloadSpeed int
|
||||
if downloadSpeedStr != "" {
|
||||
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
durationNum, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
|
||||
|
||||
// Generate secure hash for the share
|
||||
secureHash, err := generateShortUUID()
|
||||
response, err := mintShareDownloadTokenResponse(r, hash, ttl, maxUses)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Create the scope path (file: no trailing slash; matches share cache normalization)
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
|
||||
// Check if an existing share already matches these parameters
|
||||
existingShares, err := state.GetSharesInScope(scopePath, sourceInfo.Path, d.User.ID)
|
||||
if err == nil && len(existingShares) > 0 {
|
||||
for _, existing := range existingShares {
|
||||
if existing.DownloadsLimit == downloadCount &&
|
||||
existing.MaxBandwidth == downloadSpeed &&
|
||||
existing.QuickDownload &&
|
||||
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
|
||||
|
||||
response := DirectDownloadResponse{
|
||||
Status: "201",
|
||||
Hash: existing.Hash,
|
||||
DownloadURL: ShareURLFromRequest(r, existing.Hash, true, existing.Token),
|
||||
ShareURL: ShareURLFromRequest(r, existing.Hash, false, existing.Token),
|
||||
}
|
||||
return RenderJSON(w, r, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching existing share found, create a new one
|
||||
shareLink := &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
FrontendShareInfo: share.FrontendShareInfo{
|
||||
QuickDownload: true,
|
||||
},
|
||||
ShareLimits: share.ShareLimits{
|
||||
MaxBandwidth: downloadSpeed,
|
||||
DownloadsLimit: downloadCount,
|
||||
SourceName: sourceInfo.Name,
|
||||
},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: secureHash,
|
||||
Path: scopePath,
|
||||
Expire: expire,
|
||||
},
|
||||
SourcePath: idx.Path,
|
||||
UserID: d.User.ID,
|
||||
Version: 1,
|
||||
}
|
||||
|
||||
if err = state.CreateShare(shareLink); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
snap, err := state.GetShare(secureHash)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
response := DirectDownloadResponse{
|
||||
Status: "200",
|
||||
Hash: secureHash,
|
||||
DownloadURL: ShareURLFromRequest(r, secureHash, true, snap.Token),
|
||||
ShareURL: ShareURLFromRequest(r, secureHash, false, snap.Token),
|
||||
}
|
||||
|
||||
activity.RecordShareMutation(r, toActor(d), activitydb.EventShareCreate, secureHash, snap.SourceName, snap.Path, nil)
|
||||
return RenderJSON(w, r, response)
|
||||
}
|
||||
|
||||
@@ -677,7 +595,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
frontendShareInfo := shareInfo.FrontendShareInfo
|
||||
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, "")
|
||||
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, shareInfo.HasPassword())
|
||||
frontendShareInfo.BannerUrl = shareInfo.BannerURL()
|
||||
frontendShareInfo.FaviconUrl = shareInfo.FaviconURL()
|
||||
filtered := make([]users.SidebarLink, 0, len(frontendShareInfo.SidebarLinks))
|
||||
@@ -822,17 +740,15 @@ func sharePasswordFromRequest(password *string) ([]byte, int, error) {
|
||||
|
||||
// applySharePasswordUpdate sets or preserves password credentials on share update.
|
||||
// Nil password omits the field (keep existing); empty string clears; non-empty replaces.
|
||||
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword, token string) error {
|
||||
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword string) error {
|
||||
if password == nil {
|
||||
return nil
|
||||
}
|
||||
if *password == "" {
|
||||
link.PasswordHash = ""
|
||||
link.Token = ""
|
||||
return nil
|
||||
}
|
||||
link.PasswordHash = hashedPassword
|
||||
link.Token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
shareAccessScopeDownload = "download"
|
||||
shareAccessScopeUISession = "ui"
|
||||
maxShareAccessTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// shareDownloadGrantMu serializes use-count decrements for limited download tokens.
|
||||
var shareDownloadGrantMu sync.Mutex
|
||||
|
||||
type shareAccessClaims struct {
|
||||
Hash string `json:"h"`
|
||||
Exp int64 `json:"exp"`
|
||||
Scope string `json:"s"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// parseShareAccessDuration converts API duration parameters into a time.Duration.
|
||||
func parseShareAccessDuration(duration int, unit string) (time.Duration, error) {
|
||||
if duration <= 0 {
|
||||
return 0, fmt.Errorf("duration must be positive")
|
||||
}
|
||||
switch unit {
|
||||
case "minutes", "minute", "":
|
||||
return time.Duration(duration) * time.Minute, nil
|
||||
case "hours", "hour":
|
||||
return time.Duration(duration) * time.Hour, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid unit: %s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// parseShareDownloadAccessDuration validates share direct-download TTL capped at 24 hours.
|
||||
func parseShareDownloadAccessDuration(duration int, unit string) (time.Duration, error) {
|
||||
ttl, err := parseShareAccessDuration(duration, unit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ttl > maxShareAccessTTL {
|
||||
return 0, fmt.Errorf("duration exceeds maximum of 24 hours")
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// shareAccessAuthKey returns the HMAC signing key for share access tokens.
|
||||
func shareAccessAuthKey() []byte {
|
||||
return []byte(settings.Config.Auth.Key)
|
||||
}
|
||||
|
||||
// mintSignedShareAccessToken signs share access claims as a base64url.payload.signature token.
|
||||
func mintSignedShareAccessToken(claims shareAccessClaims) (token string, expiresAt int64, err error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return utils.SignHMACSHA256Base64URL(shareAccessAuthKey(), payload), claims.Exp, nil
|
||||
}
|
||||
|
||||
// verifySignedShareAccessToken parses and verifies a signed share access token payload.
|
||||
func verifySignedShareAccessToken(tokenParam string) (*shareAccessClaims, bool) {
|
||||
payload, ok := utils.VerifyHMACSHA256Base64URL(shareAccessAuthKey(), tokenParam)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var claims shareAccessClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &claims, true
|
||||
}
|
||||
|
||||
// mintShareDownloadAccessToken creates a signed, download-scoped ephemeral token for a share.
|
||||
func mintShareDownloadAccessToken(shareHash string, ttl time.Duration, maxUses int) (token string, expiresAt int64, err error) {
|
||||
if shareHash == "" {
|
||||
return "", 0, fmt.Errorf("share hash is required")
|
||||
}
|
||||
if ttl <= 0 || ttl > maxShareAccessTTL {
|
||||
return "", 0, fmt.Errorf("invalid token duration")
|
||||
}
|
||||
if maxUses < 0 {
|
||||
return "", 0, fmt.Errorf("invalid max uses")
|
||||
}
|
||||
|
||||
expiresAt = time.Now().Add(ttl).Unix()
|
||||
claims := shareAccessClaims{
|
||||
Hash: shareHash,
|
||||
Exp: expiresAt,
|
||||
Scope: shareAccessScopeDownload,
|
||||
}
|
||||
|
||||
remainingUses := -1
|
||||
if maxUses > 0 {
|
||||
idBytes := make([]byte, 12)
|
||||
if _, err = rand.Read(idBytes); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
claims.ID = base64.RawURLEncoding.EncodeToString(idBytes)
|
||||
remainingUses = maxUses
|
||||
}
|
||||
|
||||
token, expiresAt, err = mintSignedShareAccessToken(claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if claims.ID != "" {
|
||||
utils.ShareAccessGrantsCache.Set(claims.ID, utils.ShareAccessGrant{RemainingUses: remainingUses})
|
||||
}
|
||||
|
||||
return token, expiresAt, nil
|
||||
}
|
||||
|
||||
// mintShareUISessionToken creates a signed cookie-scoped token for UI share access after password entry.
|
||||
func mintShareUISessionToken(shareHash string, ttl time.Duration) (token string, expiresAt int64, err error) {
|
||||
if shareHash == "" {
|
||||
return "", 0, fmt.Errorf("share hash is required")
|
||||
}
|
||||
if ttl <= 0 || ttl > maxShareAccessTTL {
|
||||
return "", 0, fmt.Errorf("invalid token duration")
|
||||
}
|
||||
|
||||
expiresAt = time.Now().Add(ttl).Unix()
|
||||
return mintSignedShareAccessToken(shareAccessClaims{
|
||||
Hash: shareHash,
|
||||
Exp: expiresAt,
|
||||
Scope: shareAccessScopeUISession,
|
||||
})
|
||||
}
|
||||
|
||||
// validateShareUISessionToken checks a UI session token for the given share hash.
|
||||
func validateShareUISessionToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeUISession || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() <= claims.Exp
|
||||
}
|
||||
|
||||
// validateShareDownloadAccessToken checks a download token without consuming a limited use.
|
||||
func validateShareDownloadAccessToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
if time.Now().Unix() > claims.Exp {
|
||||
return false
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return true
|
||||
}
|
||||
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
|
||||
if !ok || grant.RemainingUses <= 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeShareDownloadAccessToken validates a download token and atomically consumes one use when limited.
|
||||
func authorizeShareDownloadAccessToken(tokenParam, shareHash string) bool {
|
||||
claims, ok := verifySignedShareAccessToken(tokenParam)
|
||||
if !ok || claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
|
||||
return false
|
||||
}
|
||||
if time.Now().Unix() > claims.Exp {
|
||||
return false
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
shareDownloadGrantMu.Lock()
|
||||
defer shareDownloadGrantMu.Unlock()
|
||||
|
||||
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
|
||||
if !ok || grant.RemainingUses <= 0 {
|
||||
return false
|
||||
}
|
||||
grant.RemainingUses--
|
||||
if grant.RemainingUses <= 0 {
|
||||
utils.ShareAccessGrantsCache.Delete(claims.ID)
|
||||
} else {
|
||||
utils.ShareAccessGrantsCache.Set(claims.ID, grant)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// shareRouteAllowsDownloadToken reports whether an ephemeral download token may authorize this path.
|
||||
func shareRouteAllowsDownloadToken(path string) bool {
|
||||
return strings.Contains(path, "/resources/download") ||
|
||||
strings.Contains(path, "/resources/view") ||
|
||||
strings.Contains(path, "/media/stream") ||
|
||||
strings.Contains(path, "/raw")
|
||||
}
|
||||
|
||||
// shareRequestAllowsDownloadToken reports whether an ephemeral download token may authorize this request.
|
||||
func shareRequestAllowsDownloadToken(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return shareRouteAllowsDownloadToken(r.URL.Path)
|
||||
}
|
||||
|
||||
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
|
||||
func directDownloadURL(host, scheme, hash, token string) string {
|
||||
tokenParam := ""
|
||||
if token != "" {
|
||||
tokenParam = fmt.Sprintf("&token=%s", token)
|
||||
}
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMintAndValidateShareDownloadAccessToken(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, expiresAt, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
if token == "" || expiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
|
||||
}
|
||||
if !validateShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected valid token for matching hash")
|
||||
}
|
||||
if validateShareDownloadAccessToken(token, "other-hash") {
|
||||
t.Fatal("expected invalid token for different hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareDownloadTokenRejectedOnListingRoute(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=share123&token="+token, nil)
|
||||
if shareRequestAllowsDownloadToken(req) {
|
||||
t.Fatal("listing route should not allow download token auth")
|
||||
}
|
||||
|
||||
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=share123&token="+token, nil)
|
||||
if !shareRequestAllowsDownloadToken(downloadReq) {
|
||||
t.Fatal("download route should allow download token auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeShareDownloadAccessTokenUseCount(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
if !authorizeShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected first authorization to succeed")
|
||||
}
|
||||
if authorizeShareDownloadAccessToken(token, "share123") {
|
||||
t.Fatal("expected token to be invalid after single use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintAndValidateShareUISessionToken(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, expiresAt, err := mintShareUISessionToken("share123", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareUISessionToken: %v", err)
|
||||
}
|
||||
if token == "" || expiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
|
||||
}
|
||||
if !validateShareUISessionToken(token, "share123") {
|
||||
t.Fatal("expected valid UI session token for matching hash")
|
||||
}
|
||||
if validateShareUISessionToken(token, "other-hash") {
|
||||
t.Fatal("expected invalid UI session token for different hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeShareDownloadAccessTokenConcurrentSingleUse(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
token, _, mintErr := mintShareDownloadAccessToken("share123", time.Hour, 1)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
|
||||
}
|
||||
|
||||
allowed := make(chan bool, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
allowed <- authorizeShareDownloadAccessToken(token, "share123")
|
||||
}()
|
||||
}
|
||||
|
||||
successes := 0
|
||||
for i := 0; i < 2; i++ {
|
||||
if <-allowed {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly one concurrent authorization, got %d", successes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShareDownloadAccessDurationMax24Hours(t *testing.T) {
|
||||
if _, err := parseShareDownloadAccessDuration(25, "hours"); err == nil {
|
||||
t.Fatal("expected error for duration over 24 hours")
|
||||
}
|
||||
ttl, err := parseShareDownloadAccessDuration(2, "hours")
|
||||
if err != nil {
|
||||
t.Fatalf("parseShareDownloadAccessDuration: %v", err)
|
||||
}
|
||||
if ttl != 2*time.Hour {
|
||||
t.Fatalf("ttl = %v, want 2h", ttl)
|
||||
}
|
||||
}
|
||||
@@ -9,43 +9,40 @@ import (
|
||||
func TestApplySharePasswordUpdatePreservesWhenOmitted(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "hashed",
|
||||
Token: "tok",
|
||||
}
|
||||
|
||||
if err := applySharePasswordUpdate(link, nil, "", ""); err != nil {
|
||||
if err := applySharePasswordUpdate(link, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "hashed" || link.Token != "tok" {
|
||||
t.Fatalf("expected preserved secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "hashed" {
|
||||
t.Fatalf("expected preserved password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySharePasswordUpdateClearsWhenEmpty(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "hashed",
|
||||
Token: "tok",
|
||||
}
|
||||
empty := ""
|
||||
|
||||
if err := applySharePasswordUpdate(link, &empty, "", ""); err != nil {
|
||||
if err := applySharePasswordUpdate(link, &empty, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "" || link.Token != "" {
|
||||
t.Fatalf("expected cleared secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "" {
|
||||
t.Fatalf("expected cleared password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySharePasswordUpdateReplacesWhenProvided(t *testing.T) {
|
||||
link := &share.Share{
|
||||
PasswordHash: "old",
|
||||
Token: "oldtok",
|
||||
}
|
||||
next := "new-password"
|
||||
|
||||
if err := applySharePasswordUpdate(link, &next, "newhash", "newtok"); err != nil {
|
||||
if err := applySharePasswordUpdate(link, &next, "newhash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.PasswordHash != "newhash" || link.Token != "newtok" {
|
||||
t.Fatalf("expected replaced secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
|
||||
if link.PasswordHash != "newhash" {
|
||||
t.Fatalf("expected replaced password hash, got hash=%q", link.PasswordHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestAuthenticateShareRequestUISessionCookie(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt: %v", err)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "ui_session_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=ui_session_share", nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "secret")
|
||||
status, err := AuthenticateShareRequest(rec, req, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected password auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != shareUISessionCookieName {
|
||||
t.Fatalf("expected share session cookie, got %#v", cookies)
|
||||
}
|
||||
|
||||
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
|
||||
downloadReq.AddCookie(cookies[0])
|
||||
status, err = AuthenticateShareRequest(nil, downloadReq, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected cookie auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
token, _, mintErr := mintShareUISessionToken("other_share", time.Hour)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareUISessionToken: %v", mintErr)
|
||||
}
|
||||
wrongReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
|
||||
wrongReq.AddCookie(&http.Cookie{Name: shareUISessionCookieName, Value: token})
|
||||
status, err = AuthenticateShareRequest(nil, wrongReq, link)
|
||||
if err != nil || status == http.StatusOK {
|
||||
t.Fatalf("expected mismatched hash cookie to fail, status=%d err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateShareRequestDownloadTokenDoesNotMintUISession(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt: %v", err)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "dl_token_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
downloadToken, _, err := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "secret")
|
||||
status, err := AuthenticateShareRequest(rec, req, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, err)
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("download token auth must not mint a UI session cookie when password header is also present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateShareRequestDownloadTokenWrongPasswordNoUISession(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-auth-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
|
||||
if bcryptErr != nil {
|
||||
t.Fatalf("bcrypt: %v", bcryptErr)
|
||||
}
|
||||
link := share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "dl_wrong_pw_share"},
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
|
||||
downloadToken, _, mintErr := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
|
||||
if mintErr != nil {
|
||||
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
|
||||
req.Header.Set("X-SHARE-PASSWORD", "wrong-password")
|
||||
status, authErr := AuthenticateShareRequest(rec, req, link)
|
||||
if authErr != nil || status != http.StatusOK {
|
||||
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, authErr)
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("valid download token with wrong password header must not mint a UI session cookie")
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,7 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
|
||||
u.BackendScopes = MergeDefaultEnabledBackendScopes(u.BackendScopes)
|
||||
if len(u.SidebarLinks) == 0 && len(u.BackendScopes) > 0 {
|
||||
scope := u.BackendScopes[0]
|
||||
name := scope.Path
|
||||
if src := Config.Server.SourceMap[scope.Path]; src != nil && src.Name != "" {
|
||||
name = src.Name
|
||||
}
|
||||
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
|
||||
Name: name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
|
||||
@@ -3088,7 +3088,7 @@ 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.",
|
||||
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3098,50 +3098,59 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Create direct download link",
|
||||
"summary": "Get direct download link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File path to create download link for",
|
||||
"name": "path",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"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)",
|
||||
"description": "Duration value (default: 60)",
|
||||
"name": "duration",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed (default: unlimited)",
|
||||
"description": "Duration unit: minutes or hours (default: minutes)",
|
||||
"name": "unit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
|
||||
"name": "count",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Download speed limit in kbps (default: unlimited)",
|
||||
"name": "speed",
|
||||
"in": "query"
|
||||
"description": "Share password",
|
||||
"name": "X-SHARE-PASSWORD",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Direct download link created",
|
||||
"200": {
|
||||
"description": "Ephemeral download link",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.DirectDownloadResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid parameters or path is not a file",
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or missing share password",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3150,7 +3159,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - access denied",
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3158,8 +3167,8 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"404": {
|
||||
"description": "Share not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -6830,9 +6839,6 @@ const docTemplate = `{
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"userDownloads": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -8014,13 +8020,13 @@ const docTemplate = `{
|
||||
"web.DirectDownloadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
|
||||
@@ -3077,7 +3077,7 @@
|
||||
},
|
||||
"/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.",
|
||||
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3087,50 +3087,59 @@
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Create direct download link",
|
||||
"summary": "Get direct download link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File path to create download link for",
|
||||
"name": "path",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"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)",
|
||||
"description": "Duration value (default: 60)",
|
||||
"name": "duration",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed (default: unlimited)",
|
||||
"description": "Duration unit: minutes or hours (default: minutes)",
|
||||
"name": "unit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
|
||||
"name": "count",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Download speed limit in kbps (default: unlimited)",
|
||||
"name": "speed",
|
||||
"in": "query"
|
||||
"description": "Share password",
|
||||
"name": "X-SHARE-PASSWORD",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Direct download link created",
|
||||
"200": {
|
||||
"description": "Ephemeral download link",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.DirectDownloadResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - invalid parameters or path is not a file",
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or missing share password",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3139,7 +3148,7 @@
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - access denied",
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -3147,8 +3156,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"404": {
|
||||
"description": "Share not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -6819,9 +6828,6 @@
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"userDownloads": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -8003,13 +8009,13 @@
|
||||
"web.DirectDownloadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
|
||||
@@ -1280,8 +1280,6 @@ definitions:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
userDownloads:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
@@ -2115,11 +2113,11 @@ definitions:
|
||||
type: object
|
||||
web.DirectDownloadResponse:
|
||||
properties:
|
||||
expiresAt:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
shareUrl:
|
||||
type: string
|
||||
status:
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
@@ -4443,58 +4441,66 @@ paths:
|
||||
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.
|
||||
description: Mints a download-only ephemeral token for an existing password-protected
|
||||
share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD
|
||||
header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes.
|
||||
Maximum token lifetime is 24 hours regardless of unit.
|
||||
parameters:
|
||||
- description: File path to create download link for
|
||||
- description: Share hash
|
||||
in: query
|
||||
name: path
|
||||
name: hash
|
||||
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)'
|
||||
- description: 'Duration value (default: 60)'
|
||||
in: query
|
||||
name: duration
|
||||
type: string
|
||||
- description: 'Maximum number of downloads allowed (default: unlimited)'
|
||||
- description: 'Duration unit: minutes or hours (default: minutes)'
|
||||
in: query
|
||||
name: unit
|
||||
type: string
|
||||
- description: 'Maximum number of downloads allowed with this token (default:
|
||||
unlimited within TTL)'
|
||||
in: query
|
||||
name: count
|
||||
type: string
|
||||
- description: 'Download speed limit in kbps (default: unlimited)'
|
||||
in: query
|
||||
name: speed
|
||||
- description: Share password
|
||||
in: header
|
||||
name: X-SHARE-PASSWORD
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Direct download link created
|
||||
"200":
|
||||
description: Ephemeral download link
|
||||
schema:
|
||||
$ref: '#/definitions/web.DirectDownloadResponse'
|
||||
"400":
|
||||
description: Bad request - invalid parameters or path is not a file
|
||||
description: Bad request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Invalid or missing share password
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden - access denied
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
"404":
|
||||
description: Share not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Create direct download link
|
||||
summary: Get direct download link
|
||||
tags:
|
||||
- Shares
|
||||
/api/share/list:
|
||||
|
||||
@@ -19,11 +19,10 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
|
||||
const apiPath = getPublicApiPath('media/subtitles', {
|
||||
hash,
|
||||
...baseParams,
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
})
|
||||
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
|
||||
res = await fetchURL(apiPath, {
|
||||
headers: { 'X-SHARE-PASSWORD': sharePassword },
|
||||
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
|
||||
})
|
||||
} else {
|
||||
const apiPath = getApiPath('media/subtitles', {
|
||||
@@ -57,11 +56,10 @@ export async function getLyricsPublic(path, hash, password = "") {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/lyrics", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": password || "" },
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
@@ -98,11 +96,10 @@ export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "
|
||||
path,
|
||||
hash,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/metadata", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": password || "" },
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
@@ -179,7 +176,6 @@ export function getStreamURLPublic(share, files, viewToken) {
|
||||
const params = {
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { notify } from '@/notify'
|
||||
import { state } from '@/store'
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { fetchURL } from './utils'
|
||||
|
||||
@@ -10,14 +9,13 @@ export async function getConfig(req) {
|
||||
path: req.path,
|
||||
...(req.hash && { hash: req.hash }),
|
||||
...(req.source && { source: req.source }),
|
||||
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
|
||||
}
|
||||
|
||||
const headers = {}
|
||||
if (req.hash) {
|
||||
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
|
||||
if (sharePassword) {
|
||||
headers['X-SHARE-PASSWORD'] = sharePassword
|
||||
headers['X-SHARE-PASSWORD'] = encodeURIComponent(sharePassword)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ export async function signalUploadPause(source, path, shareHash) {
|
||||
const apiPath = getPublicApiPath('resources/pause', {
|
||||
hash: shareHash,
|
||||
path: path,
|
||||
...sharePublicAuthQuery(shareHash),
|
||||
})
|
||||
await fetchURL(apiPath, { method: 'POST', headers: sharePublicAuthHeaders(shareHash) })
|
||||
return
|
||||
@@ -242,7 +241,6 @@ export async function download(format, files, shareHash = "") {
|
||||
algo: format,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && source && { source: source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
|
||||
@@ -264,6 +262,17 @@ export async function download(format, files, shareHash = "") {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function shareDownloadFetchInit(method = "GET", extraHeaders = {}) {
|
||||
return {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
...sharePublicAuthHeaders(state.shareInfo?.hash || ""),
|
||||
...extraHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort text from a failed download response (consumes body). */
|
||||
async function readDownloadErrorBody(response) {
|
||||
try {
|
||||
@@ -295,7 +304,7 @@ function appendArchiveTokenToUrl(url, token) {
|
||||
/** @returns {Promise<{ size: number, token: string | null }>} */
|
||||
async function resolveDownloadContentLength(url) {
|
||||
let token = null
|
||||
let res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
|
||||
let res = await fetch(url, shareDownloadFetchInit('HEAD'))
|
||||
if (res.ok) {
|
||||
token = res.headers.get('X-Archive-Token')
|
||||
const cl = res.headers.get('Content-Length')
|
||||
@@ -306,11 +315,7 @@ async function resolveDownloadContentLength(url) {
|
||||
}
|
||||
}
|
||||
}
|
||||
res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Range: 'bytes=0-0' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
res = await fetch(url, shareDownloadFetchInit('GET', { Range: 'bytes=0-0' }))
|
||||
const cancelBody = () => {
|
||||
if (res.body && typeof res.body.cancel === 'function') {
|
||||
res.body.cancel().catch(() => {})
|
||||
@@ -395,10 +400,7 @@ async function chunkedRangeDownloadToBlob(
|
||||
const rangeHeader = `bytes=${offset}-${end}`
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
headers: {
|
||||
Range: rangeHeader,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
...shareDownloadFetchInit('GET', { Range: rangeHeader }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
@@ -610,7 +612,6 @@ async function downloadChunked(file, shareHash = "") {
|
||||
file: file.path,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && file.source && { source: file.source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
|
||||
@@ -983,7 +984,6 @@ export function getRawViewURLPublic(share, files, viewToken) {
|
||||
const params = {
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
viewToken: viewToken,
|
||||
sessionId: state.sessionId,
|
||||
}
|
||||
@@ -1126,19 +1126,12 @@ function getSharePasswordFromStorage(hash) {
|
||||
return localStorage.getItem(`sharepass:${hash}`) || "";
|
||||
}
|
||||
|
||||
function sharePublicAuthQuery(hash) {
|
||||
if (state.shareInfo?.hash === hash && state.shareInfo.token) {
|
||||
return { token: state.shareInfo.token };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function sharePublicAuthHeaders(hash) {
|
||||
const password = getSharePasswordFromStorage(hash);
|
||||
if (!password) {
|
||||
return {};
|
||||
}
|
||||
return { "X-SHARE-PASSWORD": password };
|
||||
return { "X-SHARE-PASSWORD": encodeURIComponent(password) };
|
||||
}
|
||||
|
||||
// Fetch public share data
|
||||
@@ -1157,13 +1150,12 @@ export async function fetchFilesPublic(path, hash, password = "", content = fals
|
||||
...(skipExtendedAttrs && { skipExtendedAttrs: 'true' }),
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources", params);
|
||||
const sharePassword = password || getSharePasswordFromStorage(hash);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: {
|
||||
"X-SHARE-PASSWORD": password || "",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
headers: sharePassword ? { "X-SHARE-PASSWORD": encodeURIComponent(sharePassword) } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1195,7 +1187,6 @@ export async function getItemsPublic(hash, path, only = "") {
|
||||
path: path,
|
||||
hash: hash,
|
||||
...(only && { only: only }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
})
|
||||
const response = await fetch(apiPath, { headers: sharePublicAuthHeaders(hash) })
|
||||
const data = await response.json()
|
||||
@@ -1208,16 +1199,15 @@ export async function getItemsPublic(hash, path, only = "") {
|
||||
|
||||
// Generate a download URL
|
||||
/**
|
||||
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
|
||||
* @param {{ path: string; hash: string; inline?: boolean }} share
|
||||
* @param {string[]} files - Array of file paths (will be converted to repeated 'file' parameters)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDownloadURLPublic(share, files, inline=false) {
|
||||
const fileArray = Array.isArray(files) ? files : [files]
|
||||
const params = {
|
||||
file: fileArray, // Array will be converted to repeated 'file' params by getPublicApiPath
|
||||
file: fileArray,
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
...(inline && { inline: 'true' })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/download", params)
|
||||
@@ -1230,14 +1220,14 @@ export function getDownloadURLPublic(share, files, inline=false) {
|
||||
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPreviewURLPublic(path, size) {
|
||||
export function getPreviewURLPublic(path, size, viewToken = state.req?.viewToken) {
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
hash: state.shareInfo.hash,
|
||||
inline: 'true',
|
||||
...(size && size !== 'small' && { size: size }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
...(viewToken && { viewToken }),
|
||||
}
|
||||
const apiPath = getPublicApiPath('resources/preview', params)
|
||||
return window.origin + apiPath
|
||||
@@ -1266,7 +1256,6 @@ export function postPublic(
|
||||
hash: hash,
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
});
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
@@ -1356,7 +1345,6 @@ async function resourceActionPublic(hash, path, method, content, token = "") {
|
||||
path,
|
||||
hash: hash,
|
||||
...(token && { token }),
|
||||
...sharePublicAuthQuery(hash),
|
||||
})
|
||||
const response = await fetch(apiPath, {
|
||||
method,
|
||||
@@ -1400,7 +1388,6 @@ export async function bulkDeletePublic(items) {
|
||||
|
||||
const params = {
|
||||
hash: hash,
|
||||
...sharePublicAuthQuery(hash),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/bulk", params)
|
||||
@@ -1471,7 +1458,7 @@ export async function moveCopyPublic(
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
|
||||
...sharePublicAuthHeaders(hash),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
@@ -901,7 +901,6 @@ export default {
|
||||
? resourcesApi.getOpenFileURL(source, path, {
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
})
|
||||
: resourcesApi.getOpenFileURL(source, path);
|
||||
window.open(openUrl, "_blank");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="showWrongCredentials" class="form-invalid">
|
||||
{{ $t("login.wrongCredentials") }}
|
||||
</div>
|
||||
<div v-if="infoText">
|
||||
<div v-if="infoText" class="password-info">
|
||||
<p>{{ infoText }}</p>
|
||||
</div>
|
||||
<div class="form-flex-group">
|
||||
@@ -85,6 +85,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-info {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.password-info p {
|
||||
margin: 0;
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
.wrong__password {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
|
||||
@@ -190,7 +190,7 @@ import { notify } from "@/notify";
|
||||
import { validateLogin } from "@/utils/auth";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { setObjectProperty } from '@/utils/object.js';
|
||||
import { getObjectProperty, setObjectProperty } from '@/utils/object.js';
|
||||
import {
|
||||
sectionsFromFlatUser,
|
||||
applySectionsToFlatUser,
|
||||
@@ -751,9 +751,9 @@ export default {
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
buildProfileSnapshot(user) {
|
||||
const snapshot = {};
|
||||
let snapshot = {};
|
||||
for (const field of PROFILE_SNAPSHOT_FIELDS) {
|
||||
snapshot[field] = user?.[field];
|
||||
snapshot = setObjectProperty(snapshot, field, getObjectProperty(user, field));
|
||||
}
|
||||
if (snapshot.preview && typeof snapshot.preview === "object") {
|
||||
snapshot.preview = { ...snapshot.preview };
|
||||
@@ -814,8 +814,8 @@ export default {
|
||||
const permissionFields = ["admin", "share", "api", "realtime"];
|
||||
for (const perm of permissionFields) {
|
||||
if (
|
||||
current.permissions[perm] !== orig.permissions[perm]
|
||||
&& !this.enforcedAccountPermissions[perm]
|
||||
getObjectProperty(current.permissions, perm) !== getObjectProperty(orig.permissions, perm)
|
||||
&& !getObjectProperty(this.enforcedAccountPermissions, perm)
|
||||
) {
|
||||
fields.push("permissions");
|
||||
break;
|
||||
@@ -826,7 +826,7 @@ export default {
|
||||
if (isFlatProfileFieldEnforced(this.enforcedPreferences, field)) {
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(current.profile?.[field]) !== JSON.stringify(orig.profile?.[field])) {
|
||||
if (JSON.stringify(getObjectProperty(current.profile, field)) !== JSON.stringify(getObjectProperty(orig.profile, field))) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<script>
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { getObjectProperty } from "@/utils/object.js";
|
||||
|
||||
export default {
|
||||
name: "UserDefaultsAccountSection",
|
||||
@@ -139,22 +140,22 @@ export default {
|
||||
if (this.isFieldLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!this.enforced[field];
|
||||
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforced, field);
|
||||
},
|
||||
isPermissionDisabled(field) {
|
||||
if (this.isPermissionLocked(field)) {
|
||||
return true;
|
||||
}
|
||||
return this.respectEnforcedPolicy && !!this.enforcedPermissions[field];
|
||||
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforcedPermissions, field);
|
||||
},
|
||||
fieldDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && this.enforced[field]) {
|
||||
if (this.respectEnforcedPolicy && getObjectProperty(this.enforced, field)) {
|
||||
return this.$t("profileSettings.enforcedByAdmin");
|
||||
}
|
||||
return this.configLockTooltipForField(field);
|
||||
},
|
||||
permissionDisabledTooltip(field) {
|
||||
if (this.respectEnforcedPolicy && this.enforcedPermissions[field]) {
|
||||
if (this.respectEnforcedPolicy && getObjectProperty(this.enforcedPermissions, field)) {
|
||||
return this.$t("profileSettings.enforcedByAdmin");
|
||||
}
|
||||
return this.configLockTooltipForPermission(field);
|
||||
|
||||
@@ -218,7 +218,7 @@ import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import IndexInfo from "@/components/files/IndexInfo.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { showShareDownloadPrompt } from "@/utils/download.js";
|
||||
import ShareInfo from "@/components/files/ShareInfo.vue";
|
||||
import FileTree from '@/components/files/FileTree.vue';
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
@@ -513,33 +513,13 @@ export default {
|
||||
mutations.closeTopPrompt();
|
||||
},
|
||||
goToDownload() {
|
||||
// Check if we're in a directory with multiple items
|
||||
const hasMultipleItems = state.req.items && state.req.items.length > 1;
|
||||
if (hasMultipleItems) {
|
||||
// Show format selector for directories with multiple items
|
||||
mutations.showPrompt({
|
||||
name: "download",
|
||||
confirm: (format) => {
|
||||
mutations.closeTopPrompt();
|
||||
const downloadLink = resourcesApi.getDownloadURLPublic({
|
||||
path: "/",
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
inline: false,
|
||||
}, [state.req.path]);
|
||||
window.open(`${downloadLink}&format=${format}`, "_blank");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Direct download for single files or directories
|
||||
const downloadLink = resourcesApi.getDownloadURLPublic({
|
||||
path: "/",
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
inline: false,
|
||||
}, [state.req.path]);
|
||||
window.open(downloadLink, "_blank");
|
||||
}
|
||||
showShareDownloadPrompt([{
|
||||
path: state.req?.path || "/",
|
||||
name: state.req?.name,
|
||||
type: state.req?.type,
|
||||
size: state.req?.size,
|
||||
isDir: state.req?.type === "directory",
|
||||
}]);
|
||||
},
|
||||
navigateTo(path, hash) {
|
||||
mutations.setPreviousHistoryItem({
|
||||
|
||||
@@ -71,8 +71,8 @@ i.spin {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
position: fixed;
|
||||
padding-top: var(--header-height);
|
||||
padding-left: calc(0.5em + var(--safe-area-left));
|
||||
padding-right: calc(0.5em + var(--safe-area-right));
|
||||
padding-left: var(--safe-area-left);
|
||||
padding-right: var(--safe-area-right);
|
||||
overflow: unset;
|
||||
top: 0;
|
||||
/* fill the visible viewport only; the html background paints the black-translucent safe areas */
|
||||
|
||||
@@ -1014,7 +1014,6 @@ export const mutations = {
|
||||
{
|
||||
path: item.path,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
@@ -1186,9 +1185,6 @@ export const mutations = {
|
||||
},
|
||||
setShareInfo: (shareInfo) => {
|
||||
const merged = { ...state.shareInfo, ...shareInfo };
|
||||
if (shareInfo.token === undefined && state.shareInfo.token) {
|
||||
merged.token = state.shareInfo.token;
|
||||
}
|
||||
if (shareInfo.passwordValid === undefined && state.shareInfo.passwordValid !== undefined) {
|
||||
merged.passwordValid = state.shareInfo.passwordValid;
|
||||
}
|
||||
|
||||
@@ -93,3 +93,30 @@ async function startDownload(config, files, hash = "", options = {}) {
|
||||
notify.showError(`Error downloading: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the download format prompt and start the in-app download when confirmed. */
|
||||
export function showShareDownloadPrompt(items) {
|
||||
if (items.length === 0) {
|
||||
notify.showError("No files selected");
|
||||
return;
|
||||
}
|
||||
if (typeof items[0] === "number") {
|
||||
items = items.map((i) => state.req.items.at(i));
|
||||
}
|
||||
|
||||
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0;
|
||||
const isMultiItemArchive =
|
||||
items.length > 1 || (items.length === 1 && items[0].isDir);
|
||||
const willUseChunkedArchive =
|
||||
downloadChunkSizeMb > 0 && isMultiItemArchive;
|
||||
|
||||
mutations.showPrompt({
|
||||
name: "download",
|
||||
confirm: (format) => {
|
||||
mutations.closeTopPrompt();
|
||||
void startDownload(format, items, state.shareInfo?.hash || "", {
|
||||
silentChunkedError: willUseChunkedArchive,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -119,7 +119,6 @@ export function buildPreviewResourceUrl(
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
resolvedPath,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Maps flat runtime user objects ↔ nested user-defaults / profile sections (matches backend ProfileFromUser).
|
||||
*/
|
||||
|
||||
import { getNestedProperty, getObjectProperty } from "@/utils/object.js";
|
||||
|
||||
function boolPtr(val, defaultValue = true) {
|
||||
if (val === undefined || val === null) {
|
||||
return defaultValue;
|
||||
@@ -210,11 +212,7 @@ function enforcedFlagAt(enforced, section, field) {
|
||||
if (!enforced || !section) {
|
||||
return false;
|
||||
}
|
||||
const sectionData = enforced[section];
|
||||
if (!sectionData || typeof sectionData !== "object") {
|
||||
return false;
|
||||
}
|
||||
return !!sectionData[field];
|
||||
return !!getNestedProperty(enforced, section, field);
|
||||
}
|
||||
|
||||
/** True when global user-default enforcement locks a flat profile PATCH field. */
|
||||
@@ -225,8 +223,8 @@ export function isFlatProfileFieldEnforced(enforced, flatField) {
|
||||
if (flatField === "preview" || flatField === "fileLoading") {
|
||||
return false;
|
||||
}
|
||||
const path = FLAT_PROFILE_FIELD_ENFORCED_PATHS[flatField];
|
||||
if (!path) {
|
||||
const path = getObjectProperty(FLAT_PROFILE_FIELD_ENFORCED_PATHS, flatField);
|
||||
if (!Array.isArray(path)) {
|
||||
return false;
|
||||
}
|
||||
return enforcedFlagAt(enforced, path[0], path[1]);
|
||||
|
||||
@@ -25,6 +25,7 @@ import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { isRichTextPreviewMimeType } from "@/utils/mimetype";
|
||||
import { invalidateDirMetadataCache } from "@/utils/metadataCache.js";
|
||||
import { showShareDownloadPrompt as openShareDownloadPrompt } from "@/utils/download.js";
|
||||
|
||||
function directoryListingHasMediaChildren(req) {
|
||||
return (
|
||||
@@ -43,7 +44,7 @@ async function fetchShareItemWithParent(sharePassword) {
|
||||
false
|
||||
);
|
||||
file.hash = state.shareInfo.hash;
|
||||
mutations.setShareData({ token: file.token, passwordValid: true });
|
||||
mutations.setShareData({ passwordValid: true });
|
||||
|
||||
if (file.type === "directory") {
|
||||
return file;
|
||||
@@ -76,7 +77,6 @@ async function fetchShareItemWithParent(sharePassword) {
|
||||
const results = await Promise.all(promises);
|
||||
file = results[0];
|
||||
file.hash = state.shareInfo.hash;
|
||||
mutations.setShareData({ token: results[0].token });
|
||||
if (shouldFetchParent && results[1]?.items) {
|
||||
file.parentDirItems = results[1].items;
|
||||
}
|
||||
@@ -424,6 +424,9 @@ export default {
|
||||
mutations.replaceRequest(file);
|
||||
document.title = `${globalVars.name} - ${this.$t("general.share")} - ${file.name}`;
|
||||
await this.patchMediaMetadataIfNeeded(file);
|
||||
if (this.$route.query.download === "true") {
|
||||
this.promptShareDownload(file);
|
||||
}
|
||||
}
|
||||
|
||||
// === FILES-SPECIFIC INITIALIZATION ===
|
||||
@@ -542,6 +545,26 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
resolveShareDownloadPath(file) {
|
||||
const subPath = state.shareInfo?.subPath;
|
||||
if (subPath && subPath !== "/") {
|
||||
return subPath;
|
||||
}
|
||||
if (file?.path && file.path !== "/") {
|
||||
return file.path;
|
||||
}
|
||||
return "/";
|
||||
},
|
||||
promptShareDownload(file) {
|
||||
const downloadPath = this.resolveShareDownloadPath(file);
|
||||
openShareDownloadPrompt([{
|
||||
path: downloadPath,
|
||||
name: file?.name,
|
||||
type: file?.type,
|
||||
size: file?.size,
|
||||
isDir: file?.type === "directory",
|
||||
}]);
|
||||
},
|
||||
keyEvent(event) {
|
||||
// F1!
|
||||
if (event.key === "F1" && !event.ctrlKey && !event.metaKey) {
|
||||
|
||||
@@ -95,6 +95,7 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: unset !important;
|
||||
padding-bottom: 2.5em;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
|
||||
@@ -175,7 +175,6 @@ export default defineComponent({
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
state.req.type || state.req.name,
|
||||
@@ -210,7 +209,6 @@ export default defineComponent({
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
state.req.type || state.req.name,
|
||||
|
||||
@@ -99,7 +99,6 @@ export default defineComponent({
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
state.req.type || state.req.name,
|
||||
|
||||
@@ -1452,6 +1452,7 @@ export default {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.add-padding {
|
||||
|
||||
@@ -183,7 +183,6 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
@@ -197,7 +196,7 @@ export default {
|
||||
if (this.pdfConvertable || getRawPreview || getHeicPreview) {
|
||||
if (getters.isShare()) {
|
||||
const previewPath = removeTrailingSlash(state.req.path);
|
||||
return resourcesApi.getPreviewURLPublic(previewPath, "original");
|
||||
return resourcesApi.getPreviewURLPublic(previewPath, "original", state.req.viewToken);
|
||||
}
|
||||
return (
|
||||
`${resourcesApi.getPreviewURL(
|
||||
@@ -215,7 +214,6 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
@@ -232,7 +230,6 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
[state.req.path],
|
||||
);
|
||||
@@ -247,7 +244,6 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -592,7 +588,6 @@ export default {
|
||||
{
|
||||
path: item.path,
|
||||
hash: state.shareInfo?.hash,
|
||||
token: state.shareInfo?.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
|
||||
@@ -379,7 +379,6 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
|
||||
@@ -2053,6 +2053,10 @@ export default {
|
||||
'.audio-side-panel .lyric-line, ' +
|
||||
'.audio-side-panel input[type="radio"], ' +
|
||||
'.audio-side-panel label[for^="tab-"], ' +
|
||||
'.audio-side-panel .mode-btn, ' +
|
||||
'.audio-side-panel .repeat-one-btn, ' +
|
||||
'.audio-side-panel .queue-item, ' +
|
||||
'.audio-side-panel .clear-queue-btn, ' +
|
||||
'.lyrics-mobile .lyric-line'
|
||||
);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user