|
|
@@ -0,0 +1,364 @@
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "flag"
|
|
|
+ "fmt"
|
|
|
+ "log"
|
|
|
+ "mime"
|
|
|
+ "net/http"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "sort"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+// 说明文件候选名(小写)。需求中文件名为 reademe.md,同时兼容常见的 readme.md。
|
|
|
+var readmeCandidates = []string{"reademe.md", "readme.md"}
|
|
|
+
|
|
|
+// 允许预览的文本/代码类扩展名:以纯文本返回,避免 HTML 等同源 XSS。
|
|
|
+var previewTextExts = map[string]bool{
|
|
|
+ ".txt": true, ".md": true, ".markdown": true, ".log": true, ".csv": true,
|
|
|
+ ".json": true, ".xml": true, ".yml": true, ".yaml": true, ".toml": true,
|
|
|
+ ".ini": true, ".conf": true, ".cfg": true, ".sql": true, ".env": true,
|
|
|
+ ".html": true, ".htm": true, ".css": true, ".js": true, ".ts": true,
|
|
|
+ ".jsx": true, ".tsx": true, ".vue": true, ".go": true, ".py": true,
|
|
|
+ ".java": true, ".c": true, ".h": true, ".cpp": true, ".hpp": true,
|
|
|
+ ".cc": true, ".cs": true, ".rb": true, ".rs": true, ".php": true,
|
|
|
+ ".sh": true, ".bash": true, ".bat": true, ".ps1": true, ".lua": true,
|
|
|
+ ".kt": true, ".swift": true, ".scala": true, ".r": true, ".dart": true,
|
|
|
+ ".gradle": true, ".gitignore": true,
|
|
|
+}
|
|
|
+
|
|
|
+// 浏览器原生支持内联展示的类型。
|
|
|
+var previewInlineExts = map[string]string{
|
|
|
+ ".png": "image/png",
|
|
|
+ ".jpg": "image/jpeg",
|
|
|
+ ".jpeg": "image/jpeg",
|
|
|
+ ".gif": "image/gif",
|
|
|
+ ".webp": "image/webp",
|
|
|
+ ".bmp": "image/bmp",
|
|
|
+ ".ico": "image/x-icon",
|
|
|
+ ".svg": "image/svg+xml",
|
|
|
+ ".pdf": "application/pdf",
|
|
|
+}
|
|
|
+
|
|
|
+// 预览文本文件的大小上限(10 MiB),防止把超大文件整体读入/传输。
|
|
|
+const maxPreviewTextSize = 10 * 1024 * 1024
|
|
|
+
|
|
|
+type server struct {
|
|
|
+ root string // share 目录的绝对路径
|
|
|
+ codec *tokenCodec // 路径 <-> 分享 key 的加解密器
|
|
|
+}
|
|
|
+
|
|
|
+type entry struct {
|
|
|
+ Name string `json:"name"`
|
|
|
+ Key string `json:"key"` // 该条目的分享 key,传给任意接口即可定位
|
|
|
+ Type string `json:"type"` // file | dir
|
|
|
+ Size int64 `json:"size"`
|
|
|
+ ModTime time.Time `json:"mod_time"`
|
|
|
+}
|
|
|
+
|
|
|
+type dirDetail struct {
|
|
|
+ Key string `json:"key"` // 当前目录的 key;根目录为空串(不传 key 即根目录)
|
|
|
+ Name string `json:"name"`
|
|
|
+ Readme *string `json:"readme"` // 目录说明,无说明文件时为 null
|
|
|
+ Entries []entry `json:"entries"`
|
|
|
+}
|
|
|
+
|
|
|
+func main() {
|
|
|
+ addr := flag.String("addr", ":8080", "监听地址")
|
|
|
+ root := flag.String("root", "./share", "共享目录(仅认领该目录及其子目录)")
|
|
|
+ secretPath := flag.String("secret", "./.secret.key", "分享 key 的加密密钥文件(不存在则自动生成)")
|
|
|
+ flag.Parse()
|
|
|
+
|
|
|
+ absRoot, err := filepath.Abs(*root)
|
|
|
+ if err != nil {
|
|
|
+ log.Fatalf("解析共享目录失败: %v", err)
|
|
|
+ }
|
|
|
+ if err := os.MkdirAll(absRoot, 0o755); err != nil {
|
|
|
+ log.Fatalf("创建共享目录失败: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ secret, err := loadOrCreateSecret(filepath.Clean(*secretPath))
|
|
|
+ if err != nil {
|
|
|
+ log.Fatalf("加载加密密钥失败: %v", err)
|
|
|
+ }
|
|
|
+ codec, err := newTokenCodec(secret)
|
|
|
+ if err != nil {
|
|
|
+ log.Fatalf("初始化加解密器失败: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ s := &server{root: absRoot, codec: codec}
|
|
|
+
|
|
|
+ mux := http.NewServeMux()
|
|
|
+ mux.HandleFunc("/api/get_dict_detail", s.handleDirDetail)
|
|
|
+ mux.HandleFunc("/api/download", s.handleDownload)
|
|
|
+ mux.HandleFunc("/api/preview", s.handlePreview)
|
|
|
+
|
|
|
+ log.Printf("文件传输服务已启动: http://localhost%s", strings.TrimPrefix(*addr, ":"))
|
|
|
+ log.Printf("共享目录: %s", absRoot)
|
|
|
+ log.Printf("接口: /api/get_dict_detail | /api/download | /api/preview")
|
|
|
+ if err := http.ListenAndServe(*addr, logRequests(mux)); err != nil {
|
|
|
+ log.Fatal(err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func logRequests(next http.Handler) http.Handler {
|
|
|
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ next.ServeHTTP(w, r)
|
|
|
+ log.Printf("%s %s", r.Method, r.URL.RequestURI())
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// resolve 把接口传入的相对路径限定在 share 根目录内,防止路径穿越。
|
|
|
+func (s *server) resolve(rel string) (string, error) {
|
|
|
+ rel = filepath.FromSlash(strings.TrimSpace(rel))
|
|
|
+ if filepath.IsAbs(rel) || rel == "." {
|
|
|
+ if rel == "." {
|
|
|
+ rel = ""
|
|
|
+ } else {
|
|
|
+ return "", fmt.Errorf("非法路径")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ cleaned := filepath.Clean(rel)
|
|
|
+ if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
|
|
|
+ return "", fmt.Errorf("非法路径")
|
|
|
+ }
|
|
|
+
|
|
|
+ target := filepath.Join(s.root, cleaned)
|
|
|
+
|
|
|
+ // 解析符号链接后再校验一次,确保仍在根目录内。
|
|
|
+ realRoot, err := filepath.EvalSymlinks(s.root)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ realTarget, err := filepath.EvalSymlinks(target)
|
|
|
+ if err != nil {
|
|
|
+ if os.IsNotExist(err) {
|
|
|
+ return target, nil // 不存在的路径交由调用方通过 os.Stat 返回 404
|
|
|
+ }
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ relToRoot, err := filepath.Rel(realRoot, realTarget)
|
|
|
+ if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
|
|
|
+ return "", fmt.Errorf("非法路径")
|
|
|
+ }
|
|
|
+ return target, nil
|
|
|
+}
|
|
|
+
|
|
|
+// toSlashRel 返回相对 share 根的 slash 风格路径。
|
|
|
+func (s *server) toSlashRel(abs string) string {
|
|
|
+ rel, err := filepath.Rel(s.root, abs)
|
|
|
+ if err != nil {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+ return filepath.ToSlash(rel)
|
|
|
+}
|
|
|
+
|
|
|
+// resolveKey 解密分享 key 并把解密出的路径限定在 share 根目录内。
|
|
|
+// allowEmpty 为 true 时(目录浏览),空 key 表示根目录。
|
|
|
+func (s *server) resolveKey(key string, allowEmpty bool) (string, error) {
|
|
|
+ if key == "" {
|
|
|
+ if allowEmpty {
|
|
|
+ return s.resolve("")
|
|
|
+ }
|
|
|
+ return "", fmt.Errorf("缺少 key")
|
|
|
+ }
|
|
|
+ rel, err := s.codec.Open(strings.TrimSpace(key))
|
|
|
+ if err != nil {
|
|
|
+ // 不向前端泄露具体的解密/篡改细节
|
|
|
+ return "", fmt.Errorf("无效的分享链接")
|
|
|
+ }
|
|
|
+ return s.resolve(rel)
|
|
|
+}
|
|
|
+
|
|
|
+func (s *server) handleDirDetail(w http.ResponseWriter, r *http.Request) {
|
|
|
+ if r.Method != http.MethodGet {
|
|
|
+ writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ dir, err := s.resolveKey(r.URL.Query().Get("key"), true)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusBadRequest, err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ info, err := os.Stat(dir)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusNotFound, "目录不存在")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if !info.IsDir() {
|
|
|
+ writeError(w, http.StatusBadRequest, "目标不是目录")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ items, err := os.ReadDir(dir)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusInternalServerError, "读取目录失败: "+err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ detail := dirDetail{
|
|
|
+ Name: info.Name(),
|
|
|
+ Entries: make([]entry, 0, len(items)),
|
|
|
+ }
|
|
|
+
|
|
|
+ // 当前目录的相对路径;根目录的 key 留空(不传 key 即可访问根)。
|
|
|
+ curRel := s.toSlashRel(dir)
|
|
|
+ if curRel == "." || curRel == "" {
|
|
|
+ curRel = ""
|
|
|
+ detail.Name = "share"
|
|
|
+ } else {
|
|
|
+ detail.Key = s.codec.Seal(curRel)
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, item := range items {
|
|
|
+ full := filepath.Join(dir, item.Name())
|
|
|
+ fi, err := item.Info()
|
|
|
+ if err != nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ t := "file"
|
|
|
+ if item.IsDir() {
|
|
|
+ t = "dir"
|
|
|
+ }
|
|
|
+ detail.Entries = append(detail.Entries, entry{
|
|
|
+ Name: item.Name(),
|
|
|
+ Key: s.codec.Seal(s.toSlashRel(full)),
|
|
|
+ Type: t,
|
|
|
+ Size: fi.Size(),
|
|
|
+ ModTime: fi.ModTime(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 目录在前、文件在后,各自按名称排序。
|
|
|
+ sort.Slice(detail.Entries, func(i, j int) bool {
|
|
|
+ a, b := detail.Entries[i], detail.Entries[j]
|
|
|
+ if (a.Type == "dir") != (b.Type == "dir") {
|
|
|
+ return a.Type == "dir"
|
|
|
+ }
|
|
|
+ return strings.ToLower(a.Name) < strings.ToLower(b.Name)
|
|
|
+ })
|
|
|
+
|
|
|
+ if readmePath := findReadme(dir, items); readmePath != "" {
|
|
|
+ content, err := os.ReadFile(readmePath)
|
|
|
+ if err == nil {
|
|
|
+ text := string(content)
|
|
|
+ detail.Readme = &text
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ writeJSON(w, http.StatusOK, detail)
|
|
|
+}
|
|
|
+
|
|
|
+// findReadme 在目录条目里查找说明文件,大小写不敏感,优先 reademe.md。
|
|
|
+func findReadme(dir string, items []os.DirEntry) string {
|
|
|
+ lowerNames := make(map[string]string)
|
|
|
+ for _, item := range items {
|
|
|
+ if !item.IsDir() {
|
|
|
+ lowerNames[strings.ToLower(item.Name())] = item.Name()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for _, candidate := range readmeCandidates {
|
|
|
+ if name, ok := lowerNames[candidate]; ok {
|
|
|
+ return filepath.Join(dir, name)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
+ if r.Method != http.MethodGet {
|
|
|
+ writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ file, err := s.resolveKey(r.URL.Query().Get("key"), false)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusBadRequest, err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ info, err := os.Stat(file)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusNotFound, "文件不存在")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if info.IsDir() {
|
|
|
+ writeError(w, http.StatusBadRequest, "不支持下载目录,仅可下载单个文件")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ f, err := os.Open(file)
|
|
|
+ cd := mime.FormatMediaType("attachment", map[string]string{"filename": info.Name()})
|
|
|
+ w.Header().Set("Content-Disposition", cd)
|
|
|
+ http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
|
|
+}
|
|
|
+
|
|
|
+func (s *server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|
|
+ if r.Method != http.MethodGet {
|
|
|
+ writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ file, err := s.resolveKey(r.URL.Query().Get("key"), false)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusBadRequest, err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ info, err := os.Stat(file)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusNotFound, "文件不存在")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if info.IsDir() {
|
|
|
+ writeError(w, http.StatusBadRequest, "目录不支持预览")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ ext := strings.ToLower(filepath.Ext(file))
|
|
|
+
|
|
|
+ f, err := os.Open(file)
|
|
|
+ if err != nil {
|
|
|
+ writeError(w, http.StatusInternalServerError, "打开文件失败")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ defer f.Close()
|
|
|
+
|
|
|
+ if contentType, ok := previewInlineExts[ext]; ok {
|
|
|
+ w.Header().Set("Content-Type", contentType)
|
|
|
+ w.Header().Set("Content-Disposition", "inline")
|
|
|
+ http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if previewTextExts[ext] {
|
|
|
+ if info.Size() > maxPreviewTextSize {
|
|
|
+ writeError(w, http.StatusRequestEntityTooLarge, "文件过大,不支持预览,请直接下载")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 统一按纯文本返回,避免 html/svg 等在服务同源下执行造成 XSS。
|
|
|
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
|
+ w.Header().Set("Content-Disposition", "inline")
|
|
|
+ http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ writeError(w, http.StatusUnsupportedMediaType, "该文件类型不支持预览,请下载后查看")
|
|
|
+}
|
|
|
+
|
|
|
+func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
|
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
+ w.WriteHeader(status)
|
|
|
+ _ = json.NewEncoder(w).Encode(v)
|
|
|
+}
|
|
|
+
|
|
|
+func writeError(w http.ResponseWriter, status int, msg string) {
|
|
|
+ writeJSON(w, status, map[string]any{"code": status, "message": msg})
|
|
|
+}
|