feat: File management supports link share file (#12453)

This commit is contained in:
2026-04-09 17:38:38 +08:00
committed by GitHub
parent 218f92a960
commit 3f14f8f30e
54 changed files with 2207 additions and 24 deletions

View File

@@ -36,12 +36,13 @@ var (
cronjobService = service.NewICronjobService()
fileService = service.NewIFileService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
fileService = service.NewIFileService()
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
deviceService = service.NewIDeviceService()
fail2banService = service.NewIFail2BanService()

View File

@@ -25,6 +25,7 @@ import (
websocket2 "github.com/1Panel-dev/1Panel/agent/utils/websocket"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
qrcode "github.com/skip2/go-qrcode"
)
// @Tags File
@@ -1060,3 +1061,244 @@ func (b *BaseApi) SetFileRemark(c *gin.Context) {
}
helper.Success(c)
}
// @Tags File
// @Summary List file shares
// @Accept json
// @Param request body dto.PageInfo true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/search [post]
func (b *BaseApi) SearchFileShare(c *gin.Context) {
var req dto.PageInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := fileShareService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: list,
})
}
// @Tags File
// @Summary Get file share detail by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/detail [post]
func (b *BaseApi) GetFileShareDetail(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := fileShareService.GetByPath(req.Path)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
// @Tags File
// @Summary Get file share detail by code (no login)
// @Param code query string true "share code"
// @Success 200 {object} response.FileSharePublicInfo
// @Router /files/share/info [get]
func (b *BaseApi) GetPublicFileShareInfo(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
info, err := fileShareService.GetPublicByCode(code)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
func buildSharePublicURL(c *gin.Context, code, operateNode string) string {
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
shareURL := url.URL{
Scheme: scheme,
Host: host,
Path: "/s/" + url.PathEscape(code),
}
query := shareURL.Query()
if strings.TrimSpace(operateNode) != "" {
query.Set("operateNode", operateNode)
}
shareURL.RawQuery = query.Encode()
return shareURL.String()
}
// @Tags File
// @Summary Get file share QR code image
// @Produce png
// @Param code query string true "share code"
// @Param operateNode query string false "operate node"
// @Success 200 {file} file
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/qrcode [get]
func (b *BaseApi) GetFileShareQRCode(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if _, err := fileShareService.GetByCode(code); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
png, err := qrcode.Encode(buildSharePublicURL(c, code, c.Query("operateNode")), qrcode.Medium, 256)
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Cache-Control", "private, max-age=300")
c.Data(http.StatusOK, "image/png", png)
}
// @Tags File
// @Summary Create temporary file share link
// @Accept json
// @Param request body request.FileShareCreate true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/create [post]
// @x-panel-log {"bodyKeys":["path","expireMinutes"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建文件分享 [path]","formatEN":"Create file share [path]"}
func (b *BaseApi) CreateFileShare(c *gin.Context) {
var req request.FileShareCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := fileShareService.Create(req)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Delete file share by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/del [post]
// @x-panel-log {"bodyKeys":["path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"关闭文件分享 [path]","formatEN":"Close file share [path]"}
func (b *BaseApi) DeleteFileShare(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileShareService.DeleteByPath(req.Path); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Check file share code (no login)
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {object} dto.Response
// @Router /files/share/check [get]
func (b *BaseApi) CheckFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if err := fileShareService.Check(code, password); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Download file by share code (no login)
// @Produce octet-stream
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {file} file
// @Router /files/share/download [get]
func (b *BaseApi) DownloadFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
filePath, displayName, err := fileShareService.PrepareDownload(code, password)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
file, err := os.Open(filePath)
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(displayName))
http.ServeContent(c.Writer, c.Request, displayName, info.ModTime(), file)
}

View File

@@ -194,3 +194,9 @@ type FileRemarkUpdate struct {
Path string `json:"path" validate:"required"`
Remark string `json:"remark"`
}
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"`
}

View File

@@ -86,6 +86,22 @@ type FileRemarksRes struct {
Remarks map[string]string `json:"remarks"`
}
type FileShareInfo struct {
Code string `json:"code"`
Path string `json:"path"`
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
}
type FileSharePublicInfo struct {
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
}
type FileAIContentHit struct {
Path string `json:"path"`
Line int `json:"line"`

View File

@@ -0,0 +1,13 @@
package model
type FileShare struct {
BaseModel
Path string `gorm:"not null;uniqueIndex" json:"path"`
Token string `gorm:"not null;uniqueIndex" json:"token"`
FileName string `gorm:"not null" json:"fileName"`
ExpiresUnix int64 `json:"expiresUnix"`
PasswordSalt string `json:"passwordSalt"`
PasswordHash string `json:"passwordHash"`
MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
}

View File

@@ -0,0 +1,77 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type FileShareRepo struct{}
type IFileShareRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error)
Create(fileShare *model.FileShare) error
Save(fileShare *model.FileShare) error
Delete(opts ...DBOption) error
GetFirst(opts ...DBOption) (model.FileShare, error)
All() ([]model.FileShare, error)
WithByPath(path string) DBOption
WithByCode(code string) DBOption
}
func NewIFileShareRepo() IFileShareRepo {
return &FileShareRepo{}
}
func (r *FileShareRepo) WithByPath(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path = ?", path)
}
}
func (r *FileShareRepo) WithByCode(code string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("token = ?", code)
}
}
func (r *FileShareRepo) Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error) {
var (
items []model.FileShare
count int64
)
db := getDb(opts...).Model(&model.FileShare{})
db = db.Count(&count)
err := db.Order("path asc").Limit(size).Offset(size * (page - 1)).Find(&items).Error
return count, items, err
}
func (r *FileShareRepo) Create(fileShare *model.FileShare) error {
return global.DB.Create(fileShare).Error
}
func (r *FileShareRepo) Save(fileShare *model.FileShare) error {
return global.DB.Save(fileShare).Error
}
func (r *FileShareRepo) GetFirst(opts ...DBOption) (model.FileShare, error) {
var item model.FileShare
db := getDb(opts...).Model(&model.FileShare{})
if err := db.First(&item).Error; err != nil {
return item, err
}
return item, nil
}
func (r *FileShareRepo) Delete(opts ...DBOption) error {
db := getDb(opts...).Model(&model.FileShare{})
return db.Delete(&model.FileShare{}).Error
}
func (r *FileShareRepo) All() ([]model.FileShare, error) {
var items []model.FileShare
if err := getDb().Order("path asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -49,7 +49,8 @@ var (
runtimeRepo = repo.NewIRunTimeRepo()
phpExtensionsRepo = repo.NewIPHPExtensionsRepo()
favoriteRepo = repo.NewIFavoriteRepo()
favoriteRepo = repo.NewIFavoriteRepo()
fileShareRepo = repo.NewIFileShareRepo()
taskRepo = repo.NewITaskRepo()

View File

@@ -104,10 +104,29 @@ func (f *FileService) GetFileList(op request.FileOption) (response.FileInfo, err
if err != nil {
return fileInfo, err
}
shareMap, err := NewIFileShareService().SharePathCodeMap()
if err != nil {
return fileInfo, err
}
applyFileShares(info, shareMap)
fileInfo.FileInfo = *info
return fileInfo, nil
}
func applyFileShares(info *files.FileInfo, shareMap map[string]string) {
if info == nil {
return
}
if code, ok := shareMap[info.Path]; ok {
info.ShareCode = code
} else {
info.ShareCode = ""
}
for _, item := range info.Items {
applyFileShares(item, shareMap)
}
}
func (f *FileService) SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error) {
var (
files []response.UploadInfo

View File

@@ -0,0 +1,357 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"gorm.io/gorm"
)
type FileShareService struct{}
const (
fileShareCodeMinLength = 10
fileShareCodeMaxLength = 16
fileShareCodeDefaultLength = 13
fileShareCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)
var fileShareCodeRegexp = regexp.MustCompile(`^[A-Za-z0-9]{10,16}$`)
type IFileShareService interface {
Create(req request.FileShareCreate) (*response.FileShareInfo, error)
Page(req dto.PageInfo) (int64, []response.FileShareInfo, error)
GetByPath(path string) (*response.FileShareInfo, error)
GetByCode(code string) (*response.FileShareInfo, error)
GetPublicByCode(code string) (*response.FileSharePublicInfo, error)
DeleteByPath(path string) error
SharePathCodeMap() (map[string]string, error)
Check(code, password string) error
PrepareDownload(code, password string) (filePath, fileName string, err error)
}
func NewIFileShareService() IFileShareService {
return &FileShareService{}
}
func randomShareCode(length int) (string, error) {
if length < fileShareCodeMinLength || length > fileShareCodeMaxLength {
length = fileShareCodeDefaultLength
}
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
buf := make([]byte, length)
for i := range b {
buf[i] = fileShareCharset[int(b[i])%len(fileShareCharset)]
}
return string(buf), nil
}
func randomSalt() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashPassword(salt, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
func shareModelToInfo(item model.FileShare) response.FileShareInfo {
return response.FileShareInfo{
Code: item.Token,
Path: item.Path,
FileName: item.FileName,
ExpiresAt: item.ExpiresUnix,
Permanent: item.ExpiresUnix == 0,
HasPassword: item.PasswordHash != "",
}
}
func shareModelToPublicInfo(item model.FileShare) response.FileSharePublicInfo {
return response.FileSharePublicInfo{
FileName: item.FileName,
ExpiresAt: item.ExpiresUnix,
Permanent: item.ExpiresUnix == 0,
HasPassword: item.PasswordHash != "",
}
}
func (s *FileShareService) generateUniqueCode() (string, error) {
for i := 0; i < 8; i++ {
code, err := randomShareCode(fileShareCodeDefaultLength)
if err != nil {
return "", err
}
_, err = fileShareRepo.GetFirst(fileShareRepo.WithByCode(code))
if errors.Is(err, gorm.ErrRecordNotFound) {
return code, nil
}
if err != nil {
return "", err
}
}
return "", errors.New("failed to generate unique file share code")
}
func (s *FileShareService) Create(req request.FileShareCreate) (*response.FileShareInfo, error) {
path := strings.TrimSpace(req.Path)
if path == "" || strings.Contains(path, "..") {
return nil, buserr.New("ErrFileSharePath")
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return nil, buserr.New("ErrFileSharePath")
}
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(path))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
isNew := errors.Is(err, gorm.ErrRecordNotFound)
if isNew {
code, err := s.generateUniqueCode()
if err != nil {
return nil, err
}
item = model.FileShare{
Path: path,
Token: code,
FileName: filepath.Base(path),
}
} else if !fileShareCodeRegexp.MatchString(item.Token) {
code, err := s.generateUniqueCode()
if err != nil {
return nil, err
}
item.Token = code
}
item.FileName = filepath.Base(path)
item.MaxDownloads = 0
item.DownloadCount = 0
item.ExpiresUnix = 0
if req.ExpireMinutes > 0 {
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
}
item.PasswordSalt = salt
item.PasswordHash = hashPassword(salt, pw)
}
if isNew {
if err := fileShareRepo.Create(&item); err != nil {
return nil, err
}
} else {
if err := fileShareRepo.Save(&item); err != nil {
return nil, err
}
}
res := shareModelToInfo(item)
return &res, nil
}
func (s *FileShareService) Page(req dto.PageInfo) (int64, []response.FileShareInfo, error) {
items, err := fileShareRepo.All()
if err != nil {
return 0, nil, err
}
result := make([]response.FileShareInfo, 0, len(items))
for _, item := range items {
if err := s.pruneInvalidShare(item); err != nil {
return 0, nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
continue
}
result = append(result, shareModelToInfo(item))
}
total := len(result)
start := (req.Page - 1) * req.PageSize
if start >= total {
return int64(total), []response.FileShareInfo{}, nil
}
end := start + req.PageSize
if end > total {
end = total
}
return int64(total), result[start:end], nil
}
func (s *FileShareService) GetByPath(path string) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(strings.TrimSpace(path)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, nil
}
info := shareModelToInfo(item)
return &info, nil
}
func (s *FileShareService) GetByCode(code string) (*response.FileShareInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, buserr.New("ErrFileShareExpired")
}
info := shareModelToInfo(item)
return &info, nil
}
func (s *FileShareService) GetPublicByCode(code string) (*response.FileSharePublicInfo, error) {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(strings.TrimSpace(code)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
if err := s.pruneInvalidShare(item); err != nil {
return nil, err
}
if item.ExpiresUnix > 0 && time.Now().Unix() > item.ExpiresUnix {
return nil, buserr.New("ErrFileShareExpired")
}
info := shareModelToPublicInfo(item)
return &info, nil
}
func (s *FileShareService) DeleteByPath(path string) error {
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByPath(strings.TrimSpace(path)))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrFileShareInvalid")
}
return err
}
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
func (s *FileShareService) SharePathCodeMap() (map[string]string, error) {
items, err := fileShareRepo.All()
if err != nil {
return nil, err
}
result := make(map[string]string, len(items))
now := time.Now().Unix()
for _, item := range items {
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
continue
}
if _, err := os.Stat(item.Path); err != nil {
continue
}
result[item.Path] = item.Token
}
return result, nil
}
func (s *FileShareService) Check(code, password string) error {
_, err := s.check(code, password)
return err
}
func (s *FileShareService) PrepareDownload(code, password string) (string, string, error) {
item, err := s.check(code, password)
if err != nil {
return "", "", err
}
return item.Path, item.FileName, nil
}
func (s *FileShareService) pruneInvalidShare(item model.FileShare) error {
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
return fileShareRepo.Delete(repo.WithByID(item.ID))
}
return nil
}
func (s *FileShareService) check(code, password string) (*model.FileShare, error) {
code = strings.TrimSpace(code)
password = strings.TrimSpace(password)
if code == "" {
return nil, buserr.New("ErrFileShareInvalid")
}
item, err := fileShareRepo.GetFirst(fileShareRepo.WithByCode(code))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrFileShareInvalid")
}
return nil, err
}
now := time.Now().Unix()
if item.ExpiresUnix > 0 && now > item.ExpiresUnix {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
return nil, buserr.New("ErrFileShareExpired")
}
if item.PasswordHash != "" {
if subtle.ConstantTimeCompare([]byte(hashPassword(item.PasswordSalt, password)), []byte(item.PasswordHash)) != 1 {
return nil, buserr.New("ErrFileSharePassword")
}
}
info, err := os.Stat(item.Path)
if err != nil || info.IsDir() {
_ = fileShareRepo.Delete(repo.WithByID(item.ID))
return nil, buserr.New("ErrFileSharePath")
}
return &item, nil
}

View File

@@ -220,6 +220,7 @@ require (
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sigstore/sigstore v1.10.4 // indirect
github.com/sigstore/sigstore-go v1.1.4 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect

View File

@@ -971,6 +971,8 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=

View File

@@ -143,6 +143,13 @@ ErrLinkPathNotFound: 'The target path does not exist!'
ErrFileIsExist: 'The file or folder already exists!'
ErrFileUpload: 'Upload failed: {{ .name }} {{ .detail }}'
ErrFileDownloadDir: 'Directory download is not supported'
ErrFileSharePath: 'Invalid file path or target is not a file'
ErrFileShareInvalid: 'Invalid or expired share link'
ErrFileShareExpired: 'Share link has expired'
ErrFileSharePassword: 'Incorrect share password'
ErrFileShareExhausted: 'Download limit for this share has been reached'
ErrFileSharePasswordPolicy: 'Password must be at least 4 characters'
ErrFileShareRateLimit: 'Too many requests, please try again later'
ErrCmdNotFound: 'Command not found: {{ .name }}'
ErrSourcePathNotFound: 'Source directory does not exist'
ErrFavoriteExist: 'Path already in favorites'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'La ruta de destino no existe'
ErrFileIsExist: 'El archivo o carpeta ya existe'
ErrFileUpload: '{{ .name }} falló al subir archivo {{ .detail }}'
ErrFileDownloadDir: 'No se admite la descarga de carpetas'
ErrFileSharePath: 'Ruta no válida o el destino no es un archivo'
ErrFileShareInvalid: 'Enlace de uso compartido no válido o caducado'
ErrFileShareExpired: 'El enlace de uso compartido ha caducado'
ErrFileSharePassword: 'Contraseña de uso compartido incorrecta'
ErrFileShareExhausted: 'Se alcanzó el límite de descargas de este recurso compartido'
ErrFileSharePasswordPolicy: 'La contraseña debe tener al menos 4 caracteres'
ErrFileShareRateLimit: 'Demasiadas solicitudes, inténtelo de nuevo más tarde'
ErrCmdNotFound: 'El comando {{ .name }} no existe, instálelo primero en el host'
ErrSourcePathNotFound: 'El directorio fuente no existe'
ErrFavoriteExist: 'Esta ruta ya ha sido marcada como favorita'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'ターゲット パスが存在しません'
ErrFileIsExist: 'ファイルまたはフォルダーは既に存在します'
ErrFileUpload: '{{ .name }} はファイル {{ .detail }} のアップロードに失敗しました'
ErrFileDownloadDir: 'ダウンロード フォルダーはサポートされていません'
ErrFileSharePath: '無効なパスか、対象がファイルではありません'
ErrFileShareInvalid: '共有リンクが無効か期限切れです'
ErrFileShareExpired: '共有リンクの有効期限が切れました'
ErrFileSharePassword: '共有パスワードが正しくありません'
ErrFileShareExhausted: 'この共有のダウンロード上限に達しました'
ErrFileSharePasswordPolicy: 'パスワードは4文字以上にしてください'
ErrFileShareRateLimit: 'アクセスが多すぎます。しばらくしてから再試行してください'
ErrCmdNotFound: '{{ .name }} コマンドが存在しません。まずこのコマンドをホストにインストールしてください'
ErrSourcePathNotFound: 'ソース ディレクトリが存在しません'
ErrFavoriteExist: 'このパスはすでにお気に入りに登録されています'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: '대상 경로가 존재하지 않습니다'
ErrFileIsExist: '파일이나 폴더가 이미 존재합니다'
ErrFileUpload: '{{ .name }}이 파일 {{ .detail }}을 업로드하지 못했습니다.'
ErrFileDownloadDir: '다운로드 폴더가 지원되지 않습니다'
ErrFileSharePath: '잘못된 파일 경로이거나 대상이 파일이 아닙니다'
ErrFileShareInvalid: '유효하지 않거나 만료된 공유 링크입니다'
ErrFileShareExpired: '공유 링크가 만료되었습니다'
ErrFileSharePassword: '공유 비밀번호가 올바르지 않습니다'
ErrFileShareExhausted: '이 공유의 다운로드 한도에 도달했습니다'
ErrFileSharePasswordPolicy: '비밀번호는 4자 이상이어야 합니다'
ErrFileShareRateLimit: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요'
ErrCmdNotFound: '{{ .name }} 명령이 존재하지 않습니다. 먼저 호스트에 이 명령을 설치하세요'
ErrSourcePathNotFound: '소스 디렉토리가 존재하지 않습니다'
ErrFavoriteExist: '이 경로는 이미 즐겨찾기되었습니다'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'Laluan sasaran tidak wujud'
ErrFileIsExist: 'Fail atau folder sudah wujud'
ErrFileUpload: '{{ .name }} gagal memuat naik fail {{ .detail }}'
ErrFileDownloadDir: 'Folder muat turun tidak disokong'
ErrFileSharePath: 'Laluan fail tidak sah atau sasaran bukan fail'
ErrFileShareInvalid: 'Pautan kongsi tidak sah atau telah tamat tempoh'
ErrFileShareExpired: 'Pautan kongsi telah tamat tempoh'
ErrFileSharePassword: 'Kata laluan kongsi tidak betul'
ErrFileShareExhausted: 'Had muat turun untuk kongsi ini telah dicapai'
ErrFileSharePasswordPolicy: 'Kata laluan mestilah sekurang-kurangnya 4 aksara'
ErrFileShareRateLimit: 'Terlalu banyak permintaan, sila cuba lagi kemudian'
ErrCmdNotFound: 'Arahan {{ .name }} tidak wujud, sila pasang arahan ini pada hos dahulu'
ErrSourcePathNotFound: 'Direktori sumber tidak wujud'
ErrFavoriteExist: 'Laluan ini telah digemari'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'O caminho de destino não existe'
ErrFileIsExist: 'O arquivo ou pasta já existe'
ErrFileUpload: '{{ .name }} falhou ao carregar o arquivo {{ .detail }}'
ErrFileDownloadDir: 'A pasta de download não é suportada'
ErrFileSharePath: 'Caminho inválido ou o destino não é um arquivo'
ErrFileShareInvalid: 'Link de compartilhamento inválido ou expirado'
ErrFileShareExpired: 'O link de compartilhamento expirou'
ErrFileSharePassword: 'Senha de compartilhamento incorreta'
ErrFileShareExhausted: 'O limite de downloads deste compartilhamento foi atingido'
ErrFileSharePasswordPolicy: 'A senha deve ter pelo menos 4 caracteres'
ErrFileShareRateLimit: 'Muitas solicitações, tente novamente mais tarde'
ErrCmdNotFound: 'O comando {{ .name }} não existe, instale este comando no host primeiro'
ErrSourcePathNotFound: 'O diretório de origem não existe'
ErrFavoriteExist: 'Este caminho já foi favorito'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'Целевой путь не существует'
ErrFileIsExist: 'Файл или папка уже существует'
ErrFileUpload: '{{ .name }} не удалось загрузить файл {{ .detail }}'
ErrFileDownloadDir: 'Папка загрузки не поддерживается'
ErrFileSharePath: 'Неверный путь или цель не является файлом'
ErrFileShareInvalid: 'Недействительная или просроченная ссылка'
ErrFileShareExpired: 'Срок действия ссылки истёк'
ErrFileSharePassword: 'Неверный пароль доступа'
ErrFileShareExhausted: 'Лимит скачиваний по этой ссылке исчерпан'
ErrFileSharePasswordPolicy: 'Пароль должен содержать не менее 4 символов'
ErrFileShareRateLimit: 'Слишком много запросов, попробуйте позже'
ErrCmdNotFound: 'Команда {{ .name }} не существует, сначала установите эту команду на хосте'
ErrSourcePathNotFound: 'Исходный каталог не существует'
ErrFavoriteExist: 'Этот путь уже добавлен в избранное'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: 'Hedef yol mevcut değil'
ErrFileIsExist: 'Dosya veya klasör zaten mevcut'
ErrFileUpload: '{{ .name }} dosya yükleme başarısız {{ .detail }}'
ErrFileDownloadDir: 'Klasör indirme desteklenmiyor'
ErrFileSharePath: 'Geçersiz dosya yolu veya hedef bir dosya değil'
ErrFileShareInvalid: 'Geçersiz veya süresi dolmuş paylaşım bağlantısı'
ErrFileShareExpired: 'Paylaşım bağlantısının süresi doldu'
ErrFileSharePassword: 'Yanlış paylaşım parolası'
ErrFileShareExhausted: 'Bu paylaşım için indirme sınırına ulaşıldı'
ErrFileSharePasswordPolicy: 'Parola en az 4 karakter olmalıdır'
ErrFileShareRateLimit: 'Çok fazla istek gönderildi, lütfen daha sonra tekrar deneyin'
ErrCmdNotFound: '{{ .name }} komutu mevcut değil, lütfen önce bu komutu host a yükleyin'
ErrSourcePathNotFound: 'Kaynak dizin mevcut değil'
ErrFavoriteExist: 'Bu yol zaten favorilere eklendi'

View File

@@ -130,6 +130,13 @@ ErrLinkPathNotFound: '目標路徑不存在!'
ErrFileIsExist: '檔案或資料夾已存在!'
ErrFileUpload: '{{ .name }} 上傳檔案失敗{{ .detail }}'
ErrFileDownloadDir: '不支援下載資料夾'
ErrFileSharePath: '無效的分享路徑或目標不是檔案'
ErrFileShareInvalid: '分享連結無效或已失效'
ErrFileShareExpired: '分享連結已過期'
ErrFileSharePassword: '存取密碼錯誤'
ErrFileShareExhausted: '此分享連結的下載次數已用盡'
ErrFileSharePasswordPolicy: '存取密碼至少 4 位'
ErrFileShareRateLimit: '操作過於頻繁,請稍後再試'
ErrCmdNotFound: '{{ .name }} 指令不存在,請先在宿主機安裝此指令'
ErrSourcePathNotFound: '來源目錄不存在'
ErrFavoriteExist: '已收藏此路徑'

View File

@@ -143,6 +143,13 @@ ErrLinkPathNotFound: "目标路径不存在!"
ErrFileIsExist: "文件或文件夹已存在!"
ErrFileUpload: "{{ .name }} 上传文件失败 {{ .detail }}"
ErrFileDownloadDir: "不支持下载目录"
ErrFileSharePath: "无效的分享路径或目标不是文件"
ErrFileShareInvalid: "分享链接无效或已失效"
ErrFileShareExpired: "分享链接已过期"
ErrFileSharePassword: "访问密码错误"
ErrFileShareExhausted: "该分享链接的下载次数已用尽"
ErrFileSharePasswordPolicy: "访问密码至少 4 位"
ErrFileShareRateLimit: "访问过于频繁,请稍后再试"
ErrCmdNotFound: "{{ .name }} 命令不存在,请先在宿主机安装此命令"
ErrSourcePathNotFound: "源目录不存在"
ErrFavoriteExist: "已收藏此路径"

View File

@@ -80,6 +80,7 @@ func InitAgentDB() {
migrations.AddAgentRemarkColumn,
migrations.AddAgentWebsiteBinding,
migrations.AddFileManageAISettings,
migrations.AddFileShareTable,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@@ -51,6 +51,7 @@ var AddTable = &gormigrate.Migration{
&model.DatabaseMysql{},
&model.DatabasePostgresql{},
&model.Favorite{},
&model.FileShare{},
&model.Firewall{},
&model.Host{},
&model.Ftp{},
@@ -1217,3 +1218,10 @@ var AddFileManageAISettings = &gormigrate.Migration{
return nil
},
}
var AddFileShareTable = &gormigrate.Migration{
ID: "20260407-add-file-share-table",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.FileShare{})
},
}

View File

@@ -0,0 +1,87 @@
package middleware
import (
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
var (
fileShareCodePattern = regexp.MustCompile(`^[A-Za-z0-9]{10,16}$`)
fileShareLimiterCleanupLock sync.Mutex
fileShareLimiterCleanupAt time.Time
publicIPLimiters sync.Map
publicCodeLimiters sync.Map
)
type visitorLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
func FileSharePublicAccess() gin.HandlerFunc {
return func(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code != "" && !fileShareCodePattern.MatchString(code) {
helper.ErrorWithDetail(c, http.StatusBadRequest, "ErrFileShareInvalid", buserr.New("ErrFileShareInvalid"))
return
}
ip := c.ClientIP()
if !allowLimiter(&publicIPLimiters, "ip:"+ip, rate.Every(time.Second), 20) {
helper.ErrorWithDetail(c, http.StatusTooManyRequests, "ErrFileShareRateLimit", buserr.New("ErrFileShareRateLimit"))
return
}
if code != "" && (strings.HasSuffix(c.Request.URL.Path, "/share/check") || strings.HasSuffix(c.Request.URL.Path, "/share/download")) {
if !allowLimiter(&publicCodeLimiters, "code:"+ip+":"+code, rate.Every(5*time.Second), 4) {
helper.ErrorWithDetail(c, http.StatusTooManyRequests, "ErrFileShareRateLimit", buserr.New("ErrFileShareRateLimit"))
return
}
}
maybeCleanupLimiters()
c.Next()
}
}
func allowLimiter(store *sync.Map, key string, refill rate.Limit, burst int) bool {
now := time.Now()
value, _ := store.LoadOrStore(key, &visitorLimiter{
limiter: rate.NewLimiter(refill, burst),
lastSeen: now,
})
item := value.(*visitorLimiter)
item.lastSeen = now
return item.limiter.Allow()
}
func maybeCleanupLimiters() {
fileShareLimiterCleanupLock.Lock()
defer fileShareLimiterCleanupLock.Unlock()
now := time.Now()
if !fileShareLimiterCleanupAt.IsZero() && now.Sub(fileShareLimiterCleanupAt) < 10*time.Minute {
return
}
fileShareLimiterCleanupAt = now
cleanupLimiterMap(&publicIPLimiters, now)
cleanupLimiterMap(&publicCodeLimiters, now)
}
func cleanupLimiterMap(store *sync.Map, now time.Time) {
store.Range(func(key, value any) bool {
item, ok := value.(*visitorLimiter)
if ok && now.Sub(item.lastSeen) > 30*time.Minute {
store.Delete(key)
}
return true
})
}

View File

@@ -2,6 +2,7 @@ package router
import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/gin-gonic/gin"
)
@@ -37,6 +38,11 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.POST("/wget/stop", baseApi.StopWget)
fileRouter.POST("/move", baseApi.MoveFile)
fileRouter.GET("/download", baseApi.Download)
fileRouter.POST("/share/search", baseApi.SearchFileShare)
fileRouter.POST("/share/detail", baseApi.GetFileShareDetail)
fileRouter.POST("/share/create", baseApi.CreateFileShare)
fileRouter.POST("/share/del", baseApi.DeleteFileShare)
fileRouter.GET("/share/qrcode", baseApi.GetFileShareQRCode)
fileRouter.POST("/chunkdownload", baseApi.DownloadChunkFiles)
fileRouter.POST("/size", baseApi.Size)
fileRouter.POST("/depth/size", baseApi.DepthDirSize)
@@ -60,4 +66,12 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.POST("/convert", baseApi.ConvertFile)
fileRouter.POST("/convert/log", baseApi.ConvertLog)
}
publicShareRouter := fileRouter.Group("/share")
publicShareRouter.Use(middleware.FileSharePublicAccess())
{
publicShareRouter.GET("/info", baseApi.GetPublicFileShareInfo)
publicShareRouter.GET("/check", baseApi.CheckFileShare)
publicShareRouter.GET("/download", baseApi.DownloadFileShare)
}
}

View File

@@ -45,6 +45,7 @@ type FileInfo struct {
Items []*FileInfo `json:"items"`
ItemTotal int `json:"itemTotal"`
FavoriteID uint `json:"favoriteID"`
ShareCode string `json:"shareCode"`
IsDetail bool `json:"isDetail"`
}

View File

@@ -43,7 +43,7 @@ func Proxy() gin.HandlerFunc {
apiReq := c.GetBool("API_AUTH")
if !apiReq && strings.HasPrefix(c.Request.URL.Path, "/api/v2/") && !isLocalAPI(c.Request.URL.Path) && !checkSession(c) {
if !apiReq && strings.HasPrefix(c.Request.URL.Path, "/api/v2/") && !isLocalAPI(c.Request.URL.Path) && !isPublicFileShareAPI(c.Request.URL.Path) && !checkSession(c) {
data, _ := res.ErrorMsg.ReadFile("html/401.html")
c.Data(401, "text/html; charset=utf-8", data)
c.Abort()
@@ -90,3 +90,7 @@ func checkSession(c *gin.Context) bool {
func isLocalAPI(urlPath string) bool {
return urlPath == "/api/v2/core/xpack/sync/ssl"
}
func isPublicFileShareAPI(urlPath string) bool {
return urlPath == "/api/v2/files/share/download" || urlPath == "/api/v2/files/share/check" || urlPath == "/api/v2/files/share/info"
}

View File

@@ -23,6 +23,7 @@ export namespace File {
extension: string;
itemTotal: number;
favoriteID: number;
shareCode: string;
remark?: string;
}
@@ -302,4 +303,32 @@ export namespace File {
status: string;
message: string;
}
export interface FileShareCreate {
path: string;
expireMinutes: number;
password?: string;
}
export interface FileShareCheck {
code: string;
password?: string;
operateNode: string;
}
export interface FileShareInfo {
code: string;
path: string;
fileName: string;
expiresAt: number;
permanent: boolean;
hasPassword: boolean;
}
export interface FileSharePublicInfo {
fileName: string;
expiresAt: number;
permanent: boolean;
hasPassword: boolean;
}
}

View File

@@ -114,6 +114,30 @@ export const downloadFile = (params: File.FileDownload) => {
return http.download<BlobPart>('files/download', params, { responseType: 'blob', timeout: TimeoutEnum.T_40S });
};
export const createFileShare = (params: File.FileShareCreate) => {
return http.post<File.FileShareInfo>('files/share/create', params);
};
export const searchFileShare = (params: ReqPage) => {
return http.post<ResPage<File.FileShareInfo>>('files/share/search', params);
};
export const getFileShareDetail = (path: string) => {
return http.post<File.FileShareInfo | null>('files/share/detail', { path });
};
export const removeFileShare = (path: string) => {
return http.post<any>('files/share/del', { path });
};
export const getPublicFileShareInfo = (code: string, operateNode: string) => {
return http.get<File.FileSharePublicInfo>('files/share/info', { code, operateNode });
};
export const checkFileShare = (params: File.FileShareCheck) => {
return http.get('files/share/check', params);
};
export const computeDirSize = (params: File.DirSizeReq) => {
return http.post<File.DirSizeRes>('files/size', params, TimeoutEnum.T_5M);
};

View File

@@ -1,9 +1,9 @@
@font-face {
font-family: "iconfont"; /* Project id 4776196 */
src: url('iconfont.woff2?t=1773998579784') format('woff2'),
url('iconfont.woff?t=1773998579784') format('woff'),
url('iconfont.ttf?t=1773998579784') format('truetype'),
url('iconfont.svg?t=1773998579784#iconfont') format('svg');
src: url('iconfont.woff2?t=1775716122874') format('woff2'),
url('iconfont.woff?t=1775716122874') format('woff'),
url('iconfont.ttf?t=1775716122874') format('truetype'),
url('iconfont.svg?t=1775716122874#iconfont') format('svg');
}
.iconfont {
@@ -14,6 +14,14 @@
-moz-osx-font-smoothing: grayscale;
}
.p-qrcode:before {
content: "\e72a";
}
.p-qrcode-line:before {
content: "\e669";
}
.p-gailan1:before {
content: "\e62e";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,20 @@
"css_prefix_text": "p-",
"description": "",
"glyphs": [
{
"icon_id": "14679640",
"name": "qrcode",
"font_class": "qrcode",
"unicode": "e72a",
"unicode_decimal": 59178
},
{
"icon_id": "39643896",
"name": "qrcode",
"font_class": "qrcode-line",
"unicode": "e669",
"unicode_decimal": 58985
},
{
"icon_id": "9783460",
"name": "概览",

View File

@@ -14,6 +14,10 @@
/>
<missing-glyph />
<glyph glyph-name="qrcode" unicode="&#59178;" d="M629.650286 419.364571h194.998857c54.857143 0 81.846857 27.428571 81.846857 83.986286V694.930286c0 56.576-26.989714 83.565714-81.846857 83.565714H629.650286c-54.436571 0-81.865143-26.989714-81.865143-83.565714v-191.579429c0-56.557714 27.428571-83.986286 81.865143-83.986286z m-430.299429 0H394.788571c54.418286 0 81.846857 27.428571 81.846858 83.986286V694.930286c0 56.576-27.428571 83.565714-81.846858 83.565714H199.350857c-54.418286 0-81.846857-26.989714-81.846857-83.565714v-191.579429c0-56.557714 27.428571-83.986286 81.846857-83.986286z m0.859429 60.416c-14.994286 0-22.290286 7.716571-22.290286 23.588572V694.912c0 15.433143 7.296 23.149714 22.308571 23.149714h193.28c14.994286 0 22.710857-7.716571 22.710858-23.149714v-191.579429c0-15.853714-7.716571-23.570286-22.710858-23.570285z m430.281143 0c-14.994286 0-22.272 7.716571-22.272 23.588572V694.912c0 15.433143 7.277714 23.149714 22.272 23.149714h193.718857c14.573714 0 21.869714-7.716571 21.869714-23.149714v-191.579429c0-15.853714-7.296-23.570286-21.869714-23.570285z m-370.285715 74.148572h73.289143c6.436571 0 8.996571 2.56 8.996572 9.856v71.131428c0 6.875429-2.56 9.435429-8.996572 9.435429H260.205714c-6.418286 0-8.137143-2.56-8.137143-9.417143v-71.149714c0-7.277714 1.718857-9.874286 8.137143-9.874286z m432.859429 0h72.868571c6.418286 0 8.996571 2.56 8.996572 9.856v71.131428c0 6.875429-2.56 9.435429-8.996572 9.435429h-72.868571c-6.418286 0-8.557714-2.56-8.557714-9.417143v-71.149714c0-7.277714 2.139429-9.874286 8.557714-9.874286z m-493.714286-564.425143H394.788571c54.418286 0 81.846857 26.989714 81.846858 83.565714v192c0 56.137143-27.428571 83.565714-81.846858 83.565715H199.350857c-54.418286 0-81.846857-27.428571-81.846857-83.565715v-192c0-56.576 27.428571-83.565714 81.846857-83.565714z m377.142857 248.137143h73.289143c6.436571 0 8.996571 2.56 8.996572 9.874286v71.131428c0 6.857143-2.56 9.417143-8.996572 9.417143h-73.289143c-6.418286 0-8.137143-2.56-8.137143-9.417143v-71.131428c0-7.314286 1.718857-9.874286 8.137143-9.874286z m227.584 0h73.270857c6.436571 0 9.014857 2.56 9.014858 9.874286v71.131428c0 6.857143-2.578286 9.417143-9.014858 9.417143h-73.270857c-6.436571 0-8.594286-2.56-8.594285-9.417143v-71.131428c0-7.314286 2.157714-9.874286 8.594285-9.874286zM200.210286 49.92c-14.994286 0-22.290286 7.716571-22.290286 23.149714V264.649143c0 15.853714 7.296 23.570286 22.308571 23.570286h193.28c14.994286 0 22.710857-7.716571 22.710858-23.588572v-191.561143c0-15.433143-7.716571-23.149714-22.710858-23.149714z m59.995428 73.728h73.289143c6.436571 0 8.996571 2.56 8.996572 10.276571v70.710858c0 6.857143-2.56 9.435429-8.996572 9.435428H260.205714c-6.418286 0-8.137143-2.56-8.137143-9.435428v-70.710858c0-7.716571 1.718857-10.276571 8.137143-10.276571z m431.158857 0h73.270858c6.436571 0 8.996571 2.56 8.996571 10.276571v70.710858c0 6.857143-2.56 9.435429-8.996571 9.435428h-73.289143c-6.418286 0-8.137143-2.56-8.137143-9.435428v-70.710858c0-7.716571 1.718857-10.276571 8.155428-10.276571z m-114.870857-113.572571h73.289143c6.436571 0 8.996571 2.56 8.996572 9.856v71.131428c0 6.875429-2.56 9.435429-8.996572 9.435429h-73.289143c-6.418286 0-8.137143-2.56-8.137143-9.435429v-71.131428c0-7.296 1.718857-9.874286 8.137143-9.874286z m227.584 0h73.270857c6.436571 0 9.014857 2.56 9.014858 9.856v71.131428c0 6.875429-2.578286 9.435429-9.014858 9.435429h-73.270857c-6.436571 0-8.594286-2.56-8.594285-9.435429v-71.131428c0-7.296 2.157714-9.874286 8.594285-9.874286z" horiz-adv-x="1024" />
<glyph glyph-name="qrcode-line" unicode="&#58985;" d="M468 768H160c-17.7 0-32-14.3-32-32v-308c0-4.4 3.6-8 8-8h332c4.4 0 8 3.6 8 8V760c0 4.4-3.6 8-8 8z m-56-284H192V704h220v-220z m-138 74h56c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8h-56c-4.4 0-8-3.6-8-8v-56c0-4.4 3.6-8 8-8z m194-210H136c-4.4 0-8-3.6-8-8v-308c0-17.7 14.3-32 32-32h308c4.4 0 8 3.6 8 8V340c0 4.4-3.6 8-8 8z m-56-284H192V284h220v-220z m-138 74h56c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8h-56c-4.4 0-8-3.6-8-8v-56c0-4.4 3.6-8 8-8z m590 630H556c-4.4 0-8-3.6-8-8v-332c0-4.4 3.6-8 8-8h332c4.4 0 8 3.6 8 8V736c0 17.7-14.3 32-32 32z m-32-284H612V704h220v-220z m-138 74h56c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8h-56c-4.4 0-8-3.6-8-8v-56c0-4.4 3.6-8 8-8z m194-210h-48c-4.4 0-8-3.6-8-8v-134h-78V340c0 4.4-3.6 8-8 8H556c-4.4 0-8-3.6-8-8v-332c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8V252h78v-102c0-4.4 3.6-8 8-8h190c4.4 0 8 3.6 8 8V340c0 4.4-3.6 8-8 8zM746 64h-48c-4.4 0-8-3.6-8-8v-48c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v48c0 4.4-3.6 8-8 8z m142 0h-48c-4.4 0-8-3.6-8-8v-48c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v48c0 4.4-3.6 8-8 8z" horiz-adv-x="1024" />
<glyph glyph-name="gailan1" unicode="&#58926;" d="M485.9 408.4H315.6c-93.9 0-170.3 76.4-170.3 170.3S221.7 749 315.6 749s170.3-76.4 170.3-170.3v-170.3zM315.6 705c-69.7 0-126.3-56.7-126.3-126.3S246 452.4 315.6 452.4h126.3V578.7c0 69.7-56.6 126.3-126.3 126.3z m390.8-296.6H536.1V578.7c0 93.9 76.4 170.3 170.3 170.3s170.3-76.4 170.3-170.3-76.4-170.3-170.3-170.3z m-126.3 44h126.3c69.7 0 126.3 56.7 126.3 126.3S776 705 706.4 705s-126.3-56.7-126.3-126.3v-126.3zM706.4 19c-93.9 0-170.3 76.4-170.3 170.3V359.6h170.3c93.9 0 170.3-76.4 170.3-170.3S800.3 19 706.4 19zM580.1 315.6v-126.3c0-69.7 56.7-126.3 126.3-126.3s126.3 56.7 126.3 126.3c0 69.7-56.7 126.3-126.3 126.3H580.1zM315.6 19c-93.9 0-170.3 76.4-170.3 170.3s76.4 170.3 170.3 170.3h170.3v-170.3c0-93.9-76.4-170.3-170.3-170.3z m0 296.6c-69.7 0-126.3-56.7-126.3-126.3 0-69.7 56.7-126.3 126.3-126.3s126.3 56.7 126.3 126.3V315.6H315.6z" horiz-adv-x="1024" />
<glyph glyph-name="tuichudenglu3" unicode="&#58925;" d="M0 896h1024v-1024H0zM603.204267 265.284267h-241.834667a19.012267 19.012267 0 0 0-21.026133 14.779733 31.095467 31.095467 0 0 0-0.375467 5.870933v208.213334a18.261333 18.261333 0 0 0 20.48 20.206933h242.653867V705.979733a18.056533 18.056533 0 0 0 10.922666 17.92 17.7152 17.7152 0 0 0 20.855467-4.676266c3.413333-3.413333 7.168-6.826667 10.6496-10.478934l132.676267-132.4032q85.333333-84.957867 170.3936-170.052266a18.056533 18.056533 0 0 0 0.170666-28.637867q-121.890133-121.7536-243.848533-243.4048-34.7136-34.577067-69.495467-69.051733a18.2272 18.2272 0 0 0-20.650666-5.495467 17.954133 17.954133 0 0 0-11.5712 19.012267zM457.216 721.783467v-70.144H272.384A63.658667 63.658667 0 0 1 206.916267 586.069333c0-130.6624 0.273067-261.290667-0.170667-391.953066a65.365333 65.365333 0 0 1 50.4832-64 89.736533 89.736533 0 0 1 16.418133-1.467734h183.466667v-69.768533a13.346133 13.346133 0 0 0-2.7648-0.546133c-62.600533 0-125.3376-0.648533-187.938133 0.273066a134.2464 134.2464 0 0 0-127.146667 107.52 135.953067 135.953067 0 0 0-2.491733 25.9072c-0.170667 132.027733-0.443733 264.157867 0 396.1856a130.8672 130.8672 0 0 0 75.4688 120.456534 114.449067 114.449067 0 0 0 54.613333 13.2096c62.1568 0.170667 124.2112 0 186.368 0 1.2288-0.034133 2.321067-0.1024 3.9936-0.1024z m0 0" horiz-adv-x="1024" />

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -65,7 +65,7 @@
@click.stop
>
<li
v-for="(btn, index) in rightButtons"
v-for="(btn, index) in visibleRightButtons"
:key="index"
:class="[{ disabled: disabled(btn) }, { divided: btn.divided }]"
@click="!disabled(btn) && rightButtonClick(btn)"
@@ -147,6 +147,20 @@ const disabled = computed(() => {
return typeof btn.disabled === 'function' ? btn.disabled(rightClick.value.currentRow) : btn.disabled;
};
});
const visibleRightButtons = computed(() => {
if (!props.rightButtons) {
return [];
}
return props.rightButtons.filter((btn: any) => {
if (typeof btn.show === 'function') {
return btn.show(rightClick.value.currentRow);
}
if (typeof btn.show === 'boolean') {
return btn.show;
}
return true;
});
});
function rightButtonClick(btn: any) {
closeRightClick();
btn.click(rightClick.value.currentRow);

View File

@@ -1810,6 +1810,43 @@ const message = {
language: 'Language',
eol: 'End of line',
copyDir: 'Copy',
shareFile: 'Share file',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Paste',
changeOwner: 'Modify user and user group',
containSub: 'Apply the permission change recursively',
@@ -1839,6 +1876,8 @@ const message = {
favorite: 'Favorites',
removeFavorite: 'Remove from favorites?',
addFavorite: 'Add/Remove to Favorites',
addFavoriteAction: 'Add to Favorites',
removeFavoriteAction: 'Remove from Favorites',
clearList: 'Clean list',
deleteRecycleHelper: 'Are you sure you want to permanently delete the following files?',
typeErrOrEmpty: '[{0}] file type is wrong or empty folder',

View File

@@ -1777,6 +1777,43 @@ const message = {
language: 'Idioma',
eol: 'Fin de línea',
copyDir: 'Copiar',
shareFile: 'Compartir archivo',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Pegar',
changeOwner: 'Modificar usuario y grupo',
containSub: 'Aplicar cambio de permisos recursivamente',
@@ -1806,6 +1843,8 @@ const message = {
favorite: 'Favoritos',
removeFavorite: '¿Eliminar de favoritos?',
addFavorite: 'Agregar/Quitar de favoritos',
addFavoriteAction: 'Agregar a favoritos',
removeFavoriteAction: 'Quitar de favoritos',
clearList: 'Limpiar lista',
deleteRecycleHelper: '¿Está seguro de eliminar permanentemente los siguientes archivos?',
typeErrOrEmpty: 'El archivo [{0}] es de tipo incorrecto o carpeta vacía',

View File

@@ -1753,6 +1753,44 @@ const message = {
language: '言語',
eol: '行の終わり',
copyDir: 'コピー',
shareFile: 'ファイルを共有',
shareExpire: '有効期限',
shareExpire1h: '1時間',
shareExpire6h: '6時間',
shareExpire24h: '24時間',
shareExpire3d: '3',
shareExpire7d: '7',
sharePassword: 'パスワード',
sharePasswordPlaceholder: '空欄でパスワードなし',
shareMaxDownloads: '最大ダウンロード回数',
shareMaxDownloadsHint: '0 は無制限',
shareGenerate: 'リンクを作成',
shareRegenerate: '再共有',
shareClose: '閉じる',
shareCancel: '共有を取り消す',
shareCancelConfirm: 'このファイル共有を取り消してもよろしいですか',
shareDetail: '詳細',
shareCopyLink: 'リンクをコピー',
shareLinkLabel: '共有リンク',
shareQrCode: 'QRコード',
shareQrDialogTitle: '共有リンクQRコード画像',
shareQrDialogHelper: 'スマートフォンで QR コードをスキャンしてください',
shareSaveImage: '画像を保存',
shareOpenImage: '画像を開く',
shareExtractFile: 'ファイルを取得',
shareDownloadingHint: 'ファイルを準備していますまもなくダウンロードが開始されます',
shareInvalid: '共有リンクが無効か期限切れです',
shareDownloadFailed: 'ダウンロードに失敗しましたしばらくしてから再試行してください',
sharePasswordRequired: 'アクセスパスワード',
sharePasswordRequiredInput: 'アクセスパスワードを入力してください',
sharePasswordLengthHint: 'アクセスパスワードの長さは 4256 文字である必要があります',
shareDownloadPasswordTip:
'このリンクにはアクセスパスワードが設定されています入力してからダウンロードしてください',
shareRiskAlert:
'外部共有リンクには一定のセキュリティリスクがあります信頼できる相手とのみ共有し機密情報の漏えいに注意してください',
sharePasswordSeparate:
'パスワードが設定されています安全に共有してください受信者は URL &password=... を付けるか対応するクライアントを使用してください',
shareExpiresAt: '有効期限',
paste: 'ペースト',
changeOwner: 'ユーザーグループとユーザーグループを変更します',
containSub: '許可変更を再帰的に適用します',
@@ -1780,6 +1818,8 @@ const message = {
favorite: 'お気に入り',
removeFavorite: 'お気に入りから取り外しますか',
addFavorite: 'お気に入りの追加/削除',
addFavoriteAction: 'お気に入りに追加',
removeFavoriteAction: 'お気に入りから削除',
clearList: 'クリーンリスト',
deleteRecycleHelper: '次のファイルを永続的に削除する必要がありますか',
typeErrOrEmpty: '[{0}]ファイルタイプは間違っているか空のフォルダーです',

View File

@@ -1714,6 +1714,43 @@ const message = {
language: '언어',
eol: '줄 끝',
copyDir: '복사',
shareFile: '파일 공유',
shareExpire: '유효 기간',
shareExpire1h: '1시간',
shareExpire6h: '6시간',
shareExpire24h: '24시간',
shareExpire3d: '3일',
shareExpire7d: '7일',
sharePassword: '암호',
sharePasswordPlaceholder: '비워 두면 암호 없음',
shareMaxDownloads: '최대 다운로드 횟수',
shareMaxDownloadsHint: '0은 무제한',
shareGenerate: '링크 만들기',
shareRegenerate: '다시 공유',
shareClose: '닫기',
shareCancel: '공유 취소',
shareCancelConfirm: '이 파일 공유를 취소하시겠습니까?',
shareDetail: '상세',
shareCopyLink: '링크 복사',
shareLinkLabel: '공유 링크',
shareQrCode: 'QR 코드',
shareQrDialogTitle: '공유 링크 QR 코드 이미지',
shareQrDialogHelper: '모바일 기기로 QR 코드를 스캔하세요',
shareSaveImage: '이미지 저장',
shareOpenImage: '이미지 열기',
shareExtractFile: '파일 받기',
shareDownloadingHint: '파일을 준비하는 중입니다. 곧 다운로드가 시작됩니다.',
shareInvalid: '유효하지 않거나 만료된 공유 링크입니다',
shareDownloadFailed: '다운로드에 실패했습니다. 잠시 후 다시 시도해 주세요',
sharePasswordRequired: '접근 비밀번호',
sharePasswordRequiredInput: '접근 비밀번호를 입력해 주세요',
sharePasswordLengthHint: '접근 비밀번호 길이는 4-256자여야 합니다',
shareDownloadPasswordTip: '이 링크는 비밀번호로 보호되어 있습니다. 입력 후 다운로드하세요.',
shareRiskAlert:
'외부 공유 링크에는 일정한 보안 위험이 있습니다. 신뢰할 수 있는 사람에게만 공유하고 민감한 정보가 노출되지 않도록 주의하세요.',
sharePasswordSeparate:
'암호가 설정되었습니다. 안전하게 전달하세요. 수신자는 URL에 &password=...를 추가하거나 해당 매개변수를 지원하는 클라이언트를 사용해야 합니다.',
shareExpiresAt: '만료 시각',
paste: '붙여넣기',
changeOwner: '사용자 및 그룹 수정',
containSub: '권한 변경을 하위 폴더에 적용',
@@ -1741,6 +1778,8 @@ const message = {
favorite: '즐겨찾기',
removeFavorite: '즐겨찾기에서 제거하시겠습니까?',
addFavorite: '즐겨찾기 추가 / 제거',
addFavoriteAction: '즐겨찾기에 추가',
removeFavoriteAction: '즐겨찾기에서 제거',
clearList: '목록 정리',
deleteRecycleHelper: '다음 파일을 영구적으로 삭제하시겠습니까?',
typeErrOrEmpty: '[{0}] 파일 유형이 잘못되었거나 빈 폴더입니다.',

View File

@@ -1776,6 +1776,43 @@ const message = {
language: 'Bahasa',
eol: 'Akhir baris',
copyDir: 'Salin',
shareFile: 'Kongsi fail',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Tampal',
changeOwner: 'Ubah suai pengguna dan kumpulan pengguna',
containSub: 'Guna perubahan kebenaran secara rekursif',
@@ -1805,6 +1842,8 @@ const message = {
favorite: 'Kegemaran',
removeFavorite: 'Buang daripada kegemaran?',
addFavorite: 'Tambah/Buang ke Kegemaran',
addFavoriteAction: 'Tambah ke Kegemaran',
removeFavoriteAction: 'Buang daripada Kegemaran',
clearList: 'Bersihkan senarai',
deleteRecycleHelper: 'Adakah anda pasti mahu memadam fail berikut secara kekal?',
typeErrOrEmpty: '[{0}] jenis fail salah atau folder kosong',

View File

@@ -1892,6 +1892,43 @@ const message = {
language: 'Idioma',
eol: 'Fim de linha',
copyDir: 'Copiar',
shareFile: 'Compartilhar arquivo',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Colar',
changeOwner: 'Modificar usuário e grupo de usuários',
containSub: 'Aplicar mudança de permissões recursivamente',
@@ -1922,6 +1959,8 @@ const message = {
favorite: 'Favoritos',
removeFavorite: 'Remover dos favoritos?',
addFavorite: 'Adicionar/Remover aos favoritos',
addFavoriteAction: 'Adicionar aos favoritos',
removeFavoriteAction: 'Remover dos favoritos',
clearList: 'Limpar lista',
deleteRecycleHelper: 'Tem certeza de que deseja excluir permanentemente os seguintes arquivos?',
typeErrOrEmpty: '[{0}] tipo de arquivo errado ou pasta vazia',

View File

@@ -1763,6 +1763,43 @@ const message = {
language: 'Язык',
eol: 'Конец строки',
copyDir: 'Копировать',
shareFile: 'Поделиться файлом',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Вставить',
changeOwner: 'Изменить пользователя и группу',
containSub: 'Применить изменение прав рекурсивно',
@@ -1791,6 +1828,8 @@ const message = {
favorite: 'Избранное',
removeFavorite: 'Удалить из избранного?',
addFavorite: 'Добавить/Удалить в избранное',
addFavoriteAction: 'Добавить в избранное',
removeFavoriteAction: 'Удалить из избранного',
clearList: 'Очистить список',
deleteRecycleHelper: 'Вы уверены, что хотите навсегда удалить следующие файлы?',
typeErrOrEmpty: '[{0}] неверный тип файла или пустая папка',

View File

@@ -1769,6 +1769,43 @@ const message = {
language: 'Dil',
eol: 'Satır sonu',
copyDir: 'Kopyala',
shareFile: 'Dosyayı paylaş',
shareExpire: 'Expires in',
shareExpire1h: '1 hour',
shareExpire6h: '6 hours',
shareExpire24h: '24 hours',
shareExpire3d: '3 days',
shareExpire7d: '7 days',
sharePassword: 'Password',
sharePasswordPlaceholder: 'Leave empty for no password',
shareMaxDownloads: 'Max downloads',
shareMaxDownloadsHint: '0 means unlimited',
shareGenerate: 'Create link',
shareRegenerate: 'Share again',
shareClose: 'Close',
shareCancel: 'Cancel share',
shareCancelConfirm: 'Are you sure you want to cancel this file share?',
shareDetail: 'Details',
shareCopyLink: 'Copy link',
shareLinkLabel: 'Share link',
shareQrCode: 'QR code',
shareQrDialogTitle: 'Share link QR code',
shareQrDialogHelper: 'Scan this QR code with your mobile device',
shareSaveImage: 'Save image',
shareOpenImage: 'Open image',
shareExtractFile: 'Extract file',
shareDownloadingHint: 'Preparing your file, download will begin shortly.',
shareInvalid: 'Invalid or expired share link',
shareDownloadFailed: 'Download failed, please try again later',
sharePasswordRequired: 'Password',
sharePasswordRequiredInput: 'Please enter the share password',
sharePasswordLengthHint: 'Share password length must be 4-256 characters',
shareDownloadPasswordTip: 'This link is password-protected. Enter the password before downloading.',
shareRiskAlert:
'Shared links may carry security risks. Only share with trusted people and avoid exposing sensitive information.',
sharePasswordSeparate:
'A password is set. Share it securely. Recipients must add &password=... to the URL or use a client that can pass the password query parameter.',
shareExpiresAt: 'Expires at',
paste: 'Yapıştır',
changeOwner: 'Kullanıcı ve kullanıcı grubunu değiştir',
containSub: 'İzin değişikliğini özyinelemeli olarak uygula',
@@ -1798,6 +1835,8 @@ const message = {
favorite: 'Favoriler',
removeFavorite: 'Favorilerden kaldır?',
addFavorite: 'Favorilere Ekle/Kaldır',
addFavoriteAction: 'Favorilere ekle',
removeFavoriteAction: 'Favorilerden kaldır',
clearList: 'Listeyi temizle',
deleteRecycleHelper: 'Aşağıdaki dosyaları kalıcı olarak silmek istediğinizden emin misiniz?',
typeErrOrEmpty: '[{0}] dosya türü yanlış veya boş klasör',

View File

@@ -1633,6 +1633,42 @@ const message = {
language: '語言',
eol: '行尾符',
copyDir: '複製路徑',
shareFile: '分享檔案',
shareExpire: '有效期限',
shareExpire1h: '1 小時',
shareExpire6h: '6 小時',
shareExpire24h: '24 小時',
shareExpire3d: '3 ',
shareExpire7d: '7 ',
sharePassword: '存取密碼',
sharePasswordPlaceholder: '不填則連結無需密碼',
shareMaxDownloads: '最大下載次數',
shareMaxDownloadsHint: '0 表示不限制',
shareGenerate: '產生連結',
shareRegenerate: '再次分享',
shareClose: '關閉',
shareCancel: '取消分享',
shareCancelConfirm: '確定取消該檔案的分享',
shareDetail: '詳情',
shareCopyLink: '複製連結',
shareLinkLabel: '分享連結',
shareQrCode: '二維碼',
shareQrDialogTitle: '分享連結二維碼圖片',
shareQrDialogHelper: '手機打開微信QQ 掃一掃',
shareSaveImage: '保存圖片',
shareOpenImage: '打開圖片',
shareExtractFile: '提取檔案',
shareDownloadingHint: '檔案準備中正在開始下載',
shareInvalid: '分享連結無效或已失效',
shareDownloadFailed: '下載失敗請稍後重試',
sharePasswordRequired: '存取密碼',
sharePasswordRequiredInput: '請輸入存取密碼',
sharePasswordLengthHint: '存取密碼長度需為 4-256 個字元',
shareDownloadPasswordTip: '連結設定了存取密碼請輸入後再下載',
shareRiskAlert: '外鏈分享連結存在一定安全風險建議僅分享給可信任人員注意保護敏感資訊不被洩露',
sharePasswordSeparate:
'已設定存取密碼請以安全方式告知對方開啟連結時需在網址後加上參數 &password=密碼或使用可填寫密碼的下載工具',
shareExpiresAt: '過期時間',
paste: '貼上',
changeOwner: '修改使用者和使用者群組',
containSub: '同時修改子檔案屬性',
@@ -1659,6 +1695,8 @@ const message = {
favorite: '收藏夾',
removeFavorite: '是否從收藏夾移出',
addFavorite: '新增/移出收藏夾',
addFavoriteAction: '新增收藏夾',
removeFavoriteAction: '移出收藏夾',
clearList: '清空列表',
deleteRecycleHelper: '確定永久刪除以下檔案',
typeErrOrEmpty: '{0} 檔案類型錯誤或為空資料夾',

View File

@@ -1700,6 +1700,42 @@ const message = {
language: '语言',
eol: '行尾符',
copyDir: '复制路径',
shareFile: '分享文件',
shareExpire: '有效期限',
shareExpire1h: '1 小时',
shareExpire6h: '6 小时',
shareExpire24h: '24 小时',
shareExpire3d: '3 ',
shareExpire7d: '7 ',
sharePassword: '访问密码',
sharePasswordPlaceholder: '不填则链接无需密码',
shareMaxDownloads: '最大下载次数',
shareMaxDownloadsHint: '0 表示不限制',
shareGenerate: '生成链接',
shareRegenerate: '再次分享',
shareClose: '关闭',
shareCancel: '取消分享',
shareCancelConfirm: '确定取消该文件的分享',
shareDetail: '详情',
shareCopyLink: '复制链接',
shareLinkLabel: '分享链接',
shareQrCode: '二维码',
shareQrDialogTitle: '分享链接二维码图片',
shareQrDialogHelper: '手机打开微信QQ 扫一扫',
shareSaveImage: '保存图片',
shareOpenImage: '打开图片',
shareExtractFile: '提取文件',
shareDownloadingHint: '文件准备中正在开始下载',
shareInvalid: '分享链接无效或已失效',
shareDownloadFailed: '下载失败请稍后重试',
sharePasswordRequired: '访问密码',
sharePasswordRequiredInput: '请输入访问密码',
sharePasswordLengthHint: '访问密码长度需为 4-256 个字符',
shareDownloadPasswordTip: '链接设置了访问密码请输入后再下载',
shareRiskAlert: '外链分享链接存在一定安全风险建议仅分享给可信任人员注意保护敏感信息不被泄露',
sharePasswordSeparate:
'已设置访问密码请通过安全渠道告知对方打开链接时需在地址后加上参数 &password=密码或使用支持填写密码的下载工具',
shareExpiresAt: '过期时间',
paste: '粘贴',
changeOwner: '修改用户和用户组',
containSub: '同时修改子文件属性',
@@ -1726,6 +1762,8 @@ const message = {
favorite: '收藏夹',
removeFavorite: '是否从收藏夹移出',
addFavorite: '添加/移出收藏夹',
addFavoriteAction: '添加收藏夹',
removeFavoriteAction: '移出收藏夹',
clearList: '清空列表',
deleteRecycleHelper: '确定永久删除以下文件',
typeErrOrEmpty: '{0} 文件类型错误或为空文件夹',

View File

@@ -11,7 +11,8 @@ router.beforeEach((to, from, next) => {
NProgress.start();
axiosCanceler.removeAllPending();
const globalStore = GlobalStore();
if (to.name !== 'entrance' && !globalStore.isLogin) {
const isPublicRoute = to.name === 'entrance' || to.matched.some((record) => record.meta.requiresAuth === false);
if (!isPublicRoute && !globalStore.isLogin) {
next({
name: 'entrance',
params: to.params,

View File

@@ -73,6 +73,14 @@ export const routes: RouteRecordRaw[] = [
key: 'login',
},
},
{
path: '/s/:code',
name: 'file-share',
component: () => import('@/views/share/index.vue'),
meta: {
requiresAuth: false,
},
},
{
path: '/:code?',
name: 'entrance',

View File

@@ -607,10 +607,44 @@ export function toLowerCase(str: string) {
return str.toLowerCase();
}
export function buildFileDownloadUrl(filePath: string, currentNode: string): string {
const base = `${import.meta.env.VITE_API_URL as string}/files/download?operateNode=${currentNode}&`;
return base + 'path=' + encodeURIComponent(filePath);
}
export function buildFileSharePageUrl(code: string, currentNode: string): string {
const shareUrl = new URL(`/s/${encodeURIComponent(code)}`, window.location.origin);
shareUrl.searchParams.set('operateNode', currentNode);
return shareUrl.toString();
}
export function buildFileShareDownloadUrl(code: string, currentNode: string, password?: string): string {
const apiBase = import.meta.env.VITE_API_URL as string;
const normalizedBase = apiBase.replace(/\/$/, '');
const shareUrl = /^https?:\/\//i.test(normalizedBase)
? new URL(`${normalizedBase}/files/share/download`)
: new URL(`${normalizedBase}/files/share/download`, window.location.origin);
shareUrl.searchParams.set('operateNode', currentNode);
shareUrl.searchParams.set('code', code);
if (password && password.length > 0) {
shareUrl.searchParams.set('password', password);
}
return shareUrl.toString();
}
export function buildFileShareQrCodeUrl(code: string, currentNode: string): string {
const apiBase = import.meta.env.VITE_API_URL as string;
const normalizedBase = apiBase.replace(/\/$/, '');
const shareUrl = /^https?:\/\//i.test(normalizedBase)
? new URL(`${normalizedBase}/files/share/qrcode`)
: new URL(`${normalizedBase}/files/share/qrcode`, window.location.origin);
shareUrl.searchParams.set('operateNode', currentNode);
shareUrl.searchParams.set('code', code);
return shareUrl.toString();
}
export function downloadFile(filePath: string, currentNode: string) {
let url = `${import.meta.env.VITE_API_URL as string}/files/download?operateNode=${currentNode}&`;
let path = encodeURIComponent(filePath);
window.open(url + 'path=' + path, '_blank');
window.open(buildFileDownloadUrl(filePath, currentNode), '_blank');
}
export function downloadWithContent(content: string, fileName: string) {

View File

@@ -312,6 +312,9 @@
</el-table>
</div>
</el-popover>
<el-button class="btn" @click="openShareList">
{{ $t('file.shareList') }}
</el-button>
<el-button class="btn" @click="calculateSize(req.path)" :loading="disableBtn">
{{ $t('file.calculate') }}
</el-button>
@@ -513,6 +516,16 @@
</span>
<span v-if="row.isSymlink">-> {{ row.linkPath }}</span>
</div>
<div>
<el-button
v-if="row.shareCode"
link
type="primary"
size="large"
icon="Share"
@click="openShareFile(row)"
></el-button>
</div>
<div>
<el-button
v-if="row.favoriteID > 0"
@@ -641,6 +654,7 @@
<DeleteFile ref="deleteRef" @close="search" />
<RecycleBin ref="recycleBinRef" @close="search" />
<Favorite ref="favoriteRef" @close="search" @jump="jump" @to-favorite="toFavorite" />
<ShareList ref="shareListRef" @close="search" @detail="openShareDetail" />
<BatchRole ref="batchRoleRef" @close="search" />
<VscodeOpenDialog ref="dialogVscodeOpenRef" />
<Preview ref="previewRef" />
@@ -656,6 +670,7 @@
@open-editor="onAiSearchOpenEditor"
/>
<FileList ref="fileRef" @choose="getSearchPath" />
<FileShare ref="fileShareRef" @close="search" />
</div>
</template>
@@ -668,6 +683,7 @@ import {
computeDirSize,
fileWgetKeys,
getFileContent,
removeFileShare,
getFilesList,
setFileRemark,
removeFavorite,
@@ -711,12 +727,14 @@ import Process from './process/index.vue';
import Detail from './detail/index.vue';
import RecycleBin from './recycle-bin/index.vue';
import Favorite from './favorite/index.vue';
import ShareList from './share-list/index.vue';
import BatchRole from './batch-role/index.vue';
import Preview from './preview/index.vue';
import TextPreview from './text-preview/index.vue';
import VscodeOpenDialog from '@/components/vscode-open/index.vue';
import Convert from './convert/index.vue';
import FileAiSearchDrawer from './file-ai-search-drawer.vue';
import FileShare from './share/index.vue';
import { debounce } from 'lodash-es';
import TerminalDialog from './terminal/index.vue';
import { Dashboard } from '@/api/interface/dashboard';
@@ -841,6 +859,7 @@ const moveOpen = ref(false);
const deleteRef = ref();
const recycleBinRef = ref();
const favoriteRef = ref();
const shareListRef = ref();
const hoveredRowPath = ref(null);
const favorites = ref([]);
const batchRoleRef = ref();
@@ -1589,6 +1608,14 @@ const openDownload = (file: File.File) => {
downloadFile(file.path, globalStore.currentNode);
};
const fileShareRef = ref<InstanceType<typeof FileShare> | null>(null);
const openShareFile = (row: File.File) => {
fileShareRef.value?.acceptParams({ path: row.path });
};
const openShareDetail = (path: string) => {
fileShareRef.value?.acceptParams({ path });
};
const openDetail = (row: File.File) => {
detailRef.value.acceptParams({ path: row.path });
};
@@ -1601,6 +1628,10 @@ const openFavorite = () => {
favoriteRef.value.acceptParams();
};
const openShareList = () => {
shareListRef.value.acceptParams();
};
const changeSort = ({ prop, order }) => {
req.sortBy = prop;
req.sortOrder = order;
@@ -1645,6 +1676,18 @@ const getFavorites = async () => {
} catch (error) {}
};
const removeShareByPath = async (path: string) => {
ElMessageBox.confirm(i18n.global.t('file.shareCancelConfirm'), i18n.global.t('commons.msg.remove'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
try {
await removeFileShare(path);
await search();
} catch (error) {}
});
};
const toFavorite = (row: File.Favorite) => {
if (row.isDir) {
jump(row.path);
@@ -1768,13 +1811,33 @@ const afterButtons = [
click: copyDir,
},
{
label: i18n.global.t('file.addFavorite'),
label: i18n.global.t('file.addFavoriteAction'),
click: (row: File.File) => {
if (row?.favoriteID > 0) {
remove(row?.favoriteID);
} else {
addToFavorite(row);
}
addToFavorite(row);
},
show: (row: File.File) => row?.favoriteID === 0,
},
{
label: i18n.global.t('file.removeFavoriteAction'),
click: (row: File.File) => {
remove(row?.favoriteID);
},
show: (row: File.File) => row?.favoriteID > 0,
},
{
label: i18n.global.t('file.shareFile'),
click: openShareFile,
show: (row: File.File) => {
return !row?.isDir && !row?.shareCode;
},
},
{
label: i18n.global.t('file.shareCancel'),
click: (row: File.File) => {
removeShareByPath(row.path);
},
show: (row: File.File) => {
return !row?.isDir && !!row?.shareCode;
},
},
{

View File

@@ -0,0 +1,99 @@
<template>
<DrawerPro v-model="open" :header="$t('file.shareList')" @close="handleClose" size="large">
<template #content>
<ComplexTable :pagination-config="paginationConfig" :data="data" @search="search">
<el-table-column :label="$t('file.path')" show-overflow-tooltip prop="path" min-width="250">
<template #default="{ row }">
<el-tooltip class="box-item" effect="dark" :content="row.path" placement="top">
<span class="table-link text-ellipsis" @click="viewDetail(row)" type="primary">
<svg-icon className="table-icon" iconName="p-file-normal"></svg-icon>
{{ row.fileName }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('file.shareExpiresAt')" min-width="140">
<template #default="{ row }">
{{ formatExpire(row) }}
</template>
</el-table-column>
<fu-table-operations :buttons="buttons" :label="$t('commons.table.operate')" fix />
</ComplexTable>
</template>
</DrawerPro>
</template>
<script setup lang="ts">
import { removeFileShare, searchFileShare } from '@/api/modules/files';
import { File } from '@/api/interface/file';
import i18n from '@/lang';
import { dateFormat as formatDateTime } from '@/utils/util';
import { computed, reactive, ref } from 'vue';
const paginationConfig = reactive({
cacheSizeKey: 'share-page-size',
currentPage: 1,
pageSize: Number(localStorage.getItem('share-page-size')) || 20,
total: 0,
});
const req = reactive({
page: 1,
pageSize: 20,
});
const open = ref(false);
const data = ref<File.FileShareInfo[]>([]);
const permanentText = computed(() => String(i18n.global.t('website.ever')));
const emit = defineEmits(['close', 'detail']);
const handleClose = () => {
open.value = false;
emit('close', false);
};
const formatExpire = (row: File.FileShareInfo) => {
if (row.permanent || row.expiresAt === 0) {
return permanentText.value;
}
return formatDateTime(null, null, row.expiresAt * 1000);
};
const acceptParams = async () => {
await search();
};
const search = async () => {
req.page = paginationConfig.currentPage;
req.pageSize = paginationConfig.pageSize;
const res = await searchFileShare(req);
data.value = res.data.items;
paginationConfig.total = res.data.total;
open.value = true;
};
const closeShare = async (path: string) => {
await removeFileShare(path);
emit('close', true);
await search();
};
const viewDetail = (row: File.FileShareInfo) => {
emit('detail', row.path);
};
const buttons = [
{
label: i18n.global.t('file.shareDetail'),
click: (row: File.FileShareInfo) => {
viewDetail(row);
},
},
{
label: i18n.global.t('file.shareClose'),
click: (row: File.FileShareInfo) => {
closeShare(row.path);
},
},
];
defineExpose({ acceptParams });
</script>

View File

@@ -0,0 +1,346 @@
<template>
<DrawerPro v-model="open" :header="$t('file.shareFile')" size="large" @close="handleClose">
<el-alert
:title="$t('file.shareRiskAlert')"
type="warning"
:closable="false"
show-icon
class="share-risk-alert"
/>
<el-form
ref="shareFormRef"
:model="form"
:rules="rules"
label-position="top"
label-width="100px"
v-loading="loading"
>
<el-form-item :label="$t('file.path')">
<el-input :model-value="filePath" type="textarea" :rows="2" readonly />
</el-form-item>
<el-form-item :label="$t('file.shareExpire')">
<el-select v-model="form.expireMinutes" class="w-full">
<el-option v-for="opt in expireOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item :label="$t('file.sharePassword')" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
clearable
maxlength="256"
show-word-limit
:placeholder="$t('file.sharePasswordPlaceholder')"
autocomplete="new-password"
/>
</el-form-item>
<template v-if="shareInfo">
<el-form-item :label="$t('file.shareLinkLabel')">
<div class="share-link-bar">
<el-link class="share-link-value" :underline="false" type="primary" @click="openShareUrl">
{{ shareUrl }}
</el-link>
<div class="flex items-center justify-end">
<el-button link class="share-link-button" @click="copyLink">
<CopyDocument class="share-link-icon" size="16" />
</el-button>
<el-divider direction="vertical" />
<el-button link class="share-link-button" @click="openQrDialog">
<svg-icon class="share-link-icon" iconName="p-qrcode"></svg-icon>
</el-button>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('file.shareExpiresAt')">
<span>{{ expiresAtText }}</span>
</el-form-item>
</template>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="handleClose">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button v-if="shareInfo" :disabled="loading" @click="cancelShare">
{{ $t('file.shareClose') }}
</el-button>
<el-button v-if="shareInfo" :disabled="loading" @click="copyLink">
{{ $t('file.shareCopyLink') }}
</el-button>
<el-button type="primary" :disabled="loading" @click="generate">
{{ shareInfo ? $t('file.shareRegenerate') : $t('file.shareGenerate') }}
</el-button>
</span>
</template>
</DrawerPro>
<DialogPro v-model="qrDialogOpen" :title="$t('file.shareQrDialogTitle')" size="small">
<div class="share-qr-dialog">
<div class="share-qr-title">{{ $t('file.shareQrDialogTitle') }}</div>
<img v-if="qrCodeUrl" class="share-qr-image" :src="qrCodeUrl" :alt="$t('file.shareQrCode')" />
<div class="share-qr-helper">{{ $t('file.shareQrDialogHelper') }}</div>
<div class="share-qr-actions">
<el-button text @click="saveQrImage">
<Download class="share-link-icon" />
{{ $t('file.shareSaveImage') }}
</el-button>
<el-button text @click="openQrImage">
<Picture class="share-link-icon" />
{{ $t('file.shareOpenImage') }}
</el-button>
</div>
</div>
</DialogPro>
</template>
<script lang="ts" setup>
import { createFileShare, getFileShareDetail, removeFileShare } from '@/api/modules/files';
import { File } from '@/api/interface/file';
import { CopyDocument, Download, Picture } from '@element-plus/icons-vue';
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';
interface ShareProps {
path: string;
}
const globalStore = GlobalStore();
const open = ref(false);
const loading = ref(false);
const changed = ref(false);
const filePath = ref('');
const shareInfo = ref<File.FileShareInfo | null>(null);
const shareUrl = ref('');
const expiresAtText = ref('');
const qrCodeUrl = ref('');
const qrDialogOpen = ref(false);
const shareFormRef = ref<FormInstance>();
const form = reactive({
expireMinutes: 1440,
password: '',
});
const emit = defineEmits(['close']);
const expireOptions = computed(() => [
{ label: i18n.global.t('file.shareExpire1h'), value: 60 },
{ label: i18n.global.t('file.shareExpire6h'), value: 360 },
{ label: i18n.global.t('file.shareExpire24h'), value: 1440 },
{ label: i18n.global.t('file.shareExpire3d'), value: 4320 },
{ label: i18n.global.t('file.shareExpire7d'), value: 10080 },
{ label: i18n.global.t('website.ever'), value: 0 },
]);
const validatePassword = (_rule, value, callback) => {
const password = typeof value === 'string' ? value.trim() : '';
if (password.length === 0) {
callback();
return;
}
if (password.length < 4 || password.length > 256) {
callback(new Error(String(i18n.global.t('file.sharePasswordLengthHint'))));
return;
}
callback();
};
const rules = reactive<FormRules>({
password: [{ validator: validatePassword, trigger: 'blur' }],
});
const mapExpireMinutes = (info: File.FileShareInfo) => {
if (info.permanent || info.expiresAt === 0) {
return 0;
}
const remainMinutes = Math.max(1, Math.round((info.expiresAt * 1000 - Date.now()) / 60000));
const matched = expireOptions.value.find((item) => item.value === remainMinutes);
return matched ? matched.value : 1440;
};
const applyShareInfo = (info: File.FileShareInfo | null) => {
shareInfo.value = info;
if (!info) {
shareUrl.value = '';
expiresAtText.value = '';
qrCodeUrl.value = '';
qrDialogOpen.value = false;
form.expireMinutes = 1440;
form.password = '';
return;
}
shareUrl.value = buildFileSharePageUrl(info.code, globalStore.currentNode);
qrCodeUrl.value = buildFileShareQrCodeUrl(info.code, globalStore.currentNode);
expiresAtText.value = info.permanent
? i18n.global.t('website.ever')
: formatDateTime(null, null, info.expiresAt * 1000);
form.expireMinutes = mapExpireMinutes(info);
form.password = '';
};
const loadShareDetail = async () => {
const res = await getFileShareDetail(filePath.value);
applyShareInfo(res.data?.code ? res.data : null);
};
const resetForm = () => {
filePath.value = '';
changed.value = false;
applyShareInfo(null);
shareFormRef.value?.clearValidate();
};
const handleClose = () => {
const needRefresh = changed.value;
open.value = false;
resetForm();
emit('close', needRefresh);
};
const generate = async () => {
const valid = await shareFormRef.value?.validate().catch(() => false);
if (valid === false) {
return;
}
loading.value = true;
try {
const pw = form.password.trim();
const res = await createFileShare({
path: filePath.value,
expireMinutes: form.expireMinutes,
...(pw.length > 0 ? { password: pw } : {}),
});
applyShareInfo(res.data as File.FileShareInfo);
changed.value = true;
} finally {
loading.value = false;
}
};
const cancelShare = async () => {
loading.value = true;
try {
await removeFileShare(filePath.value);
applyShareInfo(null);
changed.value = true;
} finally {
loading.value = false;
}
};
const copyLink = () => {
if (shareUrl.value) {
copyText(shareUrl.value);
}
};
const openQrDialog = () => {
if (qrCodeUrl.value) {
qrDialogOpen.value = true;
}
};
const openQrImage = () => {
if (qrCodeUrl.value) {
window.open(qrCodeUrl.value, '_blank', 'noopener,noreferrer');
}
};
const saveQrImage = () => {
if (!qrCodeUrl.value) {
return;
}
const link = document.createElement('a');
link.href = qrCodeUrl.value;
link.download = `share-${shareInfo.value?.code || 'qrcode'}.png`;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.click();
};
const acceptParams = async (params: ShareProps) => {
resetForm();
filePath.value = params.path;
open.value = true;
loading.value = true;
try {
await loadShareDetail();
} finally {
loading.value = false;
}
};
defineExpose({ acceptParams });
</script>
<style scoped lang="scss">
.share-link-bar {
width: 100%;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.8;
}
.share-risk-alert {
margin-bottom: 18px;
}
.share-link-prefix {
color: var(--el-text-color-secondary);
}
.share-link-value {
max-width: min(100%, 480px);
justify-content: flex-start;
text-align: left;
word-break: break-all;
}
.share-link-button {
padding: 0;
}
.share-link-icon {
width: 14px;
height: 14px;
margin-right: 4px;
}
.share-qr-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 6px 0 2px;
}
.share-qr-title {
color: var(--el-color-primary);
font-size: 18px;
}
.share-qr-image {
width: 240px;
height: 240px;
object-fit: contain;
background: #fff;
}
.share-qr-helper {
color: var(--el-text-color-regular);
}
.share-qr-actions {
width: 100%;
display: flex;
justify-content: center;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div class="share-page" v-loading="initializing">
<div class="share-card">
<h1>{{ $t('file.shareLinkLabel') }}</h1>
<div v-if="shareInfo" class="share-meta">
<div class="meta-item">
<span class="meta-label">{{ $t('file.fileName') }}</span>
<span class="meta-value">{{ shareInfo.fileName }}</span>
</div>
<div class="meta-item">
<span class="meta-label">{{ $t('file.shareExpiresAt') }}</span>
<span class="meta-value">{{ expiresAtText }}</span>
</div>
</div>
<p v-if="shareInfo?.hasPassword" class="share-description">{{ $t('file.shareDownloadPasswordTip') }}</p>
<p v-else-if="shareInfo && !errorMessage" class="share-description">
{{ $t('file.shareDownloadingHint') }}
</p>
<el-form v-if="shareInfo" @submit.prevent>
<el-form-item v-if="shareInfo.hasPassword" :label="$t('file.sharePasswordRequired')">
<el-input
v-model="password"
type="password"
show-password
clearable
maxlength="256"
:placeholder="$t('file.sharePasswordPlaceholder')"
autocomplete="current-password"
@keyup.enter="downloadWithPassword"
/>
</el-form-item>
<div v-if="errorMessage" class="share-error">{{ errorMessage }}</div>
<el-button type="primary" :loading="downloading" class="share-button" @click="downloadWithPassword">
{{ $t('file.shareExtractFile') }}
</el-button>
</el-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { checkFileShare, getPublicFileShareInfo } from '@/api/modules/files';
import { File } from '@/api/interface/file';
import i18n, { loadLocaleMessages } from '@/lang';
import { buildFileShareDownloadUrl, dateFormat } from '@/utils/util';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const password = ref('');
const downloading = ref(false);
const initializing = ref(true);
const errorMessage = ref('');
const shareInfo = ref<File.FileSharePublicInfo | null>(null);
const supportedLocales = ['zh', 'zh-Hant', 'en', 'pt-BR', 'ja', 'ru', 'ms', 'ko', 'tr', 'es-ES'];
const code = computed(() => String(route.params.code || '').trim());
const currentNode = computed(() => String(route.query.operateNode || 'local'));
const expiresAtText = computed(() => {
if (!shareInfo.value) {
return '--';
}
if (shareInfo.value.permanent || shareInfo.value.expiresAt === 0) {
return String(i18n.global.t('website.ever'));
}
return dateFormat(null, null, shareInfo.value.expiresAt * 1000);
});
const getFallbackText = (type: 'invalid' | 'failed') => {
return String(i18n.global.t(type === 'invalid' ? 'file.shareInvalid' : 'file.shareDownloadFailed'));
};
const getPasswordRequiredText = () => {
return String(i18n.global.t('file.sharePasswordRequiredInput'));
};
const triggerDownload = (pwd = '') => {
window.location.href = buildFileShareDownloadUrl(code.value, currentNode.value, pwd);
};
const resolveBrowserLocale = () => {
if (typeof navigator === 'undefined') {
return 'en';
}
const browserLocale = String(navigator.language || '').trim();
const normalized = browserLocale.toLowerCase();
if (normalized.startsWith('zh-hant') || normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
return 'zh-Hant';
}
if (normalized.startsWith('zh')) {
return 'zh';
}
const exactMatch = supportedLocales.find((locale) => locale.toLowerCase() === normalized);
if (exactMatch) {
return exactMatch;
}
const prefix = normalized.split('-')[0];
const prefixMatch = supportedLocales.find((locale) => locale.toLowerCase().split('-')[0] === prefix);
return prefixMatch || 'en';
};
const applyPublicLocale = async () => {
const locale = resolveBrowserLocale();
const loaded = await loadLocaleMessages(locale);
i18n.global.locale.value = loaded;
};
const loadShareInfo = async () => {
if (!code.value) {
errorMessage.value = getFallbackText('invalid');
return;
}
const res = await getPublicFileShareInfo(code.value, currentNode.value);
shareInfo.value = res.data;
};
const downloadWithPassword = async () => {
if (!code.value) {
errorMessage.value = getFallbackText('invalid');
return;
}
if (shareInfo.value?.hasPassword && !password.value.trim()) {
errorMessage.value = getPasswordRequiredText();
return;
}
downloading.value = true;
errorMessage.value = '';
try {
await checkFileShare({
code: code.value,
password: password.value.trim(),
operateNode: currentNode.value,
});
triggerDownload(password.value.trim());
} catch (error) {
errorMessage.value = (error as { message?: string })?.message || getFallbackText('failed');
} finally {
downloading.value = false;
}
};
onMounted(async () => {
try {
await applyPublicLocale();
await loadShareInfo();
if (shareInfo.value && !shareInfo.value.hasPassword) {
await downloadWithPassword();
}
} catch (error) {
errorMessage.value = (error as { message?: string })?.message || getFallbackText('invalid');
} finally {
initializing.value = false;
}
});
</script>
<style scoped lang="scss">
.share-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(circle at top, rgba(0, 94, 235, 0.14), transparent 32%),
linear-gradient(160deg, #f5f8ff 0%, #eef4ff 42%, #ffffff 100%);
}
.share-card {
width: min(100%, 460px);
padding: 32px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 94, 235, 0.12);
box-shadow: 0 20px 60px rgba(15, 44, 92, 0.12);
h1 {
margin: 14px 0 10px;
font-size: 28px;
line-height: 1.2;
color: #16325c;
}
}
.share-description {
margin: 0 0 20px;
color: #5f6f8a;
line-height: 1.6;
}
.share-meta {
display: grid;
gap: 12px;
margin: 0 0 20px;
padding: 14px 16px;
border-radius: 14px;
background: #f7f9fd;
border: 1px solid rgba(0, 94, 235, 0.1);
}
.meta-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.meta-label {
flex: none;
color: #7a879d;
font-size: 13px;
}
.meta-value {
color: #16325c;
font-size: 14px;
text-align: right;
word-break: break-all;
}
.share-error {
margin: -6px 0 16px;
color: #d03050;
font-size: 13px;
line-height: 1.5;
}
.share-button {
width: 100%;
}
</style>