Compare commits
52
Commits
main
...
v0.7.8-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce3bf1614 | ||
|
|
615e1c432d | ||
|
|
0e07e02555 | ||
|
|
c815a9f623 | ||
|
|
1d5026984b | ||
|
|
7e80179e74 | ||
|
|
4a7390b0dc | ||
|
|
50d3513880 | ||
|
|
9a0cd4552d | ||
|
|
b0aa189d34 | ||
|
|
fa0900d4f8 | ||
|
|
b57c3adaa1 | ||
|
|
282fd6f227 | ||
|
|
cb7ad4af92 | ||
|
|
d896858bcd | ||
|
|
712ad91f20 | ||
|
|
cfa1537e2e | ||
|
|
d4e0c38f06 | ||
|
|
305a54ffb6 | ||
|
|
3acdde80ea | ||
|
|
a3d8833a2e | ||
|
|
421fd126c8 | ||
|
|
dfdc422ccf | ||
|
|
114d2136bb | ||
|
|
cfc4a1be78 | ||
|
|
d172c0fa4e | ||
|
|
fe1984f234 | ||
|
|
27bfee087c | ||
|
|
dc96f9d0ae | ||
|
|
24c83842a5 | ||
|
|
bd822497e1 | ||
|
|
ea9d2fe6df | ||
|
|
11e0681562 | ||
|
|
51d8df62b5 | ||
|
|
328872a620 | ||
|
|
3de0848fef | ||
|
|
ff829772d5 | ||
|
|
8da3a8d14c | ||
|
|
49ad06fecd | ||
|
|
24ca0297ad | ||
|
|
53f2f3c87c | ||
|
|
909987a2cf | ||
|
|
91566b2721 | ||
|
|
19ae24637d | ||
|
|
d5354e556c | ||
|
|
ff77854ed6 | ||
|
|
d53b769e61 | ||
|
|
b111ec66ec | ||
|
|
9622f1226c | ||
|
|
9d1e9e0d72 | ||
|
|
90110ac999 | ||
|
|
81cbaf7766 |
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v0.7.8-beta
|
||||
|
||||
Note: if using oidc, please update from 0.7.7 to resolve invalid_grant issue. Also - oidc no longer creates users automatically by default -- must be enabled.
|
||||
|
||||
**New Features**:
|
||||
- More oidc user creation options https://github.com/gtsteffaniak/filebrowser/issues/685
|
||||
- `auth.methods.oidc.createUser` must be true to automatically create user, defaults to false.
|
||||
- `auth.methods.oidc.adminGroup` allows using oidc provider group name to enable admin user creation.
|
||||
|
||||
**BugFixes**:
|
||||
- fix save editor info sometimes saves wrong file. https://github.com/gtsteffaniak/filebrowser/issues/701
|
||||
- make ctrl select work on mac or windows. https://github.com/gtsteffaniak/filebrowser/issues/739
|
||||
- oidc login failures introduced in 0.7.6 https://github.com/gtsteffaniak/filebrowser/issues/731
|
||||
- oidc respects non-default baseURL
|
||||
|
||||
## v0.7.7-beta
|
||||
|
||||
This release cleans up some of the native preview (image preview) feature logic. And adds simple docx and epub viewers as well. Going through all of this, I think I know how I can add full-fledge google doc and microsoft office viewer support (no edit). But, for now "onlyOffice" remains the most comprehensive solution with most compatibility and ability to fully edit. One day, I think I will be able to integrate a minimal license-free server into the docker image. But that's something for another time.
|
||||
|
||||
@@ -60,6 +60,8 @@ type OidcConfig struct {
|
||||
UserIdentifier string `json:"userIdentifier"` // the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // create user if not exists
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
+85
-61
@@ -4,6 +4,8 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,11 +20,42 @@ import (
|
||||
|
||||
// userInfo struct to hold user claims from either UserInfo or ID token
|
||||
type userInfo struct {
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Phone string `json:"phone_number"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Phone string `json:"phone_number"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
|
||||
// This function remains largely the same, but includes the 'fb_redirect' parameter
|
||||
// to redirect the user back to the original page after successful login.
|
||||
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
oidcCfg := settings.Config.Auth.Methods.OidcAuth
|
||||
if !oidcCfg.Enabled {
|
||||
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
|
||||
}
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: oidcCfg.ClientID,
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
}
|
||||
|
||||
nonce := utils.InsecureRandomIdentifier(16)
|
||||
fbRedirect := r.URL.Query().Get("redirect")
|
||||
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
|
||||
|
||||
authURL := oauth2Config.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
|
||||
@@ -49,14 +82,14 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
ctx = oidc.ClientContext(ctx, customClient)
|
||||
}
|
||||
code := r.Header.Get("X-Secret")
|
||||
code := r.URL.Query().Get("code")
|
||||
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
|
||||
|
||||
// The redirect URI MUST match the one registered with the OIDC provider
|
||||
// and used in the initial /api/auth/oidc/login handler.
|
||||
// Using r.Host here might be tricky if running behind a proxy.
|
||||
// Consider using a fixed redirect URL from settings if possible.
|
||||
redirectURL := fmt.Sprintf("%s://%s/api/auth/oidc/callback", getScheme(r), r.Host)
|
||||
redirectURL := fmt.Sprintf("%s://%s%sapi/auth/oidc/callback", getScheme(r), r.Host, config.Server.BaseURL)
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: oidcCfg.ClientID,
|
||||
@@ -88,7 +121,8 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
logger.Warningf("failed to verify ID token: %v. Falling back to UserInfo endpoint.", err)
|
||||
// this might not be necessary for certain providers like authentik
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
|
||||
// Verification failed, claimsFromIDToken remains false
|
||||
} else {
|
||||
var claims map[string]interface{}
|
||||
@@ -173,64 +207,43 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Error("No valid username found in ID token or UserInfo response.")
|
||||
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
|
||||
}
|
||||
isAdmin := false // Default to non-admin user
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
|
||||
if slices.Contains(userdata.Groups, config.Auth.Methods.OidcAuth.AdminGroup) {
|
||||
isAdmin = true // User is in the admin group, grant admin privileges
|
||||
logger.Debugf("User %s is in admin group %s, granting admin privileges.", loginUsername, config.Auth.Methods.OidcAuth.AdminGroup)
|
||||
}
|
||||
}
|
||||
// Proceed to log the user in with the OIDC data
|
||||
// userdata struct now contains info from either verified ID token or UserInfo endpoint
|
||||
return loginWithOidcUser(w, r, loginUsername)
|
||||
}
|
||||
|
||||
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
|
||||
// This function remains largely the same, but includes the 'fb_redirect' parameter
|
||||
// to redirect the user back to the original page after successful login.
|
||||
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
oidcCfg := settings.Config.Auth.Methods.OidcAuth
|
||||
if !oidcCfg.Enabled {
|
||||
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
|
||||
}
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: oidcCfg.ClientID,
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("%s/api/auth/oidc/callback", origin),
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
}
|
||||
|
||||
nonce := utils.InsecureRandomIdentifier(16)
|
||||
fbRedirect := r.URL.Query().Get("fb_redirect")
|
||||
state := fmt.Sprintf("%s:%s", nonce, fbRedirect)
|
||||
|
||||
authURL := oauth2Config.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
return 0, nil
|
||||
return loginWithOidcUser(w, r, loginUsername, isAdmin)
|
||||
}
|
||||
|
||||
// loginWithOidcUser extracts the username from the user claims (userInfo)
|
||||
// based on the configured UserIdentifier and logs the user into the application.
|
||||
// It creates a new user if one doesn't exist.
|
||||
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string) (int, error) {
|
||||
logger.Debugf("Successfully authenticated OIDC username: %s", username)
|
||||
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, isAdmin bool) (int, error) {
|
||||
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
|
||||
// Retrieve the user from the store and store it in the context
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
if err.Error() != "the resource does not exist" {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
err = storage.CreateUser(users.User{
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Username: username,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
if config.Auth.Methods.OidcAuth.CreateUser {
|
||||
err = storage.CreateUser(users.User{
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Username: username,
|
||||
}, isAdmin)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
} else {
|
||||
return http.StatusForbidden, fmt.Errorf("user %s does not exist and createUser is disabled. Your admin needs to create your user before you can access this application", username)
|
||||
}
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodOidc {
|
||||
@@ -271,20 +284,31 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string)
|
||||
// or to the root ("/") if no specific redirect was requested.
|
||||
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
|
||||
state := r.URL.Query().Get("state")
|
||||
fbRedirect := "/" // Default redirect path
|
||||
|
||||
fbRedirect := config.Server.BaseURL // Default redirect to the base URL
|
||||
if state != "" {
|
||||
// Assuming state is in the format "nonce:fb_redirect"
|
||||
parts := strings.SplitN(state, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
// TODO: Validate the nonce part against the stored nonce for CSRF protection
|
||||
// For this example, we'll just extract the redirect part
|
||||
extractedRedirect := parts[1]
|
||||
if extractedRedirect != "" {
|
||||
fbRedirect = extractedRedirect
|
||||
|
||||
// 2. Validate the nonce
|
||||
// receivedNonce := parts[0]
|
||||
// if receivedNonce != nonceCookie.Value {
|
||||
// // Handle error: nonce mismatch (possible CSRF attack)
|
||||
// return http.StatusBadRequest, fmt.Errorf("invalid state nonce")
|
||||
// }
|
||||
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
// 3. Prevent Open Redirect vulnerability
|
||||
// Ensure the redirect is to a local path.
|
||||
potentialRedirect, err := url.QueryUnescape(parts[1])
|
||||
if err == nil && strings.HasPrefix(potentialRedirect, "/") {
|
||||
fbRedirect = potentialRedirect
|
||||
} else {
|
||||
logger.Warningf("Blocked potentially malicious redirect to: %s", parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
http.Redirect(w, r, fbRedirect, http.StatusFound)
|
||||
|
||||
// Return 0 to indicate that the response has been handled by the redirect
|
||||
|
||||
@@ -1910,6 +1910,10 @@ const docTemplate = `{
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adminGroup": {
|
||||
"description": "if set, users in this group will be granted admin privileges.",
|
||||
"type": "string"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "client id of the OIDC application",
|
||||
"type": "string"
|
||||
@@ -1918,6 +1922,10 @@ const docTemplate = `{
|
||||
"description": "client secret of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"createUser": {
|
||||
"description": "create user if not exists",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableVerifyTLS": {
|
||||
"description": "disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -1899,6 +1899,10 @@
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adminGroup": {
|
||||
"description": "if set, users in this group will be granted admin privileges.",
|
||||
"type": "string"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "client id of the OIDC application",
|
||||
"type": "string"
|
||||
@@ -1907,6 +1911,10 @@
|
||||
"description": "client secret of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"createUser": {
|
||||
"description": "create user if not exists",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableVerifyTLS": {
|
||||
"description": "disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -209,12 +209,18 @@ definitions:
|
||||
type: object
|
||||
settings.OidcConfig:
|
||||
properties:
|
||||
adminGroup:
|
||||
description: if set, users in this group will be granted admin privileges.
|
||||
type: string
|
||||
clientId:
|
||||
description: client id of the OIDC application
|
||||
type: string
|
||||
clientSecret:
|
||||
description: client secret of the OIDC application
|
||||
type: string
|
||||
createUser:
|
||||
description: create user if not exists
|
||||
type: boolean
|
||||
disableVerifyTLS:
|
||||
description: disable TLS verification for the OIDC provider. This is insecure
|
||||
and should only be used for testing.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
// Optional: Warn about unused keys in your 'en.json'
|
||||
// This requires configuring the `localeDir` setting below.
|
||||
"@intlify/vue-i18n/no-unused-keys": ["warn", {
|
||||
"@intlify/vue-i18n/no-unused-keys": ["error", {
|
||||
"src": "./src", // Path to your source files
|
||||
"extensions": [".js", ".vue"]
|
||||
// Important: This tells the rule to check unused keys specifically in en.json
|
||||
@@ -40,7 +40,7 @@
|
||||
}],
|
||||
|
||||
"@intlify/vue-i18n/no-raw-text": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"ignoreNodes": ["i", "v-icon"]
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ auth:
|
||||
userIdentifier: "" # the user identifier to use for authentication. Default is "username", can be "email" or "username", or "phone"
|
||||
disableVerifyTLS: false # disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
createUser: false # create user if not exists
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges.
|
||||
key: "" # the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
adminUsername: admin # the username of the admin user. If not set, the default is "admin".
|
||||
adminPassword: admin # the password of the admin user. If not set, the default is "admin".
|
||||
|
||||
@@ -14,11 +14,14 @@ export async function getAllUsers () {
|
||||
}
|
||||
|
||||
export async function generateOTP (username, password) {
|
||||
const params = { username, password }
|
||||
const params = { username }
|
||||
try {
|
||||
let apiPath = getApiPath('api/auth/otp/generate', params)
|
||||
const res = await fetch(apiPath, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Password': password,
|
||||
}
|
||||
})
|
||||
return await res.json()
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div v-if="oidcAvailable" class="password-entry">
|
||||
<div v-if="passwordAvailable" class="or">{{ $t("login.or") }}</div>
|
||||
<a href="/api/auth/oidc/login" class="button button--block direct-login">
|
||||
<a :href="loginURL" class="button button--block direct-login">
|
||||
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
OpenID Connect
|
||||
</a>
|
||||
@@ -60,6 +60,7 @@ import Prompts from "@/components/prompts/Prompts.vue";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import { usersApi } from "@/api";
|
||||
import { initAuth } from "@/utils/auth";
|
||||
import { removeLeadingSlash } from "@/utils/url";
|
||||
import {
|
||||
name,
|
||||
logoURL,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
darkMode,
|
||||
oidcAvailable,
|
||||
passwordAvailable,
|
||||
baseURL,
|
||||
} from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
@@ -98,9 +100,19 @@ export default {
|
||||
password: "",
|
||||
recaptcha: recaptcha,
|
||||
passwordConfirm: "",
|
||||
loginURL: baseURL + "api/auth/oidc/login",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
let redirect = state.route.query.redirect;
|
||||
if (redirect === "" || redirect === undefined || redirect === null) {
|
||||
redirect = baseURL + "files/";
|
||||
} else {
|
||||
redirect = removeLeadingSlash(redirect);
|
||||
redirect = baseURL + redirect;
|
||||
}
|
||||
this.loginURL += `?redirect=${encodeURIComponent(redirect)}`;
|
||||
|
||||
if (!recaptcha) return;
|
||||
window.grecaptcha.ready(function () {
|
||||
window.grecaptcha.render("recaptcha", {
|
||||
@@ -249,4 +261,4 @@ export default {
|
||||
.or::after {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="viewer-background">
|
||||
<div v-if="loading" class="status-text">Loading document...</div>
|
||||
<div v-if="loading" class="status-text">{{ $t('files.loading') }}</div>
|
||||
<div v-else-if="error" class="status-text error">{{ error }}</div>
|
||||
<div v-else class="docx-page" v-html="docxHtml"></div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
<script>
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { state, getters } from "@/store";
|
||||
import { filesApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import ace, { version as ace_version } from "ace-builds";
|
||||
import modelist from "ace-builds/src-noconflict/ext-modelist";
|
||||
import "ace-builds/src-min-noconflict/theme-chrome";
|
||||
@@ -18,6 +19,7 @@ export default {
|
||||
data: function () {
|
||||
return {
|
||||
editor: null, // The editor instance
|
||||
filename: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -25,20 +27,24 @@ export default {
|
||||
return getters.isDarkMode();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
}
|
||||
// Wait for the DOM to update after the route change
|
||||
this.$nextTick(() => {
|
||||
this.setupEditor();
|
||||
});
|
||||
},
|
||||
beforeRouteUpdate(to, from, next) {
|
||||
// Destroy the old editor instance to ensure a clean state
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
// Call setupEditor on the next DOM update cycle
|
||||
this.$nextTick(() => {
|
||||
this.setupEditor();
|
||||
});
|
||||
|
||||
// Continue with the navigation
|
||||
next();
|
||||
},
|
||||
created() {
|
||||
window.addEventListener("keydown", this.keyEvent);
|
||||
eventBus.on("handleEditorValueRequest", this.handleEditorValueRequest);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("keydown", this.keyEvent);
|
||||
@@ -47,28 +53,33 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
mutations.resetSelected();
|
||||
mutations.addSelected({
|
||||
name: state.req.name,
|
||||
path: state.req.path,
|
||||
size: state.req.size,
|
||||
type: state.req.type,
|
||||
source: state.req.source,
|
||||
url: state.req.url,
|
||||
});
|
||||
// Wait for the initial DOM render to complete
|
||||
this.$nextTick(() => {
|
||||
this.setupEditor();
|
||||
});
|
||||
eventBus.on("handleEditorValueRequest", this.handleEditorValueRequest);
|
||||
this.setupEditor();
|
||||
},
|
||||
methods: {
|
||||
setupEditor() {
|
||||
setupEditor(attempt = 1) {
|
||||
try {
|
||||
this.filename = decodeURIComponent(this.$route.path.split("/").pop());
|
||||
// Safety Check 1: Use the component's 'filename' data property for comparison
|
||||
if (state.req.name !== this.filename) {
|
||||
if (attempt < 5) {
|
||||
console.warn(
|
||||
`[Attempt ${attempt}/5] State filename ("${state.req.name}") does not match route filename ("${this.filename}"). Retrying in 500ms...`
|
||||
);
|
||||
setTimeout(() => this.setupEditor(attempt + 1), 500);
|
||||
} else {
|
||||
const errorMsg = `[FATAL] Failed to sync state with the route for "${this.filename}" after 5 attempts. Aborting editor setup to prevent data corruption.`;
|
||||
console.error(errorMsg);
|
||||
notify.showError(errorMsg); // Using the custom notifier
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"State and route are in sync. Proceeding with editor setup for",
|
||||
this.filename
|
||||
);
|
||||
const editorEl = document.getElementById("editor");
|
||||
if (!editorEl) {
|
||||
console.warn(
|
||||
"Editor component mounted, but #editor div was not found in the DOM. Aborting setup."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,33 +87,45 @@ export default {
|
||||
"basePath",
|
||||
`https://cdn.jsdelivr.net/npm/ace-builds@${ace_version}/src-min-noconflict/`
|
||||
);
|
||||
|
||||
const fileContent =
|
||||
state.req.content == "empty-file-x6OlSil" ? "" : state.req.content || "";
|
||||
|
||||
this.editor = ace.edit(editorEl, {
|
||||
mode: modelist.getModeForPath(state.req.name).mode,
|
||||
value: fileContent,
|
||||
showPrintMargin: false,
|
||||
theme: "ace/theme/chrome",
|
||||
theme: this.isDarkMode ? "ace/theme/twilight" : "ace/theme/chrome",
|
||||
readOnly: state.req.type === "textImmutable",
|
||||
wrap: false,
|
||||
});
|
||||
|
||||
if (this.isDarkMode) {
|
||||
this.editor.setTheme("ace/theme/twilight");
|
||||
this.filename = decodeURIComponent(this.$route.path.split("/").pop());
|
||||
} catch (error) {
|
||||
notify.showError("Failed to initialize the editor. Please reload.");
|
||||
}
|
||||
},
|
||||
handleEditorValueRequest() {
|
||||
if (this.editor) {
|
||||
filesApi.put(state.req.path, state.req.source, this.editor.getValue());
|
||||
// Safety Check 2: Final verification before saving
|
||||
if (state.req.name !== this.filename) {
|
||||
// Corrected the error message to be more accurate
|
||||
const errorMsg = `Save operation aborted. The application's active file ("${state.req.name}") does not match the file you are trying to save ("${this.filename}").`;
|
||||
notify.showError(errorMsg);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.editor) {
|
||||
filesApi.put(state.req.path, state.req.source, this.editor.getValue());
|
||||
} else {
|
||||
notify.showError("Editor instance is not initialized.");
|
||||
return;
|
||||
}
|
||||
notify.showSuccess(`${this.filename} saved successfully.`);
|
||||
} catch (error) {
|
||||
notify.showError("Failed to save file. Please try again.");
|
||||
}
|
||||
},
|
||||
keyEvent(event) {
|
||||
const { key, ctrlKey, metaKey } = event;
|
||||
if (getters.currentPromptName() != null) return;
|
||||
if (!ctrlKey && !metaKey) return;
|
||||
if (key.toLowerCase() === "s") {
|
||||
if ((ctrlKey || metaKey) && key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
this.handleEditorValueRequest();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div id="viewer" :class="{ ready: isReady }"></div>
|
||||
|
||||
<div v-if="isReady" class="navigation">
|
||||
<button @click="prevPage" class="nav-button"><</button>
|
||||
<button @click="nextPage" class="nav-button">></button>
|
||||
<button @click="prevPage" class="nav-button"><</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<button @click="nextPage" class="nav-button">></button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -557,8 +557,9 @@ export default {
|
||||
}
|
||||
},
|
||||
clearCtrKey(event) {
|
||||
const { ctrlKey } = event;
|
||||
if (!ctrlKey) {
|
||||
const { ctrlKey, metaKey } = event;
|
||||
const modifierKeys = ctrlKey || metaKey;
|
||||
if (!modifierKeys) {
|
||||
this.ctrKeyPressed = false;
|
||||
}
|
||||
},
|
||||
@@ -594,9 +595,7 @@ export default {
|
||||
let newPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
|
||||
|
||||
if (modifierKeys) {
|
||||
if (ctrlKey) {
|
||||
this.ctrKeyPressed = true;
|
||||
}
|
||||
this.ctrKeyPressed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div id="previewer" @mousemove="toggleNavigation" @touchstart="toggleNavigation">
|
||||
<div class="preview">
|
||||
<ExtendedImage v-if="previewType == 'image' || pdfConvertable" :src="raw"> </ExtendedImage>
|
||||
<ExtendedImage v-if="previewType == 'image' || pdfConvertable" :src="raw">
|
||||
</ExtendedImage>
|
||||
<audio
|
||||
v-else-if="previewType == 'audio'"
|
||||
ref="player"
|
||||
@@ -114,23 +115,19 @@ export default {
|
||||
pdfConvertable() {
|
||||
const ext = "." + state.req.name.split(".").pop().toLowerCase(); // Ensure lowercase and dot
|
||||
const pdfConvertCompatibleFileExtensions = {
|
||||
".pdf": true,
|
||||
".xps": true,
|
||||
".epub": true,
|
||||
".mobi": true,
|
||||
".fb2": true,
|
||||
".cbz": true,
|
||||
".svg": true,
|
||||
".txt": true,
|
||||
".docx": true,
|
||||
".ppt": true,
|
||||
".pptx": true,
|
||||
".xlsx": true,
|
||||
".hwp": true,
|
||||
".hwpx": true,
|
||||
".md": true,
|
||||
};
|
||||
return (!!pdfConvertCompatibleFileExtensions[ext]);
|
||||
return !!pdfConvertCompatibleFileExtensions[ext];
|
||||
},
|
||||
sidebarShowing() {
|
||||
return getters.isSidebarVisible();
|
||||
@@ -140,7 +137,10 @@ export default {
|
||||
},
|
||||
raw() {
|
||||
if (this.pdfConvertable) {
|
||||
return filesApi.getPreviewURL(state.req.source, state.req.path, state.req.modified) + "&size=original";
|
||||
return (
|
||||
filesApi.getPreviewURL(state.req.source, state.req.path, state.req.modified) +
|
||||
"&size=original"
|
||||
);
|
||||
}
|
||||
return filesApi.getDownloadURL(state.req.source, state.req.path, true);
|
||||
},
|
||||
|
||||
@@ -6,7 +6,8 @@ async function globalSetup() {
|
||||
const context = await browser.newContext();
|
||||
const page: Page = await context.newPage();
|
||||
|
||||
await page.goto("http://127.0.0.1/files", { timeout: 500 });
|
||||
await page.goto("http://127.0.0.1/files");
|
||||
await page.waitForURL("**/files/", { timeout: 1000 });
|
||||
|
||||
// Create a share of folder
|
||||
await page.locator('a[aria-label="myfolder"]').waitFor({ state: 'visible' });
|
||||
|
||||
Reference in new issue
Block a user