优化:工具栏高度对齐+面板统一+远程连接架构+自动恢复预览
- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码 - 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式 - 预览区:始终显示空白预览面板、重启自动恢复上次打开文件 - 收藏夹:简化计数显示(共N项) - 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
This commit is contained in:
108
internal/agent/config/config.go
Normal file
108
internal/agent/config/config.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
FileServer FileServerConfig `yaml:"file_server"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type FileServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
MaxFileSize int64 `yaml:"max_file_size"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
AllowSymlinks bool `yaml:"allow_symlinks"`
|
||||
CheckSystemPaths bool `yaml:"check_system_paths"`
|
||||
}
|
||||
|
||||
// FileServerAddr 返回文件服务器的完整地址
|
||||
func (c *Config) FileServerAddr() string {
|
||||
return fmt.Sprintf("http://localhost:%d", c.FileServer.Port)
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// 配置文件不存在时使用默认值
|
||||
if os.IsNotExist(err) {
|
||||
return Default(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清理 origins 中的空格并去重
|
||||
seen := make(map[string]bool, len(cfg.CORS.AllowedOrigins))
|
||||
uniques := cfg.CORS.AllowedOrigins[:0]
|
||||
for _, origin := range cfg.CORS.AllowedOrigins {
|
||||
o := strings.TrimSpace(origin)
|
||||
if o != "" && !seen[o] {
|
||||
seen[o] = true
|
||||
uniques = append(uniques, o)
|
||||
}
|
||||
}
|
||||
cfg.CORS.AllowedOrigins = uniques
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: 9876,
|
||||
Host: "0.0.0.0",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
Token: "",
|
||||
},
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Log: LogConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
},
|
||||
FileServer: FileServerConfig{
|
||||
Port: 8073,
|
||||
MaxFileSize: 500 * 1024 * 1024,
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
AllowSymlinks: false,
|
||||
CheckSystemPaths: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
176
internal/agent/handler/file_handler.go
Normal file
176
internal/agent/handler/file_handler.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type writeFileReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
Type string `json:"type"` // "file" or "dir"
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type renameReq struct {
|
||||
NewPath string `json:"new_path"`
|
||||
}
|
||||
|
||||
type uploadReq struct {
|
||||
Content string `json:"content"` // base64 编码内容
|
||||
}
|
||||
|
||||
// ListOrStat 列出目录或获取文件信息(?get=stat 时返回单文件信息)
|
||||
func (h *Handler) ListOrStat(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
action := c.QueryParam("get")
|
||||
|
||||
if action == "stat" {
|
||||
info, err := h.fsSvc.GetFileInfo(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.NotFound(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
|
||||
files, err := h.fsSvc.ListDir(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
// 限制返回数量,避免大目录导致前端卡顿
|
||||
limit := c.QueryParam("limit")
|
||||
if limit != "" {
|
||||
n := 0
|
||||
for i, f := range files {
|
||||
if n >= 500 { // 硬限制 500 条
|
||||
break
|
||||
}
|
||||
files[i] = f
|
||||
n++
|
||||
}
|
||||
files = files[:n]
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(files))
|
||||
}
|
||||
|
||||
// ReadFile 读取文件文本内容
|
||||
func (h *Handler) ReadFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
content, err := h.fsSvc.ReadFile(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
// WriteFile 写入文件文本内容
|
||||
func (h *Handler) WriteFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req writeFileReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if err := h.fsSvc.WriteFile(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// Create 创建文件或目录
|
||||
func (h *Handler) Create(c echo.Context) error {
|
||||
parentPath := getPath(c)
|
||||
var req createReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("名称不能为空"))
|
||||
}
|
||||
|
||||
var result *filesystem.FileOperationResult
|
||||
var err error
|
||||
|
||||
fullPath := filepath.Join(parentPath, req.Name)
|
||||
|
||||
switch req.Type {
|
||||
case "dir":
|
||||
result, err = h.fsSvc.CreateDir(fullPath)
|
||||
default:
|
||||
result, err = h.fsSvc.CreateFile(fullPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusCreated, model.OK(result))
|
||||
}
|
||||
|
||||
// Delete 删除文件或目录
|
||||
func (h *Handler) Delete(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
result, err := h.fsSvc.DeletePath(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Rename 重命名文件或目录
|
||||
func (h *Handler) Rename(c echo.Context) error {
|
||||
oldPath := getPath(c)
|
||||
var req renameReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
req.NewPath = strings.TrimSpace(req.NewPath)
|
||||
if req.NewPath == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不能为空"))
|
||||
}
|
||||
cleanNew := filepath.Clean(req.NewPath)
|
||||
if strings.Contains(cleanNew, "..") {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不允许包含 .."))
|
||||
}
|
||||
result, err := h.fsSvc.RenamePath(oldPath, cleanNew)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Upload 上传 Base64 编码的二进制文件
|
||||
func (h *Handler) Upload(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req uploadReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if req.Content == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("内容不能为空"))
|
||||
}
|
||||
if err := h.fsSvc.SaveBase64File(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// DetectType 通过文件内容检测类型
|
||||
func (h *Handler) DetectType(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
info, err := h.fsSvc.DetectFileTypeByContent(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
37
internal/agent/handler/handler.go
Normal file
37
internal/agent/handler/handler.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"u-desk/internal/agent/config"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
fsSvc *filesystem.FileSystemService
|
||||
cfg *config.Config
|
||||
fileProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func New(fsSvc *filesystem.FileSystemService, cfg *config.Config) *Handler {
|
||||
fileTarget, _ := url.Parse(cfg.FileServerAddr() + "/localfs/")
|
||||
return &Handler{
|
||||
fsSvc: fsSvc,
|
||||
cfg: cfg,
|
||||
fileProxy: httputil.NewSingleHostReverseProxy(fileTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// getPath 从 query 参数提取并规范化文件路径
|
||||
func getPath(c echo.Context) string {
|
||||
raw := c.QueryParam("path")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// URL 已被 Echo 自动 decode,只需转换路径分隔符
|
||||
return filepath.FromSlash(raw)
|
||||
}
|
||||
64
internal/agent/handler/server_handler.go
Normal file
64
internal/agent/handler/server_handler.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// FileServerProxy 反向代理到内置文件服务器(用于媒体预览)
|
||||
func (h *Handler) FileServerProxy(c echo.Context) error {
|
||||
rawPath := c.Param("*")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少文件路径")
|
||||
}
|
||||
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
|
||||
// 防止多重 /localfs/ 前缀(循环去除所有)
|
||||
targetPath := filepath.ToSlash(clean)
|
||||
for strings.HasPrefix(targetPath, "localfs/") || strings.HasPrefix(targetPath, "localfs\\") {
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs/")
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs\\")
|
||||
}
|
||||
c.Request().URL.Path = "/localfs/" + targetPath
|
||||
h.fileProxy.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTMLPreviewProxy 代理 HTML 预览请求(直连内部服务器,避免 ReverseProxy 路径拼接问题)
|
||||
func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
|
||||
rawPath := c.QueryParam("path")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少 path 参数")
|
||||
}
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
theme := c.QueryParam("theme")
|
||||
|
||||
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
|
||||
url.QueryEscape(clean), url.QueryEscape(theme))
|
||||
|
||||
resp, err := http.Get(targetURL)
|
||||
if err != nil {
|
||||
return c.String(http.StatusBadGateway, "内部服务器不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for k, v := range resp.Header {
|
||||
c.Response().Header()[k] = v
|
||||
}
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Response(), resp.Body)
|
||||
return nil
|
||||
}
|
||||
113
internal/agent/handler/system_handler.go
Normal file
113
internal/agent/handler/system_handler.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Ping 健康检查
|
||||
func (h *Handler) Ping(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
// Info 返回 Agent 信息
|
||||
func (h *Handler) Info(c echo.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]interface{}{
|
||||
"version": "0.1.0",
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
}))
|
||||
}
|
||||
|
||||
// CommonPaths 返回常用系统路径
|
||||
func (h *Handler) CommonPaths(c echo.Context) error {
|
||||
paths := map[string]string{}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
paths["home"] = home
|
||||
paths["desktop"] = home + "/Desktop"
|
||||
paths["documents"] = home + "/Documents"
|
||||
paths["downloads"] = home + "/Downloads"
|
||||
}
|
||||
|
||||
// 根据平台添加盘符/根路径
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
_, err := os.Stat(string(drive) + ":\\")
|
||||
if err == nil {
|
||||
paths["drive_"+strings.ToLower(string(drive))] = string(drive) + ":\\"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paths["root"] = "/"
|
||||
_, err := os.Stat("/home")
|
||||
if err == nil {
|
||||
paths["users"] = "/home"
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(paths))
|
||||
}
|
||||
|
||||
// Drives 返回可用磁盘列表
|
||||
func (h *Handler) Drives(c echo.Context) error {
|
||||
type DriveInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
FsType string `json:"fs_type,omitempty"`
|
||||
Total uint64 `json:"total"`
|
||||
Free uint64 `json:"free"`
|
||||
}
|
||||
|
||||
var drives []DriveInfo
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
drivePath := string(drive) + ":\\"
|
||||
if _, err := os.Stat(drivePath); err != nil {
|
||||
continue
|
||||
}
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: strings.ToLower(string(drive)),
|
||||
Path: drivePath,
|
||||
Total: 0,
|
||||
Free: 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
parts, err := os.ReadDir("/")
|
||||
if err == nil {
|
||||
for _, p := range parts {
|
||||
name := p.Name()
|
||||
if len(name) == 2 && name[0] != '.' && name[1] != '.' && p.IsDir() {
|
||||
// 可能是挂载点
|
||||
fullPath := "/" + name
|
||||
if stat, err := os.Stat(fullPath); err == nil && stat.IsDir() {
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
})
|
||||
_ = stat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 至少返回根目录
|
||||
if len(drives) == 0 {
|
||||
drives = append(drives, DriveInfo{Name: "/", Path: "/"})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(drives))
|
||||
}
|
||||
61
internal/agent/middleware/auth.go
Normal file
61
internal/agent/middleware/auth.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const cookieName = "fs_token"
|
||||
|
||||
func Auth(token string) echo.MiddlewareFunc {
|
||||
if token == "" {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// 1. Authorization header(API 调用,首选)
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if len(auth) >= 7 && strings.HasPrefix(auth, "Bearer ") &&
|
||||
subtle.ConstantTimeCompare([]byte(auth[7:]), []byte(token)) == 1 {
|
||||
setAuthCookie(c, token)
|
||||
return next(c)
|
||||
}
|
||||
// 2. Cookie(<img>/<video> 等浏览器自动携带)
|
||||
if ck, err := c.Cookie(cookieName); err == nil &&
|
||||
subtle.ConstantTimeCompare([]byte(ck.Value), []byte(token)) == 1 {
|
||||
return next(c)
|
||||
}
|
||||
// 3. 查询参数(兼容旧版,可后续移除)
|
||||
if qt := c.QueryParam("token"); qt != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(qt), []byte(token)) == 1 {
|
||||
setAuthCookie(c, token)
|
||||
return next(c)
|
||||
}
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthCookie 首次认证成功后设置 Cookie(供 <img> 等浏览器请求自动携带)
|
||||
func setAuthCookie(c echo.Context, token string) {
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(24 * time.Hour / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: c.Request().TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
41
internal/agent/model/response.go
Normal file
41
internal/agent/model/response.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func OK(data interface{}) Response {
|
||||
return Response{Code: http.StatusOK, Data: data}
|
||||
}
|
||||
|
||||
func Created(data interface{}) Response {
|
||||
return Response{Code: http.StatusCreated, Data: data}
|
||||
}
|
||||
|
||||
func NoContent() Response {
|
||||
return Response{Code: http.StatusNoContent}
|
||||
}
|
||||
|
||||
func BadRequest(msg string) Response {
|
||||
return Response{Code: http.StatusBadRequest, Message: msg}
|
||||
}
|
||||
|
||||
func Unauthorized(msg string) Response {
|
||||
return Response{Code: http.StatusUnauthorized, Message: msg}
|
||||
}
|
||||
|
||||
func Forbidden(msg string) Response {
|
||||
return Response{Code: http.StatusForbidden, Message: msg}
|
||||
}
|
||||
|
||||
func NotFound(msg string) Response {
|
||||
return Response{Code: http.StatusNotFound, Message: msg}
|
||||
}
|
||||
|
||||
func InternalError(msg string) Response {
|
||||
return Response{Code: http.StatusInternalServerError, Message: msg}
|
||||
}
|
||||
@@ -68,9 +68,22 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
// 去除代理引入的 /localfs/ 前缀(可能有多层)
|
||||
clean := decodedPath
|
||||
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
|
||||
clean = strings.TrimPrefix(clean, "/localfs/")
|
||||
clean = strings.TrimPrefix(clean, "localfs/")
|
||||
}
|
||||
|
||||
// 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠
|
||||
filePath := filepath.FromSlash(clean)
|
||||
filePath = filepath.Clean(filePath)
|
||||
|
||||
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", ErrPathUnsafe
|
||||
}
|
||||
@@ -153,8 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
// 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
|
||||
pathPart := r.URL.Path
|
||||
for strings.HasPrefix(pathPart, "/localfs/") {
|
||||
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
|
||||
}
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
||||
pathPart = "/" + pathPart
|
||||
}
|
||||
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
|
||||
19
internal/filesystem/file_lock_linux.go
Normal file
19
internal/filesystem/file_lock_linux.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package filesystem
|
||||
|
||||
// FileLockChecker 文件锁检查器(Linux 空实现)
|
||||
type FileLockChecker struct{}
|
||||
|
||||
func NewFileLockChecker() *FileLockChecker {
|
||||
return &FileLockChecker{}
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) IsFileLocked(_path string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(_path string) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user