main.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. package main
  2. import (
  3. "encoding/json"
  4. "flag"
  5. "fmt"
  6. "log"
  7. "mime"
  8. "net/http"
  9. "os"
  10. "path/filepath"
  11. "sort"
  12. "strings"
  13. "time"
  14. )
  15. // 说明文件候选名(小写)。需求中文件名为 reademe.md,同时兼容常见的 readme.md。
  16. var readmeCandidates = []string{"reademe.md", "readme.md"}
  17. // 允许预览的文本/代码类扩展名:以纯文本返回,避免 HTML 等同源 XSS。
  18. var previewTextExts = map[string]bool{
  19. ".txt": true, ".md": true, ".markdown": true, ".log": true, ".csv": true,
  20. ".json": true, ".xml": true, ".yml": true, ".yaml": true, ".toml": true,
  21. ".ini": true, ".conf": true, ".cfg": true, ".sql": true, ".env": true,
  22. ".html": true, ".htm": true, ".css": true, ".js": true, ".ts": true,
  23. ".jsx": true, ".tsx": true, ".vue": true, ".go": true, ".py": true,
  24. ".java": true, ".c": true, ".h": true, ".cpp": true, ".hpp": true,
  25. ".cc": true, ".cs": true, ".rb": true, ".rs": true, ".php": true,
  26. ".sh": true, ".bash": true, ".bat": true, ".ps1": true, ".lua": true,
  27. ".kt": true, ".swift": true, ".scala": true, ".r": true, ".dart": true,
  28. ".gradle": true, ".gitignore": true,
  29. }
  30. // 浏览器原生支持内联展示的类型。
  31. var previewInlineExts = map[string]string{
  32. ".png": "image/png",
  33. ".jpg": "image/jpeg",
  34. ".jpeg": "image/jpeg",
  35. ".gif": "image/gif",
  36. ".webp": "image/webp",
  37. ".bmp": "image/bmp",
  38. ".ico": "image/x-icon",
  39. ".svg": "image/svg+xml",
  40. ".pdf": "application/pdf",
  41. }
  42. // 预览文本文件的大小上限(10 MiB),防止把超大文件整体读入/传输。
  43. const maxPreviewTextSize = 10 * 1024 * 1024
  44. type server struct {
  45. root string // share 目录的绝对路径
  46. codec *tokenCodec // 路径 <-> 分享 key 的加解密器
  47. }
  48. type entry struct {
  49. Name string `json:"name"`
  50. Key string `json:"key"` // 该条目的分享 key,传给任意接口即可定位
  51. Type string `json:"type"` // file | dir
  52. Size int64 `json:"size"`
  53. ModTime time.Time `json:"mod_time"`
  54. }
  55. type dirDetail struct {
  56. Key string `json:"key"` // 当前目录的 key;根目录为空串(不传 key 即根目录)
  57. Name string `json:"name"`
  58. Readme *string `json:"readme"` // 目录说明,无说明文件时为 null
  59. Entries []entry `json:"entries"`
  60. }
  61. func main() {
  62. addr := flag.String("addr", ":8080", "监听地址")
  63. root := flag.String("root", "./share", "共享目录(仅认领该目录及其子目录)")
  64. secretPath := flag.String("secret", "./.secret.key", "分享 key 的加密密钥文件(不存在则自动生成)")
  65. flag.Parse()
  66. absRoot, err := filepath.Abs(*root)
  67. if err != nil {
  68. log.Fatalf("解析共享目录失败: %v", err)
  69. }
  70. if err := os.MkdirAll(absRoot, 0o755); err != nil {
  71. log.Fatalf("创建共享目录失败: %v", err)
  72. }
  73. secret, err := loadOrCreateSecret(filepath.Clean(*secretPath))
  74. if err != nil {
  75. log.Fatalf("加载加密密钥失败: %v", err)
  76. }
  77. codec, err := newTokenCodec(secret)
  78. if err != nil {
  79. log.Fatalf("初始化加解密器失败: %v", err)
  80. }
  81. s := &server{root: absRoot, codec: codec}
  82. mux := http.NewServeMux()
  83. mux.HandleFunc("/api/get_dict_detail", s.handleDirDetail)
  84. mux.HandleFunc("/api/download", s.handleDownload)
  85. mux.HandleFunc("/api/preview", s.handlePreview)
  86. log.Printf("文件传输服务已启动: http://localhost%s", strings.TrimPrefix(*addr, ":"))
  87. log.Printf("共享目录: %s", absRoot)
  88. log.Printf("接口: /api/get_dict_detail | /api/download | /api/preview")
  89. if err := http.ListenAndServe(*addr, logRequests(mux)); err != nil {
  90. log.Fatal(err)
  91. }
  92. }
  93. func logRequests(next http.Handler) http.Handler {
  94. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  95. next.ServeHTTP(w, r)
  96. log.Printf("%s %s", r.Method, r.URL.RequestURI())
  97. })
  98. }
  99. // resolve 把接口传入的相对路径限定在 share 根目录内,防止路径穿越。
  100. func (s *server) resolve(rel string) (string, error) {
  101. rel = filepath.FromSlash(strings.TrimSpace(rel))
  102. if filepath.IsAbs(rel) || rel == "." {
  103. if rel == "." {
  104. rel = ""
  105. } else {
  106. return "", fmt.Errorf("非法路径")
  107. }
  108. }
  109. cleaned := filepath.Clean(rel)
  110. if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
  111. return "", fmt.Errorf("非法路径")
  112. }
  113. target := filepath.Join(s.root, cleaned)
  114. // 解析符号链接后再校验一次,确保仍在根目录内。
  115. realRoot, err := filepath.EvalSymlinks(s.root)
  116. if err != nil {
  117. return "", err
  118. }
  119. realTarget, err := filepath.EvalSymlinks(target)
  120. if err != nil {
  121. if os.IsNotExist(err) {
  122. return target, nil // 不存在的路径交由调用方通过 os.Stat 返回 404
  123. }
  124. return "", err
  125. }
  126. relToRoot, err := filepath.Rel(realRoot, realTarget)
  127. if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
  128. return "", fmt.Errorf("非法路径")
  129. }
  130. return target, nil
  131. }
  132. // toSlashRel 返回相对 share 根的 slash 风格路径。
  133. func (s *server) toSlashRel(abs string) string {
  134. rel, err := filepath.Rel(s.root, abs)
  135. if err != nil {
  136. return ""
  137. }
  138. return filepath.ToSlash(rel)
  139. }
  140. // resolveKey 解密分享 key 并把解密出的路径限定在 share 根目录内。
  141. // allowEmpty 为 true 时(目录浏览),空 key 表示根目录。
  142. func (s *server) resolveKey(key string, allowEmpty bool) (string, error) {
  143. if key == "" {
  144. if allowEmpty {
  145. return s.resolve("")
  146. }
  147. return "", fmt.Errorf("缺少 key")
  148. }
  149. rel, err := s.codec.Open(strings.TrimSpace(key))
  150. if err != nil {
  151. // 不向前端泄露具体的解密/篡改细节
  152. return "", fmt.Errorf("无效的分享链接")
  153. }
  154. return s.resolve(rel)
  155. }
  156. func (s *server) handleDirDetail(w http.ResponseWriter, r *http.Request) {
  157. if r.Method != http.MethodGet {
  158. writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
  159. return
  160. }
  161. dir, err := s.resolveKey(r.URL.Query().Get("key"), true)
  162. if err != nil {
  163. writeError(w, http.StatusBadRequest, err.Error())
  164. return
  165. }
  166. info, err := os.Stat(dir)
  167. if err != nil {
  168. writeError(w, http.StatusNotFound, "目录不存在")
  169. return
  170. }
  171. if !info.IsDir() {
  172. writeError(w, http.StatusBadRequest, "目标不是目录")
  173. return
  174. }
  175. items, err := os.ReadDir(dir)
  176. if err != nil {
  177. writeError(w, http.StatusInternalServerError, "读取目录失败: "+err.Error())
  178. return
  179. }
  180. detail := dirDetail{
  181. Name: info.Name(),
  182. Entries: make([]entry, 0, len(items)),
  183. }
  184. // 当前目录的相对路径;根目录的 key 留空(不传 key 即可访问根)。
  185. curRel := s.toSlashRel(dir)
  186. if curRel == "." || curRel == "" {
  187. curRel = ""
  188. detail.Name = "share"
  189. } else {
  190. detail.Key = s.codec.Seal(curRel)
  191. }
  192. for _, item := range items {
  193. full := filepath.Join(dir, item.Name())
  194. fi, err := item.Info()
  195. if err != nil {
  196. continue
  197. }
  198. t := "file"
  199. if item.IsDir() {
  200. t = "dir"
  201. }
  202. detail.Entries = append(detail.Entries, entry{
  203. Name: item.Name(),
  204. Key: s.codec.Seal(s.toSlashRel(full)),
  205. Type: t,
  206. Size: fi.Size(),
  207. ModTime: fi.ModTime(),
  208. })
  209. }
  210. // 目录在前、文件在后,各自按名称排序。
  211. sort.Slice(detail.Entries, func(i, j int) bool {
  212. a, b := detail.Entries[i], detail.Entries[j]
  213. if (a.Type == "dir") != (b.Type == "dir") {
  214. return a.Type == "dir"
  215. }
  216. return strings.ToLower(a.Name) < strings.ToLower(b.Name)
  217. })
  218. if readmePath := findReadme(dir, items); readmePath != "" {
  219. content, err := os.ReadFile(readmePath)
  220. if err == nil {
  221. text := string(content)
  222. detail.Readme = &text
  223. }
  224. }
  225. writeJSON(w, http.StatusOK, detail)
  226. }
  227. // findReadme 在目录条目里查找说明文件,大小写不敏感,优先 reademe.md。
  228. func findReadme(dir string, items []os.DirEntry) string {
  229. lowerNames := make(map[string]string)
  230. for _, item := range items {
  231. if !item.IsDir() {
  232. lowerNames[strings.ToLower(item.Name())] = item.Name()
  233. }
  234. }
  235. for _, candidate := range readmeCandidates {
  236. if name, ok := lowerNames[candidate]; ok {
  237. return filepath.Join(dir, name)
  238. }
  239. }
  240. return ""
  241. }
  242. func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
  243. if r.Method != http.MethodGet {
  244. writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
  245. return
  246. }
  247. file, err := s.resolveKey(r.URL.Query().Get("key"), false)
  248. if err != nil {
  249. writeError(w, http.StatusBadRequest, err.Error())
  250. return
  251. }
  252. info, err := os.Stat(file)
  253. if err != nil {
  254. writeError(w, http.StatusNotFound, "文件不存在")
  255. return
  256. }
  257. if info.IsDir() {
  258. writeError(w, http.StatusBadRequest, "不支持下载目录,仅可下载单个文件")
  259. return
  260. }
  261. f, err := os.Open(file)
  262. cd := mime.FormatMediaType("attachment", map[string]string{"filename": info.Name()})
  263. w.Header().Set("Content-Disposition", cd)
  264. http.ServeContent(w, r, info.Name(), info.ModTime(), f)
  265. }
  266. func (s *server) handlePreview(w http.ResponseWriter, r *http.Request) {
  267. if r.Method != http.MethodGet {
  268. writeError(w, http.StatusMethodNotAllowed, "仅支持 GET")
  269. return
  270. }
  271. file, err := s.resolveKey(r.URL.Query().Get("key"), false)
  272. if err != nil {
  273. writeError(w, http.StatusBadRequest, err.Error())
  274. return
  275. }
  276. info, err := os.Stat(file)
  277. if err != nil {
  278. writeError(w, http.StatusNotFound, "文件不存在")
  279. return
  280. }
  281. if info.IsDir() {
  282. writeError(w, http.StatusBadRequest, "目录不支持预览")
  283. return
  284. }
  285. ext := strings.ToLower(filepath.Ext(file))
  286. f, err := os.Open(file)
  287. if err != nil {
  288. writeError(w, http.StatusInternalServerError, "打开文件失败")
  289. return
  290. }
  291. defer f.Close()
  292. if contentType, ok := previewInlineExts[ext]; ok {
  293. w.Header().Set("Content-Type", contentType)
  294. w.Header().Set("Content-Disposition", "inline")
  295. http.ServeContent(w, r, info.Name(), info.ModTime(), f)
  296. return
  297. }
  298. if previewTextExts[ext] {
  299. if info.Size() > maxPreviewTextSize {
  300. writeError(w, http.StatusRequestEntityTooLarge, "文件过大,不支持预览,请直接下载")
  301. return
  302. }
  303. // 统一按纯文本返回,避免 html/svg 等在服务同源下执行造成 XSS。
  304. w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  305. w.Header().Set("Content-Disposition", "inline")
  306. http.ServeContent(w, r, info.Name(), info.ModTime(), f)
  307. return
  308. }
  309. writeError(w, http.StatusUnsupportedMediaType, "该文件类型不支持预览,请下载后查看")
  310. }
  311. func writeJSON(w http.ResponseWriter, status int, v any) {
  312. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  313. w.WriteHeader(status)
  314. _ = json.NewEncoder(w).Encode(v)
  315. }
  316. func writeError(w http.ResponseWriter, status int, msg string) {
  317. writeJSON(w, status, map[string]any{"code": status, "message": msg})
  318. }