Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af08f1c4e | ||
|
|
0fdca95851 | ||
|
|
8573e139e3 | ||
|
|
482cded2d0 | ||
|
|
120e5f3ca3 |
No files matched your search
+90
-59
@@ -8,78 +8,107 @@ on:
|
||||
- "beta/v*.*.*"
|
||||
- "stable/v*.*.*"
|
||||
jobs:
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prime Cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: _docker/Dockerfile.primecache
|
||||
push: false
|
||||
load: false
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
build_frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: frontend/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Build backend
|
||||
working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Upload backend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: filebrowser
|
||||
path: backend/filebrowser
|
||||
test_playwright:
|
||||
name: Test Playwright - general
|
||||
needs: [build_backend, build_frontend, prime_docker_cache]
|
||||
name: Test Playwright - ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: general
|
||||
dockerfile: ./_docker/Dockerfile.playwright-general
|
||||
- name: proxy
|
||||
dockerfile: ./_docker/Dockerfile.playwright-proxy
|
||||
- name: noauth
|
||||
dockerfile: ./_docker/Dockerfile.playwright-noauth
|
||||
- name: oidc
|
||||
dockerfile: ./_docker/Dockerfile.playwright-oidc
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: frontend/dist
|
||||
- name: Download backend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: filebrowser
|
||||
path: backend/
|
||||
- name: Make backend executable
|
||||
run: chmod +x backend/filebrowser
|
||||
- name: Copy frontend build to backend
|
||||
run: |
|
||||
mkdir -p backend/http/embed
|
||||
cp -r frontend/dist/* backend/http/embed
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
- name: Build and Test
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
test_playwright_proxy:
|
||||
name: Test Playwright - proxy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-proxy
|
||||
push: false
|
||||
test_playwright_noauth:
|
||||
name: Test Playwright - noauth
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-noauth
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
load: true
|
||||
cache-from: |
|
||||
type=gha,scope=${{ github.ref_name }}-playwright-base
|
||||
type=gha,scope=main-playwright-base
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
push_pr_to_registry:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
name: Build docker image
|
||||
@@ -112,3 +141,5 @@ jobs:
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
REVISION=${{ steps.meta.outputs.revision }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
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.7.18-beta
|
||||
|
||||
**Notes**:
|
||||
- desktop context menu "select multiple" enabled as optional user default (#1000)
|
||||
- onlyoffice readonly document types (".pages", ".numbers", ".key") list (#1018)
|
||||
- onlyoffice tweaks to make more consistent, added logging (#1015)
|
||||
|
||||
**BugFixes**:
|
||||
- fix lightBackground issue (#1021)
|
||||
- fix user save issues (#1020, #1027)
|
||||
- fix image preview cache issue (#989)
|
||||
- fix file/folder count issue (#989)
|
||||
- only first file was upload on drag-n-drop (#1024)
|
||||
|
||||
## v0.7.17-beta
|
||||
|
||||
See an example of custom css styling that uses the reduce-rounded-corners.css by default and allows users to choose other themes. You can add your own themes as well that users can choose from in profile settings:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
RUN npm i @playwright/test
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps firefox
|
||||
@@ -1,8 +1,19 @@
|
||||
FROM gtstef/playwright-base
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/general/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
COPY [ "./_docker/src/general/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests-playwright/", "./tests-playwright" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -1,8 +1,16 @@
|
||||
FROM gtstef/playwright-base
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/noauth/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM gtstef/playwright-base
|
||||
|
||||
COPY [ "./_docker/src/oidc/frontend/", "./" ]
|
||||
COPY [ "./frontend/tests-playwright/", "./tests-playwright" ]
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -5,7 +5,9 @@ WORKDIR /app/frontend
|
||||
COPY [ "./frontend/", "./" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN nginx & FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -0,0 +1 @@
|
||||
FROM gtstef/playwright-base
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
createUserDir: true
|
||||
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
createUser: true
|
||||
clientId: "vyBrzOmcrU3OokiWEZoObdGBscw0eYPNJauBtOJr"
|
||||
clientSecret: "gIMKbkEcckUhlGrnE2Vxi8eZhEcfxQqfwFgM0ATzk4DciHGrW2YRtQfqcpRTsmzrxEETkKvZ8yeK3sfoXnUV1IQTzqK7wxw3lEMtmeXi5clj0h7nH1rVGFq2Q3HL8zUe"
|
||||
issuerUrl: "http://localhost:8080"
|
||||
adminGroup: "Admins"
|
||||
|
||||
userDefaults:
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: false
|
||||
api: false
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
import { OAuth2Server } from 'oauth2-mock-server';
|
||||
|
||||
// This is a list of pre-defined users. You can add more users here.
|
||||
// The filebrowser application will use this data when a user logs in.
|
||||
const users = {
|
||||
// Default user
|
||||
johndoe: {
|
||||
sub: 'johndoe',
|
||||
preferred_username: 'johndoe',
|
||||
email: 'johndoe@example.com',
|
||||
groups: ['Admins', 'Users'],
|
||||
},
|
||||
// Another example user
|
||||
testuser: {
|
||||
sub: 'testuser',
|
||||
preferred_username: 'testuser',
|
||||
email: 'testuser@example.com',
|
||||
groups: ['Users'],
|
||||
},
|
||||
};
|
||||
|
||||
const server = new OAuth2Server();
|
||||
|
||||
// Generate a new RSA key for the server.
|
||||
await server.issuer.keys.generate('RS256');
|
||||
|
||||
/**
|
||||
* This hook is called before the ID token is signed.
|
||||
* We're using it to add claims to the ID token.
|
||||
* This is the primary way the application will get user information.
|
||||
*/
|
||||
server.service.on('beforeIdTokenSigning', (token, req) => {
|
||||
// By default, the mock server uses 'johndoe' as the subject.
|
||||
const user = users[token.payload.sub] || users.johndoe;
|
||||
console.log(`[ID Token] Customizing token for user: ${user.sub}`);
|
||||
Object.assign(token.payload, user);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* This hook is called before the userinfo response is sent.
|
||||
* We're using it to provide user claims when the application calls the /userinfo endpoint.
|
||||
*/
|
||||
server.service.on('beforeUserinfo', (userInfoResponse, req) => {
|
||||
const user = users.johndoe;
|
||||
console.log(`[UserInfo] Customizing userinfo for user: ${user.sub}`);
|
||||
userInfoResponse.body = user;
|
||||
});
|
||||
|
||||
|
||||
const port = 8080;
|
||||
const host = '0.0.0.0'; // Bind to 0.0.0.0 to be accessible within Docker
|
||||
await server.start(port, host);
|
||||
console.log(`OAuth 2 Mock Server listening on http://${host}:${port}`);
|
||||
console.log(`Issuer URL: ${server.issuer.url}`);
|
||||
console.log('---');
|
||||
console.log('Pre-defined users:');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
console.log('---');
|
||||
@@ -0,0 +1,48 @@
|
||||
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/oidc-setup",
|
||||
timeout: 5000,
|
||||
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. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* 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: {
|
||||
storageState: "loginAuth.json",
|
||||
actionTimeout: 5000,
|
||||
//storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
+4
-5
@@ -85,10 +85,7 @@ func runCLI() bool {
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
ok := getStore(dbConfig)
|
||||
if !ok {
|
||||
logger.Fatal("could not load db info")
|
||||
}
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
@@ -113,7 +110,9 @@ func runCLI() bool {
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
err = storage.CreateUser(newUser, asAdmin)
|
||||
newUser.Permissions = settings.Config.UserDefaults.Permissions
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser)
|
||||
if err != nil {
|
||||
logger.Errorf("could not create user: %v", err)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ func validateOidcAuth() error {
|
||||
if !oidcCfg.Enabled {
|
||||
return errors.New("OIDC is not enabled")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// If disableVerifyTLS is true, create a custom HTTP client
|
||||
|
||||
@@ -59,7 +59,7 @@ func setupFrontend() {
|
||||
Url: "https://github.com/gtsteffaniak/filebrowser/wiki",
|
||||
})
|
||||
}
|
||||
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "white")
|
||||
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
|
||||
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{}
|
||||
@@ -73,8 +73,20 @@ func setupFrontend() {
|
||||
Config.Frontend.Styling.CustomThemes[name] = thisTheme
|
||||
}
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
_, ok := Config.Frontend.Styling.CustomThemes["default"]
|
||||
if noThemes || !ok {
|
||||
if !ok {
|
||||
theme := CustomTheme{
|
||||
Description: "The default theme",
|
||||
CSS: "",
|
||||
@@ -170,6 +182,10 @@ func setupUrls() {
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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, "/")
|
||||
|
||||
@@ -34,8 +34,8 @@ type Server struct {
|
||||
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
// not exposed to config
|
||||
@@ -163,4 +163,5 @@ type UserDefaults struct {
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
}
|
||||
@@ -234,10 +234,11 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
field := t.Field(i)
|
||||
// which=all can't update password
|
||||
switch strings.ToLower(field.Name) {
|
||||
case "id", "username", "loginmethod", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
|
||||
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
|
||||
// Skip these fields
|
||||
continue
|
||||
}
|
||||
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func quickSetup(store *Storage) {
|
||||
}
|
||||
|
||||
// create new user
|
||||
func CreateUser(userInfo users.User, asAdmin bool) error {
|
||||
func CreateUser(userInfo users.User) error {
|
||||
newUser := &userInfo
|
||||
if userInfo.LoginMethod == "password" {
|
||||
if userInfo.Password == "" {
|
||||
@@ -126,16 +126,11 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
|
||||
}
|
||||
userInfo.Password = hashpass
|
||||
}
|
||||
userInfo.Permissions = settings.Config.UserDefaults.Permissions
|
||||
// must have username
|
||||
if userInfo.Username == "" {
|
||||
return fmt.Errorf("username is required to create a user")
|
||||
}
|
||||
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
|
||||
settings.ApplyUserDefaults(newUser)
|
||||
if asAdmin {
|
||||
userInfo.Permissions = settings.AdminPerms()
|
||||
}
|
||||
// create new home directories
|
||||
err := store.Users.Save(newUser, true, false)
|
||||
if err != nil {
|
||||
|
||||
@@ -102,6 +102,7 @@ type NonAdminEditable struct {
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
@@ -79,7 +79,8 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
err = storage.CreateUser(users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
}, false)
|
||||
Permissions: settings.Config.UserDefaults.Permissions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -194,7 +195,8 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
err = storage.CreateUser(user, false)
|
||||
user.Permissions = settings.Config.UserDefaults.Permissions
|
||||
err = storage.CreateUser(user)
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
|
||||
@@ -291,10 +291,15 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
err = storage.CreateUser(users.User{
|
||||
newUser := users.User{
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Username: username,
|
||||
}, isAdmin)
|
||||
Permissions: settings.Config.UserDefaults.Permissions,
|
||||
}
|
||||
if isAdmin {
|
||||
newUser.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(newUser)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -40,26 +40,26 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
}
|
||||
encodedUrl := r.URL.Query().Get("url")
|
||||
// Decode the URL-encoded path
|
||||
url, err := url.QueryUnescape(encodedUrl)
|
||||
givenUrl, err := url.QueryUnescape(encodedUrl)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
// get path from url
|
||||
pathParts := strings.Split(url, "/api/raw?files=")
|
||||
pathParts := strings.Split(givenUrl, "/api/raw?files=")
|
||||
origPathParts := strings.Split(encodedUrl, "/api/raw?files=")
|
||||
encodedPath := origPathParts[len(origPathParts)-1]
|
||||
sourceFile := pathParts[len(pathParts)-1]
|
||||
sourceSplit := strings.Split(sourceFile, "::")
|
||||
if len(sourceSplit) != 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", url)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", givenUrl)
|
||||
}
|
||||
source := sourceSplit[0]
|
||||
path := sourceSplit[1]
|
||||
urlFirst := pathParts[0]
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
urlFirst = settings.Config.Server.InternalUrl
|
||||
replacement := strings.Split(url, "/api/raw")[0]
|
||||
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
|
||||
urlFirst = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
|
||||
whatToReplace := strings.Split(givenUrl, "/api/raw")[0] + "/"
|
||||
givenUrl = strings.Replace(givenUrl, whatToReplace, urlFirst, 1)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
@@ -72,10 +72,12 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
Expand: false,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debugf("onlyofficeClientConfigGetHandler: failed to get file info for file source %v, path %v: %v", source, path, err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
id, err := getOnlyOfficeId(source, fileInfo.Path)
|
||||
if err != nil {
|
||||
logger.Debugf("getOnlyOfficeId failed for file source %v, path %v: %v", source, fileInfo.Path, err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
split := strings.Split(fileInfo.Name, ".")
|
||||
@@ -89,13 +91,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
if canEdit {
|
||||
mode = "edit"
|
||||
}
|
||||
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, encodedPath, d.token)
|
||||
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?files=%v&auth=%v", strings.TrimSuffix(urlFirst, "/"), encodedPath, d.token)
|
||||
clientConfig := map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"fileType": fileType,
|
||||
"key": id,
|
||||
"title": fileInfo.Name,
|
||||
"url": url + "&auth=" + d.token,
|
||||
"url": givenUrl + "&auth=" + d.token,
|
||||
"permissions": map[string]interface{}{
|
||||
"edit": canEdit,
|
||||
"download": true,
|
||||
@@ -139,7 +141,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
encodedPath := r.URL.Query().Get("files")
|
||||
pathParts := strings.Split(encodedPath, "::")
|
||||
if len(pathParts) < 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
|
||||
@@ -164,7 +164,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, errors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
err = storage.CreateUser(req.User, req.User.Permissions.Admin)
|
||||
err = storage.CreateUser(req.User)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -292,7 +292,9 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
totalSize += itemInfo.Size
|
||||
itemInfo.Type = "directory"
|
||||
dirInfos = append(dirInfos, *itemInfo)
|
||||
idx.NumDirs++
|
||||
if recursive {
|
||||
idx.NumDirs++
|
||||
}
|
||||
} else {
|
||||
size, shouldCountSize := idx.handleFile(file, fullCombined)
|
||||
itemInfo.DetectType(fullCombined, false)
|
||||
@@ -301,7 +303,9 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
if shouldCountSize {
|
||||
totalSize += itemInfo.Size
|
||||
}
|
||||
idx.NumFiles++
|
||||
if recursive {
|
||||
idx.NumFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,14 +414,11 @@ func HasDocConvertableExtension(name, mimetype string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
var ONLYOFFICE_EDIT_FILE_EXTENSIONS = []string{"doc", "docx", "pptx", "xls", "xlsx", "csv", "txt", "rtf", "md"}
|
||||
var ONLYOFFICE_READONLY_FILE_EXTENSIONS = []string{"pages", "numbers", "key"}
|
||||
|
||||
func CanEditOnlyOffice(modify bool, extention string) bool {
|
||||
if !modify {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(ONLYOFFICE_EDIT_FILE_EXTENSIONS, strings.ToLower(extention)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return !slices.Contains(ONLYOFFICE_READONLY_FILE_EXTENSIONS, strings.ToLower(extention))
|
||||
}
|
||||
@@ -2404,11 +2404,11 @@ const docTemplate = `{
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalUrl": {
|
||||
"description": "used by share links if set",
|
||||
"description": "used by share links if set (eg. http://mydomain.com)",
|
||||
"type": "string"
|
||||
},
|
||||
"internalUrl": {
|
||||
"description": "used by integrations if set, this is the url that an integration service will use to communicate with filebrowser",
|
||||
"description": "used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)",
|
||||
"type": "string"
|
||||
},
|
||||
"logging": {
|
||||
@@ -2661,6 +2661,10 @@ const docTemplate = `{
|
||||
"description": "show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showSelectMultiple": {
|
||||
"description": "show select multiple files on desktop",
|
||||
"type": "boolean"
|
||||
},
|
||||
"singleClick": {
|
||||
"description": "open directory on single click, also enables middle click to open in new tab",
|
||||
"type": "boolean"
|
||||
@@ -2959,6 +2963,10 @@ const docTemplate = `{
|
||||
"description": "show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showSelectMultiple": {
|
||||
"description": "show select multiple files on desktop",
|
||||
"type": "boolean"
|
||||
},
|
||||
"singleClick": {
|
||||
"description": "open directory on single click, also enables middle click to open in new tab",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -2393,11 +2393,11 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalUrl": {
|
||||
"description": "used by share links if set",
|
||||
"description": "used by share links if set (eg. http://mydomain.com)",
|
||||
"type": "string"
|
||||
},
|
||||
"internalUrl": {
|
||||
"description": "used by integrations if set, this is the url that an integration service will use to communicate with filebrowser",
|
||||
"description": "used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)",
|
||||
"type": "string"
|
||||
},
|
||||
"logging": {
|
||||
@@ -2650,6 +2650,10 @@
|
||||
"description": "show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showSelectMultiple": {
|
||||
"description": "show select multiple files on desktop",
|
||||
"type": "boolean"
|
||||
},
|
||||
"singleClick": {
|
||||
"description": "open directory on single click, also enables middle click to open in new tab",
|
||||
"type": "boolean"
|
||||
@@ -2948,6 +2952,10 @@
|
||||
"description": "show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showSelectMultiple": {
|
||||
"description": "show select multiple files on desktop",
|
||||
"type": "boolean"
|
||||
},
|
||||
"singleClick": {
|
||||
"description": "open directory on single click, also enables middle click to open in new tab",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -425,11 +425,11 @@ definitions:
|
||||
description: disable type detection by header, useful if filesystem is slow.
|
||||
type: boolean
|
||||
externalUrl:
|
||||
description: used by share links if set
|
||||
description: used by share links if set (eg. http://mydomain.com)
|
||||
type: string
|
||||
internalUrl:
|
||||
description: used by integrations if set, this is the url that an integration
|
||||
service will use to communicate with filebrowser
|
||||
description: used by integrations if set, this is the base domain that an
|
||||
integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
type: string
|
||||
logging:
|
||||
items:
|
||||
@@ -618,6 +618,9 @@ definitions:
|
||||
description: show hidden files in the UI. On windows this includes files starting
|
||||
with a dot and windows hidden files
|
||||
type: boolean
|
||||
showSelectMultiple:
|
||||
description: show select multiple files on desktop
|
||||
type: boolean
|
||||
singleClick:
|
||||
description: open directory on single click, also enables middle click to
|
||||
open in new tab
|
||||
@@ -830,6 +833,9 @@ definitions:
|
||||
description: show hidden files in the UI. On windows this includes files starting
|
||||
with a dot and windows hidden files
|
||||
type: boolean
|
||||
showSelectMultiple:
|
||||
description: show select multiple files on desktop
|
||||
type: boolean
|
||||
singleClick:
|
||||
description: open directory on single click, also enables middle click to
|
||||
open in new tab
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.18.0",
|
||||
|
||||
@@ -40,8 +40,8 @@ server:
|
||||
defaultUserScope: "/" # default "/" should match folders under path
|
||||
defaultEnabled: false # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user
|
||||
externalUrl: "" # used by share links if set
|
||||
internalUrl: "" # used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
auth:
|
||||
@@ -92,9 +92,12 @@ frontend:
|
||||
disableNavButtons: false # disable the nav buttons in the sidebar
|
||||
styling:
|
||||
customCSS: "" # if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
lightBackground: "white" # specify a valid CSS color property value to use as the background color in light mode
|
||||
lightBackground: "#f5f5f5" # specify a valid CSS color property value to use as the background color in light mode
|
||||
darkBackground: "#141D24" # Specify a valid CSS color property value to use as the background color in dark mode
|
||||
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
|
||||
default:
|
||||
description: The default theme
|
||||
css: ""
|
||||
@@ -141,6 +144,7 @@ userDefaults:
|
||||
disableOfficePreviewExt: "" # deprecated: use disablePreviewExt instead
|
||||
disableOnlyOfficeExt: "" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
integrations:
|
||||
office: # validate:omitempty
|
||||
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fetchURL, adjustedData } from './utils'
|
||||
import { getApiPath, removePrefix, doubleEncode } from '@/utils/url.js'
|
||||
import { getApiPath, doubleEncode } from '@/utils/url.js'
|
||||
import { mutations } from '@/store'
|
||||
import { notify } from '@/notify'
|
||||
import { externalUrl,baseURL } from '@/utils/constants'
|
||||
import { externalUrl } from '@/utils/constants'
|
||||
|
||||
// Notify if errors occur
|
||||
export async function fetchFiles(source, path, content = false) {
|
||||
@@ -230,7 +230,7 @@ export function getDownloadURL(source, path, inline, useExternal) {
|
||||
}
|
||||
const apiPath = getApiPath('api/raw', params)
|
||||
if (externalUrl && useExternal) {
|
||||
return externalUrl + removePrefix(apiPath, baseURL)
|
||||
return externalUrl + apiPath
|
||||
}
|
||||
return window.origin + apiPath
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchURL, fetchJSON, adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath,removePrefix } from "@/utils/url.js";
|
||||
import { externalUrl,baseURL } from "@/utils/constants";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
import { externalUrl } from "@/utils/constants";
|
||||
|
||||
export async function list() {
|
||||
const apiPath = getApiPath("api/shares");
|
||||
@@ -44,7 +44,7 @@ export async function create(path, source, password = "", expires = "", unit = "
|
||||
export function getShareURL(share) {
|
||||
if (externalUrl) {
|
||||
const apiPath = getApiPath(`share/${share.hash}`)
|
||||
return externalUrl + removePrefix(apiPath, baseURL);
|
||||
return externalUrl + apiPath
|
||||
}
|
||||
return window.origin+getApiPath(`share/${share.hash}`);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
show="info"
|
||||
/>
|
||||
<action
|
||||
v-if="!isMultiple && !isSearchActive && isMobileDevice"
|
||||
v-if="showSelectMultiple"
|
||||
icon="check_circle"
|
||||
:label="$t('buttons.selectMultiple')"
|
||||
@action="toggleMultipleSelection"
|
||||
@@ -159,8 +159,17 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isMobileDevice() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || getters.isMobile();
|
||||
showSelectMultiple() {
|
||||
if (this.isMultiple || this.isSearchActive) {
|
||||
return false;
|
||||
}
|
||||
if (state.user.showSelectMultiple) {
|
||||
return true;
|
||||
}
|
||||
if (getters.isMobile()) {
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
},
|
||||
noItems() {
|
||||
return !this.showEdit && !this.showSave && !this.showDelete;
|
||||
|
||||
@@ -36,6 +36,13 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route: {
|
||||
handler() {
|
||||
// hide tooltip when route changes
|
||||
this.tooltip.show = false;
|
||||
this.tooltip.content = "";
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
handler(newTooltip) {
|
||||
if (newTooltip.show) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<span
|
||||
@mouseenter="handleMouseEnter($event)"
|
||||
@mouseleave="handleMouseLeave($event)"
|
||||
v-if="isPreviewImg && imageState !== 'error' && !disablePreviewExt && !officeFileDisabled"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
v-if="hasPreviewImage"
|
||||
:class="{ 'image-preview': hasPreviewImage }"
|
||||
>
|
||||
<i
|
||||
v-if="hasMotion"
|
||||
@@ -63,29 +64,30 @@ export default {
|
||||
previewTimeouts: [],
|
||||
// UPDATED: Manage image state directly
|
||||
imageState: "loading", // Can be 'loading', 'loaded', or 'error'
|
||||
imageTargetSrc: "", // This is now a data property
|
||||
currentThumbnail: "", // Add currentThumbnail to data
|
||||
color: "", // Add color to data
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasPreviewImage() {
|
||||
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
|
||||
},
|
||||
disablePreviewExt() {
|
||||
const ext = "." + this.filename.split(".").pop().toLowerCase(); // Ensure lowercase and dot
|
||||
return state.user.disablePreviewExt?.includes(ext);
|
||||
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
|
||||
// @ts-ignore
|
||||
return state.user?.disablePreviewExt?.includes(ext);
|
||||
},
|
||||
officeFileDisabled() {
|
||||
const ext = "." + this.filename.split(".").pop().toLowerCase(); // Ensure lowercase and dot
|
||||
return state.user.disablePreviewExt?.includes(ext);
|
||||
},
|
||||
imageTargetSrc() {
|
||||
if (this.showLargeIcon) {
|
||||
// Use the large version of the thumbnail if available
|
||||
return this.thumbnailUrl + "&size=large" || PLACEHOLDER_URL;
|
||||
}
|
||||
return this.thumbnailUrl || PLACEHOLDER_URL;
|
||||
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
|
||||
// @ts-ignore
|
||||
return state.user?.disablePreviewExt?.includes(ext);
|
||||
},
|
||||
pdfConvertable() {
|
||||
if (!muPdfAvailable) {
|
||||
return false; // If muPDF is not available
|
||||
}
|
||||
const ext = "." + this.filename.split(".").pop().toLowerCase(); // Ensure lowercase and dot
|
||||
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
|
||||
const pdfConvertCompatibleFileExtensions = {
|
||||
".pdf": true,
|
||||
".xps": true,
|
||||
@@ -102,7 +104,7 @@ export default {
|
||||
".hwpx": true,
|
||||
".md": true,
|
||||
};
|
||||
return (!!pdfConvertCompatibleFileExtensions[ext]);
|
||||
return ext in pdfConvertCompatibleFileExtensions;
|
||||
},
|
||||
// NEW: A single computed property to determine the final image src
|
||||
imageDisplaySrc() {
|
||||
@@ -110,7 +112,7 @@ export default {
|
||||
return ERROR_URL;
|
||||
}
|
||||
// Show placeholder only for the initial load, not during hover animations
|
||||
if (this.imageState === "loading" && this.imageTargetSrc === this.thumbnailUrl) {
|
||||
if (this.imageState === "loading") {
|
||||
return PLACEHOLDER_URL;
|
||||
}
|
||||
return this.imageTargetSrc;
|
||||
@@ -126,6 +128,7 @@ export default {
|
||||
this.getIconForType().simpleType === "video" &&
|
||||
state.user.preview?.video &&
|
||||
mediaAvailable &&
|
||||
// @ts-ignore
|
||||
state.user.preview.motionVideoPreview
|
||||
);
|
||||
},
|
||||
@@ -139,6 +142,7 @@ export default {
|
||||
if (this.mimetype == "text/csv") {
|
||||
return false;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (this.pdfConvertable && state.user.preview?.office) {
|
||||
return true;
|
||||
}
|
||||
@@ -154,6 +158,7 @@ export default {
|
||||
}
|
||||
if (
|
||||
this.getIconForType().simpleType === "document" &&
|
||||
// @ts-ignore
|
||||
state.user.preview?.office &&
|
||||
onlyOfficeUrl != ""
|
||||
) {
|
||||
@@ -164,6 +169,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
// NEW: Centralized method to load any image and handle its state
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
loadImage(url) {
|
||||
if (!url) {
|
||||
this.imageState = "error";
|
||||
@@ -194,6 +202,7 @@ export default {
|
||||
if (this.imageState == "loaded") {
|
||||
mutations.setPreviewSource(imageUrl);
|
||||
}
|
||||
// @ts-ignore
|
||||
if (!state.user.preview.motionVideoPreview || !this.hasMotion) {
|
||||
return;
|
||||
}
|
||||
@@ -227,6 +236,7 @@ export default {
|
||||
// Schedule next update
|
||||
index = nextIndex;
|
||||
const timeoutId = setTimeout(updateThumbnail, 750);
|
||||
// @ts-ignore
|
||||
this.previewTimeouts.push(timeoutId);
|
||||
};
|
||||
updateThumbnail();
|
||||
@@ -236,13 +246,28 @@ export default {
|
||||
this.previewTimeouts = [];
|
||||
mutations.setPreviewSource("");
|
||||
// UPDATED: Reset to the base thumbnail URL. The watcher will handle reloading it.
|
||||
this.imageTargetSrc = this.thumbnailUrl;
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
getIconForType() {
|
||||
return getTypeInfo(this.mimetype);
|
||||
},
|
||||
updateImageTargetSrc() {
|
||||
let newSrc = this.thumbnailUrl || PLACEHOLDER_URL;
|
||||
if (this.showLargeIcon) {
|
||||
newSrc = (this.thumbnailUrl ? this.thumbnailUrl + "&size=large" : "") || PLACEHOLDER_URL;
|
||||
}
|
||||
if (this.imageTargetSrc !== newSrc) {
|
||||
this.imageTargetSrc = newSrc;
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
thumbnailUrl() {
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
showLargeIcon() {
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
// UPDATED: Added a check for isPreviewImg
|
||||
imageTargetSrc: {
|
||||
handler(newSrc) {
|
||||
@@ -257,9 +282,12 @@ export default {
|
||||
mounted() {
|
||||
const result = this.getIconForType();
|
||||
this.classes = result.classes || "material-icons";
|
||||
// @ts-ignore
|
||||
this.color = result.color || "lightgray";
|
||||
this.materialIcon = result.materialIcon || "";
|
||||
// @ts-ignore
|
||||
this.svgPath = result.svgPath || "";
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -383,4 +411,9 @@ export default {
|
||||
color: white;
|
||||
font-size: 1.5em !important;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
'no-select': true,
|
||||
'listing-item': true,
|
||||
activebutton: isSelected,
|
||||
hiddenFile: isHiddenNotSelected && !this.isDraggedOver,
|
||||
hiddenFile: isHiddenNotSelected && this && !this.isDraggedOver,
|
||||
'half-selected': isDraggedOver,
|
||||
'drag-hover': isDraggedOver,
|
||||
}"
|
||||
@@ -23,12 +23,12 @@
|
||||
:data-name="name"
|
||||
:aria-label="name"
|
||||
:aria-selected="isSelected"
|
||||
@contextmenu="onRightClick($event)"
|
||||
@click="click($event)"
|
||||
@touchstart="addSelected($event)"
|
||||
@touchmove="handleTouchMove($event)"
|
||||
@touchend="cancelContext($event)"
|
||||
@mouseup="cancelContext($event)"
|
||||
@contextmenu="onRightClick"
|
||||
@click="click"
|
||||
@touchstart="addSelected"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="cancelContext"
|
||||
@mouseup="cancelContext"
|
||||
>
|
||||
<div :class="{ 'gallery-div': galleryView }">
|
||||
<Icon
|
||||
@@ -88,6 +88,7 @@ export default {
|
||||
isLongPress: false,
|
||||
isSwipe: false,
|
||||
isDraggedOver: false,
|
||||
contextTimeout: null,
|
||||
};
|
||||
},
|
||||
props: [
|
||||
@@ -108,6 +109,7 @@ export default {
|
||||
return state.user.viewMode === "gallery";
|
||||
},
|
||||
quickDownloadEnabled() {
|
||||
// @ts-ignore
|
||||
return state.user?.quickDownload && !this.galleryView && !this.isDir;
|
||||
},
|
||||
isHiddenNotSelected() {
|
||||
@@ -126,6 +128,7 @@ export default {
|
||||
return state.selected;
|
||||
},
|
||||
isClicked() {
|
||||
// @ts-ignore
|
||||
if (state.user.singleClick || !this.allowedView) {
|
||||
return false;
|
||||
}
|
||||
@@ -135,12 +138,18 @@ export default {
|
||||
return this.selected.indexOf(this.index) !== -1;
|
||||
},
|
||||
isDraggable() {
|
||||
// @ts-ignore
|
||||
return this.readOnly == undefined && state.user.permissions?.modify;
|
||||
},
|
||||
canDrop() {
|
||||
if (!this.isDir || this.readOnly !== undefined) return false;
|
||||
for (let i of this.selected) {
|
||||
if (state.req.items[i].path === this.path && state.req.source === this.source) {
|
||||
if (
|
||||
// @ts-ignore
|
||||
state.req.items[i].path === this.path &&
|
||||
// @ts-ignore
|
||||
state.req.source === this.source
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -150,14 +159,17 @@ 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];
|
||||
return shareApi.getPreviewURL(hash, path, state.req.modified);
|
||||
// @ts-ignore
|
||||
return shareApi.getPreviewURL(hash, path, this.modified);
|
||||
}
|
||||
return filesApi.getPreviewURL(state.req.source, path, state.req.modified);
|
||||
// @ts-ignore
|
||||
return filesApi.getPreviewURL(state.req.source, path, this.modified);
|
||||
},
|
||||
isThumbsEnabled() {
|
||||
return enableThumbs;
|
||||
@@ -174,13 +186,16 @@ export default {
|
||||
observer.observe(this.$el);
|
||||
},
|
||||
methods: {
|
||||
/** @param {MouseEvent} event */
|
||||
downloadFile(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
downloadFiles(state.selected);
|
||||
},
|
||||
/** @param {TouchEvent} event */
|
||||
handleTouchMove(event) {
|
||||
if (!state.isSafari) return;
|
||||
const touch = event.touches[0];
|
||||
@@ -190,11 +205,13 @@ export default {
|
||||
const movementThreshold = 10; // Adjust as needed
|
||||
if (deltaX > movementThreshold || deltaY > movementThreshold) {
|
||||
this.isSwipe = true;
|
||||
// @ts-ignore
|
||||
this.cancelContext(); // Cancel long press if swipe is detected
|
||||
}
|
||||
},
|
||||
handleTouchEnd() {
|
||||
if (!state.isSafari) return;
|
||||
// @ts-ignore
|
||||
this.cancelContext(); // Clear timeout
|
||||
this.isSwipe = false; // Reset swipe state
|
||||
},
|
||||
@@ -205,12 +222,10 @@ export default {
|
||||
}
|
||||
this.isLongPress = false;
|
||||
},
|
||||
/** @param {string} path */
|
||||
updateHashAndNavigate(path) {
|
||||
// Update hash in the browser without full page reload
|
||||
window.location.hash = path;
|
||||
|
||||
// Optional: Trigger native navigation
|
||||
window.location.href = this.getRelative(path);
|
||||
},
|
||||
getUrl() {
|
||||
if (this.hash) {
|
||||
@@ -221,11 +236,13 @@ export default {
|
||||
}
|
||||
return baseURL + "files" + url.encodedPath(this.path);
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
onRightClick(event) {
|
||||
event.preventDefault(); // Prevent default context menu
|
||||
// If one or fewer items are selected, reset the selection
|
||||
if (!state.multiple && getters.selectedCount() < 2) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
mutations.showHover({
|
||||
@@ -236,6 +253,10 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {IntersectionObserverEntry[]} entries
|
||||
* @param {IntersectionObserver} observer
|
||||
*/
|
||||
handleIntersect(entries, observer) {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
@@ -251,26 +272,33 @@ export default {
|
||||
: getHumanReadableFilesize(this.size);
|
||||
},
|
||||
getTime() {
|
||||
// @ts-ignore
|
||||
return getters.getTime(this.modified);
|
||||
},
|
||||
dragLeave() {
|
||||
this.isDraggedOver = false;
|
||||
},
|
||||
/** @param {DragEvent} event */
|
||||
dragStart(event) {
|
||||
if (this.selected.indexOf(this.index) === -1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
event.dataTransfer.setData(
|
||||
"application/x-filebrowser-internal-drag",
|
||||
"true"
|
||||
);
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData(
|
||||
"application/x-filebrowser-internal-drag",
|
||||
"true"
|
||||
);
|
||||
}
|
||||
},
|
||||
/** @param {DragEvent} event */
|
||||
dragOver(event) {
|
||||
if (!this.canDrop) return;
|
||||
event.preventDefault();
|
||||
this.isDraggedOver = true;
|
||||
},
|
||||
/** @param {DragEvent} event */
|
||||
async drop(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -283,8 +311,11 @@ export default {
|
||||
let items = [];
|
||||
for (let i of state.selected) {
|
||||
items.push({
|
||||
// @ts-ignore
|
||||
from: state.req.items[i].path,
|
||||
// @ts-ignore
|
||||
fromSource: state.req.items[i].source,
|
||||
// @ts-ignore
|
||||
to: this.path + "/" + state.req.items[i].name,
|
||||
toSource: this.source,
|
||||
});
|
||||
@@ -292,9 +323,14 @@ export default {
|
||||
|
||||
const conflict = upload.checkConflict(
|
||||
items,
|
||||
// @ts-ignore
|
||||
(await filesApi.fetchFiles(this.source, this.path)).items
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {boolean} overwrite
|
||||
* @param {boolean} rename
|
||||
*/
|
||||
let action = async (overwrite, rename) => {
|
||||
await filesApi.moveCopy(items, "move", overwrite, rename);
|
||||
};
|
||||
@@ -302,6 +338,10 @@ export default {
|
||||
if (conflict) {
|
||||
mutations.showHover({
|
||||
name: "replace-rename",
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} option
|
||||
*/
|
||||
confirm: (event, option) => {
|
||||
const overwrite = option === "overwrite";
|
||||
const rename = option === "rename";
|
||||
@@ -316,6 +356,7 @@ export default {
|
||||
|
||||
action(false, false);
|
||||
},
|
||||
/** @param {TouchEvent} event */
|
||||
addSelected(event) {
|
||||
if (!state.isSafari) {
|
||||
return;
|
||||
@@ -331,13 +372,16 @@ export default {
|
||||
if (state.multiple) {
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
this.contextTimeout = setTimeout(() => {
|
||||
if (!this.isSwipe) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
click(event) {
|
||||
if (event.button === 0) {
|
||||
// Left-click
|
||||
@@ -366,18 +410,21 @@ export default {
|
||||
let fi = 0;
|
||||
let la = 0;
|
||||
|
||||
if (this.index > state.lastSelectedIndex) {
|
||||
fi = state.lastSelectedIndex;
|
||||
la = this.index;
|
||||
} else {
|
||||
fi = this.index;
|
||||
la = state.lastSelectedIndex;
|
||||
if (state.lastSelectedIndex !== null) {
|
||||
if (this.index > state.lastSelectedIndex) {
|
||||
fi = state.lastSelectedIndex;
|
||||
la = this.index;
|
||||
} else {
|
||||
fi = this.index;
|
||||
la = state.lastSelectedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
mutations.resetSelected();
|
||||
|
||||
for (; fi <= la; fi++) {
|
||||
if (state.selected.indexOf(fi) === -1) {
|
||||
// @ts-ignore
|
||||
mutations.addSelected(fi);
|
||||
}
|
||||
}
|
||||
@@ -400,6 +447,7 @@ export default {
|
||||
|
||||
if (state.selected.length > 1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
}
|
||||
@@ -414,6 +462,7 @@ export default {
|
||||
) {
|
||||
mutations.resetSelected();
|
||||
}
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
},
|
||||
@@ -423,6 +472,7 @@ export default {
|
||||
url.goToItem(this.source, this.path, "", shareHash);
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
const previousHash = state.req.items[this.index].name;
|
||||
url.goToItem(this.source, this.path, previousHash);
|
||||
},
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
v-focus
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
aria-label="Cancel"
|
||||
:title="$t('buttons.cancel')"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button class="button button--flat" @click="deleteUser">
|
||||
<button class="button button--flat" aria-label="Confirm Delete" @click="deleteUser">
|
||||
{{ $t("buttons.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -366,9 +366,19 @@ export default {
|
||||
};
|
||||
|
||||
const processItems = async (items) => {
|
||||
// When items are passed as a prop from ListingView, they can be either
|
||||
// an array of DataTransferItem (from drag and drop) or an array of File (from input).
|
||||
if (Array.isArray(items)) {
|
||||
processFileList(items);
|
||||
if (items.length > 0 && items[0] instanceof File) {
|
||||
// This is an array of File objects from the input fallback in ListingView
|
||||
processFileList(items);
|
||||
} else {
|
||||
// This is an array of DataTransferItem from drag and drop in ListingView
|
||||
await processDroppedItems(items);
|
||||
}
|
||||
} else if (items) {
|
||||
// This case handles a FileList object from the upload prompt's own input fields.
|
||||
// It is not an array, so we convert it.
|
||||
await processDroppedItems(Array.from(items));
|
||||
}
|
||||
};
|
||||
@@ -464,13 +474,16 @@ export default {
|
||||
|
||||
const processDroppedItems = async (items) => {
|
||||
const filesToUpload = [];
|
||||
for (const item of items) {
|
||||
const promises = items.map(item => {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
const files = await getFilesFromDirectoryEntry(entry);
|
||||
filesToUpload.push(...files);
|
||||
return getFilesFromDirectoryEntry(entry);
|
||||
}
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const allFiles = await Promise.all(promises);
|
||||
allFiles.forEach(files => filesToUpload.push(...files));
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
uploadManager.add(state.req.path, filesToUpload);
|
||||
|
||||
@@ -297,19 +297,19 @@ export default {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.status")}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.status || ''}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.status || 'unknown'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.assessment")}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.assessment || ''}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.assessment || 'unknown'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.files")}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.files || ''}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.files || 0}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.folders")}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.folders || ''}</td>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.folders || 0}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.lastScanned")}</td>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
|
||||
|
||||
body {
|
||||
body {
|
||||
font-family: "Roboto", sans-serif;
|
||||
padding-top: 4em;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--background);
|
||||
color: #333333;
|
||||
overflow:auto;
|
||||
overflow:initial;
|
||||
|
||||
@@ -140,12 +140,10 @@ body.rtl #listingView {
|
||||
}
|
||||
|
||||
#listingView.gallery .item .text {
|
||||
text-shadow: 0 0 2px white;
|
||||
position: absolute;
|
||||
transform: translate(0, 5.5em);
|
||||
text-shadow: 0 0 2px white;
|
||||
pointer-events: none;
|
||||
font-weight: bold;
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
#listingView.gallery .item .text .name {
|
||||
@@ -164,8 +162,6 @@ body.rtl #listingView {
|
||||
|
||||
#listingView.gallery .item span {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
"helpText2": "The index: Search utilizes the index which automatically gets updated on the configured interval (default: 5 minutes). Searching when the program has just started may result in incomplete results."
|
||||
},
|
||||
"profileSettings": {
|
||||
"showSelectMultiple": "Show select multiple in context menu on desktop",
|
||||
"showSelectMultipleDescription": "By default, desktop context menu does not have \"select multiple\" option like mobile. This option always shows it in the context menu.",
|
||||
"customTheme": "Choose your theme",
|
||||
"defaultThemeDescription": "default - The default theme",
|
||||
"setDateFormat": "Set exact date format",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import Vue from "vue";
|
||||
export const eventBus = new Vue();
|
||||
@@ -914,7 +914,7 @@ export default {
|
||||
mutations.showHover({
|
||||
name: "upload",
|
||||
props: {
|
||||
initialItems: event.dataTransfer.items,
|
||||
initialItems: Array.from(event.dataTransfer.items),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -92,6 +92,12 @@
|
||||
:name="$t('profileSettings.popupPreview')"
|
||||
:description="$t('profileSettings.popupPreviewDescription')"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="localuser.showSelectMultiple"
|
||||
:name="$t('profileSettings.showSelectMultiple')"
|
||||
:description="$t('profileSettings.showSelectMultipleDescription')"
|
||||
/>
|
||||
</div>
|
||||
<h3>{{ $t("profileSettings.editorViewerOptions") }}</h3>
|
||||
<div class="settings-items">
|
||||
@@ -392,6 +398,7 @@ export default {
|
||||
"disableSearchOptions",
|
||||
"hideSidebarFileActions",
|
||||
"editorQuickSave",
|
||||
"showSelectMultiple",
|
||||
]);
|
||||
if (themeChanged) {
|
||||
window.location.reload();
|
||||
|
||||
@@ -1,60 +1,24 @@
|
||||
import { test, expect } from '../test-setup'
|
||||
|
||||
test('create and delete testuser', async ({
|
||||
page,
|
||||
checkForErrors,
|
||||
context
|
||||
}) => {
|
||||
await page.goto('/settings')
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Settings")
|
||||
await page.locator('button[aria-label="Add New User"]').click()
|
||||
await page.locator('#username').fill('testuser')
|
||||
await page.locator('input[aria-label="Password1"]').fill('testpassword')
|
||||
await page.locator('input[aria-label="Password2"]').fill('testpass')
|
||||
// check that the invalid-field class is added properly
|
||||
await expect(page.locator('input[aria-label="Password2"]')).toHaveClass(
|
||||
'input input--block form-form invalid-form'
|
||||
)
|
||||
await page.locator('input[aria-label="Password2"]').fill('testpassword')
|
||||
await page.locator('input[aria-label="Save User"]').click()
|
||||
test("adjusting theme colors", async({ page, checkForErrors, context }) => {
|
||||
await page.goto("/files/");
|
||||
const originalPrimaryColor = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
|
||||
});
|
||||
await expect(originalPrimaryColor).toBe('#2196f3');
|
||||
|
||||
// click the edit button for testuser
|
||||
const userRow = page.locator('tr.item', { hasText: 'testuser' })
|
||||
const editLink = await userRow
|
||||
.locator('td[aria-label="Edit User"] a')
|
||||
.getAttribute('href')
|
||||
await page.goto(editLink!)
|
||||
checkForErrors()
|
||||
})
|
||||
|
||||
|
||||
|
||||
test("two factor auth check", async ({ page, checkForErrors, context }) => {
|
||||
// go to settings
|
||||
await page.goto("/settings");
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
// click the edit button for testuser
|
||||
const userRow = page.locator('tr.item', { hasText: 'admin' })
|
||||
const editLink = await userRow
|
||||
.locator('td[aria-label="Edit User"] a')
|
||||
.getAttribute('href')
|
||||
await page.goto(editLink!)
|
||||
|
||||
// Toggle the two factor authentication switch
|
||||
const twoFactorCheckbox = page.locator('.toggle-container:has-text("Two-Factor Authentication") input[type="checkbox"]');
|
||||
const twoFactorToggle = page.locator('.toggle-container:has-text("Two-Factor Authentication") label.switch');
|
||||
// Check if it's currently enabled
|
||||
const isChecked = await twoFactorCheckbox.isChecked();
|
||||
// Toggle it by clicking the label (since checkbox is hidden)
|
||||
await twoFactorToggle.click();
|
||||
// Verify it changed state
|
||||
await expect(twoFactorCheckbox).toBeChecked();
|
||||
await page.locator('button[aria-label="Generate Code"]').click();
|
||||
// check for the otp url
|
||||
await expect(page.locator('p[aria-label="otp-url"]')).toBeVisible();
|
||||
|
||||
// check that the otp-url is not empty
|
||||
const otpUrl = await page.locator('p[aria-label="otp-url"]').textContent();
|
||||
expect(otpUrl).not.toBe("");
|
||||
// Verify the page title
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
|
||||
await page.locator('i[aria-label="settings"]').click();
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
|
||||
await page.locator('button', { hasText: 'violet' }).click();
|
||||
const popup = page.locator('#popup-notification-content');
|
||||
await popup.waitFor({ state: 'visible' });
|
||||
await expect(popup).toHaveText('Settings updated!');
|
||||
const newPrimaryColor = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
|
||||
});
|
||||
await expect(newPrimaryColor).toBe('#9b59b6');
|
||||
// Check for console errors
|
||||
checkForErrors();
|
||||
});
|
||||
@@ -20,26 +20,3 @@ test("sidebar links", async({ page, checkForErrors, context }) => {
|
||||
// Check for console errors
|
||||
checkForErrors();
|
||||
});
|
||||
|
||||
test("adjusting theme colors", async({ page, checkForErrors, context }) => {
|
||||
await page.goto("/files/");
|
||||
const originalPrimaryColor = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
|
||||
});
|
||||
await expect(originalPrimaryColor).toBe('#2196f3');
|
||||
|
||||
// Verify the page title
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
|
||||
await page.locator('i[aria-label="settings"]').click();
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
|
||||
await page.locator('button', { hasText: 'violet' }).click();
|
||||
const popup = page.locator('#popup-notification-content');
|
||||
await popup.waitFor({ state: 'visible' });
|
||||
await expect(popup).toHaveText('Settings updated!');
|
||||
const newPrimaryColor = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
|
||||
});
|
||||
await expect(newPrimaryColor).toBe('#9b59b6');
|
||||
// Check for console errors
|
||||
checkForErrors();
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { test, expect } from "../test-setup";
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
test('create, check settings, and delete testuser2', async ({
|
||||
page,
|
||||
checkForErrors,
|
||||
}) => {
|
||||
await page.goto('/settings')
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Settings")
|
||||
await page.locator('button[aria-label="Add New User"]').click()
|
||||
await page.locator('#username').fill('testuser2')
|
||||
await page.locator('input[aria-label="Password1"]').fill('testpassword')
|
||||
await page.locator('input[aria-label="Password2"]').fill('testpass')
|
||||
// check that the invalid-field class is added properly
|
||||
await expect(page.locator('input[aria-label="Password2"]')).toHaveClass(
|
||||
'input input--block form-form invalid-form'
|
||||
)
|
||||
await page.locator('input[aria-label="Password2"]').fill('testpassword')
|
||||
|
||||
// Just create the user first
|
||||
await page.locator('input[aria-label="Save User"]').click();
|
||||
await page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 201)
|
||||
|
||||
// We should be back on the settings page
|
||||
await expect(page.locator('tr.item', { hasText: 'testuser2' })).toBeVisible();
|
||||
|
||||
// Now, click the edit button for testuser2
|
||||
const userRow = page.locator('tr.item', { hasText: 'testuser2' })
|
||||
const editLink = await userRow
|
||||
.locator('td[aria-label="Edit User"] a')
|
||||
.getAttribute('href')
|
||||
await page.goto(editLink!)
|
||||
|
||||
// Now on the edit page, toggle the settings
|
||||
const settingsToToggle = [
|
||||
"Administrator",
|
||||
"Prevent the user from changing the password",
|
||||
"Edit files",
|
||||
"Share files",
|
||||
"Create and manage long-live API keys",
|
||||
"Enable real-time connections and updates",
|
||||
];
|
||||
|
||||
for (const settingName of settingsToToggle) {
|
||||
const toggleContainer = page.locator(".toggle-container", { hasText: settingName });
|
||||
const toggleSwitch = toggleContainer.locator("label.switch");
|
||||
await toggleSwitch.click();
|
||||
}
|
||||
|
||||
// Save the updated settings
|
||||
await page.locator('input[aria-label="Save User"]').click();
|
||||
|
||||
// The app might show a notification and stay on the page. Let's reload to ensure we have the latest state.
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
for (const settingName of settingsToToggle) {
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).toBeChecked();
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
await page.locator('button[aria-label="Delete User"]').click();
|
||||
await page.locator('button[aria-label="Confirm Delete"]').click();
|
||||
|
||||
// After deletion, we should be back on the settings page.
|
||||
await expect(page.locator('tr.item', { hasText: 'testuser2' })).not.toBeVisible();
|
||||
checkForErrors()
|
||||
})
|
||||
|
||||
test("two factor auth check", async ({ page, checkForErrors, context }) => {
|
||||
// go to settings
|
||||
await page.goto("/settings");
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
// click the edit button for testuser
|
||||
const userRow = page.locator('tr.item', { hasText: 'admin' })
|
||||
const editLink = await userRow
|
||||
.locator('td[aria-label="Edit User"] a')
|
||||
.getAttribute('href')
|
||||
await page.goto(editLink!)
|
||||
|
||||
// Toggle the two factor authentication switch
|
||||
const twoFactorCheckbox = page.locator('.toggle-container:has-text("Two-Factor Authentication") input[type="checkbox"]');
|
||||
const twoFactorToggle = page.locator('.toggle-container:has-text("Two-Factor Authentication") label.switch');
|
||||
// Check if it's currently enabled
|
||||
const isChecked = await twoFactorCheckbox.isChecked();
|
||||
// Toggle it by clicking the label (since checkbox is hidden)
|
||||
await twoFactorToggle.click();
|
||||
// Verify it changed state
|
||||
await expect(twoFactorCheckbox).toBeChecked();
|
||||
await page.locator('button[aria-label="Generate Code"]').click();
|
||||
// check for the otp url
|
||||
await expect(page.locator('p[aria-label="otp-url"]')).toBeVisible();
|
||||
|
||||
// check that the otp-url is not empty
|
||||
const otpUrl = await page.locator('p[aria-label="otp-url"]').textContent();
|
||||
expect(otpUrl).not.toBe("");
|
||||
checkForErrors();
|
||||
});
|
||||
|
||||
test.describe("User Settings Persistence", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/settings");
|
||||
const userRow = page.locator("tr.item", { hasText: "testuser1" });
|
||||
const editLink = await userRow
|
||||
.locator('td[aria-label="Edit User"] a')
|
||||
.getAttribute("href");
|
||||
await page.goto(editLink!);
|
||||
});
|
||||
|
||||
async function toggleSettingAndSave(page: Page, settingName: string) {
|
||||
const toggleContainer = page.locator(".toggle-container", { hasText: settingName });
|
||||
const toggleSwitch = toggleContainer.locator("label.switch");
|
||||
await toggleSwitch.click();
|
||||
await page.locator('input[aria-label="Save User"]').click();
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test('should persist "Prevent the user from changing the password" setting', async ({ page }) => {
|
||||
const settingName = "Prevent the user from changing the password";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should persist "Administrator" setting', async ({ page }) => {
|
||||
const settingName = "Administrator";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked(); // Admin user should have this on
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should persist "Edit files" setting', async ({ page }) => {
|
||||
const settingName = "Edit files";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName); // Revert
|
||||
});
|
||||
|
||||
test('should persist "Share files" setting', async ({ page }) => {
|
||||
const settingName = "Share files";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should persist "Create and manage long-live API keys" setting', async ({ page }) => {
|
||||
const settingName = "Create and manage long-live API keys";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should persist "Enable real-time connections and updates" setting', async ({ page }) => {
|
||||
const settingName = "Enable real-time connections and updates";
|
||||
const checkbox = page.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await toggleSettingAndSave(page, settingName);
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should persist "allowed login method" setting', async ({ page }) => {
|
||||
const loginMethodSelector = page.locator("#loginMethod");
|
||||
await expect(loginMethodSelector).toHaveValue("password");
|
||||
|
||||
await loginMethodSelector.selectOption({ label: "Proxy" });
|
||||
await page.locator('input[aria-label="Save User"]').click();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator("#loginMethod")).toHaveValue("proxy");
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ async function globalSetup() {
|
||||
localStorage.setItem('shareHashFile', hash);
|
||||
}, shareHashFile);
|
||||
|
||||
|
||||
await context.storageState({ path: "./loginAuth.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Browser, firefox, expect, Page } from "@playwright/test";
|
||||
|
||||
// Perform authentication and store auth state
|
||||
async function globalSetup() {
|
||||
const browser: Browser = await firefox.launch();
|
||||
const context = await browser.newContext();
|
||||
const page: Page = await context.newPage();
|
||||
|
||||
// The final URL we expect to land on after the login dance.
|
||||
const finalUrl = "http://127.0.0.1/files";
|
||||
const oidcLoginUrl = `http://127.0.0.1/api/auth/oidc/login?redirect=${encodeURIComponent("/files")}`;
|
||||
|
||||
// Go directly to the OIDC login URL. This will start the redirect chain.
|
||||
await page.goto(oidcLoginUrl);
|
||||
|
||||
// The mock OIDC provider will auto-login and redirect back.
|
||||
// We just need to wait for the final landing page.
|
||||
await page.waitForURL(finalUrl, { timeout: 10000 });
|
||||
|
||||
// Final check to ensure we are on the correct page.
|
||||
await expect(page).toHaveURL(finalUrl);
|
||||
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.find((c) => c.name === "auth")?.value).toBeDefined();
|
||||
|
||||
await context.storageState({ path: "./loginAuth.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from "../test-setup";
|
||||
|
||||
test("Create first new file", async ({ page, checkForErrors, context }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('#listingView .message > span')).toHaveText('It feels lonely here...');
|
||||
await page.locator('#listingView').click({ button: "right" });
|
||||
await page.locator('button[aria-label="New file"]').click();
|
||||
await page.locator('input[aria-label="FileName Field"]').fill('test.txt');
|
||||
await page.locator('button[aria-label="Create"]').click();
|
||||
await expect(page).toHaveTitle("Graham's Filebrowser - Files - test.txt");
|
||||
await page.locator('button[aria-label="Close"]').click();
|
||||
await expect(page.locator('#listingView .file-items')).toHaveCount(1);
|
||||
checkForErrors();
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'oauth2-mock-server';
|
||||
@@ -4,6 +4,7 @@
|
||||
"allowJs": true,
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node10",
|
||||
"strict": true,
|
||||
@@ -18,8 +19,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"checkJs": false, // check later
|
||||
"checkJs": true,
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.vue", "tests-playwright/general/auth.spec.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "tests-playwright/**/*.ts", "../_docker/src/proxy/frontend/playwright.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -62,11 +62,9 @@ test-frontend:
|
||||
test-playwright: build-frontend
|
||||
cd backend && GOOS=linux go build -o filebrowser .
|
||||
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-general .
|
||||
docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests
|
||||
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-noauth .
|
||||
docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests
|
||||
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-proxy .
|
||||
docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests
|
||||
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-oidc .
|
||||
|
||||
run-proxy: build-frontend
|
||||
cd _docker && docker compose up -d --build
|
||||
Reference in new issue
Block a user