Compare commits

...
11 Commits
62 changed files with 1613 additions and 546 deletions

No files matched your search

+15
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+36 -2
View File
@@ -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)
}
}
}
+15 -2
View File
@@ -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 := &copyFileRangeRejectingWriter{}
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)
}
}
+13 -11
View File
@@ -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)
}
}
-1
View File
@@ -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)
}
+55 -35
View File
@@ -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, &notnull, &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)
}
}
+3 -3
View File
@@ -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)
}
}
+3
View File
@@ -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])
}
}
+4 -2
View File
@@ -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)
+37 -16
View File
@@ -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])
}
}
+7 -2
View File
@@ -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
+7 -7
View File
@@ -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))
}
}
+37
View File
@@ -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))
}
}
-1
View File
@@ -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,
+2 -2
View File
@@ -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])
+1
View File
@@ -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)
)
+33
View File
@@ -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) {
+5
View File
@@ -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
}
+77 -33
View File
@@ -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).
+1 -2
View File
@@ -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 {
+31 -14
View File
@@ -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)
+8 -4
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
}
+239
View File
@@ -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)
}
+129
View File
@@ -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 -12
View File
@@ -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)
}
}
+120
View File
@@ -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")
}
}
-5
View File
@@ -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: "",
+35 -29
View File
@@ -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": {
+35 -29
View File
@@ -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": {
+33 -27
View File
@@ -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:
+3 -7
View File
@@ -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 -3
View File
@@ -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)
}
}
+23 -36
View File
@@ -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),
})
-1
View File
@@ -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");
+10 -1
View File
@@ -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;
+6 -6
View File
@@ -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);
+8 -28
View File
@@ -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({
+2 -2
View File
@@ -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 */
-4
View File
@@ -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;
}
+27
View File
@@ -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,
});
},
});
}
-1
View File
@@ -119,7 +119,6 @@ export function buildPreviewResourceUrl(
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
resolvedPath,
+5 -7
View File
@@ -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 -2
View File
@@ -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) {
+1
View File
@@ -95,6 +95,7 @@ export default {
align-items: center;
justify-content: center;
height: unset !important;
padding-bottom: 2.5em;
}
.tool-content {
-2
View File
@@ -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,
-1
View File
@@ -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,
+1
View File
@@ -1452,6 +1452,7 @@ export default {
height: 100%;
display: flex;
flex-direction: column;
padding-right: 0.5em;
}
.add-padding {
+1 -6
View File
@@ -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,
-1
View File
@@ -379,7 +379,6 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
typeHint,
+4
View File
@@ -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'
);
},