Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 0af08f1c4e updated with more changes (#1033) 2025-07-30 16:20:20 -05:00
Graham Steffaniak 0fdca95851 more tests cicd (#1031) 2025-07-30 12:02:51 -05:00
Graham Steffaniak 8573e139e3 updated counting (#1030) 2025-07-29 18:57:36 -05:00
Graham Steffaniak 482cded2d0 fix save user issue (#1028) 2025-07-29 18:02:51 -05:00
mordillo 120e5f3ca3 LightBackground bugfix (#1022) 2025-07-28 15:17:40 -05:00
54 changed files with 823 additions and 268 deletions

No files matched your search

+90 -59
View File
@@ -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
+14
View File
@@ -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 -1
View File
@@ -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
+13 -2
View File
@@ -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
+9 -1
View File
@@ -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
+14
View File
@@ -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
+3 -1
View File
@@ -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
+1
View File
@@ -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. */
+38
View File
@@ -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
View File
@@ -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)
}
-1
View File
@@ -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
+18 -2
View File
@@ -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, "/")
+3 -2
View File
@@ -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
}
+2 -1
View File
@@ -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)
}
}
+1 -6
View File
@@ -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 {
+1
View File
@@ -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 {
+4 -2
View File
@@ -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)
+7 -2
View File
@@ -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
}
+11 -9
View File
@@ -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)
+1 -1
View File
@@ -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
}
+6 -2
View File
@@ -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++
}
}
}
+2 -5
View File
@@ -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))
}
+10 -2
View File
@@ -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"
+10 -2
View File
@@ -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"
+9 -3
View File
@@ -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
+1
View File
@@ -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",
+7 -3
View File
@@ -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
+3 -3
View File
@@ -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) {
+3 -3
View File
@@ -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}`);
}
+12 -3
View File
@@ -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;
+7
View File
@@ -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) {
+51 -18
View File
@@ -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>
+73 -23
View File
@@ -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>
+18 -5
View File
@@ -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);
+4 -4
View File
@@ -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>
+2 -4
View File
@@ -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;
+1 -5
View File
@@ -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%;
}
+2
View File
@@ -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",
+2
View File
@@ -0,0 +1,2 @@
import Vue from "vue";
export const eventBus = new Vue();
+1 -1
View File
@@ -914,7 +914,7 @@ export default {
mutations.showHover({
name: "upload",
props: {
initialItems: event.dataTransfer.items,
initialItems: Array.from(event.dataTransfer.items),
},
});
} else {
+7
View File
@@ -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();
}
+30
View File
@@ -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();
});
+1
View File
@@ -0,0 +1 @@
declare module 'oauth2-mock-server';
+3 -2
View File
@@ -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"]
}
+1 -3
View File
@@ -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