diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index b87f8e0302e4..63adf86d64b1 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -197,7 +197,7 @@ type FileRemarkUpdate struct { } type FileShareCreate struct { - Path string `json:"path" validate:"required"` - ExpireMinutes int `json:"expireMinutes" validate:"min=0,max=10080"` - Password string `json:"password" validate:"omitempty,min=4,max=256"` + Path string `json:"path" validate:"required"` + ExpireMinutes int `json:"expireMinutes" validate:"min=0,max=10080"` + Password *string `json:"password"` } diff --git a/agent/app/dto/response/file.go b/agent/app/dto/response/file.go index 3784646306ff..c5ecaa6eb646 100644 --- a/agent/app/dto/response/file.go +++ b/agent/app/dto/response/file.go @@ -93,6 +93,7 @@ type FileShareInfo struct { ExpiresAt int64 `json:"expiresAt"` Permanent bool `json:"permanent"` HasPassword bool `json:"hasPassword"` + Password string `json:"password,omitempty"` } type FileSharePublicInfo struct { diff --git a/agent/app/model/file_share.go b/agent/app/model/file_share.go index e9297dc7e4f2..092cd6f5fe81 100644 --- a/agent/app/model/file_share.go +++ b/agent/app/model/file_share.go @@ -6,6 +6,7 @@ type FileShare struct { Token string `gorm:"not null;uniqueIndex" json:"token"` FileName string `gorm:"not null" json:"fileName"` ExpiresUnix int64 `json:"expiresUnix"` + PasswordEnc string `json:"-"` PasswordSalt string `json:"passwordSalt"` PasswordHash string `json:"passwordHash"` MaxDownloads int `json:"maxDownloads"` diff --git a/agent/app/service/file_share.go b/agent/app/service/file_share.go index f48c953dd1bd..9718e712ccc6 100644 --- a/agent/app/service/file_share.go +++ b/agent/app/service/file_share.go @@ -19,6 +19,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/utils/encrypt" "gorm.io/gorm" ) @@ -88,6 +89,17 @@ func shareModelToInfo(item model.FileShare) response.FileShareInfo { } } +func fillSharePassword(info *response.FileShareInfo, item model.FileShare) { + if info == nil || item.PasswordEnc == "" { + return + } + password, err := encrypt.StringDecrypt(item.PasswordEnc) + if err != nil { + return + } + info.Password = password +} + func shareModelToPublicInfo(item model.FileShare) response.FileSharePublicInfo { return response.FileSharePublicInfo{ FileName: item.FileName, @@ -156,19 +168,29 @@ func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileSh item.ExpiresUnix = time.Now().Add(time.Duration(req.ExpireMinutes) * time.Minute).Unix() } - pw := strings.TrimSpace(req.Password) - item.PasswordSalt = "" - item.PasswordHash = "" - if pw != "" { - if utf8.RuneCountInString(pw) < 4 { - return nil, buserr.New("ErrFileSharePasswordPolicy") - } - salt, err := randomSalt() - if err != nil { - return nil, err + if req.Password != nil { + pw := strings.TrimSpace(*req.Password) + if pw == "" { + item.PasswordEnc = "" + item.PasswordSalt = "" + item.PasswordHash = "" + } else { + pwLen := utf8.RuneCountInString(pw) + if pwLen < 4 || pwLen > 256 { + return nil, buserr.New("ErrFileSharePasswordPolicy") + } + enc, err := encrypt.StringEncrypt(pw) + if err != nil { + return nil, err + } + item.PasswordEnc = enc + salt, err := randomSalt() + if err != nil { + return nil, err + } + item.PasswordSalt = salt + item.PasswordHash = hashPassword(salt, pw) } - item.PasswordSalt = salt - item.PasswordHash = hashPassword(salt, pw) } if isNew { @@ -182,6 +204,7 @@ func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileSh } res := shareModelToInfo(item) + fillSharePassword(&res, item) return &res, nil } @@ -227,6 +250,7 @@ func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, erro return nil, nil } info := shareModelToInfo(item) + fillSharePassword(&info, item) return &info, nil } diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index 63b1098bbecb..c0816893f910 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -1220,7 +1220,7 @@ var AddFileManageAISettings = &gormigrate.Migration{ } var AddFileShareTable = &gormigrate.Migration{ - ID: "20260407-add-file-share-table", + ID: "20260410-add-file-share-table", Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.FileShare{}) }, diff --git a/core/constant/common.go b/core/constant/common.go index f28953daf2dd..0db78a5c11d1 100644 --- a/core/constant/common.go +++ b/core/constant/common.go @@ -185,6 +185,7 @@ var DynamicRoutes = []string{ `^/databases/mysql/setting/[^/]+/[^/]+$`, `^/databases/postgresql/setting/[^/]+/[^/]+$`, `^/websites/[^/]+/config/[^/]+$`, + `^/s/[A-Za-z0-9]{10,16}$`, } var CertStore atomic.Value diff --git a/core/utils/security/security.go b/core/utils/security/security.go index c5530ac07127..be258850cfb2 100644 --- a/core/utils/security/security.go +++ b/core/utils/security/security.go @@ -18,6 +18,8 @@ import ( "github.com/gin-gonic/gin" ) +var publicSharePagePattern = regexp.MustCompile(`^/s/[A-Za-z0-9]{10,16}$`) + func HandleNotRoute(c *gin.Context) bool { if !checkBindDomain(c) { HandleNotSecurity(c, "err_domain") @@ -137,6 +139,9 @@ func checkFrontendPath(c *gin.Context) bool { if !isFrontendPath(c) { return false } + if isPublicFileSharePagePath(c.Request.URL.Path) { + return true + } authService := service.NewIAuthService() if authService.GetSecurityEntrance() != "" { return authService.IsLogin(c) @@ -144,6 +149,11 @@ func checkFrontendPath(c *gin.Context) bool { return true } +func isPublicFileSharePagePath(path string) bool { + reqUri := strings.TrimSuffix(path, "/") + return publicSharePagePattern.MatchString(reqUri) +} + func checkBindDomain(c *gin.Context) bool { settingRepo := repo.NewISettingRepo() status, _ := settingRepo.Get(repo.WithByKey("BindDomain")) diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index 13fdf9c330be..620a8e70990e 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -324,6 +324,7 @@ export namespace File { expiresAt: number; permanent: boolean; hasPassword: boolean; + password?: string; } export interface FileSharePublicInfo { diff --git a/frontend/src/views/host/file-management/share/index.vue b/frontend/src/views/host/file-management/share/index.vue index 4528d44b914a..64f7edf5f7d7 100644 --- a/frontend/src/views/host/file-management/share/index.vue +++ b/frontend/src/views/host/file-management/share/index.vue @@ -23,9 +23,9 @@ - + - {{ expiresAtText }} + @@ -102,7 +102,7 @@ import i18n from '@/lang'; import { GlobalStore } from '@/store'; import { buildFileSharePageUrl, buildFileShareQrCodeUrl, copyText, dateFormat as formatDateTime } from '@/utils/util'; import type { FormInstance, FormRules } from 'element-plus'; -import { computed, reactive, ref } from 'vue'; +import { computed, reactive, ref, watch } from 'vue'; interface ShareProps { path: string; @@ -119,9 +119,12 @@ const expiresAtText = ref(''); const qrCodeUrl = ref(''); const qrDialogOpen = ref(false); const shareFormRef = ref(); +const syncingPassword = ref(false); +const initialSharePassword = ref(''); +const passwordTouched = ref(false); const form = reactive({ expireMinutes: 1440, - password: '', + sharePassword: '', }); const emit = defineEmits(['close']); @@ -149,9 +152,22 @@ const validatePassword = (_rule, value, callback) => { }; const rules = reactive({ - password: [{ validator: validatePassword, trigger: 'blur' }], + sharePassword: [{ validator: validatePassword, trigger: 'blur' }], }); +watch( + () => form.sharePassword, + (val) => { + if (syncingPassword.value) { + return; + } + if (val === initialSharePassword.value) { + return; + } + passwordTouched.value = true; + }, +); + const mapExpireMinutes = (info: File.FileShareInfo) => { if (info.permanent || info.expiresAt === 0) { return 0; @@ -169,7 +185,11 @@ const applyShareInfo = (info: File.FileShareInfo | null) => { qrCodeUrl.value = ''; qrDialogOpen.value = false; form.expireMinutes = 1440; - form.password = ''; + form.sharePassword = ''; + syncingPassword.value = true; + initialSharePassword.value = ''; + passwordTouched.value = false; + syncingPassword.value = false; return; } shareUrl.value = buildFileSharePageUrl(info.code, globalStore.currentNode); @@ -178,7 +198,11 @@ const applyShareInfo = (info: File.FileShareInfo | null) => { ? i18n.global.t('website.ever') : formatDateTime(null, null, info.expiresAt * 1000); form.expireMinutes = mapExpireMinutes(info); - form.password = ''; + syncingPassword.value = true; + form.sharePassword = info.password || ''; + initialSharePassword.value = form.sharePassword; + passwordTouched.value = false; + syncingPassword.value = false; }; const loadShareDetail = async () => { @@ -207,12 +231,21 @@ const generate = async () => { } loading.value = true; try { - const pw = form.password.trim(); - const res = await createFileShare({ + const pw = form.sharePassword.trim(); + const payload: File.FileShareCreate = { path: filePath.value, expireMinutes: form.expireMinutes, - ...(pw.length > 0 ? { password: pw } : {}), - }); + }; + + // Only send password when user explicitly changes it. + if (passwordTouched.value) { + payload.password = pw; // empty string means "clear password" + } else if (!shareInfo.value?.hasPassword && pw.length > 0) { + // New share: allow setting password when it wasn't loaded from server. + payload.password = pw; + } + + const res = await createFileShare(payload); applyShareInfo(res.data as File.FileShareInfo); changed.value = true; } finally { @@ -233,7 +266,19 @@ const cancelShare = async () => { const copyLink = () => { if (shareUrl.value) { - copyText(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); + } +}; + +const openShareUrl = () => { + if (shareUrl.value) { + window.open(shareUrl.value, '_blank', 'noopener,noreferrer'); } };