Compare commits

...
40 Commits
Author SHA1 Message Date
Graham Steffaniak 90896f02e2 Fix gallery (#1083) 2025-08-19 19:58:57 -05:00
f6531c5a98 Last changes (#1079)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Socramk <155040241+socramcaro2@users.noreply.github.com>
2025-08-19 17:57:10 -05:00
24e8bc0fec Merge translations (#1078)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Socramk <155040241+socramcaro2@users.noreply.github.com>
2025-08-19 16:24:12 -05:00
Graham Steffaniak f5b9860eaf included new changes for share (#1075) 2025-08-19 16:03:23 -05:00
Graham Steffaniak e8e4ddc659 More touchups 0.8.0 (#1073) 2025-08-18 15:13:04 -05:00
Graham Steffaniak 77e1e4fdde add deny all permission (#1069) 2025-08-17 10:12:38 -05:00
Graham Steffaniak 623bc9c5ac Update changes for share (#1066) 2025-08-16 12:47:49 -05:00
Graham SteffaniakandKurami d21bc1a336 updated with more share features requested (#1060)
Co-authored-by: Kurami <178341547+Kurami32@users.noreply.github.com>
2025-08-14 10:36:03 -05:00
Graham Steffaniak adec56a120 updated styling (#1047) 2025-08-11 16:18:23 -05:00
Graham Steffaniak 4f1797cced updated to finalize share listing view (#1046) 2025-08-05 14:21:05 -05:00
Graham Steffaniak a44215a086 updated styles and translations (#1042) 2025-08-04 15:27:41 -05:00
Graham Steffaniak a434f812e8 updated some changes (#1041) 2025-08-04 11:19:23 -05:00
Graham Steffaniak 732cb23ab6 updated 2025-07-31 14:12:41 -05:00
Graham Steffaniak 087ce77afd Merge branch 'main' into dev/v0.8.0 2025-07-30 21:22:52 -05:00
Graham Steffaniak d6d54440f9 updated to merge 2025-07-29 10:17:59 -05:00
Graham Steffaniak 89ea2d077b Merge branch 'main' into dev/v0.8.0 2025-07-29 10:15:29 -05:00
Graham Steffaniak 080256bcff updated with share refactor (#993) 2025-07-23 09:36:49 -05:00
Graham Steffaniak e08690b66d fixed context 2025-07-21 18:45:38 -05:00
Graham Steffaniak 961f5392ae Merge tag 'v0.7.16-beta' into dev/v0.8.0 2025-07-21 18:40:33 -05:00
Graham Steffaniak f95fa49cf6 updated with fixes for main issues (#991) 2025-07-21 14:52:37 -05:00
Graham Steffaniak 4e4d3063ba fix file loading div (#975) 2025-07-17 11:25:49 -05:00
Graham Steffaniak da4c5d3e13 updated merge to main 2025-07-11 16:07:24 -05:00
Graham Steffaniak 386f52cd6b Merge branch 'main' into dev/v0.8.0 2025-07-11 15:46:32 -05:00
Graham Steffaniak c2bca1d2ca More rules updates (#877) 2025-06-30 20:19:45 -05:00
Graham Steffaniak 987c53fe67 Merge branch 'dev/v0.8.0' of github.com:gtsteffaniak/filebrowser into dev/v0.8.0 2025-06-30 14:10:48 -05:00
Graham Steffaniak db79d3bfaa rebased 2025-06-30 14:10:34 -05:00
Graham Steffaniak 12fb2ff18f updated with permission controls 2025-06-30 14:05:53 -05:00
Graham Steffaniak 51bc524282 rebased 2025-06-30 14:04:41 -05:00
Graham Steffaniak 0d14851276 fix linting 2025-06-30 14:04:24 -05:00
Graham Steffaniak d18270ad94 updated with permission controls 2025-06-30 14:04:24 -05:00
Graham Steffaniak e7838ef5ab fix linting 2025-06-30 14:04:24 -05:00
Graham Steffaniak a4f531997c updated with permission controls 2025-06-30 14:04:24 -05:00
Graham Steffaniak 5fdf7b5fd6 Merge branch 'dev/v0.8.0' of github.com:gtsteffaniak/filebrowser into dev/v0.8.0 2025-06-25 19:17:07 -05:00
Graham Steffaniak a8743ed446 rebased 2025-06-25 19:15:57 -05:00
Graham Steffaniak 2355fa50ba fix linting 2025-06-25 19:15:10 -05:00
Graham Steffaniak c6ba7d0412 updated with permission controls 2025-06-25 19:15:09 -05:00
Graham Steffaniak f4a29bef22 fix linting 2025-06-23 18:16:23 -05:00
Graham Steffaniak 0af46f6517 updated with permission controls 2025-06-23 18:16:23 -05:00
Graham Steffaniak f0e3c1138f fix linting 2025-06-17 19:20:58 -05:00
Graham Steffaniak 6898786f2b updated with permission controls 2025-06-17 19:09:01 -05:00
197 changed files with 10151 additions and 3369 deletions

No files matched your search

+2
View File
@@ -73,6 +73,8 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-noauth
- name: oidc
dockerfile: ./_docker/Dockerfile.playwright-oidc
- name: sharing
dockerfile: ./_docker/Dockerfile.playwright-sharing
steps:
- name: Checkout
uses: actions/checkout@v4
+26
View File
@@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.8.0-beta
This is a major release, new features and changes could introduce breaking behavior. Here are the known potentially breaking changes:
- all public api and share url's get a `/public` prefix, making it easier to use with a reverse proxy. Any existing share link will still work but get redirected.
- a small change to styling you may need to update your custom styling, for example the id `#input` was renamed `#search-input`
**New Features**:
- New access control system. You can add new allow / deny / denyAll rules for users/groups for specific paths on specific sources.
- groups currently only works with provided oidc groups, but will add a full group management option for manual creation. https://github.com/gtsteffaniak/filebrowser/issues/545
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
- many share links customization enhancements
- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
- one-time download links
- customize share theme https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
- shares can be modified/configured after creation.
- download throttling for shares
**Notes**:
- hover effect on list/compact view https://github.com/gtsteffaniak/filebrowser/issues/1036
**BugFixes**:
- fix new file "true" content issue https://github.com/gtsteffaniak/filebrowser/issues/1048
- editor allows device default popup https://github.com/gtsteffaniak/filebrowser/issues/1049
## v0.7.18-beta
**Notes**:
+9 -4
View File
@@ -22,7 +22,7 @@
## About
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has has a web page interface that allows you to create secure shared links, users with their own specific permissions and settings, and offers a great viewing experience for many file types.
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
@@ -36,7 +36,7 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Revamped UI
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Simplified configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
@@ -47,14 +47,19 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
- Instantly switches view modes and sort order without reloading data.
- Folder sizes are displayed.
- Navigating remembers the last scroll position.
7. ✅ Developer API support
7. ✅ Highly configurable and customizable sharing options, incuding:
- share expiration time
- users who can access share (including anonymous)
- styling and themes
- file viewing, editing, and uploading permissions
8. ✅ Directory-level access control that can be scoped to user or group.
8. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- :construction: rules are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
> [!WARNING]
-1
View File
@@ -6,7 +6,6 @@ COPY [ "./_docker/src/general/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/general/frontend/", "./" ]
COPY [ "./frontend/tests-playwright/", "./tests-playwright" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
-1
View File
@@ -1,7 +1,6 @@
FROM gtstef/playwright-base
COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests-playwright/", "./tests-playwright" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
+1 -1
View File
@@ -2,7 +2,7 @@ FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/proxy/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
+17
View File
@@ -0,0 +1,17 @@
FROM gtstef/playwright-base
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/sharing/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/sharing/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests-playwright/global-setup",
timeout: 5000,
testDir: "./tests-playwright/general",
globalSetup: "./tests/playwright/global-setup",
timeout: 10000,
testDir: "./tests/playwright/general",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests-playwright/noauth-setup",
timeout: 5000,
testDir: "./tests-playwright/noauth",
globalSetup: "./tests/playwright/noauth-setup",
timeout: 10000,
testDir: "./tests/playwright/noauth",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests-playwright/oidc-setup",
globalSetup: "./tests/playwright/oidc-setup",
timeout: 5000,
testDir: "./tests-playwright/oidc",
testDir: "./tests/playwright/oidc",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -12,7 +12,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
//globalSetup: "./global-setup",
timeout: 3000,
testDir: "./tests-playwright/proxy",
testDir: "./tests/playwright/proxy",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
+21
View File
@@ -0,0 +1,21 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "."
name: "docker"
config:
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
@@ -0,0 +1,47 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 10000,
testDir: "./tests/playwright/sharing",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+4 -2
View File
@@ -33,6 +33,9 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
return response, errors.ErrPermissionDenied
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err
@@ -47,7 +50,7 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
if err != nil {
return response, err
}
} else {
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
} else {
@@ -328,7 +331,6 @@ func getContent(realPath string) (string, error) {
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
+2 -2
View File
@@ -11,10 +11,10 @@ import (
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -258,7 +258,7 @@ func createConfig(configpath string) {
if err == nil {
var isDir bool
// Resolve symlinks and get the real path
realPath, isDir, _ = utils.ResolveSymlinks(absolutePath)
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
if realPath != "" && isDir {
break // Valid path found, exit loop
}
+3 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
@@ -22,7 +23,7 @@ import (
"github.com/swaggo/swag"
)
var store *storage.Storage
var store *bolt.BoltStore
func getStore(configFile string) bool {
// Use the config file (global flag)
@@ -119,7 +120,7 @@ func StartFilebrowser() {
logger.Info("Shutdown complete.")
}
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
-4
View File
@@ -21,10 +21,6 @@ func validateUserInfo() {
for _, user := range usersList {
changePass := false
updateUser := false
if user.Username == "publicUser" {
settings.ApplyUserDefaults(user)
updateUser = true
}
if updateUserScopes(user) {
updateUser = true
}
+15 -54
View File
@@ -63,44 +63,21 @@ func setupFrontend() {
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
Config.Frontend.Styling.CustomCSS = readCustomCSS(Config.Frontend.Styling.CustomCSS)
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
for name, theme := range Config.Frontend.Styling.CustomThemes {
thisTheme := CustomTheme{
Description: theme.Description,
CSS: theme.CSS, // Copy the CSS file path from the original theme
}
Config.Frontend.Styling.CustomThemeOptions[name] = thisTheme
thisTheme.CSS = readCustomCSS(theme.CSS) // Now read the CSS file content and replace the path
Config.Frontend.Styling.CustomThemes[name] = thisTheme
addCustomTheme(name, theme.Description, theme.CSS)
}
noThemes := len(Config.Frontend.Styling.CustomThemes) == 0
if noThemes {
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{
"default": {
Description: "The default theme",
CSS: "",
},
"alternative": {
Description: "Reduce rounded corners",
CSS: "reduce-rounded-corners.css",
},
addCustomTheme("default", "The default theme", "")
// check if file exists
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
}
}
_, ok := Config.Frontend.Styling.CustomThemes["default"]
if !ok {
theme := CustomTheme{
Description: "The default theme",
CSS: "",
}
if noThemes {
// If there are no themes at all, initialize the map with just default
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{
"default": theme,
}
} else {
// If there are themes but no default, add default to existing themes
Config.Frontend.Styling.CustomThemes["default"] = theme
}
Config.Frontend.Styling.CustomThemeOptions["default"] = theme
addCustomTheme("default", "The default theme", "")
}
}
@@ -183,11 +160,15 @@ func setupUrls() {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if Config.Server.BaseURL != "/" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
if Config.Server.ExternalUrl != "" {
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
}
if Config.Server.InternalUrl != "" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
}
}
Config.Server.InternalUrl = strings.Trim(Config.Server.InternalUrl, "/")
Config.Server.ExternalUrl = strings.Trim(Config.Server.ExternalUrl, "/")
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
@@ -539,23 +520,3 @@ func modifyExcludeInclude(config *Source) {
normalize(config.Config.Include.RootFiles, true)
normalize(config.Config.NeverWatchPaths, true)
}
func readCustomCSS(path string) string {
if path == "" {
return ""
}
content, err := os.ReadFile(path)
if err != nil {
// Only log if the file exists but cannot be read (e.g., permission error)
if !os.IsNotExist(err) {
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
return ""
}
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
}
@@ -1,6 +1,7 @@
package settings
import (
"os"
"regexp"
"strings"
@@ -71,3 +72,38 @@ func FallbackColor(val, defaultColor string) string {
logger.Warningf("Invalid CSS color value provided: '%s'. Falling back to default: '%s'", val, defaultColor)
return defaultColor
}
func addCustomTheme(name, description, cssFilePath string) {
cssContent := ""
if cssFilePath != "" {
cssContent = readCustomCSS(cssFilePath)
}
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
Description: description,
CSS: cssContent,
}
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
Description: description,
CSS: cssFilePath,
}
}
func readCustomCSS(path string) string {
if path == "" {
return ""
}
content, err := os.ReadFile(path)
if err != nil {
// Only log if the file exists but cannot be read (e.g., permission error)
if !os.IsNotExist(err) {
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
return ""
}
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
}
+4 -27
View File
@@ -6,14 +6,12 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -117,30 +115,9 @@ func JoinPathAsUnix(parts ...string) string {
return joinedPath
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := iteminfo.IsDirectory(info)
return path, isDir, nil
}
func NonNilSlice[T any](in []T) []T {
if in == nil {
return []T{}
}
return in
}
-27
View File
@@ -4,35 +4,8 @@ import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
+897
View File
@@ -0,0 +1,897 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"slices"
"sort"
"sync"
"time"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var accessCache = cache.NewCache(1 * time.Minute)
const accessRulesBucket = "access_rules"
const accessRulesKey = "rules"
const accessChangedKey = "newRule:"
type RuleMap map[string]*AccessRule
type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
}
// RuleSet groups users and groups for allow/deny lists.
type RuleSet struct {
Users StringSet
Groups StringSet
}
// AccessRule defines allow/deny lists for a path.
type AccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny RuleSet
Allow RuleSet
}
type FrontendRuleSet struct {
Users []string `json:"users"`
Groups []string `json:"groups"`
}
type FrontendAccessRule struct {
DenyAll bool `json:"denyAll,omitempty"`
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
}
// GroupMap maps group names to a set of usernames.
type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath]
Groups GroupMap // key: group name, value: set of usernames
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
})
if err != nil {
return err
}
return s.DB.Set(accessRulesBucket, accessRulesKey, data)
}
// LoadFromDB loads all rules from the DB if DB is set.
func (s *Storage) LoadFromDB() error {
if s.DB == nil {
return nil
}
var data []byte
err := s.DB.Get(accessRulesBucket, accessRulesKey, &data)
if err != nil {
return err
}
var storage dbStorage
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
s.mux.Lock()
s.AllRules = storage.AllRules
if s.AllRules == nil {
s.AllRules = make(SourceRuleMap)
}
s.Groups = storage.Groups
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.mux.Unlock()
return nil
}
// NewStorage creates a new Storage instance. Optionally pass a DB for persistence and users storage.
// After creating Storage with a DB, call LoadFromDB() to load rules from the database on startup.
// Example:
//
// store := NewStorage(db, usersStore)
// err := store.LoadFromDB()
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
}
return s
}
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
// The caller must hold the lock.
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
if _, ok := s.AllRules[sourcePath]; !ok {
s.AllRules[sourcePath] = make(RuleMap)
}
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
rule = &AccessRule{
Deny: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
}
s.AllRules[sourcePath][indexPath] = rule
}
logger.Debugf("Created rule for source: %s and index: %s", sourcePath, indexPath)
return rule
}
// DenyUser adds a user to the deny list for a given source and index path.
func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Users[username]; ok {
return errors.ErrExist
}
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowUser adds a user to the allow list for a given source and index path.
func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
if s.Users != nil {
_, err := s.Users.Get(username)
if err != nil {
return fmt.Errorf("user '%s' does not exist: %w", username, err)
}
}
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Users[username]; ok {
return errors.ErrExist
}
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyGroup adds a group to the deny list for a given source and index path.
func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// AllowGroup adds a group to the allow list for a given source and index path.
func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
}
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// DenyAll sets a rule to deny all access for a given source and index path.
func (s *Storage) DenyAll(sourcePath, indexPath string) error {
s.mux.Lock()
defer s.mux.Unlock()
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if rule.DenyAll {
return errors.ErrExist
}
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
return s.SaveToDB()
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(versionKey).(int); ok {
version = v
}
// Check cache with versioned key
permKey := fmt.Sprintf("perm:%s:%d:%s:%s", sourcePath, version, indexPath, username)
if p, ok := accessCache.Get(permKey).(bool); ok {
return p
}
// Not in cache, compute, then cache it.
result := s.computePermitted(sourcePath, indexPath, username)
accessCache.Set(permKey, result)
return result
}
func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool {
for {
permitted, found := s.permittedAtExactPath(sourcePath, indexPath, username)
if found {
return permitted
}
indexPath = utils.GetParentDirectoryPath(indexPath)
if indexPath == "" {
break
}
}
return true
}
// permittedAtExactPath checks if a rule exists at the given path and evaluates it if so.
func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (bool, bool) {
s.mux.RLock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
s.mux.RUnlock()
return true, false
}
rule, ok := rulesBySource[indexPath]
s.mux.RUnlock()
if !ok {
return true, false
}
return s.permittedRule(rule, username), true
}
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
func (s *Storage) permittedRule(rule *AccessRule, username string) bool {
// Check user deny
if _, found := rule.Deny.Users[username]; found {
return false
}
if rule.DenyAll {
return false
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
// If any allow is present, user must be in at least one
hasUserAllow := len(rule.Allow.Users) > 0
hasGroupAllow := len(rule.Allow.Groups) > 0
if hasUserAllow || hasGroupAllow {
if hasUserAllow {
if _, found := rule.Allow.Users[username]; found {
return true
}
}
if hasGroupAllow {
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true
}
}
}
} else {
return true
}
// Fallback to deny rules
if _, found := rule.Deny.Users[username]; found {
return false
}
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
return true
}
// isUserInGroup checks if a username is in a group.
func (s *Storage) isUserInGroup(username, group string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
users, ok := s.Groups[group]
if !ok {
return false
}
_, found := users[username]
return found
}
// GetRule retrieves a rule for a sourcePath and indexPath.
func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
frontendRules := FrontendAccessRule{
Deny: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
Allow: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
},
}
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, false
}
rule, ok := rulesBySource[indexPath]
if !ok || rule == nil {
return frontendRules, false
}
// Convert AccessRule to FrontendAccessRule
frontendRules.DenyAll = rule.DenyAll
frontendRules.Deny.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users)))
frontendRules.Deny.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups)))
frontendRules.Allow.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users)))
frontendRules.Allow.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups)))
logger.Debugf("Returning frontend rules for source: %s and index: %s", sourcePath, indexPath)
return frontendRules, ok
}
// GetAllRules returns all access rules as a map.
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
value, ok := accessCache.Get(accessChangedKey + sourcePath).(map[string]FrontendAccessRule)
if ok {
return value, nil
}
s.mux.RLock()
defer s.mux.RUnlock()
// Return a copy to avoid external mutation
frontendRules := make(map[string]FrontendAccessRule, len(s.AllRules))
rules, ok := s.AllRules[sourcePath]
if !ok {
return frontendRules, nil
}
for indexPath, rule := range rules {
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
// cache responses
accessCache.Set(accessChangedKey+sourcePath, frontendRules)
return frontendRules, nil
}
// AddUserToGroup adds a username to a group.
func (s *Storage) AddUserToGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; ok {
return nil
}
s.Groups[group][username] = struct{}{}
return s.SaveToDB()
}
// GetAllGroups returns all group names.
func (s *Storage) GetAllGroups() []string {
s.mux.RLock()
defer s.mux.RUnlock()
groups := make([]string, 0, len(s.Groups))
for group := range s.Groups {
groups = append(groups, group)
}
sort.Strings(groups)
return groups
}
// GetUserGroups returns all groups for a specific user.
func (s *Storage) GetUserGroups(username string) []string {
s.mux.RLock()
defer s.mux.RUnlock()
var groups []string
for group, users := range s.Groups {
if _, ok := users[username]; ok {
groups = append(groups, group)
}
}
return utils.NonNilSlice(groups)
}
// SyncUserGroups updates a user's group memberships.
// It removes the user from groups not in the new list and adds them to new ones.
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
newGroupsSet[g] = struct{}{}
}
// Iterate over all existing groups to find the user's current memberships
for group, users := range s.Groups {
_, userIsInGroup := users[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
changed = true
}
}
// Add user to new groups
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
changed = true
}
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveUserFromGroup removes a username from a group.
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
users, ok := s.Groups[group]
if !ok {
return nil
}
if _, ok := users[username]; !ok {
return nil
}
delete(users, username)
if len(s.Groups[group]) == 0 {
delete(s.Groups, group)
}
return s.SaveToDB()
}
// RemoveAllowUser removes a user from the allow list for a given source and index path.
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Users[username]
if exists {
delete(rule.Allow.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveAllowGroup removes a group from the allow list for a given source and index path.
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Allow.Groups[groupname]
if exists {
delete(rule.Allow.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyUser removes a user from the deny list for a given source and index path.
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Users[username]
if exists {
delete(rule.Deny.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return false, nil
}
// RemoveDenyGroup removes a group from the deny list for a given source and index path.
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
_, exists := rule.Deny.Groups[groupname]
if exists {
delete(rule.Deny.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return exists, s.SaveToDB()
}
return exists, nil
}
// RemoveDenyAll removes the deny all rule for a given source and index path.
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
rule, ok := s.AllRules[sourcePath][indexPath]
if !ok {
return false, nil
}
removed := false
if rule.DenyAll {
rule.DenyAll = false
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, false)
return true, s.SaveToDB()
}
return false, nil
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
changedSourcePaths := make(map[string]struct{})
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
changedSourcePaths[sourcePath] = struct{}{}
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
}
if changed {
return s.SaveToDB()
}
return nil
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
userRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return userRules
}
for indexPath, rule := range rulesBySource {
userHasRule := false
if _, ok := rule.Allow.Users[username]; ok {
userHasRule = true
}
if !userHasRule {
if _, ok := rule.Deny.Users[username]; ok {
userHasRule = true
}
}
if userHasRule {
userRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return userRules
}
// GetRulesForGroup returns all rules for a specific group for a given sourcePath.
func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
groupRules := make(map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return groupRules
}
for indexPath, rule := range rulesBySource {
groupHasRule := false
if _, ok := rule.Allow.Groups[groupname]; ok {
groupHasRule = true
}
if !groupHasRule {
if _, ok := rule.Deny.Groups[groupname]; ok {
groupHasRule = true
}
}
if groupHasRule {
groupRules[indexPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
}
}
return groupRules
}
// GetAllRulesByUsers returns a map of usernames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
allUserRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allUserRules
}
for indexPath, rule := range rulesBySource {
hasAllowUsers := len(rule.Allow.Users) > 0
hasDenyUsers := len(rule.Deny.Users) > 0
if !hasAllowUsers && !hasDenyUsers {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for user := range rule.Allow.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
for user := range rule.Deny.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
}
}
return allUserRules
}
// GetAllRulesByGroups returns a map of groupnames to their rules for a given sourcePath.
func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]FrontendAccessRule {
s.mux.RLock()
defer s.mux.RUnlock()
allGroupRules := make(map[string]map[string]FrontendAccessRule)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return allGroupRules
}
for indexPath, rule := range rulesBySource {
hasAllowGroups := len(rule.Allow.Groups) > 0
hasDenyGroups := len(rule.Deny.Groups) > 0
if !hasAllowGroups && !hasDenyGroups {
continue
}
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
},
Allow: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups))),
},
}
for group := range rule.Allow.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
for group := range rule.Deny.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
}
}
return allGroupRules
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
// The caller MUST hold the mutex lock.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(key).(int); ok {
version = v
}
accessCache.Set(key, version+1)
}
+211
View File
@@ -0,0 +1,211 @@
package access_test
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
userStore := users.NewStorage(boltusers.NewUsersBackend(db))
return access.NewStorage(db, userStore), userStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
u := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: username}
err := userStore.Save(u, false, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
}
func TestPermitted_UserBlacklist(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.DenyUser("mnt/storage", "/secret", "alice"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/secret", "alice") {
t.Error("alice should not be permitted (denied)")
}
if !s.Permitted("mnt/storage", "/secret", "bob") {
t.Error("bob should be permitted (not denied)")
}
}
func TestPermitted_UserWhitelist(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if err := s.AllowUser("mnt/storage", "/vip", "bob"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (allowed)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (not denied)")
}
}
func TestPermitted_GroupBlacklist(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("admins", "alice")
if err := s.DenyGroup("mnt/storage", "/admin", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/admin", "bob") == false {
t.Error("bob should be permitted (not in denied group)")
}
if s.Permitted("mnt/storage", "/admin", "alice") {
t.Error("alice should not be permitted (in denied group)")
}
}
func TestPermitted_GroupWhitelist(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
_ = s.AddUserToGroup("vip", "bob")
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if !s.Permitted("mnt/storage", "/vip", "bob") {
t.Error("bob should be permitted (in allowed group)")
}
if !s.Permitted("mnt/storage", "/vip", "alice") {
t.Error("alice should be permitted (not in denied group)")
}
}
func TestPermitted_NoRule(t *testing.T) {
s, _ := createTestStorage(t)
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
if !s.Permitted("mnt/storage", "/public", "anyone") {
t.Error("anyone should be permitted if no rule exists")
}
}
func TestPermitted_CombinedRules(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
createTestUser(t, userStore, "carol")
createTestUser(t, userStore, "eve")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
err = s.AddUserToGroup("vip", "bob")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
err = s.AddUserToGroup("admins", "alice")
if err != nil {
t.Errorf("AddUserToGroup failed: %v", err)
}
if err := s.DenyUser("mnt/storage", "/combo", "eve"); err != nil {
t.Errorf("DenyUser failed: %v", err)
}
if err := s.AllowUser("mnt/storage", "/combo", "carol"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if err := s.DenyGroup("mnt/storage", "/combo", "admins"); err != nil {
t.Errorf("DenyGroup failed: %v", err)
}
if err := s.AllowGroup("mnt/storage", "/combo", "vip"); err != nil {
t.Errorf("AllowGroup failed: %v", err)
}
if s.Permitted("mnt/storage", "/combo", "eve") {
t.Error("eve should not be permitted (user denied)")
}
if !s.Permitted("mnt/storage", "/combo", "carol") {
t.Error("carol should be permitted (user allowed)")
}
if s.Permitted("mnt/storage", "/combo", "alice") {
t.Error("alice should not be permitted (in group denied)")
}
if !s.Permitted("mnt/storage", "/combo", "bob") {
t.Error("bob should be permitted (in group allowed)")
}
}
func TestPermitted_DenyAll(t *testing.T) {
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Test DenyAll
if err = s.DenyAll("mnt/storage", "/private"); err != nil {
t.Errorf("DenyAll failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all)")
}
if s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should not be permitted (deny all)")
}
// Test that Allow rule is overridden by DenyAll
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
t.Errorf("AllowUser failed: %v", err)
}
if s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should not be permitted (deny all overrides allow)")
}
// Test removing DenyAll
removed, err := s.RemoveDenyAll("mnt/storage", "/private")
if err != nil {
t.Errorf("RemoveDenyAll failed: %v", err)
}
if !removed {
t.Error("RemoveDenyAll should have removed the rule")
}
// After removing DenyAll, alice should be permitted due to the Allow rule
if !s.Permitted("mnt/storage", "/private", "alice") {
t.Error("alice should be permitted after removing deny all")
}
// Bob should be permitted as he is not on any list, and there is an allow list which he is not on, but he is not on deny either.
if !s.Permitted("mnt/storage", "/private", "bob") {
t.Error("bob should be permitted after removing deny all (not on allow list but not denied)")
}
}
+34
View File
@@ -0,0 +1,34 @@
package crud
// CrudBackend defines the basic CRUD operations for a type T.
type CrudBackend[T any] interface {
GetByID(id any) (*T, error)
GetAll() ([]*T, error)
Save(obj *T) error
DeleteByID(id any) error
}
// Storage provides generic CRUD operations for any type T.
type Storage[T any] struct {
backend CrudBackend[T]
}
func NewStorage[T any](backend CrudBackend[T]) *Storage[T] {
return &Storage[T]{backend: backend}
}
func (s *Storage[T]) Get(id any) (*T, error) {
return s.backend.GetByID(id)
}
func (s *Storage[T]) GetAll() ([]*T, error) {
return s.backend.GetAll()
}
func (s *Storage[T]) Save(obj *T) error {
return s.backend.Save(obj)
}
func (s *Storage[T]) Delete(id any) error {
return s.backend.DeleteByID(id)
}
+37 -9
View File
@@ -1,19 +1,47 @@
package share
import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisablingFileViewer bool `json:"disableFileViewer,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
ViewMode string `json:"viewMode,omitempty"`
}
type CreateBody struct {
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
CommonShare
Hash string `json:"hash,omitempty"`
SourceName string `json:"sourceName,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
}
// Link is the information needed to build a shareable link.
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
CommonShare
Mu sync.Mutex `json:"-"`
Downloads int `json:"-"`
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Source string `json:"source" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
+61 -51
View File
@@ -4,6 +4,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
)
// StorageBackend is the interface to implement for a share storage.
@@ -17,104 +18,113 @@ type StorageBackend interface {
Delete(hash string) error
}
// Storage is a storage.
type Storage struct {
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{back: back}
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
}
}
// All wraps a StorageBackend.All.
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
}
}
return links, nil
return s.filterExpired(links)
}
// FindByUserID wraps a StorageBackend.FindByUserID.
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
}
}
return links, nil
return s.filterExpired(links)
}
// GetByHash wraps a StorageBackend.GetByHash.
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
return link, nil
}
// GetPermanent wraps a StorageBackend.GetPermanent
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
return s.back.GetPermanent(path, source, id)
}
// Gets wraps a StorageBackend.Gets
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
}
}
return links, nil
return s.filterExpired(links)
}
// Save wraps a StorageBackend.Save
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
return s.back.Save(l)
}
// Delete wraps a StorageBackend.Delete
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
return s.back.Delete(hash)
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
+20 -5
View File
@@ -5,18 +5,33 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *settings.Storage, error) {
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
shareStore := share.NewStorage(shareBackend{db: db})
settingsStore := settings.NewStorage(settingsBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
return authStore, userStore, shareStore, settingsStore, nil
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
}, nil
}
+8
View File
@@ -21,6 +21,11 @@ type usersBackend struct {
db *storm.DB
}
// NewUsersBackend returns a users.StorageBackend backed by storm DB.
func NewUsersBackend(db *storm.DB) users.StorageBackend {
return &usersBackend{db: db}
}
func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
@@ -153,6 +158,9 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
if user.Username == "anonymous" {
return fmt.Errorf("username cannot be 'anonymous'")
}
logger.Debugf("Saving user [%s] changepass: %v", user.Username, changePass)
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
+13
View File
@@ -18,3 +18,16 @@ func get(db *storm.DB, name string, to interface{}) error {
func Save(db *storm.DB, name string, from interface{}) error {
return db.Set("config", name, from)
}
func SaveAccessRules(db *storm.DB, name string, from interface{}) error {
return db.Set("access_rules", name, from)
}
func GetAccessRules(db *storm.DB, name string, to interface{}) error {
err := db.Get("access_rules", name, to)
if err == storm.ErrNotFound {
return errors.ErrNotExist
}
return err
}
+9 -22
View File
@@ -7,27 +7,16 @@ import (
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type Storage struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
}
var userStore *users.Storage
var store *Storage
func InitializeDb(path string) (*Storage, bool, error) {
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
exists, err := dbExists(path)
if err != nil {
panic(err)
@@ -39,20 +28,18 @@ func InitializeDb(path string) (*Storage, bool, error) {
}
logger.Fatalf("could not open database: %v", err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
store, err := bolt.NewStorage(db)
if err != nil {
return nil, exists, err
}
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
return nil, exists, err
}
store = &Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
if !exists {
quickSetup(store)
}
@@ -80,7 +67,7 @@ func dbExists(path string) (bool, error) {
return false, err
}
func quickSetup(store *Storage) {
func quickSetup(store *bolt.BoltStore) {
settings.Config.Auth.Key = utils.GenerateKey()
err := store.Settings.Save(&settings.Config)
utils.CheckErr("store.Settings.Save", err)
@@ -132,7 +119,7 @@ func CreateUser(userInfo users.User) error {
}
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
// create new home directories
err := store.Users.Save(newUser, true, false)
err := userStore.Save(newUser, true, false)
if err != nil {
return err
}
+38 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
)
// StorageBackend is the interface to implement for a users storage.
@@ -29,8 +30,43 @@ type Store interface {
DeleteApiKey(username uint, name string) error
}
// Storage is a users storage.
// crudBackend implements crud.CrudBackend[User] for users storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*User, error) {
switch v := id.(type) {
case string, uint:
return c.back.GetBy(v)
default:
return nil, errors.ErrInvalidDataType
}
}
func (c *crudBackend) GetAll() ([]*User, error) {
return c.back.Gets()
}
func (c *crudBackend) Save(obj *User) error {
// Use default values for changePass and disableScopeChange
return c.back.Save(obj, false, false)
}
func (c *crudBackend) DeleteByID(id any) error {
switch v := id.(type) {
case string:
return c.back.DeleteByUsername(v)
case uint:
return c.back.DeleteByID(v)
default:
return errors.ErrInvalidDataType
}
}
// Storage is a users storage using generics.
type Storage struct {
Generic *crud.Storage[User]
back StorageBackend
updated map[uint]int64
mux sync.RWMutex
@@ -39,6 +75,7 @@ type Storage struct {
// NewStorage creates a users storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{
Generic: crud.NewStorage[User](&crudBackend{back: back}),
back: back,
updated: map[uint]int64{},
}
+2 -8
View File
@@ -110,14 +110,8 @@ type FileLoading struct {
ChunkSize int `json:"uploadChunkSizeMb"`
}
var PublicUser = User{
NonAdminEditable: NonAdminEditable{
Password: "publicUser", // temp user not registered
ViewMode: "normal",
},
Username: "publicUser", // temp user not registered
LockPassword: true,
Permissions: Permissions{},
var AnonymousUser = User{
Username: "anonymous", // temp user not registered
}
func CleanUsername(s string) string {
+15 -14
View File
@@ -4,7 +4,7 @@ go 1.24.2
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.14.1
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-playground/validator/v10 v10.27.0
@@ -18,12 +18,13 @@ require (
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.5
golang.org/x/crypto v0.40.0
golang.org/x/image v0.29.0
golang.org/x/mod v0.26.0
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/mod v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.34.0
golang.org/x/sys v0.35.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -54,7 +55,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
@@ -90,8 +91,8 @@ require (
github.com/ghostiam/protogetter v0.3.15 // indirect
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-openapi/jsonpointer v0.21.2 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
@@ -108,7 +109,7 @@ require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4 // indirect
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
@@ -135,7 +136,7 @@ require (
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.5.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -235,10 +236,10 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
google.golang.org/protobuf v1.36.6 // indirect
+30 -28
View File
@@ -105,8 +105,8 @@ github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkAp
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
@@ -145,8 +145,8 @@ github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7Lsp
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -219,8 +219,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -229,8 +229,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
@@ -285,8 +285,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4 h1:vCeHcs8N7MOccOOsOVIy1xcYu+kBkA4J5urTgigww7c=
github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695 h1:W3KSdht88NFfM71hd6Y4j2uiW81/aoqjPrMVoV/ph1E=
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -425,8 +425,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.5.0 h1:j2nSgpabbV1JOwgP4Kn449sJUHq3cVLAZVBoOYn44V8=
github.com/jupiterrider/ffi v0.5.0/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI=
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -659,8 +659,8 @@ github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
@@ -743,8 +743,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -763,8 +763,8 @@ golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rP
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -792,8 +792,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -838,8 +838,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -920,8 +920,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -942,11 +942,13 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1003,8 +1005,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
+267
View File
@@ -0,0 +1,267 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
type GroupListResponse struct {
Groups []string `json:"groups"`
}
// accessGetHandler lists all access rules or retrieves a specific rule.
// @Summary List access rules
// @Description Lists access rules. Can be filtered by source, path, user, or group. Can also be grouped by user or group.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string false "Source name (e.g. 'default')"
// @Param path query string false "Index path (e.g. /secret)"
// @Param user query string false "Username to filter rules for"
// @Param group query string false "Group name to filter rules for"
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [get]
func accessGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
user := r.URL.Query().Get("user")
group := r.URL.Query().Get("group")
var sourcePath string
if sourceName != "" {
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
sourcePath = index.Path
}
// Return rules based on input parameters
if user != "" {
rules := store.Access.GetRulesForUser(sourcePath, user)
return renderJSON(w, r, rules)
}
if group != "" {
rules := store.Access.GetRulesForGroup(sourcePath, group)
return renderJSON(w, r, rules)
}
if sourceName == "" {
return http.StatusBadRequest, fmt.Errorf("source parameter is required for this query")
}
if indexPath != "" {
rule, _ := store.Access.GetFrontendRules(sourcePath, indexPath)
return renderJSON(w, r, rule)
}
// List all rules for the source by default
all, err := store.Access.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("failed to retrieve access rules: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to retrieve access rules: %w", err)
}
return renderJSON(w, r, all)
}
// accessPostHandler adds or updates an access rule.
// @Summary Add or update access rule
// @Description Add or update an access rule for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param body body object{allow=bool,ruleCategory=string,value=string} true "Rule details: allow (true/false), ruleCategory (user/group), value (username or groupname)"
// @Success 200 {object} map[string]string "Rule added or updated"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [post]
func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
var body struct {
Allow bool `json:"allow"`
RuleCategory string `json:"ruleCategory"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)
}
if indexPath == "" || body.RuleCategory == "" || (body.RuleCategory != "all" && body.Value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var err error
if body.Allow {
switch body.RuleCategory {
case "user":
err = store.Access.AllowUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.AllowGroup(index.Path, indexPath, body.Value)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user' or 'group'")
}
} else {
switch body.RuleCategory {
case "user":
err = store.Access.DenyUser(index.Path, indexPath, body.Value)
case "group":
err = store.Access.DenyGroup(index.Path, indexPath, body.Value)
case "all":
err = store.Access.DenyAll(index.Path, indexPath)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
logger.Errorf("failed to add or update rule: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to add or update rule: %w", err)
}
return renderJSON(w, r, map[string]string{"message": "rule added or updated"})
}
// accessDeleteHandler deletes a single user or group from a rule.
// @Summary Delete access rule entry
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath.
// @Tags Access
// @Accept json
// @Produce json
// @Param source query string true "Source path prefix (e.g. mnt/storage)"
// @Param path query string true "Index path (e.g. /secret)"
// @Param ruleType query string true "Rule type (allow or deny)"
// @Param ruleCategory query string true "Rule category (user or group)"
// @Param value query string true "Username or groupname to remove"
// @Success 200 {object} map[string]string "Rule entry deleted"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access [delete]
func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return 500, fmt.Errorf("source not found: %s", sourceName)
}
ruleType := r.URL.Query().Get("ruleType")
ruleCategory := r.URL.Query().Get("ruleCategory")
value := r.URL.Query().Get("value")
allow := ruleType == "allow"
if indexPath == "" || ruleCategory == "" || (ruleCategory != "all" && value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
var found bool
var err error
if allow {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveAllowUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveAllowGroup(index.Path, indexPath, value)
}
} else {
switch ruleCategory {
case "user":
found, err = store.Access.RemoveDenyUser(index.Path, indexPath, value)
case "group":
found, err = store.Access.RemoveDenyGroup(index.Path, indexPath, value)
case "all":
found, err = store.Access.RemoveDenyAll(index.Path, indexPath)
}
}
if !found {
if err != nil {
logger.Errorf("failed to remove rule entry: %v", err)
}
return http.StatusNotFound, fmt.Errorf("entry not found in rule")
}
return renderJSON(w, r, map[string]string{"message": "rule entry deleted"})
}
// groupGetHandler retrieves all groups or groups for a specific user.
// @Summary Get all groups or groups for a user
// @Description Returns a list of all groups or the groups for a specific user.
// @Tags Access
// @Accept json
// @Produce json
// @Param user query string false "User name"
// @Success 200 {object} GroupListResponse "Object containing a list of groups"
// @Failure 403 {object} map[string]string "Forbidden"
// @Router /api/access/groups [get]
func groupGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
user := r.URL.Query().Get("user")
if user != "" {
groups := store.Access.GetUserGroups(user)
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
groups := store.Access.GetAllGroups()
return renderJSON(w, r, &GroupListResponse{Groups: groups})
}
// groupPostHandler adds a user to a group.
// @Summary Add a user to a group
// @Description Adds a user to a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User added to group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [post]
func groupPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.AddUserToGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// groupDeleteHandler removes a user from a group.
// @Summary Remove a user from a group
// @Description Removes a user from a group.
// @Tags Access
// @Accept json
// @Produce json
// @Param group query string true "Group name"
// @Param user query string true "User name"
// @Success 200 "User removed from group successfully"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/access/group [delete]
func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := store.Access.RemoveUserFromGroup(group, user)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
+93 -19
View File
@@ -13,7 +13,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/go-logger/logger"
// http-swagger middleware
)
@@ -37,13 +37,12 @@ func (d dirFS) Open(name string) (fs.File, error) {
}
var (
store *storage.Storage
config *settings.Settings
//fileCache diskcache.Interface
store *bolt.BoltStore
config *settings.Settings
assetFs fs.FS
)
func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete chan struct{}) {
func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete chan struct{}) {
store = storage
config = &settings.Config
var err error
@@ -65,6 +64,8 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
// Public group routing (new structure)
publicRoutes := http.NewServeMux()
// User routes
api.HandleFunc("GET /users", withUser(userGetHandler))
@@ -98,19 +99,25 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Share routes
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
// Public routes
api.HandleFunc("GET /public/publicUser", withoutUser(publicUserGetHandler))
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
api.HandleFunc("GET /public/preview", withHashFile(publicPreviewHandler))
// Access routes
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
api.HandleFunc("DELETE /access", withAdmin(accessDeleteHandler))
api.HandleFunc("GET /access/groups", withAdmin(groupGetHandler))
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
// Create API sub-router for public API endpoints
publicAPI := http.NewServeMux()
// NEW PUBLIC ROUTES - All publicly accessible endpoints
// Share management routes (require permission but are publicly accessible)
publicRoutes.HandleFunc("GET /shares", withPermShare(shareListHandler))
publicRoutes.HandleFunc("GET /share", withPermShare(shareGetHandler))
publicRoutes.HandleFunc("POST /share", withPermShare(sharePostHandler))
publicRoutes.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
// Public API routes (hash-based authentication)
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
publicAPI.HandleFunc("GET /preview", withHashFile(publicPreviewHandler))
publicAPI.HandleFunc("GET /shared", withHashFile(publicShareHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
@@ -119,17 +126,84 @@ func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete c
d.ctx = ctx // Pass the parent context to ensure proper shutdown
return sseHandler(w, r, d)
}))
// Job routes
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/getToken", withUser(onlyofficeGetTokenHandler))
publicAPI.HandleFunc("GET /onlyoffice/getToken", withHashFile(onlyofficeGetTokenHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
// Share routes (DEPRECATED - maintain for backwards compatibility)
// These will redirect to the new /public/shares endpoints
api.HandleFunc("GET /shares", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/shares"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/share"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("POST /share", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Server.BaseURL+"public/share", http.StatusPermanentRedirect)
})
api.HandleFunc("DELETE /share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/share"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusPermanentRedirect)
})
api.HandleFunc("GET /public/raw", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/raw"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /public/share", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/shared"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
api.HandleFunc("GET /public/preview", func(w http.ResponseWriter, r *http.Request) {
newURL := config.Server.BaseURL + "public/api/preview"
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
})
// Mount the public API sub-router
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
// Mount the route groups
apiPath := config.Server.BaseURL + "api"
publicPath := config.Server.BaseURL + "public"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
// New frontend share route handler - handle share page and any subpaths
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Public static assets (needed for shared pages to load CSS/JS/images)
publicRoutes.HandleFunc("GET /static/", staticFilesHandler)
// Static and index file handlers
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
+97 -38
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
"time"
@@ -14,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -21,12 +24,12 @@ import (
)
type requestContext struct {
user *users.User
fileInfo iteminfo.ExtendedFileInfo
path string
token string
share *share.Link
ctx context.Context
user *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
ctx context.Context
MaxBandwidth int
}
type HttpResponse struct {
@@ -42,15 +45,26 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
// Middleware to handle file requests by hash and pass it to the handler
func withHashFileHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
path := r.URL.Query().Get("path")
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Retrieve the user (using the public user by default)
data.user = &users.PublicUser
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
if link.DisableAnonymous && data.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("share is not available to anonymous users")
}
if len(link.AllowedUsernames) > 0 {
if !slices.Contains(link.AllowedUsernames, data.user.Username) {
return http.StatusForbidden, fmt.Errorf("share is not available to this user")
}
}
data.share = link
// Authenticate the share request if needed
@@ -61,32 +75,29 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
if path == "" || path == "/" {
data.path = link.Path
}
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
// Get file information with options
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: data.path,
Source: source.Name,
Modify: false,
Expand: true,
Path: utils.JoinPathAsUnix(link.Path, path),
Source: source.Name,
Modify: false,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
})
file.Token = link.Token
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err)
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.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
// Set the file info in the `data` object
data.fileInfo = file
// Call the next handler with the data
return fn(w, r, data)
}
})
}
// Middleware to ensure the user is an admin
@@ -100,23 +111,61 @@ func withAdminHelper(fn handleFunc) handleFunc {
})
}
// if withUserHelper fails, try without user
// withOrWithoutUserHelper is a middleware that tries to authenticate a user.
// If authentication is successful, the user is added to the request context.
// If authentication fails, the request continues without a user.
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Try to authenticate user first
status, err := withUserHelper(fn)(w, r, data)
// If user authentication succeeded, return the result
if err == nil {
return status, nil
var link *share.Link
hash := r.URL.Query().Get("hash")
if hash != "" {
// Get the file link by hash
link, _ = store.Share.GetByHash(hash)
} else {
prefix := config.Server.BaseURL + "public/share/"
reconstructed := config.Server.BaseURL + "public" + r.URL.Path
if strings.HasPrefix(reconstructed, prefix) {
remaining := strings.TrimPrefix(reconstructed, prefix)
if remaining != "" {
if idx := strings.IndexByte(remaining, '/'); idx >= 0 {
remaining = remaining[:idx]
}
if remaining != "" {
var err error
link, err = store.Share.GetByHash(remaining)
if err != nil {
logger.Debugf("error getting share by hash: %v", err)
}
}
}
}
}
// If user authentication failed, call the handler without user context
// Clear any user data that might have been partially set
data.user = nil
data.token = ""
// Call the handler function without user context
return fn(w, r, data)
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if link != nil {
data.share = link
if data.user != nil {
data.user.CustomTheme = link.ShareTheme
}
}
return fn(w, r, data)
}
// Only fall back to anonymous if authentication actually failed
if status == http.StatusUnauthorized || status == http.StatusForbidden {
data.user = &users.AnonymousUser
// If user authentication failed, call the handler without user context
// Clear any user data that might have been partially set
data.token = ""
if link != nil {
data.share = link
data.user.CustomTheme = link.ShareTheme
}
// Call the handler function without user context
return fn(w, r, data)
}
return status, fmt.Errorf("could not authenticate share request")
}
}
@@ -178,6 +227,9 @@ func withUserHelper(fn handleFunc) handleFunc {
logger.Errorf("no auth: %v", err)
return http.StatusInternalServerError, err
}
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
@@ -188,6 +240,9 @@ func withUserHelper(fn handleFunc) handleFunc {
}
data.user = user
setUserInResponseWriter(w, data.user)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
@@ -219,10 +274,14 @@ func withUserHelper(fn handleFunc) handleFunc {
if err != nil {
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
}
return fn(w, r, data)
}
}
+15 -8
View File
@@ -11,7 +11,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -23,16 +22,10 @@ func setupTestEnv(t *testing.T) {
if err != nil {
t.Fatal(err)
}
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
store, err = bolt.NewStorage(db)
if err != nil {
t.Fatal(err)
}
store = &storage.Storage{
Auth: authStore,
Users: userStore,
Share: shareStore,
Settings: settingsStore,
}
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
@@ -162,6 +155,20 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
},
expectedStatusCode: 0, // zero means 200 on helpers
},
{
name: "Private share, valid password when token exists",
share: &share.Link{
Hash: "pw_and_token_hash",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "some_random_token",
Source: "/srv",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "password",
},
expectedStatusCode: 0, // zero means 200 on helpers
},
{
name: "Private share, no auth provided",
share: &share.Link{
+20 -13
View File
@@ -264,22 +264,23 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Errorf("No valid username found for identifier '%v' in ID token or UserInfo response.", oidcCfg.UserIdentifier)
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
}
isAdmin := false // Default to non-admin user
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
if slices.Contains(userdata.Groups, config.Auth.Methods.OidcAuth.AdminGroup) {
isAdmin = true // User is in the admin group, grant admin privileges
logger.Debugf("User %s is in admin group %s, granting admin privileges.", loginUsername, config.Auth.Methods.OidcAuth.AdminGroup)
}
}
// Proceed to log the user in with the OIDC data
// userdata struct now contains info from either verified ID token or UserInfo endpoint
return loginWithOidcUser(w, r, loginUsername, isAdmin)
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, isAdmin bool) (int, error) {
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, groups []string) (int, error) {
isAdmin := false // Default to non-admin user
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
if slices.Contains(groups, config.Auth.Methods.OidcAuth.AdminGroup) {
isAdmin = true // User is in the admin group, grant admin privileges
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, config.Auth.Methods.OidcAuth.AdminGroup)
}
}
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
@@ -323,11 +324,17 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if user.LoginMethod != users.LoginMethodOidc {
return http.StatusForbidden, errors.ErrWrongLoginMethod
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Sync OIDC groups with access control system
if err = store.Access.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync oidc user %s groups: %v", username, err)
}
// Generate a signed token for the user
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err2 != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
if strings.Contains(err2.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err2)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
+6 -4
View File
@@ -60,10 +60,12 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
return errToStatus(err), err
+17 -30
View File
@@ -7,10 +7,7 @@ import (
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -32,7 +29,17 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.Downloads > d.share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
d.share.Mu.Lock()
d.share.Downloads++
d.share.Mu.Unlock()
encodedFiles := r.URL.Query().Get("files")
if encodedFiles == "" {
return http.StatusBadRequest, fmt.Errorf("no file list provided, should be in the format '/path1||/path2||/path3'")
}
// Decode the URL-encoded path
f, err := url.QueryUnescape(encodedFiles)
if err != nil {
@@ -41,26 +48,25 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
fileList := []string{}
for _, file := range strings.Split(f, "||") {
fileList = append(fileList, d.fileInfo.Source+"::"+d.fileInfo.Path+file)
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
}
var status int
status, err = rawFilesHandler(w, r, d, fileList)
if err != nil {
logger.Errorf("public share handler: error processing filelist: %v", err)
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
return status, fmt.Errorf("error processing filelist: %v", f)
}
return status, nil
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
d.fileInfo.Path = strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
// disable onlyoffice for public share
d.fileInfo.OnlyOfficeId = ""
return renderJSON(w, r, d.fileInfo)
}
func publicUserGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return renderJSON(w, r, users.PublicUser)
}
// health godoc
// @Summary Health Check
// @Schemes
@@ -96,27 +102,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/public/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
if config.Server.DisablePreviews || d.share.DisableThumbnails {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
var err error
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(d.share.Path, path),
Modify: d.user.Permissions.Modify,
Source: d.fileInfo.Source,
Expand: true,
})
if err != nil {
logger.Debugf("public preview handler: error getting file info: %v", err)
return 400, fmt.Errorf("file not found")
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
+79 -15
View File
@@ -4,6 +4,7 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
@@ -13,12 +14,49 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
@@ -56,24 +94,32 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
source := splitFile[0]
path = splitFile[1]
var err error
userScope := "/"
if d.user.Username != "publicUser" {
if d.share == nil {
var userScope string
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
if err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
}
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
realPath, _, err := idx.GetRealPath(userScope, path)
_, err = files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
})
if err != nil {
return fmt.Errorf("failed to get real path for %s: %v", path, err)
return err
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
return err
@@ -185,19 +231,20 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
firstFilePath := splitFile[1]
// decode url encoded source name
var err error
var userscope string
fileName := filepath.Base(firstFilePath)
userscope := "/"
if d.user.Username != "publicUser" {
if d.share == nil {
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
idx := indexing.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
realPath, isDir, err := idx.GetRealPath(userscope, firstFilePath)
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -232,7 +279,15 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
// serve content allows for range requests.
// video scrubbing, etc.
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
@@ -300,7 +355,15 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
w.Header().Set("Content-Type", "application/octet-stream")
// Stream the file
_, err = io.Copy(w, fd)
var reader io.Reader = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
_, err = io.Copy(w, reader)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Errorf("Failed to copy archive data to response: %v", err)
@@ -324,14 +387,15 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
userScope := "/"
if d.user.Username != "publicUser" {
var userScope string
if d.share == nil {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
}
realPath, isDir, err := idx.GetRealPath(userScope, path)
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return http.StatusInternalServerError, err
}
+28 -18
View File
@@ -86,11 +86,13 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
scopePath := utils.JoinPathAsUnix(userscope, path)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
Access: store.Access,
Username: d.user.Username,
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
return errToStatus(err), err
@@ -158,10 +160,12 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
})
if err != nil {
return errToStatus(err), err
@@ -218,10 +222,12 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
}
idx := indexing.GetIndex(source)
if idx == nil {
@@ -383,10 +389,12 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
@@ -544,6 +552,8 @@ func patchAction(ctx context.Context, params patchActionParams) error {
idx := indexing.GetIndex(params.srcIndex)
srcPath := idx.MakeIndexPath(params.src)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: params.d.user.Username,
Path: srcPath,
Source: params.srcIndex,
IsDir: params.isSrcDir,
@@ -587,6 +597,6 @@ func mockData(w http.ResponseWriter, r *http.Request) {
if err != nil || err2 != nil {
return
}
mockDir := utils.CreateMockData(NumDirs, numFiles)
mockDir := indexing.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+77 -25
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -29,29 +30,25 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [get]
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var (
s []*share.Link
err error
)
var err error
var shares []*share.Link
if d.user.Permissions.Admin {
s, err = store.Share.All()
shares, err = store.Share.All()
} else {
s, err = store.Share.FindByUserID(d.user.ID)
}
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
if err != nil {
return http.StatusInternalServerError, err
shares, err = store.Share.FindByUserID(d.user.ID)
}
sort.Slice(s, func(i, j int) bool {
if s[i].UserID != s[j].UserID {
return s[i].UserID < s[j].UserID
if err != nil && err != errors.ErrNotExist {
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
sort.Slice(shares, func(i, j int) bool {
if shares[i].UserID != shares[j].UserID {
return shares[i].UserID < shares[j].UserID
}
return s[i].Expire < s[j].Expire
return shares[i].Expire < shares[j].Expire
})
return renderJSON(w, r, s)
return renderJSON(w, r, shares)
}
// shareGetsHandler retrieves share links for a specific resource path.
@@ -83,10 +80,13 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
scopePath := utils.JoinPathAsUnix(userscope, path)
s, err := store.Share.Gets(scopePath, sourcePath.Path, d.user.ID)
if err == errors.ErrNotExist {
if err == errors.ErrNotExist || len(s) == 0 {
return renderJSON(w, r, []*share.Link{})
}
// Overwrite the Source field with the source name from the query for each link
for _, link := range s {
link.Source = source
}
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
@@ -133,17 +133,21 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Router /api/shares [post]
func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var s *share.Link
var err error
var body share.CreateBody
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err = json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
}
secure_hash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
// check if body.Hash is a valid hash
if body.Hash != "" {
s, err = store.Share.GetByHash(body.Hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid hash provided")
}
}
var expire int64 = 0
@@ -184,6 +188,23 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
if s != nil {
s.Expire = expire
s.PasswordHash = stringHash
s.Token = token
s.CommonShare = body.CommonShare
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
}
// create a new share link
secure_hash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
}
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
@@ -191,7 +212,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceName := r.URL.Query().Get("source")
source := config.Server.NameToSource[sourceName]
source, ok := config.Server.NameToSource[sourceName]
if !ok {
// try to find source by path
for _, s := range config.Server.Sources {
if s.Path == sourceName {
source = s
ok = true
break
}
}
}
if !ok {
return http.StatusForbidden, fmt.Errorf("source with name not found")
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
if err != nil {
return http.StatusForbidden, err
@@ -199,18 +234,24 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
scopePath := utils.JoinPathAsUnix(userscope, path)
s = &share.Link{
Path: scopePath,
Hash: secure_hash,
Source: source.Path, // path instead to persist accoss name change
Expire: expire,
UserID: d.user.ID,
Hash: secure_hash,
PasswordHash: stringHash,
Token: token,
CommonShare: body.CommonShare,
}
if err := store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
// Overwrite the Source field with the source name from the query for each link
s.Source = sourceName
if body.Hash != "" {
return renderJSON(w, r, s)
}
return renderJSON(w, r, s)
}
@@ -241,3 +282,14 @@ func generateShortUUID() (string, error) {
// Trim the length to 22 characters for a shorter ID
return uuid[:22], nil
}
func redirectToShare(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Remove the base URL and "/share/" prefix to get the full path after share
sharePath := strings.TrimPrefix(r.URL.Path, config.Server.BaseURL+"share/")
newURL := config.Server.BaseURL + "public/share/" + sharePath
if r.URL.RawQuery != "" {
newURL += "?" + r.URL.RawQuery
}
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
return http.StatusMovedPermanently, nil
}
+68 -25
View File
@@ -45,37 +45,80 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
}
}
staticURL := config.Server.BaseURL + "static"
data := map[string]interface{}{
"CustomCSS": config.Frontend.Styling.CustomCSS,
"title": config.Frontend.Name,
"customCSS": config.Frontend.Styling.CustomCSS,
"userSelectedTheme": userSelectedTheme,
"LightBackground": config.Frontend.Styling.LightBackground,
"DarkBackground": config.Frontend.Styling.DarkBackground,
"StaticURL": config.Server.BaseURL + "static",
"BaseURL": config.Server.BaseURL,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": staticURL + "/img/icons/favicon-256x256.png",
"color": "var(--primaryColor)",
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server",
}
shareOverrides := map[string]interface{}{
"banner": "",
"title": "",
"quickDownload": false,
"description": "",
"themeColor": "",
"disableThumbnails": false,
"viewMode": "list",
"disableFileViewer": false,
}
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil {
disableNavButtons = disableNavButtons || d.share.HideNavButtons
shareOverrides["viewMode"] = d.share.ViewMode
shareOverrides["banner"] = d.share.Banner
shareOverrides["title"] = d.share.Title
shareOverrides["description"] = d.share.Description
shareOverrides["themeColor"] = d.share.ThemeColor
shareOverrides["quickDownload"] = d.share.QuickDownload
shareOverrides["disableThumbnails"] = d.share.DisableThumbnails
shareOverrides["disableFileViewer"] = d.share.DisablingFileViewer
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
} else {
data["favicon"] = staticURL + "/" + d.share.Favicon
}
}
if d.share.Description != "" {
data["description"] = d.share.Description
}
if d.share.Title != "" {
data["title"] = d.share.Title
}
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"name": config.Frontend.Name,
"disableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.DarkMode,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"LoginPage": auther.LoginPage(),
"EnableThumbs": !config.Server.DisablePreviews,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.SourceMap),
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
"MuPdfAvailable": config.Server.MuPdfAvailable,
"UpdateAvailable": utils.GetUpdateAvailableUrl(),
"DisableNavButtons": settings.Config.Frontend.DisableNavButtons,
"UserSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"baseURL": config.Server.BaseURL,
"version": version.Version,
"commitSHA": version.CommitSHA,
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"noAuth": config.Auth.Methods.NoAuth,
"loginPage": auther.LoginPage(),
"enableThumbs": !config.Server.DisablePreviews,
"externalLinks": config.Frontend.ExternalLinks,
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"sourceCount": len(config.Server.SourceMap),
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"mediaAvailable": config.Integrations.Media.FfmpegPath != "",
"muPdfAvailable": config.Server.MuPdfAvailable,
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"share": shareOverrides,
}
jsonVars, err := json.Marshal(data["globalVars"])
if err != nil {
+1 -68
View File
@@ -4,12 +4,6 @@
package indexing
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"syscall"
)
@@ -18,69 +12,8 @@ func CheckWindowsHidden(realpath string) bool {
return false
}
func getPartitionSize(path string) (uint64, error) {
if runtime.GOOS == "darwin" {
return getFilesystemSize(path)
}
// Get the device containing the path
var stat syscall.Stat_t
err := syscall.Stat(path, &stat)
if err != nil {
return 0, err
}
// Get the device number
dev := stat.Dev
major := (dev >> 8) & 0xff
minor := dev & 0xff
// Read /proc/partitions to find the partition size
file, err := os.Open("/proc/partitions")
if err != nil {
// Fallback to filesystem size if we can't read /proc/partitions
return getFilesystemSize(path)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "major") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
// Parse major and minor device numbers
majorNum, err1 := strconv.ParseUint(fields[0], 10, 32)
minorNum, err2 := strconv.ParseUint(fields[1], 10, 32)
blocks, err3 := strconv.ParseUint(fields[2], 10, 64)
if err1 != nil || err2 != nil || err3 != nil {
continue
}
// Check if this matches our device
if uint32(majorNum) == uint32(major) && uint32(minorNum) == uint32(minor) {
// blocks are in 1024-byte units in /proc/partitions
return blocks * 1024, nil
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("error reading /proc/partitions: %v", err)
}
// Fallback to filesystem size if partition not found
return getFilesystemSize(path)
}
// getFilesystemSize returns the filesystem size (fallback method)
func getFilesystemSize(path string) (uint64, error) {
func getPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
+1 -1
View File
@@ -382,7 +382,7 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := utils.ResolveSymlinks(absolutePath)
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err == nil {
RealPathCache.Set(joinedPath, realPath)
RealPathCache.Set(joinedPath+":isdir", isDir)
+4
View File
@@ -3,6 +3,8 @@ package iteminfo
import (
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
)
type ItemInfo struct {
@@ -37,6 +39,8 @@ type ExtendedFileInfo struct {
// FileOptions are the options when getting a file info.
type FileOptions struct {
Access *access.Storage
Username string // username for access control
Path string // realpath
Source string
IsDir bool
+30
View File
@@ -1,6 +1,8 @@
package iteminfo
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
@@ -57,3 +59,31 @@ func (info *FileInfo) SortItems() {
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
})
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := IsDirectory(info)
return path, isDir, nil
}
}
}
+25
View File
@@ -31,3 +31,28 @@ func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
idx.UpdateMetadata(dirInfo)
}
}
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
+1 -1
View File
@@ -10,7 +10,7 @@ input,
#listingView .item img,
.sizeInputWrapper,
.sizeInput,
#search #input,
#search-input,
.floating-tooltip,
.settings-items>.item,
.button-group {
+445
View File
@@ -15,6 +15,385 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/access": {
"get": {
"description": "Lists access rules. Can be filtered by source, path, user, or group. Can also be grouped by user or group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "List access rules",
"parameters": [
{
"type": "string",
"description": "Source name (e.g. 'default')",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Username to filter rules for",
"name": "user",
"in": "query"
},
{
"type": "string",
"description": "Group name to filter rules for",
"name": "group",
"in": "query"
}
],
"responses": {
"200": {
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"schema": {
"type": "object"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"description": "Add or update an access rule for a sourcePath and indexPath.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Add or update access rule",
"parameters": [
{
"type": "string",
"description": "Source path prefix (e.g. mnt/storage)",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query",
"required": true
},
{
"description": "Rule details: allow (true/false), ruleCategory (user/group), value (username or groupname)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"allow": {
"type": "boolean"
},
"ruleCategory": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
],
"responses": {
"200": {
"description": "Rule added or updated",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Delete a user or group from an allow or deny list for a sourcePath and indexPath.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Delete access rule entry",
"parameters": [
{
"type": "string",
"description": "Source path prefix (e.g. mnt/storage)",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Rule type (allow or deny)",
"name": "ruleType",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Rule category (user or group)",
"name": "ruleCategory",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Username or groupname to remove",
"name": "value",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Rule entry deleted",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/access/group": {
"post": {
"description": "Adds a user to a group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Add a user to a group",
"parameters": [
{
"type": "string",
"description": "Group name",
"name": "group",
"in": "query",
"required": true
},
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "User added to group successfully"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Removes a user from a group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Remove a user from a group",
"parameters": [
{
"type": "string",
"description": "Group name",
"name": "group",
"in": "query",
"required": true
},
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "User removed from group successfully"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/access/groups": {
"get": {
"description": "Returns a list of all groups or the groups for a specific user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Get all groups or groups for a user",
"parameters": [
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query"
}
],
"responses": {
"200": {
"description": "Object containing a list of groups",
"schema": {
"$ref": "#/definitions/http.GroupListResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"description": "Returns a logout URL for the frontend to redirect to.",
@@ -1842,6 +2221,17 @@ const docTemplate = `{
}
}
},
"http.GroupListResponse": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"http.HttpResponse": {
"type": "object",
"properties": {
@@ -2686,27 +3076,82 @@ const docTemplate = `{
"share.Link": {
"type": "object",
"properties": {
"allowUpload": {
"description": "AllowEdit bool ` + "`" + `json:\"allowEdit,omitempty\"` + "`" + `",
"type": "boolean"
},
"allowedUsernames": {
"type": "array",
"items": {
"type": "string"
}
},
"banner": {
"type": "string"
},
"description": {
"type": "string"
},
"disableAnonymous": {
"type": "boolean"
},
"disableFileViewer": {
"type": "boolean"
},
"disableThumbnails": {
"type": "boolean"
},
"downloadsLimit": {
"type": "integer"
},
"expire": {
"type": "integer"
},
"favicon": {
"type": "string"
},
"hash": {
"type": "string"
},
"hideNavButtons": {
"type": "boolean"
},
"keepAfterExpiration": {
"type": "boolean"
},
"maxBandwidth": {
"type": "integer"
},
"password_hash": {
"type": "string"
},
"path": {
"type": "string"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"source": {
"type": "string"
},
"themeColor": {
"type": "string"
},
"title": {
"type": "string"
},
"token": {
"description": "Token is a random value that will only be set when PasswordHash is set. It is\nURL-Safe and is used to download links in password-protected shares via a\nquery arg.",
"type": "string"
},
"userID": {
"type": "integer"
},
"viewMode": {
"type": "string"
}
}
},
+445
View File
@@ -4,6 +4,385 @@
"contact": {}
},
"paths": {
"/api/access": {
"get": {
"description": "Lists access rules. Can be filtered by source, path, user, or group. Can also be grouped by user or group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "List access rules",
"parameters": [
{
"type": "string",
"description": "Source name (e.g. 'default')",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Username to filter rules for",
"name": "user",
"in": "query"
},
{
"type": "string",
"description": "Group name to filter rules for",
"name": "group",
"in": "query"
}
],
"responses": {
"200": {
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
"schema": {
"type": "object"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"description": "Add or update an access rule for a sourcePath and indexPath.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Add or update access rule",
"parameters": [
{
"type": "string",
"description": "Source path prefix (e.g. mnt/storage)",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query",
"required": true
},
{
"description": "Rule details: allow (true/false), ruleCategory (user/group), value (username or groupname)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"allow": {
"type": "boolean"
},
"ruleCategory": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
],
"responses": {
"200": {
"description": "Rule added or updated",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Delete a user or group from an allow or deny list for a sourcePath and indexPath.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Delete access rule entry",
"parameters": [
{
"type": "string",
"description": "Source path prefix (e.g. mnt/storage)",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Index path (e.g. /secret)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Rule type (allow or deny)",
"name": "ruleType",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Rule category (user or group)",
"name": "ruleCategory",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Username or groupname to remove",
"name": "value",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Rule entry deleted",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/access/group": {
"post": {
"description": "Adds a user to a group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Add a user to a group",
"parameters": [
{
"type": "string",
"description": "Group name",
"name": "group",
"in": "query",
"required": true
},
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "User added to group successfully"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Removes a user from a group.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Remove a user from a group",
"parameters": [
{
"type": "string",
"description": "Group name",
"name": "group",
"in": "query",
"required": true
},
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "User removed from group successfully"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/access/groups": {
"get": {
"description": "Returns a list of all groups or the groups for a specific user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Access"
],
"summary": "Get all groups or groups for a user",
"parameters": [
{
"type": "string",
"description": "User name",
"name": "user",
"in": "query"
}
],
"responses": {
"200": {
"description": "Object containing a list of groups",
"schema": {
"$ref": "#/definitions/http.GroupListResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"description": "Returns a logout URL for the frontend to redirect to.",
@@ -1831,6 +2210,17 @@
}
}
},
"http.GroupListResponse": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"http.HttpResponse": {
"type": "object",
"properties": {
@@ -2675,27 +3065,82 @@
"share.Link": {
"type": "object",
"properties": {
"allowUpload": {
"description": "AllowEdit bool `json:\"allowEdit,omitempty\"`",
"type": "boolean"
},
"allowedUsernames": {
"type": "array",
"items": {
"type": "string"
}
},
"banner": {
"type": "string"
},
"description": {
"type": "string"
},
"disableAnonymous": {
"type": "boolean"
},
"disableFileViewer": {
"type": "boolean"
},
"disableThumbnails": {
"type": "boolean"
},
"downloadsLimit": {
"type": "integer"
},
"expire": {
"type": "integer"
},
"favicon": {
"type": "string"
},
"hash": {
"type": "string"
},
"hideNavButtons": {
"type": "boolean"
},
"keepAfterExpiration": {
"type": "boolean"
},
"maxBandwidth": {
"type": "integer"
},
"password_hash": {
"type": "string"
},
"path": {
"type": "string"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"source": {
"type": "string"
},
"themeColor": {
"type": "string"
},
"title": {
"type": "string"
},
"token": {
"description": "Token is a random value that will only be set when PasswordHash is set. It is\nURL-Safe and is used to download links in password-protected shares via a\nquery arg.",
"type": "string"
},
"userID": {
"type": "integer"
},
"viewMode": {
"type": "string"
}
}
},
+301
View File
@@ -12,6 +12,13 @@ definitions:
name:
type: string
type: object
http.GroupListResponse:
properties:
groups:
items:
type: string
type: array
type: object
http.HttpResponse:
properties:
message:
@@ -638,16 +645,51 @@ definitions:
type: object
share.Link:
properties:
allowUpload:
description: AllowEdit bool `json:"allowEdit,omitempty"`
type: boolean
allowedUsernames:
items:
type: string
type: array
banner:
type: string
description:
type: string
disableAnonymous:
type: boolean
disableFileViewer:
type: boolean
disableThumbnails:
type: boolean
downloadsLimit:
type: integer
expire:
type: integer
favicon:
type: string
hash:
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
type: boolean
maxBandwidth:
type: integer
password_hash:
type: string
path:
type: string
quickDownload:
type: boolean
shareTheme:
type: string
source:
type: string
themeColor:
type: string
title:
type: string
token:
description: |-
Token is a random value that will only be set when PasswordHash is set. It is
@@ -656,6 +698,8 @@ definitions:
type: string
userID:
type: integer
viewMode:
type: string
type: object
users.AuthToken:
properties:
@@ -862,6 +906,263 @@ definitions:
info:
contact: {}
paths:
/api/access:
delete:
consumes:
- application/json
description: Delete a user or group from an allow or deny list for a sourcePath
and indexPath.
parameters:
- description: Source path prefix (e.g. mnt/storage)
in: query
name: source
required: true
type: string
- description: Index path (e.g. /secret)
in: query
name: path
required: true
type: string
- description: Rule type (allow or deny)
in: query
name: ruleType
required: true
type: string
- description: Rule category (user or group)
in: query
name: ruleCategory
required: true
type: string
- description: Username or groupname to remove
in: query
name: value
required: true
type: string
produces:
- application/json
responses:
"200":
description: Rule entry deleted
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Delete access rule entry
tags:
- Access
get:
consumes:
- application/json
description: Lists access rules. Can be filtered by source, path, user, or group.
Can also be grouped by user or group.
parameters:
- description: Source name (e.g. 'default')
in: query
name: source
type: string
- description: Index path (e.g. /secret)
in: query
name: path
type: string
- description: Username to filter rules for
in: query
name: user
type: string
- description: Group name to filter rules for
in: query
name: group
type: string
produces:
- application/json
responses:
"200":
description: Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule,
map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule
schema:
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: List access rules
tags:
- Access
post:
consumes:
- application/json
description: Add or update an access rule for a sourcePath and indexPath.
parameters:
- description: Source path prefix (e.g. mnt/storage)
in: query
name: source
required: true
type: string
- description: Index path (e.g. /secret)
in: query
name: path
required: true
type: string
- description: 'Rule details: allow (true/false), ruleCategory (user/group),
value (username or groupname)'
in: body
name: body
required: true
schema:
properties:
allow:
type: boolean
ruleCategory:
type: string
value:
type: string
type: object
produces:
- application/json
responses:
"200":
description: Rule added or updated
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Add or update access rule
tags:
- Access
/api/access/group:
delete:
consumes:
- application/json
description: Removes a user from a group.
parameters:
- description: Group name
in: query
name: group
required: true
type: string
- description: User name
in: query
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: User removed from group successfully
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Remove a user from a group
tags:
- Access
post:
consumes:
- application/json
description: Adds a user to a group.
parameters:
- description: Group name
in: query
name: group
required: true
type: string
- description: User name
in: query
name: user
required: true
type: string
produces:
- application/json
responses:
"200":
description: User added to group successfully
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Add a user to a group
tags:
- Access
/api/access/groups:
get:
consumes:
- application/json
description: Returns a list of all groups or the groups for a specific user.
parameters:
- description: User name
in: query
name: user
type: string
produces:
- application/json
responses:
"200":
description: Object containing a list of groups
schema:
$ref: '#/definitions/http.GroupListResponse'
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Get all groups or groups for a user
tags:
- Access
/api/auth/logout:
post:
description: Returns a logout URL for the frontend to redirect to.
+740
View File
@@ -0,0 +1,740 @@
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92478a24..5085e058 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,17 +11,16 @@ All notable changes to this project will be documented in this file. For commit
**New Features**:
- New access control system that works like shares. You can add new allow / deny rules for users/groups for specific paths on specific sources.
- #- The concept of 'groups', either automatically from OIDC groups claim or defined https://github.com/gtsteffaniak/filebrowser/issues/545
+ - The concept of 'groups', either automatically from OIDC groups claim or defined https://github.com/gtsteffaniak/filebrowser/issues/545
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
- many share links customization enhancements
- #- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
- #- one-time download links
- #- customize share theme, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
- #- share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
- #- "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
+ - only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
+ - one-time download links
+ - customize share theme, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
+ - share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
+ - "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
- upload to share links https://github.com/gtsteffaniak/filebrowser/issues/661
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
- -
- public/private folder logic https://github.com/gtsteffaniak/filebrowser/issues/505
**BugFixes**:
diff --git a/backend/http/middleware.go b/backend/http/middleware.go
index 5ba397fe..595f00e1 100644
--- a/backend/http/middleware.go
+++ b/backend/http/middleware.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
- "net/url"
"runtime"
"strings"
"time"
@@ -44,14 +43,8 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
// Middleware to handle file requests by hash and pass it to the handler
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
+ path := r.URL.Query().Get("path")
hash := r.URL.Query().Get("hash")
- encodedPath := r.URL.Query().Get("path")
- // Decode the URL-encoded path
- path, err := url.QueryUnescape(encodedPath)
- if err != nil {
- return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
- }
- data.path = path
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
@@ -71,6 +64,11 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
+ data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
+ if path == "" || path == "/" {
+ data.path = link.Path
+ }
+
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
diff --git a/backend/http/middleware_test.go b/backend/http/middleware_test.go
index ff7c173e..eb257951 100644
--- a/backend/http/middleware_test.go
+++ b/backend/http/middleware_test.go
@@ -155,20 +155,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
},
expectedStatusCode: 0, // zero means 200 on helpers
},
- {
- name: "Private share, valid password when token exists",
- share: &share.Link{
- Hash: "pw_and_token_hash",
- UserID: 1,
- PasswordHash: passwordBcrypt,
- Token: "some_random_token",
- Source: "/srv",
- },
- extraHeaders: map[string]string{
- "X-SHARE-PASSWORD": "password",
- },
- expectedStatusCode: 0, // zero means 200 on helpers
- },
{
name: "Private share, no auth provided",
share: &share.Link{
diff --git a/backend/http/public.go b/backend/http/public.go
index ef65dfa6..0e641fbd 100644
--- a/backend/http/public.go
+++ b/backend/http/public.go
@@ -7,6 +7,9 @@ import (
"net/url"
"strings"
+ "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
+ "github.com/gtsteffaniak/filebrowser/backend/common/utils"
+ "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -49,7 +52,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
- d.fileInfo.Path = "/" + strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
+ d.fileInfo.Path = strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
return renderJSON(w, r, d.fileInfo)
}
@@ -91,5 +94,24 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
+ path := r.URL.Query().Get("path")
+ var err error
+ if path == "" {
+ return http.StatusBadRequest, fmt.Errorf("invalid request path")
+ }
+ fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
+ Path: utils.JoinPathAsUnix(d.share.Path, path),
+ Modify: false, // TODO make this configurable
+ Source: d.fileInfo.Source,
+ Expand: true,
+ })
+ if err != nil {
+ logger.Debugf("public preview handler: error getting file info: %v", err)
+ return 400, fmt.Errorf("file not found")
+ }
+ if fileInfo.Type == "directory" {
+ return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
+ }
+ d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 9399a292..2ef5f38e 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -7,3 +7,6 @@ import search from "./search";
// Note: shareApi has been consolidated into publicApi
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
+
+// For backward compatibility, export publicApi as shareApi as well
+export { publicApi as shareApi };
diff --git a/frontend/src/api/public.js b/frontend/src/api/public.js
index 1dcb4125..d94713c5 100644
--- a/frontend/src/api/public.js
+++ b/frontend/src/api/public.js
@@ -2,7 +2,6 @@ import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, removePrefix } from "@/utils/url.js";
import { externalUrl, baseURL } from "@/utils/constants";
-import { state } from "@/store";
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
@@ -13,8 +12,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
const params = {
path,
hash,
- ...(content && { content: 'true' }),
- ...(state.share.token && { token: state.share.token })
+ ...(content && { content: 'true' })
}
const apiPath = getApiPath("public/api/shared", params);
const response = await fetch(apiPath, {
@@ -24,17 +22,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
});
if (!response.ok) {
- const error = new Error(response.statusText);
- // attempt to marshal json response
- let data = null;
- try {
- data = await response.json()
- } catch (e) {
- // ignore
- }
- if (data) {
- error.message = data.message;
- }
+ const error = new Error("Failed to connect to the server.");
error.status = response.status;
throw error;
}
@@ -57,14 +45,13 @@ export function getDownloadURL(share, files) {
}
// Generate a preview URL for public shares
-export function getPreviewURL(path) {
+export function getPreviewURL(hash, path) {
try {
const params = {
path: encodeURIComponent(path),
size: 'small',
- hash: state.share.hash,
- inline: 'true',
- ...(state.share.token && { token: state.share.token })
+ hash: hash,
+ inline: 'true'
}
const apiPath = getApiPath('public/api/preview', params)
return window.origin + apiPath
diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js
new file mode 100644
index 00000000..729b7599
--- /dev/null
+++ b/frontend/src/api/share.js
@@ -0,0 +1,66 @@
+import { fetchURL, fetchJSON, adjustedData } from "./utils";
+import { notify } from "@/notify";
+import { getApiPath } from "@/utils/url.js";
+import { externalUrl } from "@/utils/constants";
+
+export async function list() {
+ const apiPath = getApiPath("api/shares");
+ return fetchJSON(apiPath);
+}
+
+export async function get(path, source) {
+ try {
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
+ const apiPath = getApiPath("api/share",params);
+ let data = fetchJSON(apiPath);
+ return adjustedData(data, path);
+ } catch (err) {
+ notify.showError(err.message || "Error fetching data");
+ throw err;
+ }
+}
+
+export async function remove(hash) {
+ const params = { hash };
+ const apiPath = getApiPath("api/share",params);
+ await fetchURL(apiPath, {
+ method: "DELETE",
+ });
+}
+
+export async function create(path, source, password = "", expires = "", unit = "hours") {
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
+ const apiPath = getApiPath("api/share",params);
+ let body = "{}";
+ if (password != "" || expires !== "" || unit !== "hours") {
+ body = JSON.stringify({ password: password, expires: expires, unit: unit });
+ }
+ return fetchJSON(apiPath, {
+ method: "POST",
+ body: body,
+ });
+}
+
+export function getShareURL(share) {
+ if (externalUrl) {
+ const apiPath = getApiPath(`share/${share.hash}`)
+ return externalUrl + apiPath
+ }
+ return window.origin+getApiPath(`share/${share.hash}`);
+}
+
+export function getPreviewURL(hash, path) {
+ try {
+ const params = {
+ path: encodeURIComponent(path),
+ size: 'small',
+ hash: hash,
+ inline: 'true'
+ }
+ const apiPath = getApiPath('api/public/preview', params)
+ return window.origin + apiPath
+ } catch (err) {
+ notify.showError(err.message || 'Error getting preview URL')
+ throw err
+ }
+}
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
index 29c2749f..93848c1c 100644
--- a/frontend/src/components/files/ListingItem.vue
+++ b/frontend/src/components/files/ListingItem.vue
@@ -67,7 +67,7 @@ import { enableThumbs } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
-import { filesApi,publicApi } from "@/api";
+import { filesApi, shareApi } from "@/api";
import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
@@ -162,8 +162,8 @@ export default {
// @ts-ignore
let path = url.removeTrailingSlash(state.req.path) + "/" + this.name;
if (getters.isShare()) {
- let urlPath = getters.getSharePath(this.name) ;
- return publicApi.getPreviewURL(urlPath);
+ let urlPath = getters.getSharePath() + "/" + this.name;
+ return shareApi.getPreviewURL(state.share.hash, urlPath, state.req.modified);
}
// @ts-ignore
return filesApi.getPreviewURL(state.req.source, path, this.modified);
@@ -226,7 +226,7 @@ export default {
},
getUrl() {
if (this.hash) {
- return baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
+ return baseURL + "share/" + this.hash + url.encodedPath(this.path);
}
if (serverHasMultipleSources) {
return baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
diff --git a/frontend/src/components/prompts/ActionApi.vue b/frontend/src/components/prompts/ActionApi.vue
index f2ebaa3f..1a381565 100644
--- a/frontend/src/components/prompts/ActionApi.vue
+++ b/frontend/src/components/prompts/ActionApi.vue
@@ -90,3 +90,20 @@ export default {
},
};
</script>
+
+<style>
+/* Basic styling for the prompt */
+.card.floating {
+ padding: 20px;
+}
+.card-content {
+ margin-bottom: 16px;
+}
+.card-action {
+ display: flex;
+ justify-content: flex-end;
+}
+.card-action .button {
+ margin-left: 8px;
+}
+</style>
diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue
index be1ad5ad..8689100d 100644
--- a/frontend/src/components/prompts/Share.vue
+++ b/frontend/src/components/prompts/Share.vue
@@ -2,11 +2,10 @@
<div class="card-title">
<h2>{{ $t("buttons.share") }}</h2>
</div>
- <div class="card-content">
- <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
- <p> {{ $t('share.notice') }} </p>
-
- <div v-if="listing">
+ <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
+ <p> {{ $t('share.notice') }} </p>
+ <template v-if="listing">
+ <div class="card-content">
<table>
<tbody>
<tr>
@@ -46,7 +45,20 @@
</table>
</div>
- <div v-else>
+ <div class="card-action">
+ <button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
+ :title="$t('buttons.close')">
+ {{ $t("buttons.close") }}
+ </button>
+ <button class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
+ :title="$t('buttons.new')">
+ {{ $t("buttons.new") }}
+ </button>
+ </div>
+ </template>
+
+ <template v-else>
+ <div class="card-content">
<p>{{ $t("settings.shareDuration") }}</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="1" @keyup.enter="submit" v-model.trim="time" />
@@ -58,7 +70,7 @@
</select>
</div>
<p>{{ $t("prompts.optionalPassword") }}</p>
- <input class="input" type="password" autocomplete="off" v-model.trim="password" />
+ <input class="input" type="password" v-model.trim="password" />
<div class="settings-items">
<ToggleSwitch class="item" v-model="readOnly" :name="'Read-only access'" />
@@ -80,32 +92,23 @@
</select>
</div>
</div>
- </div>
- <div class="card-action">
- <button v-if="listing" class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
- :title="$t('buttons.close')">
- {{ $t("buttons.close") }}
- </button>
- <button v-if="listing" class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
- :title="$t('buttons.new')">
- {{ $t("buttons.new") }}
- </button>
-
- <button v-if="!listing" class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
- :title="$t('buttons.cancel')">
- {{ $t("buttons.cancel") }}
- </button>
- <button v-if="!listing" class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
- :title="$t('buttons.share')">
- {{ $t("buttons.share") }}
- </button>
- </div>
+ <div class="card-action">
+ <button class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
+ :title="$t('buttons.cancel')">
+ {{ $t("buttons.cancel") }}
+ </button>
+ <button class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
+ :title="$t('buttons.share')">
+ {{ $t("buttons.share") }}
+ </button>
+ </div>
+ </template>
</template>
<script>
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
-import { publicApi } from "@/api";
+import { shareApi, publicApi } from "@/api";
import Clipboard from "clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
@@ -188,7 +191,7 @@ export default {
try {
// get last element of the path
- const links = await publicApi.get(this.subpath, this.source);
+ const links = await shareApi.get(this.subpath, this.source);
this.links = links;
} catch (err) {
notify.showError(err);
@@ -215,9 +218,9 @@ export default {
let isPermanent = !this.time || this.time == 0;
let res = null;
if (isPermanent) {
- res = await publicApi.create(this.subpath, this.source, this.password);
+ res = await shareApi.create(this.subpath, this.source, this.password);
} else {
- res = await publicApi.create(
+ res = await shareApi.create(
this.subpath,
this.source,
this.password,
@@ -241,7 +244,7 @@ export default {
async deleteLink(event, link) {
event.preventDefault();
try {
- await publicApi.remove(link.hash);
+ await shareApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
if (this.links.length === 0) {
this.listing = false;
@@ -254,7 +257,7 @@ export default {
return fromNow(time, state.user.locale)
},
buildLink(share) {
- return publicApi.getShareURL(share);
+ return shareApi.getShareURL(share);
},
hasDownloadLink() {
if (state.isSearchActive) {
diff --git a/frontend/src/components/sidebar/General.vue b/frontend/src/components/sidebar/General.vue
index 58f02c10..0e030aeb 100644
--- a/frontend/src/components/sidebar/General.vue
+++ b/frontend/src/components/sidebar/General.vue
@@ -159,7 +159,7 @@ export default {
isStickySidebar: () => getters.isStickySidebar(),
isMobile: () => getters.isMobile(),
isListingView: () => getters.currentView() == "listingView",
- user: () => (state.user),
+ user: () => (console.log(state.user), state.user),
isDarkMode: () => getters.isDarkMode(),
showSources: () => !getters.isShare(),
currentPrompt: () => getters.currentPrompt(),
diff --git a/frontend/src/css/dashboard.css b/frontend/src/css/dashboard.css
index 2e1fbc8f..77da5938 100644
--- a/frontend/src/css/dashboard.css
+++ b/frontend/src/css/dashboard.css
@@ -226,10 +226,6 @@ body.rtl .card-title>*:first-child {
overflow: auto;
}
-.card-content h3 {
- text-align: center;
-}
-
.card h2 {
font-weight: 500;
}
diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js
index cb062702..d97e7eed 100644
--- a/frontend/src/store/getters.js
+++ b/frontend/src/store/getters.js
@@ -201,9 +201,10 @@ export const getters = {
sharePathBase: () => {
return '/public/share/' + getters.shareHash() + '/'
},
- getSharePath: (subPath = "") => {
+ getSharePath: () => {
let urlPath = getters.routePath('public/share')
- return "/" + removeLeadingSlash(urlPath.split(state.share.hash)[1]) + "/" + subPath
+ let parts = urlPath.split('/')
+ return "/" + removeLeadingSlash(parts.slice(2).join('/'))
},
currentView: () => {
let listingView = ''
@@ -288,6 +289,7 @@ export const getters = {
},
currentPromptName: () => {
+ console.log("currentPromptName", state.prompts);
// Ensure state.prompts is an array
if (!Array.isArray(state.prompts) || state.prompts.length === 0) {
return ""
diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js
index 8dc0b19f..b2b13461 100644
--- a/frontend/src/store/mutations.js
+++ b/frontend/src/store/mutations.js
@@ -191,6 +191,7 @@ export const mutations = {
emitStateChanged();
},
showHover: (value) => {
+ console.log("showHover", value);
if (typeof value === "object") {
state.prompts.push({
name: value?.name,
@@ -206,6 +207,7 @@ export const mutations = {
props: value?.props,
});
}
+ console.log("showHover", state.prompts);
emitStateChanged();
},
setLoading: (loadType, status) => {
@@ -221,6 +223,7 @@ export const mutations = {
emitStateChanged();
},
setCurrentUser: (value) => {
+ console.log('setCurrentUser', value);
try {
// If value is null or undefined, emit state change and exit early
if (!value) {
@@ -243,6 +246,7 @@ export const mutations = {
} catch (error) {
console.log(error);
}
+ console.log('setCurrentUser', state.user);
emitStateChanged();
},
setJWT: (value) => {
@@ -303,6 +307,7 @@ export const mutations = {
if (!state.user) {
state.user = {};
}
+ console.log('updateCurrentUser', value);
// Store previous state for comparison
const previousUser = { ...state.user };
@@ -315,12 +320,14 @@ export const mutations = {
i18n.default.locale = state.user.locale;
localStorage.setItem("userLocale", state.user.locale);
}
+ console.log('updateCurrentUser2', state.user);
// Update localStorage if stickySidebar exists
if (state.user.stickySidebar && getters.currentView() == "listingView") {
state.multiButtonState = "menu";
} else if (state.showSidebar) {
state.multiButtonState = "back";
}
+ console.log('updateCurrentUser3', state.user);
// Update users if there's any change in state.user
if (JSON.stringify(state.user) !== JSON.stringify(previousUser)) {
// Only update the properties that were actually provided in the input
diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue
index 4c69ecd7..6f8b892b 100644
--- a/frontend/src/views/Files.vue
+++ b/frontend/src/views/Files.vue
@@ -9,9 +9,8 @@
<div class="card-title">
<h2>{{ $t("general.password") }}</h2>
</div>
- <div class="card-content form-flex-group">
+ <div class="card-content">
<input
- class="input share-password"
v-focus
type="password"
:placeholder="$t('general.password')"
@@ -149,10 +148,8 @@ export default {
});
// Add glow effect
element.classList.add('scroll-glow');
- console.log("scrollToHash", element)
// Remove glow effect after animation completes
setTimeout(() => {
- console.log("scrollToHash remove glow", element)
element.classList.remove('scroll-glow');
}, 1000);
}
@@ -211,15 +208,19 @@ export default {
this.shareHash = parts[1]
this.shareSubPath = "/" + parts.slice(2).join("/");
+ // Store share data in state for use by components
+ mutations.setShareData({
+ hash: this.shareHash,
+ token: this.shareToken,
+ subPath: this.shareSubPath,
+ });
+
// Handle password
- if (this.sharePassword === "") {
+ if (this.sharePassword === "" || this.sharePassword === null) {
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
} else {
localStorage.setItem("sharepass:" + this.shareHash, this.sharePassword);
}
- if (this.sharePassword === null) {
- this.sharePassword = "";
- }
mutations.resetSelected();
mutations.setMultiple(false);
@@ -229,15 +230,11 @@ export default {
let file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword);
file.hash = this.shareHash;
this.shareToken = file.token;
- // Store share data in state for use by components
- mutations.setShareData({
- hash: this.shareHash,
- token: this.shareToken,
- subPath: this.shareSubPath,
- });
+
// If not a directory, fetch content for preview components
if (file.type != "directory") {
const content = !getters.fileViewingDisabled(file.name);
+
file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword, content);
file.hash = this.shareHash;
this.shareToken = file.token;
@@ -324,7 +321,7 @@ export default {
};
</script>
-<style>
+<style scoped>
.share__wrong__password {
color: #ff4757;
text-align: center;
@@ -351,8 +348,4 @@ export default {
color: inherit;
}
}
-
-.share-password {
- width: 100%;
-}
</style>
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index 4547e3d2..38d75dc1 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -122,4 +122,7 @@ export default {
background-color: var(--surfaceSecondary);
}
+.card-content {
+ padding: 1em;
+}
</style>
diff --git a/frontend/src/views/settings/Profile.vue b/frontend/src/views/settings/Profile.vue
index f65fe30f..e34e405a 100644
--- a/frontend/src/views/settings/Profile.vue
+++ b/frontend/src/views/settings/Profile.vue
@@ -428,6 +428,9 @@ export default {
</script>
<style scoped>
+.card-content h3 {
+ text-align: center;
+}
#disablePreviews,
#disableViewing,
diff --git a/frontend/src/views/settings/Shares.vue b/frontend/src/views/settings/Shares.vue
index cd28af70..6d39166e 100644
--- a/frontend/src/views/settings/Shares.vue
+++ b/frontend/src/views/settings/Shares.vue
@@ -49,7 +49,7 @@
<script>
import { notify } from "@/notify";
-import { publicApi } from "@/api";
+import { shareApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import Errors from "@/views/Errors.vue";
@@ -70,7 +70,7 @@ export default {
async created() {
mutations.setLoading("shares", true);
try {
- let links = await publicApi.list();
+ let links = await shareApi.list();
if (links.length === 0) {
this.links = [];
return;
@@ -115,7 +115,7 @@ export default {
mutations.closeHovers();
try {
- publicApi.remove(link.hash);
+ shareApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
notify.showSuccess(this.$t("settings.shareDeleted"));
} catch (e) {
@@ -128,7 +128,7 @@ export default {
return fromNow(time);
},
buildLink(share) {
- return publicApi.getShareURL(share);
+ return shareApi.getShareURL(share);
},
},
};
+1 -1
View File
@@ -44,7 +44,7 @@
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.54.1",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
+47
View File
@@ -0,0 +1,47 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/local-setup",
timeout: 5000,
testDir: "./tests/playwright/copy",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+52 -1
View File
@@ -97,7 +97,58 @@ frontend:
customThemes: # A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
alternative:
description: Reduce rounded corners
css: reduce-rounded-corners.css
css: |-
.card,
#listingView.gallery .item,
#listingView.normal .item,
.button,
button,
.button-action,
.input,
input,
#listingView.gallery .item img,
#listingView .item img,
.sizeInputWrapper,
.sizeInput,
#search-input,
.floating-tooltip,
.settings-items>.item,
.button-group {
border-radius: 0.25em !important;
}
#listingView.compact .header {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
#listingView.list .item:last-child,
#results {
border-bottom-left-radius: 0.25em !important;
border-bottom-right-radius: 0.25em !important;
}
#listingView.list .item:first-child {
border-top-left-radius: 0.25em !important;
border-top-right-radius: 0.25em !important;
}
#breadcrumbs ul li:last-child a {
border-top-right-radius: 0.25em !important;
border-bottom-right-radius: 0.25em !important;
}
#breadcrumbs ul li:first-child a {
border-top-left-radius: 0.25em !important;
border-bottom-left-radius: 0.25em !important;
}
.button-group button {
border-radius: 0 !important;
}
.icon {
border-radius: 0.1em !important;
}
default:
description: The default theme
css: ""
+16 -16
View File
@@ -5,51 +5,51 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>{{ if .Name }}{{ .Name }}{{ else }}FileBrowser Quantum{{ end }}</title>
<title>{{ .title }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
<link rel="icon" type="image/png" sizes="256x256" href="{{ .favicon }}">
<!--<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">-->
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
<meta name="theme-color" content="{{ .color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="assets">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .StaticURL }}/img/icons/android-chrome-256x256.png">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .appIcon }}">
<!-- Add to home screen for Windows -->
<meta name="msapplication-TileImage" content="{{ .StaticURL }}/img/icons/mstile-144x144.png">
<meta name="msapplication-TileColor" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
<meta name="msapplication-TileImage" content="{{ .winIcon }}">
<meta name="msapplication-TileColor" content="{{ .color }}">
<meta name="description" content="FileBrowser Quantum is a file manager for the web which can be used to manage files on your server">
<meta name="description" content="{{ .description }}">
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
<link rel="canonical" href="https://github.com/gtsteffaniak">
<link rel="canonical" href="https://github.com/quantumx-apps">
<!-- Inject Some Variables and generate the manifest json -->
<script>
window.FileBrowser = JSON.parse('{{ .globalVars }}');
window.filebrowser = JSON.parse('{{ .globalVars }}');
var dynamicManifest = {
"name": window.FileBrowser.Name || 'FileBrowser Quantum',
"name": window.filebrowser.name || 'FileBrowser Quantum',
"icons": [
{
"src": window.location.origin + "{{ .StaticURL }}/img/icons/android-chrome-256x256.png",
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-256x256.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": window.location.origin + "{{ .StaticURL }}/img/icons/android-chrome-192x192.png",
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
],
"start_url": window.location.origin + "{{ .BaseURL }}",
"start_url": window.location.origin + "{{ .baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": window.FileBrowser.Color || "#455a64"
"theme_color": "{{ .color }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
@@ -61,7 +61,7 @@
<style>
/* Basic Styles */
:root {
--background: {{ .LightBackground }};
--background: {{ .lightBackground }};
--alt-background: #ddd;
--surfacePrimary: gray;
--surfaceSecondary: lightgray;
@@ -71,7 +71,7 @@
--activeWhiteIcon: gray;
}
.dark-mode {
--background: {{ .DarkBackground }};
--background: {{ .darkBackground }};
--alt-background: #283136;
--surfacePrimary: #20292F;
--surfaceSecondary: #3A4147;
@@ -155,7 +155,7 @@
transform: scale(1.0);
}
}
{{ .CustomCSS }}
{{ .customCSS }}
{{ .userSelectedTheme }}
</style>
</head>
+55
View File
@@ -0,0 +1,55 @@
import { fetchJSON } from './utils'
import { getApiPath } from '@/utils/url.js'
/**
* @param {string} source
* @param {string} path
* @returns {Promise<any>}
*/
export async function get(source, path) {
const apiPath = getApiPath('api/access', { source, path })
return fetchJSON(apiPath)
}
/**
* @param {string} source
* @returns {Promise<any>}
*/
export async function getAll(source) {
const apiPath = getApiPath('api/access', { source })
return fetchJSON(apiPath)
}
/**
* @returns {Promise<{groups: string[]}>}
*/
export async function getGroups() {
const apiPath = getApiPath('api/access/groups')
return fetchJSON(apiPath)
}
/**
* @param {string} source
* @param {string} path
* @param {object} body
* @returns {Promise<any>}
*/
export async function add(source, path, body) {
const apiPath = getApiPath('api/access', { source, path });
return fetchJSON(apiPath, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
});
}
/**
* @param {string} source
* @param {string} path
* @param {{ allow: boolean; ruleCategory: string; value: string; }} body
* @returns {Promise<any>}
*/
export async function del(source, path, body) {
const ruleType = body.allow ? 'allow' : 'deny';
const { ruleCategory, value } = body;
const apiPath = getApiPath('api/access', { source, path, ruleType, ruleCategory, value });
return fetchJSON(apiPath, {
method: 'DELETE'
});
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function download(format, files, shareHash = "") {
}
}
fileargs = fileargs.slice(0, -2) // remove trailing "||"
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'api/public/dl', {
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', {
files: fileargs,
algo: format,
hash: shareHash
+4 -3
View File
@@ -1,8 +1,9 @@
import * as filesApi from "./files";
import * as shareApi from "./share";
import * as publicApi from "./public";
import * as usersApi from "./users";
import * as settingsApi from "./settings";
import * as publicApi from "./public";
import * as accessApi from "./access";
import search from "./search";
export { filesApi, shareApi, usersApi, settingsApi, publicApi, search };
// Note: shareApi has been consolidated into publicApi
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
+156 -26
View File
@@ -1,11 +1,29 @@
import { adjustedData } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
import { state } from "@/store";
// ============================================================================
// PUBLIC API ENDPOINTS (hash-based authentication)
// ============================================================================
// Fetch public share data
export async function fetchPub(path, hash, password = "") {
const params = { path, hash }
const apiPath = getApiPath("api/public/share", params);
/**
* @param {string} path
* @param {string} hash
* @param {string} password
* @param {boolean} content
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false) {
const params = {
path,
hash,
...(content && { content: 'true' }),
...(state.share.token && { token: state.share.token })
}
const apiPath = getApiPath("public/api/shared", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
@@ -13,36 +31,148 @@ export async function fetchPub(path, hash, password = "") {
});
if (!response.ok) {
const error = new Error("Failed to connect to the server.");
error.status = response.status;
const error = new Error(response.statusText);
// attempt to marshal json response
let data = null;
try {
data = await response.json()
} catch (e) {
// ignore
}
if (data) {
error.message = data.message;
}
(/** @type {any} */ (error)).status = response.status;
throw error;
}
let data = await response.json()
const adjusted = adjustedData(data, `/share/${hash}${path}`);
const adjusted = adjustedData(data);
return adjusted
}
// Get the public user data
export async function getPublicUser() {
// Generate a download URL
/**
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
* @param {string[]} files
* @returns {string}
*/
export function getDownloadURL(share, files, inline=false) {
const params = {
files: files,
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getApiPath("public/api/raw", params);
return window.origin + apiPath
}
// Generate a preview URL for public shares
/**
* @param {string} path
* @returns {string}
*/
export function getPreviewURL(path,size="small") {
try {
const apiPath = getApiPath("api/public/publicUser");
const response = await fetch(apiPath);
return response.json();
} catch (err) {
notify.showError(err.message || "Error fetching public user");
const params = {
path: encodeURIComponent(path),
size: size,
hash: state.share.hash,
inline: 'true',
...(state.share.token && { token: state.share.token })
}
const apiPath = getApiPath('public/api/preview', params)
return window.origin + apiPath
} catch (/** @type {any} */ err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
// ============================================================================
// SHARE MANAGEMENT API (permission-based authentication)
// ============================================================================
// List all shares
export async function list() {
const apiPath = getApiPath("public/shares");
return fetchJSON(apiPath);
}
// Get share information
/**
* @param {string} path
* @param {string} source
* @returns {Promise<any>}
*/
export async function get(path, source) {
try {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("public/share", params);
let data = await fetchJSON(apiPath);
return adjustedData(data);
} catch (/** @type {any} */ err) {
notify.showError(err.message || "Error fetching data");
throw err;
}
}
// Generate a download URL
export function getDownloadURL(share,files) {
const params = {
path: share.path,
files: files,
hash: share.hash,
token: share.token,
...(share.inline && { inline: 'true' })
}
const apiPath = getApiPath("api/public/dl", params);
return window.origin+apiPath
// Remove/delete a share
/**
* @param {string} hash
* @returns {Promise<void>}
*/
export async function remove(hash) {
const params = { hash };
const apiPath = getApiPath("public/share", params);
await fetchURL(apiPath, {
method: "DELETE",
});
}
// Create a new share
/**
* @param {string} path
* @param {string} source
* @param {Record<string, any>} bodyObj
* @returns {Promise<Share>}
*/
export async function create(path, source, bodyObj = {}) {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("public/share", params);
return fetchJSON(apiPath, {
method: "POST",
body: JSON.stringify(bodyObj || {}),
});
}
// Get the shareable URL for a share
/**
* @param {{ hash: string }} share
* @returns {string}
*/
export function getShareURL(share) {
if (externalUrl) {
const apiPath = getApiPath(`public/share/${share.hash}`)
return externalUrl + apiPath;
}
return window.origin + getApiPath(`public/share/${share.hash}`);
}
/**
* @typedef {object} Share
* @property {string} hash
* @property {string} path
* @property {string} source
* @property {number} expire
* @property {number} downloadsLimit
* @property {number} maxBandwidth
* @property {string} shareTheme
* @property {boolean} disableAnonymous
* @property {boolean} disableThumbnails
* @property {boolean} keepAfterExpiration
* @property {string[]} allowedUsernames
* @property {string} viewMode
* @property {string} token
* @property {boolean} inline
*/
-66
View File
@@ -1,66 +0,0 @@
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
export async function list() {
const apiPath = getApiPath("api/shares");
return fetchJSON(apiPath);
}
export async function get(path, source) {
try {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share",params);
let data = fetchJSON(apiPath);
return adjustedData(data, path);
} catch (err) {
notify.showError(err.message || "Error fetching data");
throw err;
}
}
export async function remove(hash) {
const params = { hash };
const apiPath = getApiPath("api/share",params);
await fetchURL(apiPath, {
method: "DELETE",
});
}
export async function create(path, source, password = "", expires = "", unit = "hours") {
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share",params);
let body = "{}";
if (password != "" || expires !== "" || unit !== "hours") {
body = JSON.stringify({ password: password, expires: expires, unit: unit });
}
return fetchJSON(apiPath, {
method: "POST",
body: body,
});
}
export function getShareURL(share) {
if (externalUrl) {
const apiPath = getApiPath(`share/${share.hash}`)
return externalUrl + apiPath
}
return window.origin+getApiPath(`share/${share.hash}`);
}
export function getPreviewURL(hash, path) {
try {
const params = {
path: encodeURIComponent(path),
size: 'small',
hash: hash,
inline: 'true'
}
const apiPath = getApiPath('api/public/preview', params)
return window.origin + apiPath
} catch (err) {
notify.showError(err.message || 'Error getting preview URL')
throw err
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ export async function update(user, which = ['all']) {
const excludeKeys = ['id', 'name']
// Filter out the keys from "which"
which = which.filter(item => !excludeKeys.includes(item))
if (user.username === 'publicUser') {
if (user.username === 'anonymous') {
return
}
const apiPath = getApiPath('api/users', { id: user.id })
+4 -16
View File
@@ -49,14 +49,14 @@ export default {
return state.req;
},
multistate() {
if (!state.multiButtonState && getters.currentView() == "settings") {
return "close";
}
return state.multiButtonState;
return getters.multibuttonState();
},
stickSidebar() {
return state.stickSidebar;
},
currentView() {
return getters.currentView();
}
},
mounted() {
this.reEvalAction();
@@ -77,18 +77,6 @@ export default {
const currentView = getters.currentView();
if (currentView == "settings") {
mutations.setActiveSettingsView(getters.currentHash());
mutations.setMultiButtonState("close");
} else if (
currentView == "editor" ||
currentView == "preview" ||
currentView == "onlyOfficeEditor" ||
currentView == "markdownViewer" ||
currentView == "epubViewer" ||
currentView == "docViewer"
) {
mutations.setMultiButtonState("close");
} else {
mutations.setMultiButtonState("menu");
}
},
actionMultiIcon() {
+3 -2
View File
@@ -67,7 +67,7 @@ export default {
return state.req.hasUpdate;
},
addPadding() {
return getters.isStickySidebar() || getters.currentView() == "share";
return getters.isStickySidebar() || getters.isShare();
},
isCardView() {
return getters.isCardView();
@@ -112,9 +112,10 @@ export default {
},
methods: {
updatePaths() {
if (getters.currentView() == "share") {
if (getters.isShare()) {
this.base = getters.sharePathBase();
this.path = getters.routePath(this.base);
} else {
this.path = encodedPath(state.req.path);
this.base = "/";
-4
View File
@@ -114,10 +114,6 @@ button {
border-right: none;
}
button:hover {
background: #e0e0e0;
}
button:disabled {
cursor: not-allowed !important;
}
+74 -27
View File
@@ -10,7 +10,7 @@
ref="contextMenu"
v-if="showContext"
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
class="button no-select"
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
:key="showCreate ? 'create-mode' : 'normal-mode'"
>
@@ -101,6 +101,12 @@
:label="$t('buttons.delete')"
show="delete"
/>
<action
v-if="showAccess"
icon="lock"
:label="$t('access.rules')"
@action="showAccessHover"
/>
</div>
</transition>
<transition
@@ -117,7 +123,7 @@
top: '3em',
right: '1em',
}"
class="button no-select"
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('buttons.openFile')" @action="goToRaw()" />
@@ -137,7 +143,7 @@ import { onlyOfficeUrl } from "@/utils/constants.js";
import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
import { filesApi } from "@/api";
import { filesApi, publicApi } from "@/api";
export default {
name: "ContextMenu",
components: {
@@ -163,7 +169,7 @@ export default {
if (this.isMultiple || this.isSearchActive) {
return false;
}
if (state.user.showSelectMultiple) {
if (state.user?.showSelectMultiple) {
return true;
}
if (getters.isMobile()) {
@@ -171,18 +177,28 @@ export default {
}
return false
},
noItems() {
return !this.showEdit && !this.showSave && !this.showDelete;
hasOverflowItems() {
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw;
},
showGoToRaw() {
return getters.currentView() == "preview" ||
getters.currentView() == "markdownViewer"
const cv = getters.currentView();
return cv == "preview" || cv == "markdownViewer" || cv == "editor";
},
showEdit() {
return getters.currentView() == "markdownViewer" && state.user.permissions.modify;
const cv = getters.currentView();
if (getters.isShare()) {
// TODO: add support for editing shared files
return false;
}
return cv == "markdownViewer" && state.user?.permissions?.modify;
},
showDelete() {
return state.user.permissions.modify && this.isPreview;
const cv = getters.currentView();
if (getters.isShare()) {
// TODO: add support for deleting shared files
return false;
}
return state.user?.permissions?.modify && (cv == "preview" || cv == "onlyOfficeEditor" || cv == "markdownViewer" || cv == "epubViewer" || cv == "docViewer" || cv == "editor");
},
isPreview() {
const cv = getters.currentView();
@@ -191,21 +207,29 @@ export default {
cv == "onlyOfficeEditor" ||
cv == "markdownViewer" ||
cv == "epubViewer" ||
cv == "docViewer"
cv == "docViewer" ||
cv == "editor"
);
},
showSave() {
return getters.currentView() == "editor" && state.user.permissions.modify;
if (getters.isShare()) {
// TODO: add support for saving shared files
return false;
}
return getters.currentView() == "editor" && state.user?.permissions?.modify;
},
showOverflow() {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
return state.user?.permissions?.admin && this.showCreate;
},
showShare() {
return (
state.user?.permissions &&
state.user?.permissions.share &&
state.user.username != "publicUser" &&
getters.currentView() != "share" &&
state.user.username != "anonymous" &&
!getters.isShare() &&
!this.isSearchActive
);
},
@@ -227,6 +251,9 @@ export default {
centered() {
return this.showCentered || this.isMobileDevice || !this.posX || !this.posY;
},
isMobileDevice() {
return state.isMobile;
},
isDarkMode() {
return getters.isDarkMode();
},
@@ -235,13 +262,19 @@ export default {
},
userPerms() {
return {
upload: state.user.permissions?.modify && state.selected.length > 0,
share: state.user.permissions.share,
modify: state.user.permissions.modify,
upload: state.user?.permissions?.modify && state.selected.length > 0,
share: state.user?.permissions?.share,
modify: state.user?.permissions?.modify,
};
},
},
watch: {
hasOverflowItems: {
handler(hasItems) {
mutations.setContextMenuHasItems(hasItems);
},
immediate: true,
},
showContext: {
handler(newVal) {
if (newVal) {
@@ -263,6 +296,15 @@ export default {
}
},
methods: {
showAccessHover() {
mutations.showHover({
name: "access",
props: {
sourceName: state.sources.current,
path: state.req?.path || "",
},
});
},
// Animation methods
beforeEnter(el) {
this.isAnimating = true;
@@ -352,17 +394,23 @@ export default {
mutations.closeHovers();
},
startDownload() {
mutations.closeHovers();
downloadFiles(state.selected);
},
goToRaw() {
const downloadUrl = filesApi.getDownloadURL(
state.req.source,
state.req.path,
true,
false
);
window.open(downloadUrl, "_blank");
if (getters.isShare()) {
window.open(publicApi.getDownloadURL(state.share, state.req.path, true), "_blank");
mutations.closeHovers();
return;
}
const downloadUrl = filesApi.getDownloadURL(
state.req?.source || "",
state.req?.path || "",
true,
false
);
window.open(downloadUrl, "_blank");
mutations.closeHovers();
},
async edit() {
window.location.hash = "#edit";
@@ -376,7 +424,7 @@ export default {
notify.showSuccess("File Saved!");
} catch (e) {
buttons.done(button);
notify.showError("Error saving file: ", e);
notify.showError(`Error saving file: ${e}`);
}
mutations.closeHovers();
@@ -385,7 +433,7 @@ export default {
mutations.showHover({
name: "upload",
props: {
filesToReplace: state.selected.map((item) => item.name),
filesToReplace: state.selected.map((item) => item.name || ""),
},
});
},
@@ -405,7 +453,6 @@ export default {
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0px 0px 15px 0px #404040;
}
#context-menu.centered {
@@ -25,4 +25,7 @@ export default {
canvas.notification-spinner {
color: #fff;
}
#popup-notification {
z-index: 6;
}
</style>
+2 -1
View File
@@ -101,7 +101,8 @@ export default {
if (!this.isValNumeric) return 100;
if (this.max <= 0) return 0;
var pct = (this.val / this.max) * 100;
return Math.max(0, Math.min(pct.toFixed(2), 100));
var finalPct = Math.max(0, Math.min(pct.toFixed(2), 100));
return finalPct < 7 ? 0 : finalPct;
},
displayed_text() {
if (!this.isValNumeric) return this.val;
+7 -10
View File
@@ -5,7 +5,7 @@
@click="clearContext"
>
<!-- Search input section -->
<div id="input" @click="open">
<div id="search-input" @click="open">
<!-- Close button visible when search is active -->
<button
v-if="active"
@@ -34,7 +34,7 @@
</div>
<!-- Search results for desktop -->
<div v-show="active" id="results" ref="result">
<div v-show="active" id="results" class="fb-shadow" ref="result">
<div class="inputWrapper" style="display: flex">
<select
v-if="multipleSources"
@@ -551,16 +551,18 @@ export default {
border-left: 1px solid gray;
border-right: 1px solid gray;
word-wrap: break-word;
height: -webkit-fill-available;
}
.searchContext.input {
background-color: var(--primaryColor) !important;
border-radius: 0em;
border-radius: 0em !important;
color: white;
border: unset;
width: 25%;
min-width: 7em;
max-width: 15em;
height: -webkit-fill-available;
}
#results > #result-list {
@@ -589,7 +591,6 @@ export default {
border-top-right-radius: 0px;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
-webkit-box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
background-color: lightgray;
max-height: 80vh;
@@ -633,7 +634,7 @@ export default {
transform: translateX(-50%);
}
#search #input {
#search-input {
background-color: rgba(100, 100, 100, 0.2);
display: flex;
height: 100%;
@@ -678,10 +679,6 @@ export default {
background-color: var(--surfacePrimary);
}
.search-entry:hover {
background-color: var(--surfacePrimary);
}
.text-container {
margin-left: 0.25em;
white-space: nowrap;
@@ -749,7 +746,7 @@ body.rtl #search #result ul > * {
display: block;
}
#search.active #input {
#search.active #search-input {
background-color: var(--background);
border-color: black;
border-style: solid;
+1 -1
View File
@@ -2,7 +2,7 @@
<div
ref="tooltipRef"
v-show="tooltip.show"
class="floating-tooltip"
class="floating-tooltip fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
:style="tooltipStyle"
v-html="tooltip.content"
+5 -1
View File
@@ -32,6 +32,7 @@ import {
} from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state } from "@/store";
import { shareOverrides } from "@/utils/constants";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = baseURL + "static/img/placeholder.png"; // A generic loading placeholder
@@ -71,7 +72,10 @@ export default {
},
computed: {
hasPreviewImage() {
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
if (shareOverrides.disableThumbnails) {
return false;
}
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
},
disablePreviewExt() {
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
+32 -21
View File
@@ -53,11 +53,15 @@
</p>
</div>
<Icon
@click="downloadFile"
@click.stop="downloadFile"
v-if="quickDownloadEnabled"
:filename="name"
mimetype="file_download"
style="padding-right: 0.5em"
class="download-icon"
role="button"
aria-label="Download"
tabindex="0"
/>
</a>
</template>
@@ -67,12 +71,12 @@ import { enableThumbs } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { filesApi, shareApi } from "@/api";
import { filesApi,publicApi } from "@/api";
import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import Icon from "@/components/files/Icon.vue";
import { baseURL, serverHasMultipleSources } from "@/utils/constants";
import { baseURL, serverHasMultipleSources, shareOverrides } from "@/utils/constants";
export default {
name: "item",
@@ -109,6 +113,11 @@ export default {
return state.user.viewMode === "gallery";
},
quickDownloadEnabled() {
// @ts-ignore
if (getters.isShare()) {
// @ts-ignore
return shareOverrides.quickDownload && !this.isDir;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView && !this.isDir;
},
@@ -135,7 +144,7 @@ export default {
return this.isSelected;
},
isSelected() {
return this.selected.indexOf(this.index) !== -1;
return state.selected.indexOf(this.index) !== -1;
},
isDraggable() {
// @ts-ignore
@@ -143,7 +152,7 @@ export default {
},
canDrop() {
if (!this.isDir || this.readOnly !== undefined) return false;
for (let i of this.selected) {
for (const i of this.selected) {
if (
// @ts-ignore
state.req.items[i].path === this.path &&
@@ -159,17 +168,12 @@ export default {
if (!enableThumbs) {
return "";
}
// @ts-ignore
let path = url.removeTrailingSlash(state.req.path) + "/" + this.name;
if (getters.currentView() == "share") {
let urlPath = getters.routePath("share");
// Step 1: Split the path by '/'
const hash = urlPath.split("/")[1];
// @ts-ignore
return shareApi.getPreviewURL(hash, path, this.modified);
const previewPath = url.removeTrailingSlash(state.req.path) + "/" + this.name;
if (getters.isShare()) {
return publicApi.getPreviewURL(previewPath);
}
// @ts-ignore
return filesApi.getPreviewURL(state.req.source, path, this.modified);
return filesApi.getPreviewURL(state.req.source, previewPath, this.modified);
},
isThumbsEnabled() {
return enableThumbs;
@@ -229,7 +233,7 @@ export default {
},
getUrl() {
if (this.hash) {
return baseURL + "share/" + this.hash + url.encodedPath(this.path);
return baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
}
if (serverHasMultipleSources) {
return baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
@@ -280,7 +284,7 @@ export default {
},
/** @param {DragEvent} event */
dragStart(event) {
if (this.selected.indexOf(this.index) === -1) {
if (state.selected.indexOf(this.index) === -1) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
@@ -324,7 +328,7 @@ export default {
const conflict = upload.checkConflict(
items,
// @ts-ignore
(await filesApi.fetchFiles(this.source, this.path)).items
(await filesApi.fetchFiles(this.source, this.path)).items || []
);
/**
@@ -406,7 +410,7 @@ export default {
this.open();
}
if (event.shiftKey && state.selected.length > 0) {
if (event.shiftKey && this.selected.length > 0) {
let fi = 0;
let la = 0;
@@ -423,7 +427,7 @@ export default {
mutations.resetSelected();
for (; fi <= la; fi++) {
if (state.selected.indexOf(fi) === -1) {
if (this.selected.indexOf(fi) === -1) {
// @ts-ignore
mutations.addSelected(fi);
}
@@ -431,7 +435,7 @@ export default {
return;
}
if (state.selected.indexOf(this.index) !== -1) {
if (this.selected.indexOf(this.index) !== -1) {
if (event.ctrlKey || event.metaKey) {
mutations.removeSelected(this.index);
mutations.setLastSelectedIndex(this.index);
@@ -445,7 +449,7 @@ export default {
return;
}
if (state.selected.length > 1) {
if (this.selected.length > 1) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
@@ -481,6 +485,12 @@ export default {
</script>
<style>
.download-icon {
font-size: 1.5em;
cursor: pointer;
color: var(--secondaryColor);
}
.icon-download {
font-size: 0.5em;
}
@@ -492,6 +502,7 @@ export default {
.hiddenFile {
opacity: 0.5;
}
.activebutton {
height: 10em;
}
+189
View File
@@ -0,0 +1,189 @@
<template>
<div class="share card">
<div class="share__box">
<div v-if="shareOverrides.banner" class="banner">
<img :src="getShareBanner" />
</div>
<div v-if="shareOverrides.title" class="share__box__element">
<h2>{{ shareOverrides.title }}</h2>
</div>
<div v-if="shareOverrides.description" class="share__box__element">
<p>{{ shareOverrides.description }}</p>
</div>
<hr v-if="shareOverrides.banner || shareOverrides.title || shareOverrides.description" />
<div class="share__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
<div class="share__box__element" :title="modTime">
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element">
<strong>{{ $t("prompts.size") }}:</strong> {{ humanSize }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="share__box__element share__box__center">
<button class="button button--flat" @click="goToLink()"> {{ $t("buttons.download") }} </button>
</div>
<div v-if="req.type" class="share__box__element share__box__center">
<qrcode-vue class="qrcode" :value="getLink(false)" size="200" level="M"></qrcode-vue>
</div>
</div>
</div>
</template>
<script>
import { publicApi } from "@/api";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getTypeInfo } from "@/utils/mimetype";
import QrcodeVue from "qrcode.vue";
import { shareOverrides } from "@/utils/constants";
export default {
name: "ShareInfo",
components: {
QrcodeVue,
},
props: {
hash: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
subPath: {
type: String,
default: "/",
},
},
computed: {
getShareBanner() {
if (this.shareOverrides.banner.startsWith("http")) {
return this.shareOverrides.banner;
}
return publicApi.getDownloadURL(state.share, [this.shareOverrides.banner]);
},
shareOverrides() {
return shareOverrides;
},
req() {
return state.req;
},
humanSize() {
if (!state.req.modified) return "";
if (state.req.type == "directory") {
return state.req.items.length + " items (" + getHumanReadableFilesize(state.req.size) + ")";
}
return getHumanReadableFilesize(state.req.size);
},
humanTime() {
if (!state.req.modified) return "";
return getters.getTime(state.req.modified);
},
modTime() {
return new Date(Date.parse(state.req.modified)).toLocaleString();
},
isImage() {
return state.req.type ? getTypeInfo(state.req.type).simpleType === "image" : false;
},
isMedia() {
if (!state.req.type) return false;
return (
getTypeInfo(state.req.type).simpleType === "video" ||
getTypeInfo(state.req.type).simpleType === "audio"
);
},
},
methods: {
goToLink() {
window.open(this.getLink(false), "_blank");
},
getLink(inline = false) {
return publicApi.getDownloadURL({
path: "/",
hash: state.share.hash,
token: state.share.token,
inline: inline,
}, [state.req.path]);
},
},
};
</script>
<style scoped>
.banner {
width: 100%;
padding-bottom: 1em;
}
.banner img {
width: 100%;
}
.share {
display: flex;
}
.share__box {
width: 100%;
}
.share__box__header {
font-size: 1.2em;
font-weight: bold;
text-align: center;
padding-bottom: 1em;
border-bottom: 1px solid #eee;
margin-bottom: 1em;
}
.share__box__element {
margin: 0.5em 0;
}
.share__box__element canvas {
border-style: solid;
}
.share__box__center {
text-align: center;
}
.share__box__icon {
font-size: 4em;
color: #6c7b7f;
}
.share__box__icon i {
font-size: 4em;
}
.share__box__icon img,
.share__box__icon video {
max-width: 100%;
height: auto;
border-radius: 0.3em;
}
.button {
display: inline-flex;
align-items: center;
gap: 0.5em;
margin: 0.25em;
}
.button i {
font-size: 1.2em;
}
/* Dark mode support */
.dark-mode .share__box {
background: var(--surfacePrimary);
color: var(--textPrimary);
}
.dark-mode .share__box__header {
border-color: var(--divider);
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<div class="card-title">
<h2>{{ $t("access.accessManagement") }}</h2>
</div>
<div class="card-content">
<div v-if="isEditingPath">
<file-list @update:selected="updateTempPath" :browse-source="sourceName"></file-list>
<div style="margin-top: 1em; text-align: right;">
<button class="button button--flat" @click="cancelPathChange">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" @click="confirmPathChange">
{{ $t("buttons.ok") }}
</button>
</div>
</div>
<div v-else>
<p>{{ $t("prompts.source", { suffix: ":" }) }} {{ currentSource }}</p>
<div aria-label="access-path" class="searchContext" @click="startPathEdit" style="cursor: pointer;">
{{ $t("search.path") }} {{ currentPath }}
</div>
<!-- Add Form -->
<div class="form-flex-group" >
<select class="input flat-right form-compact" v-model="addType">
<option value="user">{{ $t("general.user") }}</option>
<option value="group">{{ $t("general.group") }}</option>
<option value="all">{{ $t("access.all") }}</option>
</select>
<select v-if="addType !== 'all'" class="input flat-right flat-left form-compact" v-model="addListType">
<option value="deny">{{ $t("access.deny") }}</option>
<option value="allow">{{ $t("access.allow") }}</option>
</select>
<input v-if="addType !== 'all'" class="input flat-right flat-left form-grow form-compact" v-model="addName" :placeholder="$t('access.enterName')" list="group-suggestions" />
<datalist id="group-suggestions">
<option v-for="group in groups" :key="group" :value="group"></option>
</datalist>
<button class="button form-button flat-left form-compact" @click="submitAdd">
<i class="material-icons">add</i>
</button>
</div>
<table v-if="entries.length > 0">
<tbody>
<tr>
<th>{{ $t("access.allowDeny") }}</th>
<th>{{ $t("access.userGroup") }}</th>
<th>{{ $t("general.name") }}</th>
<th>{{ $t("buttons.edit") }}</th>
</tr>
<tr v-for="entry in entries" :key="entry.type + '-' + entry.name">
<td>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</td>
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") : $t('access.all')) }}</td>
<td>{{ entry.name }}</td>
<td>
<button @click="deleteAccess(entry)" class="action" :aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')">
<i class="material-icons">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-action">
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.close')"
:title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
</div>
</template>
<script>
import { notify } from "@/notify";
import { accessApi } from "@/api";
import { mutations } from "@/store";
import FileList from "./FileList.vue";
export default {
name: "access",
components: { FileList },
props: {
sourceName: { type: String, required: true },
path: { type: String, required: true, default: "/" }
},
data() {
return {
isEditingPath: false,
tempPath: this.path,
currentPath: this.path,
currentSource: this.sourceName,
tempSource: this.sourceName,
rule: { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } },
addType: "user",
addListType: "deny",
addName: "",
groups: []
};
},
computed: {
entries() {
/** @type {{allow: boolean, type: "user" | "group" | "all", name: string}[]} */
const entries = [];
if (this.rule.denyAll) {
entries.push({ allow: false, type: "all", name: this.$t("access.all") });
}
(this.rule.deny?.users || []).forEach(name => {
entries.push({ allow: false, type: "user", name });
});
(this.rule.deny?.groups || []).forEach(name => {
entries.push({ allow: false, type: "group", name });
});
(this.rule.allow?.users || []).forEach(name => {
entries.push({ allow: true, type: "user", name });
});
(this.rule.allow?.groups || []).forEach(name => {
entries.push({ allow: true, type: "group", name });
});
return entries;
}
},
async mounted() {
await this.fetchRule();
await this.fetchGroups();
},
watch: {
sourceName(newSourceName) {
this.currentSource = newSourceName;
this.tempSource = newSourceName;
this.fetchRule();
},
path(newPath) {
this.currentPath = newPath;
this.tempPath = newPath;
this.isEditingPath = false;
this.fetchRule();
}
},
methods: {
closeHovers() {
mutations.closeHovers();
},
startPathEdit() {
this.tempPath = this.currentPath;
this.isEditingPath = true;
},
/**
* @param {{path: string, source: string}} pathOrData
*/
updateTempPath(pathOrData) {
if (pathOrData && pathOrData.path) {
this.tempPath = pathOrData.path;
this.tempSource = pathOrData.source;
}
},
confirmPathChange() {
this.currentPath = this.tempPath;
this.currentSource = this.tempSource;
this.isEditingPath = false;
this.fetchRule();
},
cancelPathChange() {
this.isEditingPath = false;
},
async fetchGroups() {
try {
const response = await accessApi.getGroups();
this.groups = response.groups;
} catch (e) {
notify.showError(e);
}
},
async fetchRule() {
try {
this.rule = await accessApi.get(this.currentSource, this.currentPath);
} catch (e) {
notify.showError(e);
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
}
},
/**
* @param {{allow: boolean, type: string, name: string}} entry
*/
async deleteAccess(entry) {
try {
const body = {
allow: entry.allow,
ruleCategory: entry.type,
value: entry.type === 'all' ? '' : entry.name,
};
await accessApi.del(this.currentSource, this.currentPath, body);
notify.showSuccess(this.$t("access.deleted"));
await this.fetchRule();
this.$emit('updated');
} catch (e) {
notify.showError(e);
}
},
async submitAdd() {
if (!this.addName.trim() && this.addType !== "all") {
notify.showError(this.$t("access.enterName"));
return;
}
try {
const body = {
allow: this.addListType === 'allow' && this.addType !== 'all',
ruleCategory: this.addType,
value: this.addName.trim()
};
await accessApi.add(
this.currentSource,
this.currentPath,
body
);
notify.showSuccess(this.$t("access.added"));
this.addName = "";
await this.fetchRule();
this.$emit('updated');
} catch (e) {
notify.showError(e);
}
},
closePrompt() {
if (mutations && mutations.closeHovers) {
mutations.closeHovers();
} else {
this.$emit('close');
}
}
}
};
</script>
<style scoped>
.form-flex-group {
margin-top: 1em;
}
</style>
@@ -1,5 +1,4 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t('api.title') }}:</h2> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
@@ -47,7 +46,6 @@
{{ $t('buttons.delete') }}
</button>
</div>
</div>
</template>
<script>
@@ -92,21 +90,3 @@ export default {
},
};
</script>
<style>
/* Basic styling for the prompt */
.card.floating {
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.card-content {
margin-bottom: 16px;
}
.card-action {
display: flex;
justify-content: flex-end;
}
.card-action .button {
margin-left: 8px;
}
</style>
+33 -58
View File
@@ -1,66 +1,41 @@
<template>
<div class="card floating create-api__prompt__card" id="create-api">
<div class="card-title">
<h2>{{ $t('api.createTitle') }}</h2>
<div class="card-title">
<h2>{{ $t('api.createTitle') }}</h2>
</div>
<div class="card-content">
<!-- API Key Name Input -->
<p>{{ $t('api.keyName') }}</p>
<input v-focus class="input" type="text" v-model.trim="apiName"
:placeholder="$t('api.keyNamePlaceholder')" />
<!-- Duration Input -->
<p>{{ $t('api.tokenDuration') }}</p>
<div class="inputWrapper">
<input class="sizeInput roundedInputLeft input" v-model.number="duration" type="number" min="1"
:placeholder="$t('api.durationNumberPlaceholder')" />
<select v-model="unit" class="roundedInputRight input">
<option value="days">{{ $t('api.days') }}</option>
<option value="months">{{ $t('api.months') }}</option>
</select>
</div>
<div class="card-content">
<!-- API Key Name Input -->
<p>{{ $t('api.keyName') }}</p>
<input
v-focus
class="input input--block"
type="text"
v-model.trim="apiName"
:placeholder="$t('api.keyNamePlaceholder')"
/>
<!-- Duration Input -->
<p>{{ $t('api.tokenDuration') }}</p>
<div class="inputWrapper">
<input
class="sizeInput roundedInputLeft input"
v-model.number="duration"
type="number"
min="1"
:placeholder="$t('api.durationNumberPlaceholder')"
/>
<select v-model="unit" class="roundedInputRight input">
<option value="days">{{ $t('api.days') }}</option>
<option value="months">{{ $t('api.months') }}</option>
</select>
</div>
<!-- Permissions Input -->
<p>{{ $t('api.permissionNote') }}</p>
<div class="settings-items">
<ToggleSwitch
v-for="(isEnabled, permission) in permissions"
:key="permission"
class="item"
v-model="permissions[permission]"
:name="permission"
/>
</div>
<!-- Permissions Input -->
<p>{{ $t('api.permissionNote') }}</p>
<div class="settings-items">
<ToggleSwitch v-for="(isEnabled, permission) in permissions" :key="permission" class="item"
v-model="permissions[permission]" :name="permission" />
</div>
</div>
<div class="card-action">
<button
@click="closeHovers"
class="button button--flat button--grey"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--blue"
@click="createAPIKey"
:title="$t('buttons.create')"
>
{{ $t("buttons.create") }}
</button>
</div>
<div class="card-action">
<button @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat button--blue" @click="createAPIKey" :title="$t('buttons.create')">
{{ $t("buttons.create") }}
</button>
</div>
</template>
@@ -1,5 +1,4 @@
<template>
<div class="card floating">
<div class="card-content">
<p v-if="selectedCount === 1">
{{ $t("prompts.deleteMessageSingle") }}
@@ -21,7 +20,6 @@
{{ $t("buttons.delete") }}
</button>
</div>
</div>
</template>
<script>
+14 -55
View File
@@ -1,29 +1,21 @@
<template>
<div class="card floating">
<div class="card-content">
<p>{{$t('prompts.deleteUserMessage')}}</p>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
v-focus
aria-label="Cancel"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" aria-label="Confirm Delete" @click="deleteUser">
{{ $t("buttons.delete") }}
</button>
</div>
<div class="card-content">
<p>{{ $t('prompts.deleteUserMessage') }}</p>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" v-focus aria-label="Cancel"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" aria-label="Confirm Delete" @click="deleteUser">
{{ $t("buttons.delete") }}
</button>
</div>
</template>
<script>
import { usersApi } from "@/api";
import { notify } from "@/notify";
import buttons from "@/utils/buttons";
import { state, mutations, getters } from "@/store";
import { mutations, getters } from "@/store";
export default {
name: "delete",
@@ -40,8 +32,8 @@ export default {
event.preventDefault();
try {
await usersApi.remove(this.user.id);
this.$router.push({ path: "/settings",hash:"#users-main" });
notify.showSuccess(this.$t("settings.userDeleted"));
window.location.reload();
} catch (e) {
e.message === "403"
? notify.showError(this.$t("errors.forbidden"), false)
@@ -50,40 +42,7 @@ export default {
},
closeHovers() {
mutations.closeHovers();
},
submit: async function () {
buttons.loading("delete");
try {
if (!this.isListing) {
await usersApi.remove(this.$route.path);
buttons.success("delete");
this.currentPrompt?.confirm();
this.closeHovers();
return;
}
this.closeHovers();
if (getters.selectedCount() === 0) {
return;
}
let promises = [];
for (let index of this.selected) {
promises.push(usersApi.remove(state.req.items[index].path));
}
await Promise.all(promises);
buttons.success("delete");
mutations.setReload(true); // Handle reload as needed
} catch (e) {
buttons.done("delete");
notify.showError(e);
if (this.isListing) mutations.setReload(true); // Handle reload as needed
}
},
}
},
};
</script>
+1 -3
View File
@@ -1,5 +1,4 @@
<template>
<div class="card floating" id="download">
<div class="card-title">
<h2>{{ $t("prompts.download") }}</h2>
</div>
@@ -14,8 +13,7 @@
v-focus
>
{{ ext }}
</button>
</div>
</button>
</div>
</template>
+1 -1
View File
@@ -6,7 +6,7 @@
{{ $t("prompts.destinationSource") }}
</label>
<select id="destinationSource" v-model="currentSource" @change="onSourceChange"
class="input input--block">
class="input">
<option v-for="source in availableSources" :key="source" :value="source">
{{ source }}
</option>
+21 -28
View File
@@ -1,34 +1,27 @@
<template>
<div class="card floating help">
<div class="card-title">
<h2>{{ $t("help.help") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("help.help") }}</h2>
</div>
<div class="card-content">
<ul>
<li><strong>F1</strong> - {{ $t("help.f1") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>F2</strong> - {{ $t("help.f2") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>DEL</strong> - {{ $t("help.del") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>ESC</strong> - {{ $t("help.esc") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + S</strong> - {{ $t("help.ctrl.s") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + F</strong> - {{ $t("help.ctrl.f") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + Click</strong> - {{ $t("help.ctrl.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>Click</strong> - {{ $t("help.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>Double click</strong> - {{ $t("help.doubleClick") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</ul>
</div>
<div class="card-content">
<ul>
<li><strong>F1</strong> - {{ $t("help.f1") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>F2</strong> - {{ $t("help.f2") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>DEL</strong> - {{ $t("help.del") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>ESC</strong> - {{ $t("help.esc") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + S</strong> - {{ $t("help.ctrl.s") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + F</strong> - {{ $t("help.ctrl.f") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>CTRL + Click</strong> - {{ $t("help.ctrl.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>Click</strong> - {{ $t("help.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<li><strong>Double click</strong> - {{ $t("help.doubleClick") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</ul>
</div>
<div class="card-action">
<button
type="submit"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
>
{{ $t("buttons.ok") }}
</button>
</div>
<div class="card-action">
<button type="submit" @click="closeHovers" class="button button--flat" :aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')">
{{ $t("buttons.ok") }}
</button>
</div>
</template>
+56 -68
View File
@@ -1,77 +1,65 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.fileInfo") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.fileInfo") }}</h2>
</div>
<div class="card-content">
<p v-if="selected.length > 1">
{{ $t("prompts.filesSelected", { count: selected.length }) }}
</p>
<div class="card-content">
<p v-if="selected.length > 1">
{{ $t("prompts.filesSelected", { count: selected.length }) }}
</p>
<p class="break-word" v-if="selected.length < 2">
<strong>{{ $t("prompts.displayName") }}</strong> {{ name }}
</p>
<p v-if="!dir || selected.length > 1">
<strong>{{ $t("prompts.size") }}:</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span id="content_length"></span> {{ humanSize }}
</p>
<p v-if="!dir || selected.length > 1">
<strong>{{$t('prompts.typeName')}}</strong>
<span id="content_length"></span> {{ type }}
</p>
<p v-if="selected.length < 2" :title="modTime">
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
<p class="break-word" v-if="selected.length < 2">
<strong>{{ $t("prompts.displayName") }}</strong> {{ name }}
</p>
<p v-if="!dir || selected.length > 1">
<strong>{{ $t("prompts.size") }}:</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span id="content_length"></span> {{ humanSize }}
</p>
<p v-if="!dir || selected.length > 1">
<strong>{{ $t('prompts.typeName') }}</strong>
<span id="content_length"></span> {{ type }}
</p>
<p v-if="selected.length < 2" :title="modTime">
<strong>{{ $t("prompts.lastModified", { suffix: ":" }) }}</strong>
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
<template v-if="dir && selected.length === 0">
<p>
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req.numFiles }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
<p>
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req.numDirs }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
</template>
<template v-if="dir && selected.length === 0">
<p>
<strong>{{ $t("prompts.numberFiles", { suffix: ":" }) }}</strong> {{ req.numFiles }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
<p>
<strong>{{ $t("prompts.numberDirs", { suffix: ":" }) }}</strong> {{ req.numDirs }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</p>
</template>
<template v-if="!dir">
<p>
<strong>MD5: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code
><a @click="checksum($event, 'md5')">{{ $t("prompts.show") }}</a></code
>
</p>
<p>
<strong>SHA1: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code
><a @click="checksum($event, 'sha1')">{{ $t("prompts.show") }}</a></code
>
</p>
<p>
<strong>SHA256: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code
><a @click="checksum($event, 'sha256')">{{ $t("prompts.show") }}</a></code
>
</p>
<p>
<strong>SHA512: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code
><a @click="checksum($event, 'sha512')">{{ $t("prompts.show") }}</a></code
>
</p>
</template>
</div>
<template v-if="!dir">
<p>
<strong>MD5: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code><a @click="checksum($event, 'md5')">{{ $t("prompts.show") }}</a></code>
</p>
<p>
<strong>SHA1: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code><a @click="checksum($event, 'sha1')">{{ $t("prompts.show") }}</a></code>
</p>
<p>
<strong>SHA256: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code><a @click="checksum($event, 'sha256')">{{ $t("prompts.show") }}</a></code>
</p>
<p>
<strong>SHA512: </strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<code><a @click="checksum($event, 'sha512')">{{ $t("prompts.show") }}</a></code>
</p>
</template>
</div>
<div class="card-action">
<button
type="submit"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
>
{{ $t("buttons.ok") }}
</button>
</div>
<div class="card-action">
<button type="submit" @click="closeHovers" class="button button--flat" :aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')">
{{ $t("buttons.ok") }}
</button>
</div>
</template>
<script>
+25 -27
View File
@@ -1,35 +1,33 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t(`prompts.${operation}`) }}</h2>
</div>
<div class="card-title">
<h2>{{ $t(`prompts.${operation}`) }}</h2>
</div>
<div class="card-content">
<p v-if="operation === 'copy'">{{ $t("prompts.copyMessage") }}</p>
<div class="card-content">
<p v-if="operation === 'copy'">{{ $t("prompts.copyMessage") }}</p>
<file-list ref="fileList" @update:selected="updateDestination">
</file-list>
</div>
<file-list ref="fileList" @update:selected="updateDestination">
</file-list>
</div>
<div class="card-action" style="display: flex; align-items: center; justify-content: space-between">
<template v-if="user.permissions.modify">
<button class="button button--flat" @click="$refs.fileList.createDir()"
:aria-label="$t('sidebar.newFolder')" :title="$t('sidebar.newFolder')" style="justify-self: left">
<span>{{ $t("sidebar.newFolder") }}</span>
</button>
</template>
<div>
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button :disabled="destContainsSrc" class="button button--flat" @click="performOperation"
:aria-label="$t(`buttons.${operation}`)" :title="$t(`buttons.${operation}`)">
{{ $t(`buttons.${operation}`) }}
</button>
</div>
</div>
<div class="card-action" style="display: flex; align-items: center; justify-content: space-between">
<template v-if="user.permissions.modify">
<button class="button button--flat" @click="$refs.fileList.createDir()" :aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')" style="justify-self: left">
<span>{{ $t("sidebar.newFolder") }}</span>
</button>
</template>
<div>
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button :disabled="destContainsSrc" class="button button--flat" @click="performOperation"
:aria-label="$t(`buttons.${operation}`)" :title="$t(`buttons.${operation}`)">
{{ $t(`buttons.${operation}`) }}
</button>
</div>
</div>
</template>
<script>
+17 -33
View File
@@ -1,39 +1,23 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.newDir") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.newDir") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.newDirMessage") }}</p>
<input
aria-label="New Folder Name"
class="input input--block"
type="text"
@keyup.enter="submit"
v-model.trim="name"
v-focus
/>
</div>
<div class="card-content">
<p>{{ $t("prompts.newDirMessage") }}</p>
<input aria-label="New Folder Name" class="input" type="text" @keyup.enter="submit" v-model.trim="name"
v-focus />
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat"
:aria-label="$t('buttons.create')"
:title="$t('buttons.create')"
@click="submit"
>
{{ $t("buttons.create") }}
</button>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" :aria-label="$t('buttons.create')" :title="$t('buttons.create')"
@click="submit">
{{ $t("buttons.create") }}
</button>
</div>
</template>
<script>
+18 -33
View File
@@ -1,39 +1,23 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.newFile") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.newFile") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.newFileMessage") }}</p>
<input
class="input input--block"
aria-label="FileName Field"
v-focus
type="text"
@keyup.enter="submit"
v-model.trim="name"
/>
</div>
<div class="card-content">
<p>{{ $t("prompts.newFileMessage") }}</p>
<input class="input" aria-label="FileName Field" v-focus type="text" @keyup.enter="submit"
v-model.trim="name" />
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat"
@click="submit"
:aria-label="$t('buttons.create')"
:title="$t('buttons.create')"
>
{{ $t("buttons.create") }}
</button>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" @click="submit" :aria-label="$t('buttons.create')"
:title="$t('buttons.create')">
{{ $t("buttons.create") }}
</button>
</div>
</template>
<script>
@@ -69,6 +53,7 @@ export default {
await filesApi.post(
state.req.source,
state.req.path + "/" + this.name,
"",
true
);
goToItem(
+10 -7
View File
@@ -1,7 +1,6 @@
<template>
<div>
<div class="card floating fb-shadow" v-if="showOverlay" :aria-label="promptLabel">
<component
v-if="showOverlay"
:ref="currentPromptName"
:is="currentPromptName"
v-bind="currentPromptProps"
@@ -27,13 +26,16 @@ import DeleteUser from "./DeleteUser.vue";
import CreateApi from "./CreateApi.vue";
import ActionApi from "./ActionApi.vue";
import Sidebar from "../sidebar/Sidebar.vue";
import UserEdit from "./UserEdit.vue";
import buttons from "@/utils/buttons";
import Totp from "./Totp.vue";
import Access from "./Access.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
export default {
name: "prompts",
components: {
UserEdit,
Info,
Delete,
Rename,
@@ -53,6 +55,7 @@ export default {
DeleteUser,
CreateApi,
ActionApi,
Access,
},
data() {
return {
@@ -95,14 +98,14 @@ export default {
});
},
computed: {
promptLabel() {
return getters.currentPromptName() + "-prompt";
},
currentPromptName() {
if (getters.currentPromptName() == null) {
return "";
}
return getters.currentPromptName();
},
currentPrompt() {
if (getters.currentPrompt() == null) {
if (!getters.currentPrompt()) {
return {
props: {},
};
@@ -125,7 +128,7 @@ export default {
return state.plugins;
},
showOverlay() {
return getters.currentPromptName() !== "more";
return getters.currentPromptName() !== "" && getters.currentPromptName() !== "ContextMenu" && getters.currentPromptName() !== "OverflowMenu";
},
},
methods: {},
+21 -39
View File
@@ -1,44 +1,26 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.rename") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.rename") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.renameMessage", { filename: oldName() }) }}</p>
<input
class="input input--block"
:class="{ 'invalid-form': !validateFileName(name) }"
v-focus
type="text"
@keyup.enter="submit"
v-model.trim="name"
/>
<p v-if="!validateFileName(name) && name.length > 0" class="validation-error">
{{ $t("prompts.invalidName") }}
</p>
</div>
<div class="card-content">
<p>{{ $t("prompts.renameMessage", { filename: oldName() }) }}</p>
<input class="input" :class="{ 'form-invalid': !validateFileName(name) }" v-focus type="text"
@keyup.enter="submit" v-model.trim="name" />
<p v-if="!validateFileName(name) && name.length > 0" class="validation-error">
{{ $t("prompts.invalidName") }}
</p>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
@click="submit"
class="button button--flat"
:disabled="!validateFileName(name) || name.length === 0"
type="submit"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')"
>
{{ $t("buttons.rename") }}
</button>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button @click="submit" class="button button--flat" :disabled="!validateFileName(name) || name.length === 0"
type="submit" :aria-label="$t('buttons.rename')" :title="$t('buttons.rename')">
{{ $t("buttons.rename") }}
</button>
</div>
</template>
<script>
@@ -142,7 +124,7 @@ export default {
</script>
<style scoped>
.invalid-form {
.form-invalid {
border-color: var(--red) !important;
}
+15 -25
View File
@@ -1,31 +1,21 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.replace") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.replace") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.replaceMessage") }}</p>
</div>
<div class="card-content">
<p>{{ $t("prompts.replaceMessage") }}</p>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeTopHover"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--red"
@click="currentPrompt.confirm"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')"
>
{{ $t("buttons.replace") }}
</button>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeTopHover" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat button--red" @click="currentPrompt.confirm" :aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')">
{{ $t("buttons.replace") }}
</button>
</div>
</template>
<script>
@@ -1,42 +1,25 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.replace") }}</h2>
</div>
<div class="card-title">
<h2>{{ $t("prompts.replace") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.replaceMessage") }}</p>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
tabindex="3"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--blue"
@click="(event) => currentPrompt.confirm(event, 'rename')"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')"
tabindex="2"
>
{{ $t("buttons.rename") }}
</button>
<button
id="focus-prompt"
class="button button--flat button--red"
@click="(event) => currentPrompt.confirm(event, 'overwrite')"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')"
tabindex="1"
>
{{ $t("buttons.replace") }}
</button>
</div>
<div class="card-content">
<p>{{ $t("prompts.replaceMessage") }}</p>
</div>
<div class="card-action">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')" tabindex="3">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat button--blue" @click="(event) => currentPrompt.confirm(event, 'rename')"
:aria-label="$t('buttons.rename')" :title="$t('buttons.rename')" tabindex="2">
{{ $t("buttons.rename") }}
</button>
<button id="focus-prompt" class="button button--flat button--red"
@click="(event) => currentPrompt.confirm(event, 'overwrite')" :aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')" tabindex="1">
{{ $t("buttons.replace") }}
</button>
</div>
</template>
+531 -149
View File
@@ -1,157 +1,347 @@
<template>
<div class="card floating share__promt__card" id="share">
<div class="card-title">
<h2>{{ $t("buttons.share") }}</h2>
</div>
<div aria-label="share-path" class="searchContext"> {{$t('search.path')}} {{ subpath }}</div>
<div class="card-title">
<h2>{{ $t("buttons.share") }}</h2>
</div>
<div class="card-content">
<div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
<p> {{ $t('share.notice') }} </p>
<template v-if="listing">
<div class="card-content">
<table>
<tbody>
<tr>
<th>#</th> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<th>{{ $t("settings.shareDuration") }}</th>
<th></th>
<th></th>
</tr>
<tr v-for="link in links" :key="link.hash">
<td>{{ link.hash }}</td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t("permanent") }}</template>
</td>
<td class="small">
<button
class="action copy-clipboard"
:data-clipboard-text="buildLink(link)"
:aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"
>
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small" v-if="hasDownloadLink()">
<button
class="action copy-clipboard"
:data-clipboard-text="buildDownloadLink(link)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
>
<i class="material-icons">content_paste_go</i>
</button>
</td>
<td class="small">
<button
class="action"
@click="deleteLink($event, link)"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"
>
<i class="material-icons">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="listing">
<table>
<tbody>
<tr>
<th>#</th> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<th>{{ $t("settings.shareDuration") }}</th>
<th></th>
<th></th>
</tr>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.close')"
:title="$t('buttons.close')"
<tr v-for="link in links" :key="link.hash">
<td>{{ link.hash }}</td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t("permanent") }}</template>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="buildLink(link)"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small" v-if="hasDownloadLink()">
<button class="action copy-clipboard" :data-clipboard-text="buildDownloadLink(link)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-icons">content_paste_go</i>
</button>
</td>
<td class="small">
<button class="action" @click="deleteLink($event, link)" :aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')">
<i class="material-icons">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<p>
{{ $t("settings.shareDuration") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDurationDescription'))"
@mouseleave="hideTooltip"
>
{{ $t("buttons.close") }}
</button>
<button
class="button button--flat button--blue"
@click="() => switchListing()"
:aria-label="$t('buttons.new')"
:title="$t('buttons.new')"
>
{{ $t("buttons.new") }}
</button>
help
</i>
</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0" @keyup.enter="submit" v-model.trim="time" />
<select class="flat-left input form-dropdown" v-model="unit" :aria-label="$t('time.unit')">
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
<option value="days">{{ $t("time.days") }}</option>
</select>
</div>
</template>
<p>
{{ $t("prompts.optionalPassword") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.passwordDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="password" autocomplete="new-password" v-model.trim="password" />
<template v-else>
<div class="card-content">
<p>{{ $t("settings.shareDuration") }}</p>
<div class="input-group input">
<input
v-focus
type="number"
max="2147483647"
min="1"
@keyup.enter="submit"
v-model.trim="time"
/>
<select class="right" v-model="unit" :aria-label="$t('time.unit')">
<option value="seconds">{{ $t("time.seconds") }}</option>
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
<option value="days">{{ $t("time.days") }}</option>
<div class="settings-items">
<!--
<ToggleSwitch class="item" v-model="allowEdit" :name="'Allow modifications'" />
<ToggleSwitch class="item" v-model="allowUpload" :name="'Allow uploading'" />
<ToggleSwitch class="item" v-model="disablingFileViewer" :name="'Disable File Viewer'" />
-->
<ToggleSwitch
class="item"
v-model="quickDownload"
:name="$t('profileSettings.showQuickDownload')"
:description="$t('profileSettings.showQuickDownloadDescription')"
/>
<ToggleSwitch class="item" v-model="disableAnonymous" :name="$t('share.disableAnonymous')" :description="$t('share.disableAnonymousDescription')" />
<ToggleSwitch class="item" v-model="enableAllowedUsernames" :name="$t('share.enableAllowedUsernames')" :description="$t('share.enableAllowedUsernamesDescription')" />
<div v-if="enableAllowedUsernames" class="item">
<input class="input" type="text" v-model.trim="allowedUsernames" :placeholder="$t('share.allowedUsernamesPlaceholder')" />
</div>
</div>
<!-- <ViewMode :viewMode="viewMode" @update:viewMode="viewMode = $event" /> -->
<p>
{{ $t("prompts.shareTheme") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<div v-if="Object.keys(availableThemes).length > 0" class="form-flex-group">
<select class="input" v-model="shareTheme">
<option v-for="(theme, key) in availableThemes" :key="key" :value="key">
{{ String(key) === "default" ? $t("profileSettings.defaultThemeDescription") : `${key} - ${theme.description}` }}
</option>
</select>
</div>
<p>{{ $t("prompts.optionalPassword") }}</p>
<input class="input input--block" type="password" v-model.trim="password" />
<div class="advanced-toggle">
<button
class="button button--flat button--block"
@click="showAdvanced = !showAdvanced"
:aria-expanded="showAdvanced ? 'true' : 'false'"
aria-controls="advanced-settings"
:aria-label="showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore')"
:title="showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore')"
>
{{ showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore') }}
</button>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="() => switchListing()"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--blue"
@click="submit"
aria-label="Share-Confirm"
:title="$t('buttons.share')"
>
{{ $t("buttons.share") }}
</button>
<transition
name="expand"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<div id="advanced-settings" v-show="showAdvanced">
<div class="settings-items">
<ToggleSwitch class="item" v-model="keepAfterExpiration" :name="$t('share.keepAfterExpiration')" :description="$t('share.keepAfterExpirationDescription')" />
<ToggleSwitch class="item" v-model="disableThumbnails" :name="$t('share.disableThumbnails')" :description="$t('share.disableThumbnailsDescription')" />
<ToggleSwitch class="item" v-model="disableNavButtons" :name="$t('share.hideNavButtons')" :description="$t('share.hideNavButtonsDescription')" />
</div>
<p>
{{ $t("prompts.downloadsLimit") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.downloadsLimitDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="number" min="0" v-model.number="downloadsLimit" />
<p>
{{ $t("prompts.maxBandwidth") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.maxBandwidthDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="number" min="0" v-model.number="maxBandwidth" />
<p>
{{ $t("prompts.shareThemeColor") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeColorDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="text" v-model.trim="themeColor" />
<p>
{{ $t("prompts.shareTitle") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareTitleDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="text" v-model.trim="title" />
<p>
{{ $t("prompts.shareDescription") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDescriptionHelp'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<textarea class="input" v-model.trim="description"></textarea>
<p>
{{ $t("prompts.shareBanner") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareBannerDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="text" v-model.trim="banner" />
<p>
{{ $t("prompts.shareFavicon") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareFaviconDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<input class="input" type="text" v-model.trim="favicon" />
</div>
</template>
</transition>
</div>
</div>
<div class="card-action">
<button v-if="listing" class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
:title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
<button v-if="listing" class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
:title="$t('buttons.new')">
{{ $t("buttons.new") }}
</button>
<button v-if="!listing" class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button v-if="!listing" class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
:title="$t('buttons.share')">
{{ $t("buttons.share") }}
</button>
</div>
</template>
<script>
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi, publicApi } from "@/api";
import { publicApi } from "@/api";
import Clipboard from "clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { userSelectableThemes } from "@/utils/constants";
//import ViewMode from "@/components/settings/ViewMode.vue";
/**
* @typedef {import('@/api/public').Share} Share
*/
/**
* @typedef {object} FilebrowserFile
* @property {string} name
* @property {string} path
* @property {string} source
* @property {boolean} isDir
* @property {string} type
*/
/**
* @typedef {object} FilebrowserRequest
* @property {FilebrowserFile[]} items
* @property {number} numDirs
* @property {number} numFiles
* @property {{by: string, asc: boolean}} sorting
* @property {string} path
* @property {string} source
*/
export default {
name: "share",
components: {
ToggleSwitch,
//ViewMode,
},
props: {
editing: {
type: Boolean,
default: false,
},
link: {
type: Object,
default: () => ({}),
},
},
data() {
return {
time: "",
unit: "hours",
/** @type {Share[]} */
links: [],
/** @type {Clipboard | null} */
clip: null,
subpath: "",
source: "",
password: "",
listing: true,
allowEdit: false,
downloadsLimit: "",
shareTheme: "default",
disableAnonymous: false,
allowUpload: false,
maxBandwidth: "",
disablingFileViewer: false,
disableThumbnails: false,
enableAllowedUsernames: false,
allowedUsernames: "",
keepAfterExpiration: false,
themeColor: "",
banner: "",
title: "",
description: "",
favicon: "",
showAdvanced: false,
quickDownload: false,
disableNavButtons: false,
//viewMode: "normal",
};
},
computed: {
availableThemes() {
return userSelectableThemes || {};
},
closeHovers() {
return mutations.closeHovers;
},
req() {
return state.req; // Access state directly
return /** @type {FilebrowserRequest} */ (/** @type {unknown} */ (state.req));
},
selected() {
return state.selected; // Access state directly
return /** @type {(number | FilebrowserFile)[]} */ (state.selected);
},
selectedCount() {
return state.selected.length; // Compute selectedCount directly from state
@@ -161,37 +351,83 @@ export default {
},
url() {
if (state.isSearchActive) {
return buildItemUrl(state.selected[0].source,state.selected[0].path)
const file = /** @type {FilebrowserFile} */ (this.selected[0]);
return buildItemUrl(file.source, file.path);
}
if (!this.isListing) {
return state.route.path;
return this.req.path;
}
if (getters.selectedCount() !== 1) {
if (this.selectedCount !== 1) {
// selecting current view image
return state.route.path;
return this.req.path;
}
return buildItemUrl(state.req.items[this.selected[0]].source,state.req.items[this.selected[0]].path)
const index = /** @type {number} */ (this.selected[0]);
return buildItemUrl(this.req.items[index].source, this.req.items[index].path);
},
isEditMode() {
return this.editing && this.link && Object.keys(this.link).length > 0;
}
},
watch: {
listing(isListing) {
if (!isListing) {
this.password = "";
}
},
isEditMode: {
immediate: true,
handler(isEditMode) {
if (isEditMode) {
this.listing = false;
this.time = this.link.expire
? String(Math.round((new Date(this.link.expire * 1000).getTime() - new Date().getTime()) / 3600000))
: "0";
this.unit = "hours";
this.password = "";
this.downloadsLimit = this.link.downloadsLimit ? String(this.link.downloadsLimit) : "";
this.maxBandwidth = this.link.maxBandwidth ? String(this.link.maxBandwidth) : "";
this.shareTheme = this.link.shareTheme || "default";
this.disableAnonymous = this.link.disableAnonymous || false;
this.disableThumbnails = this.link.disableThumbnails || false;
this.enableAllowedUsernames = Array.isArray(this.link.allowedUsernames) && this.link.allowedUsernames.length > 0;
this.allowedUsernames = this.enableAllowedUsernames ? this.link.allowedUsernames.join(", ") : "";
this.keepAfterExpiration = this.link.keepAfterExpiration || false;
this.themeColor = this.link.themeColor || "";
this.banner = this.link.banner || "";
this.title = this.link.title || "";
this.description = this.link.description || "";
this.favicon = this.link.favicon || "";
this.quickDownload = this.link.quickDownload || false;
this.disableNavButtons = this.link.hideNavButtons || false;
this.viewMode = this.link.viewMode || "normal";
}
},
},
},
async beforeMount() {
let path = state.req.path;
this.source = state.req.source;
if (this.isEditMode) {
this.subpath = this.link.path;
this.source = this.link.source;
return;
}
let path = this.req.path;
this.source = this.req.source;
if (state.isSearchActive) {
path = state.selected[0].path;
this.source = state.selected[0].source;
} else if (getters.selectedCount() === 1) {
const selected = getters.getFirstSelected();
const file = /** @type {FilebrowserFile} */ (this.selected[0]);
path = file.path;
this.source = file.source;
} else if (this.selectedCount === 1) {
const index = /** @type {number} */ (this.selected[0]);
const selected = this.req.items[index];
path = selected.path;
this.source = selected.source;
this.source = state.req.items[state.selected[0]].source;
}
// double encode # to fix issue with # in path
// replace all # with %23
this.subpath = path.replace(/#/g, "%23");
try {
// get last element of the path
const links = await shareApi.get(this.subpath, this.source);
const links = await publicApi.get(this.subpath, this.source);
this.links = links;
} catch (err) {
notify.showError(err);
@@ -209,28 +445,123 @@ export default {
notify.showSuccess(this.$t("success.linkCopied"));
});
},
beforeUnmount() {
this.clip.destroy();
},
methods: {
/**
* @param {Element} el
*/
beforeEnter(el) {
const element = /** @type {HTMLElement} */ (el);
element.style.height = '0';
element.style.opacity = '0';
},
/**
* @param {Element} el
* @param {() => void} done
*/
enter(el, done) {
const element = /** @type {HTMLElement} */ (el);
element.style.transition = '';
element.style.height = '0';
element.style.opacity = '0';
void element.offsetHeight;
element.style.transition = 'height 0.3s, opacity 0.3s';
element.style.height = element.scrollHeight + 'px';
element.style.opacity = '1';
setTimeout(() => {
element.style.height = 'auto';
done();
}, 300);
},
/**
* @param {Element} el
* @param {() => void} done
*/
leave(el, done) {
const element = /** @type {HTMLElement} */ (el);
element.style.transition = 'height 0.3s, opacity 0.3s';
element.style.height = element.scrollHeight + 'px';
void element.offsetHeight;
element.style.height = '0';
element.style.opacity = '0';
setTimeout(done, 300);
},
/**
* @param {MouseEvent} event
* @param {string} text
*/
showTooltip(event, text) {
mutations.showTooltip({
content: text,
x: event.clientX,
y: event.clientY,
});
},
hideTooltip() {
mutations.hideTooltip();
},
async submit() {
try {
let isPermanent = !this.time || this.time == 0;
let res = null;
if (isPermanent) {
res = await shareApi.create(this.subpath, this.source, this.password);
} else {
res = await shareApi.create(
this.subpath,
this.source,
this.password,
this.time.toString(),
this.unit
);
let isPermanent = !this.time || this.time === "0";
const payload = {
path: this.subpath,
sourceName: this.source,
source: this.source,
password: this.password,
expires: isPermanent ? "" : this.time.toString(),
unit: this.unit,
disableAnonymous: this.disableAnonymous,
allowUpload: this.allowUpload,
maxBandwidth: this.maxBandwidth,
downloadsLimit: this.downloadsLimit,
shareTheme: this.shareTheme,
disablingFileViewer: this.disablingFileViewer,
disableThumbnails: this.disableThumbnails,
allowedUsernames: this.enableAllowedUsernames ? this.allowedUsernames.split(',').map(u => u.trim()) : [],
keepAfterExpiration: this.keepAfterExpiration,
hash: '',
themeColor: this.themeColor,
banner: this.banner,
title: this.title,
description: this.description,
favicon: this.favicon,
quickDownload: this.quickDownload,
hideNavButtons: this.disableNavButtons,
};
if (this.isEditMode) {
payload.hash = this.link.hash;
}
this.links.push(res);
this.sort();
const res = await publicApi.create(payload.path, payload.source, {
password: payload.password,
expires: payload.expires,
unit: payload.unit,
disableAnonymous: payload.disableAnonymous,
allowUpload: payload.allowUpload,
maxBandwidth: Number(payload.maxBandwidth) || 0,
downloadsLimit: Number(payload.downloadsLimit) || 0,
shareTheme: payload.shareTheme,
disableFileViewer: payload.disablingFileViewer,
disableThumbnails: payload.disableThumbnails,
allowedUsernames: payload.allowedUsernames,
hash: payload.hash,
keepAfterExpiration: payload.keepAfterExpiration,
themeColor: payload.themeColor,
banner: payload.banner,
title: payload.title,
description: payload.description,
favicon: payload.favicon,
quickDownload: payload.quickDownload,
hideNavButtons: payload.hideNavButtons,
//viewMode: this.viewMode,
});
if (!this.isEditMode) {
this.links.push(res);
this.sort();
} else {
// reload page to see changes
window.location.reload();
}
this.time = "";
this.unit = "hours";
@@ -241,10 +572,14 @@ export default {
notify.showError(err);
}
},
/**
* @param {Event} event
* @param {Share} link
*/
async deleteLink(event, link) {
event.preventDefault();
try {
await shareApi.remove(link.hash);
await publicApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
if (this.links.length === 0) {
this.listing = false;
@@ -253,28 +588,40 @@ export default {
notify.showError(err);
}
},
/**
* @param {number} time
*/
humanTime(time) {
return fromNow(time, state.user.locale)
},
/**
* @param {Share} share
*/
buildLink(share) {
return shareApi.getShareURL(share);
return publicApi.getShareURL(share);
},
hasDownloadLink() {
if (state.isSearchActive) {
return state.selected[0].type != "directory";
const file = /** @type {FilebrowserFile} */ (this.selected[0]);
return file.type !== "directory";
}
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
const index = /** @type {number} */ (this.selected[0]);
return this.selected.length === 1 && !this.req.items[index].isDir;
},
/**
* @param {Share} share
*/
buildDownloadLink(share) {
share.source = this.source;
share.path = "/";
return publicApi.getDownloadURL(share);
const index = /** @type {number} */ (this.selected[0]);
return publicApi.getDownloadURL(share, [this.req.items[index].name]);
},
sort() {
this.links = this.links.sort((a, b) => {
if (a.expire === 0) return -1;
if (b.expire === 0) return 1;
return new Date(a.expire) - new Date(b.expire);
return a.expire - b.expire;
});
},
switchListing() {
@@ -288,3 +635,38 @@ export default {
},
};
</script>
<style scoped>
.advanced-toggle {
margin-top: 1em;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.description {
font-size: 0.9em;
color: #666;
margin-top: 4px;
margin-bottom: 8px;
}
/* Prevent inputs from expanding to container height during expand transition */
.input {
height: auto;
}
.expand-enter-active,
.expand-leave-active {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.expand-enter,
.expand-leave-to {
height: 0 !important;
opacity: 0;
}
</style>
+12 -22
View File
@@ -1,26 +1,16 @@
<template>
<div class="card floating">
<div class="card-content">
<p>{{ $t("prompts.deleteMessageShare", { path }) }}</p>
</div>
<div class="card-action">
<button
@click="closeHovers"
class="button button--flat button--grey"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
@click="submit"
class="button button--flat button--red"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"
>
{{ $t("buttons.delete") }}
</button>
</div>
<div class="card-content">
<p>{{ $t("prompts.deleteMessageShare", { path }) }}</p>
</div>
<div class="card-action">
<button @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button @click="submit" class="button button--flat button--red" :aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')">
{{ $t("buttons.delete") }}
</button>
</div>
</template>
<script>
+24 -39
View File
@@ -1,45 +1,30 @@
<template>
<div class="card floating create-api__prompt__card" id="create-api">
<div class="card-title">
<h2>{{ $t("otp.name") }}</h2>
</div>
<div v-if="error !== ''" class="wrong-login card">{{ error }}</div>
<div v-if="succeeded" >{{ $t("otp.verificationSucceed") }}</div>
<div v-if="!succeeded" class="card-content">
<p v-if="generate">{{ $t("otp.generate") }}</p>
<div v-if="generate" class="share__box__element share__box__center">
<p aria-label="otp-url">{{ this.url }}</p>
<qrcode-vue class="qrcode" :value="this.url" size="200" level="M"></qrcode-vue>
</div>
<p>{{ $t("otp.verifyInstructions") }}</p>
<input
v-focus
class="input input--block"
type="text"
v-model="code"
@keyup.enter="verifyCode"
:placeholder="$t('otp.codeInputPlaceholder')"
/>
<div class="card-title">
<h2>{{ $t("otp.name") }}</h2>
</div>
<div v-if="error !== ''" class="wrong-login card">{{ error }}</div>
<div v-if="succeeded">{{ $t("otp.verificationSucceed") }}</div>
<div v-if="!succeeded" class="card-content">
<p v-if="generate">{{ $t("otp.generate") }}</p>
<div v-if="generate" class="share__box__element share__box__center">
<p aria-label="otp-url">{{ this.url }}</p>
<qrcode-vue class="qrcode" :value="this.url" size="200" level="M"></qrcode-vue>
</div>
<p>{{ $t("otp.verifyInstructions") }}</p>
<input v-focus class="input" type="text" v-model="code" @keyup.enter="verifyCode"
:placeholder="$t('otp.codeInputPlaceholder')" />
</div>
<div class="card-action">
<button
@click="closeHovers"
class="button button--flat button--grey"
:aria-label="succeeded ? $t('buttons.close') : $t('buttons.cancel')"
:title="succeeded ? $t('buttons.close') : $t('buttons.cancel')"
>
{{ succeeded ? $t('buttons.close') : $t('buttons.cancel') }}
</button>
<button
v-if="!succeeded"
class="button button--flat button--blue"
@click="verifyCode"
:title="$t('buttons.verify')"
>
{{ $t("buttons.verify") }}
</button>
</div>
<div class="card-action">
<button @click="closeHovers" class="button button--flat button--grey"
:aria-label="succeeded ? $t('buttons.close') : $t('buttons.cancel')"
:title="succeeded ? $t('buttons.close') : $t('buttons.cancel')">
{{ succeeded ? $t('buttons.close') : $t('buttons.cancel') }}
</button>
<button v-if="!succeeded" class="button button--flat button--blue" @click="verifyCode"
:title="$t('buttons.verify')">
{{ $t("buttons.verify") }}
</button>
</div>
</template>
+92 -176
View File
@@ -1,193 +1,108 @@
<template>
<div class="card floating" >
<div class="card-title">
<h2>{{ $t("prompts.upload") }}</h2>
<p>{{ uploadSettingsDescription }}</p>
<div class="card-title">
<h2>{{ $t("prompts.upload") }}</h2>
<p>{{ uploadSettingsDescription }}</p>
</div>
<div class="upload-prompt" :class="{ dropping: isDragging }" @dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-icons">cloud_upload</i>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<div class="button-group">
<button @click="triggerFilePicker" class="button button--flat">
{{ $t("buttons.file") }}
</button>
<button style="margin-left: 1em" @click="triggerFolderPicker" class="button button--flat">
{{ $t("buttons.folder") }}
</button>
</div>
</div>
<div
class="upload-prompt"
:class="{ dropping: isDragging }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-icons">cloud_upload</i>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<div class="button-group">
<button @click="triggerFilePicker" class="button button--flat">
{{ $t("buttons.file") }}
</div>
<div class="card-content" @drop.prevent="onDrop">
<div v-if="showConflictPrompt" class="conflict-overlay">
<div class="card">
<div class="card-content">
<p>{{ $t("prompts.conflictsDetected") }}</p>
</div>
<div class="card-actions">
<button @click="resolveConflict(false)" class="button button--flat button--grey">
{{ $t("buttons.cancel") }}
</button>
<button
style="margin-left: 1em"
@click="triggerFolderPicker"
class="button button--flat"
>
{{ $t("buttons.folder") }}
<button @click="resolveConflict(true)" class="button button--flat button--red">
{{ $t("buttons.replace") }}
</button>
</div>
</div>
</div>
<div
class="card-content"
@drop.prevent="onDrop"
>
<div v-if="showConflictPrompt" class="conflict-overlay">
<div class="card">
<div class="card-content">
<p>{{ $t("prompts.conflictsDetected") }}</p>
</div>
<div class="card-actions">
<button
@click="resolveConflict(false)"
class="button button--flat button--grey"
>
{{ $t("buttons.cancel") }}
</button>
<button
@click="resolveConflict(true)"
class="button button--flat button--red"
>
{{ $t("buttons.replace") }}
</button>
</div>
</div>
</div>
<div v-if="files.length > 0" class="upload-list">
<div v-for="file in files" :key="file.id" class="upload-item">
<i class="material-icons file-icon">{{file.type === "directory" ? "folder" : "insert_drive_file" }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<progress-bar
v-if="file.type !== 'directory'"
:val="
file.status === 'completed'
? $t('prompts.completed')
: file.status === 'error'
? $t('prompts.error')
: file.status === 'conflict'
<div v-if="files.length > 0" class="upload-list">
<div v-for="file in files" :key="file.id" class="upload-item">
<i class="material-icons file-icon">{{ file.type === "directory" ? "folder" : "insert_drive_file" }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<progress-bar v-if="file.type !== 'directory'" :val="file.status === 'completed'
? $t('prompts.completed')
: file.status === 'error'
? $t('prompts.error')
: file.status === 'conflict'
? $t('prompts.conflictsDetected')
: (file.progress / 100) * file.size
"
:unit="file.status === 'completed' || file.status === 'error' ? '' : 'bytes'"
:max="file.size"
:status="file.status"
text-position="inside"
size="20"
>
</progress-bar>
<div v-else class="status-label">
<span>{{ $t(`prompts.${file.status}`) }}</span>
</div>
</div>
<div class="file-actions">
<button
v-if="file.status === 'uploading'"
@click="uploadManager.pause(file.id)"
class="action"
:aria-label="$t('buttons.pause')"
:title="$t('buttons.pause')"
>
<i class="material-icons">pause</i>
</button>
<button
v-if="file.status === 'paused'"
@click="uploadManager.resume(file.id)"
class="action"
:aria-label="$t('buttons.resume')"
:title="$t('buttons.resume')"
>
<i class="material-icons">play_arrow</i>
</button>
<button
v-if="file.status === 'error'"
@click="uploadManager.retry(file.id)"
class="action"
:aria-label="$t('buttons.retry')"
:title="$t('buttons.retry')"
>
<i class="material-icons">replay</i>
</button>
<button
v-if="file.status === 'conflict'"
@click="handleConflictAction(file)"
class="action"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')"
>
<i class="material-icons">sync_problem</i>
</button>
<button
@click="cancelUpload(file.id)"
class="action"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
<i class="material-icons">close</i>
</button>
" :unit="file.status === 'completed' || file.status === 'error' ? '' : 'bytes'" :max="file.size"
:status="file.status" text-position="inside" size="20">
</progress-bar>
<div v-else class="status-label">
<span>{{ $t(`prompts.${file.status}`) }}</span>
</div>
</div>
<div class="file-actions">
<button v-if="file.status === 'uploading'" @click="uploadManager.pause(file.id)" class="action"
:aria-label="$t('buttons.pause')" :title="$t('buttons.pause')">
<i class="material-icons">pause</i>
</button>
<button v-if="file.status === 'paused'" @click="uploadManager.resume(file.id)" class="action"
:aria-label="$t('buttons.resume')" :title="$t('buttons.resume')">
<i class="material-icons">play_arrow</i>
</button>
<button v-if="file.status === 'error'" @click="uploadManager.retry(file.id)" class="action"
:aria-label="$t('buttons.retry')" :title="$t('buttons.retry')">
<i class="material-icons">replay</i>
</button>
<button v-if="file.status === 'conflict'" @click="handleConflictAction(file)" class="action"
:aria-label="$t('buttons.replace')" :title="$t('buttons.replace')">
<i class="material-icons">sync_problem</i>
</button>
<button @click="cancelUpload(file.id)" class="action" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
<i class="material-icons">close</i>
</button>
</div>
</div>
</div>
<div class="card-actions">
<button
@click="close"
class="button button--flat button--grey"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.close") }}
</button>
<div class="spacer"></div>
<button
v-if="canPauseAll"
@click="uploadManager.pauseAll"
class="button button--flat"
:aria-label="$t('buttons.pauseAll')"
:title="$t('buttons.pauseAll')"
>
{{ $t("buttons.pauseAll") }}
</button>
<button
v-if="canResumeAll"
@click="uploadManager.resumeAll"
class="button button--flat"
:aria-label="$t('buttons.resumeAll')"
:title="$t('buttons.resumeAll')"
>
{{ $t("buttons.resumeAll") }}
</button>
<button
@click="clearCompleted"
class="button button--flat"
:disabled="!hasCompleted"
:aria-label="$t('buttons.clearCompleted')"
:title="$t('buttons.clearCompleted')"
>
{{ $t("buttons.clearCompleted") }}
</button>
</div>
<input
ref="fileInput"
@change="onFilePicked"
type="file"
multiple
style="display: none"
/>
<input
ref="folderInput"
@change="onFolderPicked"
type="file"
webkitdirectory
directory
multiple
style="display: none"
/>
</div>
<div class="card-actions">
<button @click="close" class="button button--flat button--grey" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.close") }}
</button>
<div class="spacer"></div>
<button v-if="canPauseAll" @click="uploadManager.pauseAll" class="button button--flat"
:aria-label="$t('buttons.pauseAll')" :title="$t('buttons.pauseAll')">
{{ $t("buttons.pauseAll") }}
</button>
<button v-if="canResumeAll" @click="uploadManager.resumeAll" class="button button--flat"
:aria-label="$t('buttons.resumeAll')" :title="$t('buttons.resumeAll')">
{{ $t("buttons.resumeAll") }}
</button>
<button @click="clearCompleted" class="button button--flat" :disabled="!hasCompleted"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')">
{{ $t("buttons.clearCompleted") }}
</button>
</div>
<input ref="fileInput" @change="onFilePicked" type="file" multiple style="display: none" />
<input ref="folderInput" @change="onFolderPicked" type="file" webkitdirectory directory multiple
style="display: none" />
</template>
<script>
@@ -573,7 +488,8 @@ export default {
.upload-list {
overflow-y: auto;
padding-right: 0.5em; /* To avoid scrollbar overlapping content */
padding-right: 0.5em;
/* To avoid scrollbar overlapping content */
flex-grow: 1;
display: flex;
flex-direction: column-reverse;
@@ -1,6 +1,6 @@
<template>
<errors v-if="error" :errorCode="error.status" />
<form @submit="save" class="card active" v-if="loaded">
<form @submit.prevent="save" v-if="loaded">
<div class="card-title">
<h2 v-if="isNew">{{ $t("settings.newUser") }}</h2>
<h2 v-else-if="actor.id == user.id">
@@ -8,50 +8,43 @@
</h2>
<h2 v-else>{{ $t("settings.modifyOtherUser") }} {{ user.username }}</h2>
</div>
<div class="card-content minimal-card">
<user-form
v-model:user="user"
:createUserDir="createUserDir"
:isNew="isNew"
@update:createUserDir="(updatedDir) => (createUserDir = updatedDir)"
/>
<user-form v-model:user="user" :createUserDir="createUserDir" :isNew="isNew"
@update:createUserDir="(updatedDir) => (createUserDir = updatedDir)" />
</div>
<div v-if="actor.permissions.admin" class="card-action">
<button
v-if="!isNew"
@click.prevent="deletePrompt"
type="button"
class="button button--flat button--red"
aria-label="Delete User"
:title="$t('buttons.delete')"
>
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button v-if="!isNew" @click.prevent="deletePrompt" type="button" class="button button--flat button--red"
aria-label="Delete User" :title="$t('buttons.delete')">
{{ $t("buttons.delete") }}
</button>
<input
aria-label="Save User"
class="button button--flat"
type="submit"
:value="$t('buttons.save')"
/>
<input aria-label="Save User" class="save-button button button--flat" type="submit" :value="$t('buttons.save')" />
</div>
</form>
</template>
<script>
import { getters, mutations, state } from "@/store";
import { mutations, state } from "@/store";
import { usersApi, settingsApi } from "@/api";
import UserForm from "@/components/settings/UserForm.vue";
import Errors from "@/views/Errors.vue";
import { notify } from "@/notify";
export default {
name: "user",
name: "user-edit",
components: {
UserForm,
Errors,
},
props: {
userId: {
type: [String, Number],
required: false,
},
},
data() {
return {
error: null,
@@ -69,7 +62,6 @@ export default {
};
},
created() {
mutations.setActiveSettingsView("");
this.fetchData();
},
computed: {
@@ -80,10 +72,13 @@ export default {
return state.settings;
},
isNew() {
return getters.routePath().endsWith("settings/users/new");
return !this.userId;
},
},
methods: {
closeHovers() {
mutations.closeHovers();
},
async fetchData() {
mutations.setLoading("users", true);
try {
@@ -92,9 +87,7 @@ export default {
this.user = defaults;
this.user.password = "";
} else {
const id = Array.isArray(state.route.params.id)
? state.route.params.id.join("")
: state.route.params.id;
const id = this.userId;
if (id === undefined) {
return;
}
@@ -127,6 +120,7 @@ export default {
await usersApi.update(this.user, fields);
notify.showSuccess(this.$t("settings.userUpdated"));
}
window.location.reload();
} catch (e) {
notify.showError(e);
}
@@ -141,4 +135,8 @@ export default {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.save-button {
width: 33%;
}
</style>
+21 -18
View File
@@ -8,22 +8,24 @@
</h2>
<div v-if="user.loginMethod == 'password' && passwordAvailable && !isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-group">
<div class="form-flex-group">
<input
class="input input--block form-form"
:class="{ 'invalid-form': invalidPassword }"
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-group">
<div class="form-flex-group">
<input
class="input input--block form-form"
:class="{ 'flat-right': !isNew, 'invalid-form': invalidPassword }"
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
aria-label="Password2"
type="password"
autocomplete="new-password"
:placeholder="$t('settings.enterPasswordAgain')"
v-model="user.password"
id="password"
@@ -31,7 +33,7 @@
<button
v-if="!isNew"
type="button"
class="button form-button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
@@ -53,7 +55,7 @@
<p v-if="isNew">
<label for="username">{{ $t("settings.username") }}</label>
<input
class="input input--block"
class="input"
type="text"
v-model="user.username"
id="username"
@@ -63,30 +65,31 @@
<div v-if="user.loginMethod == 'password' && passwordAvailable && isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-group">
<div class="form-flex-group">
<input
class="input input--block form-form"
:class="{ 'invalid-form': invalidPassword }"
class="input form-form"
:class="{ 'form-invalid': invalidPassword }"
aria-label="Password1"
type="password"
:placeholder="$t('settings.enterPassword')"
v-model="passwordRef"
/>
</div>
<div class="form-group">
<div class="form-flex-group">
<input
class="input input--block form-form"
:class="{ 'flat-right': !isNew, 'invalid-form': invalidPassword }"
class="input form-form"
:class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
type="password"
:placeholder="$t('settings.enterPasswordAgain')"
aria-label="Password2"
v-model="user.password"
autocomplete="new-password"
id="password"
/>
<button
v-if="!isNew"
type="button"
class="button form-button"
class="button form-button flat-left"
@click="submitUpdatePassword"
>
{{ $t("buttons.update") }}
@@ -111,7 +114,7 @@
<label for="scopes">{{ $t("settings.scopes") }}</label>
<div
class="scope-list"
:class="{ 'invalid-form': duplicateSources.includes(source.name) }"
:class="{ 'form-invalid': duplicateSources.includes(source.name) }"
v-for="(source, index) in selectedSources"
:key="index"
>
@@ -158,7 +161,7 @@
<p v-if="stateUser.username !== user.username">
<label for="locale">{{ $t("settings.language") }}</label>
<languages
class="input input--block"
class="input"
id="locale"
v-model:locale="user.locale"
@input="emitUpdate"
@@ -166,7 +169,7 @@
</p>
<div v-if="stateUser.permissions.admin">
<label for="loginMethod">{{ $t("settings.loginMethodDescription") }}</label>
<select v-model="user.loginMethod" class="input input--block" id="loginMethod">
<select v-model="user.loginMethod" class="input" id="loginMethod">
<option value="password">Password</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="oidc">OIDC</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="proxy">Proxy</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
@@ -1,9 +1,11 @@
<template>
<select v-on:change="change" :value="viewMode">
<option v-for="mode in viewModes" :key="mode" :value="mode">
{{ mode }}
</option>
</select>
<div class="form-flex-group">
<select class="input" v-on:change="change" :value="viewMode">
<option v-for="mode in viewModes" :key="mode" :value="mode">
{{ mode }}
</option>
</select>
</div>
</template>
<script>
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more