From 96b90b8cf26afd1933ecfb3a8c7ab27870ea7852 Mon Sep 17 00:00:00 2001 From: lan-yonghui Date: Mon, 3 Aug 2026 19:05:13 +0800 Subject: [PATCH] fix: Implement file sharing password processing and remote download file name processing --- core/init/router/proxy.go | 6 +- core/middleware/helper.go | 11 +++ core/middleware/password_expired.go | 4 ++ frontend/src/utils/file.ts | 71 +++++++++++++++++++ .../host/file-management/share/index.vue | 10 +-- .../views/host/file-management/wget/index.vue | 4 +- frontend/src/views/share/index.vue | 12 +++- 7 files changed, 102 insertions(+), 16 deletions(-) diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index c6ea7dc56129..3be526474dbc 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -40,7 +40,7 @@ func Proxy() gin.HandlerFunc { apiReq := c.GetBool("API_AUTH") - if !apiReq && !isLocalAPI(reqPath) && !isPublicFileShareAPI(reqPath) && !checkSession(c) { + if !apiReq && !isLocalAPI(reqPath) && !middleware.IsPublicFileShareAPI(reqPath) && !checkSession(c) { data, _ := res.ErrorMsg.ReadFile("html/401.html") c.Data(401, "text/html; charset=utf-8", data) c.Abort() @@ -97,7 +97,3 @@ func checkSession(c *gin.Context) bool { func isLocalAPI(urlPath string) bool { return urlPath == "/api/v2/core/xpack/sync/ssl" || urlPath == "/api/v2/core/xpack/settings/search" } - -func isPublicFileShareAPI(urlPath string) bool { - return urlPath == "/api/v2/files/share/download" || urlPath == "/api/v2/files/share/check" || urlPath == "/api/v2/files/share/info" -} diff --git a/core/middleware/helper.go b/core/middleware/helper.go index 4f42c35a8805..b46ca99e49e2 100644 --- a/core/middleware/helper.go +++ b/core/middleware/helper.go @@ -11,3 +11,14 @@ func ShouldProxyToAgent(reqPath string) bool { } return true } + +func IsPublicFileShareAPI(reqPath string) bool { + switch reqPath { + case "/api/v2/files/share/info", + "/api/v2/files/share/check", + "/api/v2/files/share/download": + return true + default: + return false + } +} diff --git a/core/middleware/password_expired.go b/core/middleware/password_expired.go index 249f0fab6bb3..aa4a5a3b688e 100644 --- a/core/middleware/password_expired.go +++ b/core/middleware/password_expired.go @@ -27,6 +27,10 @@ func PasswordExpired() gin.HandlerFunc { c.Next() return } + if IsPublicFileShareAPI(c.Request.URL.Path) { + c.Next() + return + } if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") || c.Request.URL.Path == "/api/v2/core/settings/search" || c.Request.URL.Path == "/api/v2/core/settings/search/base" || diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts index 7d29c8ec4fe0..08a34de5847d 100644 --- a/frontend/src/utils/file.ts +++ b/frontend/src/utils/file.ts @@ -200,3 +200,74 @@ export const resolveEditorLanguage = (path: string, extension = '', name = '') = return 'yaml'; }; + +const normalizeUrlFilename = (value: string) => { + let decoded = value.trim().replace(/^['"]|['"]$/g, ''); + try { + decoded = decodeURIComponent(decoded); + } catch { + // Keep the original value when it contains an incomplete escape sequence. + } + return ( + decoded + .replace(/[\u0000-\u001f\u007f]/g, '') + .split(/[\\/]/) + .pop() + ?.trim() || '' + ); +}; + +const getFilenameFromContentDisposition = (contentDisposition: string) => { + const extendedMatch = contentDisposition.match(/(?:^|;)\s*filename\*\s*=\s*(?:"([^"]*)"|([^;]*))/i); + const extendedValue = extendedMatch?.[1] || extendedMatch?.[2]?.trim(); + if (extendedValue) { + const encodedValue = extendedValue.match(/^[^']*'[^']*'(.*)$/)?.[1] || extendedValue; + return normalizeUrlFilename(encodedValue); + } + + const filenameMatch = contentDisposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i); + return normalizeUrlFilename(filenameMatch?.[1] || filenameMatch?.[2] || ''); +}; + +export const getFilenameFromUrl = (value: string) => { + const normalizedValue = value.trim(); + try { + const url = new URL(normalizedValue); + const dispositionKeys = ['response-content-disposition', 'rscd', 'content-disposition']; + for (const key of dispositionKeys) { + const disposition = Array.from(url.searchParams.entries()).find( + ([paramKey]) => paramKey.toLowerCase() === key, + )?.[1]; + if (disposition) { + const filename = getFilenameFromContentDisposition(disposition); + if (filename) { + return filename; + } + } + } + return normalizeUrlFilename(url.pathname.slice(url.pathname.lastIndexOf('/') + 1)); + } catch { + const urlWithoutParams = normalizedValue.replace(/[?#].*$/, ''); + return normalizeUrlFilename(urlWithoutParams.slice(urlWithoutParams.lastIndexOf('/') + 1)); + } +}; + +const FILE_SHARE_PASSWORD_KEY = 'k'; + +export const withFileSharePassword = (value: string, password: string) => { + const shareUrl = new URL(value); + const hashParams = new URLSearchParams(shareUrl.hash.slice(1)); + const normalizedPassword = password.trim(); + if (normalizedPassword) { + hashParams.set(FILE_SHARE_PASSWORD_KEY, normalizedPassword); + } else { + hashParams.delete(FILE_SHARE_PASSWORD_KEY); + } + shareUrl.hash = hashParams.toString(); + return shareUrl.toString(); +}; + +export const getFileSharePasswordFromHash = (hash: string) => { + const hashParams = new URLSearchParams(hash.replace(/^#/, '')); + return hashParams.get(FILE_SHARE_PASSWORD_KEY)?.trim() || ''; +}; diff --git a/frontend/src/views/host/file-management/share/index.vue b/frontend/src/views/host/file-management/share/index.vue index 159adc98e520..4289dd63e3fb 100644 --- a/frontend/src/views/host/file-management/share/index.vue +++ b/frontend/src/views/host/file-management/share/index.vue @@ -100,7 +100,7 @@ import { File } from '@/api/interface/file'; import { CopyDocument, Download, Picture } from '@element-plus/icons-vue'; import i18n from '@/lang'; import { useGlobalStore } from '@/composables/useGlobalStore'; -import { buildFileSharePageUrl, buildFileShareQrCodeUrl } from '@/utils/file'; +import { buildFileSharePageUrl, buildFileShareQrCodeUrl, withFileSharePassword } from '@/utils/file'; import { copyText } from '@/utils/clipboard'; import { dateFormat as formatDateTime } from '@/utils/date'; import type { FormInstance, FormRules } from 'element-plus'; @@ -266,13 +266,7 @@ const cancelShare = async () => { const copyLink = () => { if (shareUrl.value) { - const password = form.sharePassword.trim(); - const content = password - ? `${i18n.global.t('file.shareLinkLabel')}:${shareUrl.value},${i18n.global.t( - 'file.sharePassword', - )}:${password}` - : `${i18n.global.t('file.shareLinkLabel')}:${shareUrl.value}`; - copyText(content); + copyText(withFileSharePassword(shareUrl.value, form.sharePassword)); } }; diff --git a/frontend/src/views/host/file-management/wget/index.vue b/frontend/src/views/host/file-management/wget/index.vue index e82f93052b3b..4e1c46787de0 100644 --- a/frontend/src/views/host/file-management/wget/index.vue +++ b/frontend/src/views/host/file-management/wget/index.vue @@ -61,6 +61,7 @@ import { FormInstance, FormRules } from 'element-plus'; import { reactive, ref } from 'vue'; import FileList from '@/components/file-list/index.vue'; import { MsgSuccess } from '@/utils/message'; +import { getFilenameFromUrl } from '@/utils/file'; interface WgetProps { path: string; @@ -143,8 +144,7 @@ const submit = async (formEl: FormInstance | undefined) => { }; const getFileName = (url: string) => { - const paths = url.split('/'); - addForm.name = paths[paths.length - 1]; + addForm.name = getFilenameFromUrl(url); }; const acceptParams = (props: WgetProps) => { diff --git a/frontend/src/views/share/index.vue b/frontend/src/views/share/index.vue index a3cf4f230d09..276f3b9c9b02 100644 --- a/frontend/src/views/share/index.vue +++ b/frontend/src/views/share/index.vue @@ -42,7 +42,7 @@ import { checkFileShare, getPublicFileShareInfo } from '@/api/modules/files'; import { File } from '@/api/interface/file'; import i18n, { loadLocaleMessages } from '@/lang'; -import { buildFileShareDownloadUrl } from '@/utils/file'; +import { buildFileShareDownloadUrl, getFileSharePasswordFromHash } from '@/utils/file'; import { dateFormat } from '@/utils/date'; import { computed, onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; @@ -80,6 +80,15 @@ const triggerDownload = (pwd = '') => { window.location.href = buildFileShareDownloadUrl(code.value, currentNode.value, pwd); }; +const applySharedPassword = () => { + const sharedPassword = getFileSharePasswordFromHash(window.location.hash); + if (!sharedPassword) { + return; + } + password.value = sharedPassword; + window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`); +}; + const resolveBrowserLocale = () => { if (typeof navigator === 'undefined') { return 'en'; @@ -151,6 +160,7 @@ const downloadWithPassword = async () => { onMounted(async () => { try { + applySharedPassword(); await applyPublicLocale(); await loadShareInfo(); if (shareInfo.value && !shareInfo.value.hasPassword) {