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');
}
};