Compare commits
40
Commits
main
...
v0.8.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90896f02e2 | ||
|
|
f6531c5a98 | ||
|
|
24e8bc0fec | ||
|
|
f5b9860eaf | ||
|
|
e8e4ddc659 | ||
|
|
77e1e4fdde | ||
|
|
623bc9c5ac | ||
|
|
d21bc1a336 | ||
|
|
adec56a120 | ||
|
|
4f1797cced | ||
|
|
a44215a086 | ||
|
|
a434f812e8 | ||
|
|
732cb23ab6 | ||
|
|
087ce77afd | ||
|
|
d6d54440f9 | ||
|
|
89ea2d077b | ||
|
|
080256bcff | ||
|
|
e08690b66d | ||
|
|
961f5392ae | ||
|
|
f95fa49cf6 | ||
|
|
4e4d3063ba | ||
|
|
da4c5d3e13 | ||
|
|
386f52cd6b | ||
|
|
c2bca1d2ca | ||
|
|
987c53fe67 | ||
|
|
db79d3bfaa | ||
|
|
12fb2ff18f | ||
|
|
51bc524282 | ||
|
|
0d14851276 | ||
|
|
d18270ad94 | ||
|
|
e7838ef5ab | ||
|
|
a4f531997c | ||
|
|
5fdf7b5fd6 | ||
|
|
a8743ed446 | ||
|
|
2355fa50ba | ||
|
|
c6ba7d0412 | ||
|
|
f4a29bef22 | ||
|
|
0af46f6517 | ||
|
|
f0e3c1138f | ||
|
|
6898786f2b |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,7 +10,7 @@ input,
|
||||
#listingView .item img,
|
||||
.sizeInputWrapper,
|
||||
.sizeInput,
|
||||
#search #input,
|
||||
#search-input,
|
||||
.floating-tooltip,
|
||||
.settings-items>.item,
|
||||
.button-group {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = "/";
|
||||
|
||||
@@ -114,10 +114,6 @@ button {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+28
-30
@@ -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>
|
||||
@@ -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
Reference in new issue
Block a user