Compare commits

...
52 Commits
Author SHA1 Message Date
Graham Steffaniak 1ce3bf1614 oidc-fix (#741) 2025-06-09 20:00:40 -05:00
Graham Steffaniak 615e1c432d Fix edit bug (#740) 2025-06-09 17:01:40 -05:00
Graham Steffaniak 0e07e02555 Merge branch 'dev/v0.7.8' of github.com:gtsteffaniak/filebrowser into dev/v0.7.8 2025-06-09 14:42:55 -05:00
Graham Steffaniak c815a9f623 updated with pdf viewer (#730) 2025-06-06 20:15:28 -05:00
Graham Steffaniak 1d5026984b Empty image error (#726) 2025-06-06 18:34:42 -05:00
Graham Steffaniak 7e80179e74 added docx and epub viewer 2025-06-06 17:26:59 -05:00
Graham Steffaniak 4a7390b0dc Merge branch 'dev/v0.7.7' of github.com:gtsteffaniak/filebrowser into dev/v0.7.7 2025-06-06 16:09:56 -05:00
Graham SteffaniakandGilles 50d3513880 Fix sizes icons (#725)
Co-authored-by: Gilles <gilles@wittezaele.fr>
2025-06-06 15:31:03 -05:00
Graham Steffaniak 9a0cd4552d Merge branch 'main' into dev/v0.7.7 2025-06-06 14:50:00 -05:00
Graham Steffaniak b0aa189d34 fix null tag release 2025-06-06 14:37:59 -05:00
Graham Steffaniak fa0900d4f8 guard against null tag 2025-06-06 14:37:14 -05:00
Graham Steffaniak b57c3adaa1 release full version 2025-06-06 14:31:27 -05:00
Graham Steffaniak 282fd6f227 updated with password login fixes (#724) 2025-06-06 14:26:17 -05:00
Graham Steffaniak cb7ad4af92 fix otp issue (#722) 2025-06-05 20:24:19 -05:00
Graham Steffaniak d896858bcd add more preview options (#721) 2025-06-05 17:09:06 -05:00
Graham Steffaniak 712ad91f20 Add zig cross compile (#720) 2025-06-05 15:53:19 -05:00
Graham Steffaniak cfa1537e2e don't update latest 2025-06-04 20:08:38 -05:00
Graham Steffaniak d4e0c38f06 updated release to only do arm64 2025-06-04 20:05:31 -05:00
Graham Steffaniak 305a54ffb6 updated (#718) 2025-06-04 19:30:34 -05:00
Graham Steffaniak 3acdde80ea updated mupdf 2025-06-04 19:14:28 -05:00
Graham Steffaniak a3d8833a2e updated releaser 2025-06-04 18:59:23 -05:00
Graham Steffaniak 421fd126c8 Merge branch 'dev/v0.7.7' of github.com:gtsteffaniak/filebrowser into dev/v0.7.7 2025-06-04 18:42:00 -05:00
Graham Steffaniak dfdc422ccf Rebased commit (#717) 2025-06-04 18:25:36 -05:00
Graham Steffaniak 114d2136bb Goreleaser (#715) 2025-06-04 18:22:27 -05:00
Graham Steffaniak cfc4a1be78 su do 2025-06-04 18:22:27 -05:00
Graham Steffaniak d172c0fa4e updated cross compilers 2025-06-04 18:22:27 -05:00
Graham Steffaniak fe1984f234 Change name (#713) 2025-06-04 18:22:25 -05:00
Graham Steffaniak 27bfee087c updated release tag 2025-06-04 18:22:07 -05:00
Graham Steffaniak dc96f9d0ae updated 2025-06-04 18:22:07 -05:00
Graham Steffaniak 24c83842a5 updated with build-essential 2025-06-04 18:22:07 -05:00
Graham Steffaniak bd822497e1 Build with cgo (#707) 2025-06-04 18:22:07 -05:00
Graham Steffaniak ea9d2fe6df updated with no config (#706) 2025-06-04 18:22:07 -05:00
Graham Steffaniak 11e0681562 updated workflow (#705) 2025-06-04 18:22:07 -05:00
Graham Steffaniak 51d8df62b5 updated with office preview fixes (#703) 2025-06-04 18:22:07 -05:00
Graham Steffaniak 328872a620 Beta/v0.7.6 (#697) 2025-06-04 18:22:05 -05:00
Graham Steffaniak 3de0848fef Goreleaser (#715) 2025-06-04 17:21:21 -05:00
Graham Steffaniak ff829772d5 su do 2025-06-04 16:54:39 -05:00
Graham Steffaniak 8da3a8d14c updated cross compilers 2025-06-04 16:51:43 -05:00
Graham Steffaniak 49ad06fecd Merge branch 'dev/v0.7.7' of github.com:gtsteffaniak/filebrowser into dev/v0.7.7 2025-06-04 16:32:35 -05:00
Graham Steffaniak 24ca0297ad Change name (#713) 2025-06-04 16:26:04 -05:00
Graham Steffaniak 53f2f3c87c updated release tag 2025-06-04 16:16:34 -05:00
Graham Steffaniak 909987a2cf updated 2025-06-04 16:08:02 -05:00
Graham Steffaniak 91566b2721 updated with build-essential 2025-06-04 15:17:20 -05:00
Graham Steffaniak 19ae24637d Build with cgo (#707) 2025-06-04 14:51:33 -05:00
Graham Steffaniak d5354e556c updated with no config (#706) 2025-06-04 12:54:19 -05:00
Graham Steffaniak ff77854ed6 updated workflow (#705) 2025-06-04 11:12:27 -05:00
Graham Steffaniak d53b769e61 updated with office preview fixes (#703) 2025-06-04 09:45:52 -05:00
Graham Steffaniak b111ec66ec updated with slim build (#694) 2025-06-01 16:05:30 -05:00
Graham Steffaniak 9622f1226c Build multiarch (#693) 2025-06-01 14:25:34 -05:00
Graham Steffaniak 9d1e9e0d72 updated dockerfile and workflow (#692) 2025-06-01 12:41:05 -05:00
Graham Steffaniak 90110ac999 updated dockerfile and workflow (#691) 2025-05-31 20:05:11 -05:00
Graham Steffaniak 81cbaf7766 updated with image placeholders (#690) 2025-05-31 19:21:34 -05:00
16 changed files with 225 additions and 122 deletions

No files matched your search

+15
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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"
+8
View File
@@ -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"
+6
View File
@@ -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.
+2 -2
View File
@@ -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"]
}
+2
View File
@@ -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".
+4 -1
View File
@@ -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) {
+14 -2
View File
@@ -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 -1
View File
@@ -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>
+63 -40
View File
@@ -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();
}
+2 -2
View File
@@ -7,8 +7,8 @@
<div id="viewer" :class="{ ready: isReady }"></div>
<div v-if="isReady" class="navigation">
<button @click="prevPage" class="nav-button">&lt;</button>
<button @click="nextPage" class="nav-button">&gt;</button>
<button @click="prevPage" class="nav-button">&lt;</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<button @click="nextPage" class="nav-button">&gt;</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</template>
+4 -5
View File
@@ -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;
}
+7 -7
View File
@@ -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);
},
+2 -1
View File
@@ -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' });