关习习 3 週間 前
コミット
1c7a56c59c
6 ファイル変更、606 行追加、0 行削除
  1. 5 0
      .gitignore
  2. 83 0
      README.md
  3. 65 0
      build.bat
  4. 3 0
      go.mod
  5. 364 0
      main.go
  6. 86 0
      token.go

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+*.exe
+.idea/
+.secret.key
+/share/
+/dist/

+ 83 - 0
README.md

@@ -0,0 +1,83 @@
+# data_send_service
+
+基于 Go 标准库的轻量文件传输服务,仅认领运行目录下的 `share/` 文件夹及其所有子文件夹,零第三方依赖。
+
+所有接口均为 **GET**,URL 中只传一个加密的 `key`,不暴露任何文件路径,且 key 由 base64url 字符(`A-Za-z0-9-_`)构成,**链接永远不需要做 URL 转义**,方便一键分享。
+
+## 运行
+
+```bash
+go run .                         # 默认监听 :8080
+go run . -addr :9000             # 自定义端口
+go run . -root D:/path/to/share  # 自定义共享目录
+go run . -secret ./my.key        # 自定义密钥文件位置
+```
+
+启动时若 `share/` 不存在会自动创建;密钥文件 `.secret.key` 不存在时会自动生成随机 AES-256 密钥。
+
+## 分享 key 机制
+
+- 每个文件/目录的相对路径经 **AES-256-GCM** 加密后生成 key,后端收到后解密回路径。
+- GCM 自带完整性校验:key 被篡改或不是本服务签发的,一律拒绝。
+- 随机 nonce:同一文件每次列目录得到的 key 字面不同,但全部有效。
+- 密钥持久化在服务端 `.secret.key`(已加入 .gitignore),**密钥不变则所有历史链接永久有效,服务重启不影响**;删除或更换该文件,旧链接立即全部失效(等价于全局吊销)。
+- key 只解决“定位文件”,下载时的文件名仍取自真实文件。
+
+## API
+
+### 1. GET /api/get_dict_detail
+
+获取目录下的文件和子目录列表。不传 `key` 表示根目录;进入子目录时传该目录条目的 `key`。若目录下存在 `reademe.md`(兼容 `readme.md`,大小写不敏感),其内容会放入 `readme` 字段。
+
+```
+GET /api/get_dict_detail
+GET /api/get_dict_detail?key=<目录key>
+```
+
+响应:
+
+```json
+{
+  "key": "",
+  "name": "share",
+  "readme": "# 共享根目录\n……",
+  "entries": [
+    {
+      "name": "文档资料",
+      "key": "KrL-2DN0fcoyrWQM4taObwQjXOWDx4S724XQIQQfBR_Nan8H0qCQ1A",
+      "type": "dir",
+      "size": 0,
+      "mod_time": "2026-10-10T18:39:16Z"
+    }
+  ]
+}
+```
+
+`type` 为 `file` 或 `dir`;目录在前、文件在后,各自按名称排序。
+
+### 2. GET /api/download
+
+下载单个文件,支持 Range/断点续传:
+
+```
+GET /api/download?key=<文件key>
+```
+
+不支持下载目录。
+
+### 3. GET /api/preview
+
+- **文本/代码类**(txt、md、json、go、py、js、yml 等,统一以 `text/plain; charset=utf-8` 返回,防止同源 XSS):大小上限 10 MiB。
+- **浏览器原生支持的类型**(png、jpg、gif、webp、svg、pdf 等):返回对应 MIME,可直接内联展示。
+- 其他类型返回 `415`,提示走下载。
+
+```
+GET /api/preview?key=<文件key>
+```
+
+## 安全说明
+
+- 路径经 key 加密对外不可见;解密后仍会做路径清洗和符号链接解析,拒绝 `..` 穿越、绝对路径,任何请求都无法逃逸出 `share/`。
+- HTML/SVG 等预览强制按纯文本返回,避免在服务同源下执行脚本。
+- `.secret.key` 是安全核心:持有它即可伪造任意文件链接,勿提交到代码仓库或外泄。
+- 错误统一返回 `{"code": <http状态码>, "message": "..."}`,不泄露解密细节。

+ 65 - 0
build.bat

@@ -0,0 +1,65 @@
+@echo off
+setlocal enabledelayedexpansion
+
+REM ============================================================
+REM Cross-compile script: Windows / Linux, x86 / ARM
+REM Binaries are output to dist\ (static, CGO_ENABLED=0)
+REM ============================================================
+
+set APPNAME=data_send_service
+set OUTDIR=dist
+
+REM Each target is "os-arch-GOARM"; use "none" when GOARM is unused.
+REM x86 32-bit=386, x86 64-bit=amd64, ARM 32-bit=arm(GOARM=7), ARM 64-bit=arm64
+set TARGETS=windows-amd64-none windows-386-none windows-arm64-none linux-amd64-none linux-386-none linux-arm64-none linux-arm-7
+
+if not exist %OUTDIR% mkdir %OUTDIR%
+
+set CGO_ENABLED=0
+
+echo ========================================
+echo Start cross-compiling %APPNAME%
+echo Output directory: %OUTDIR%
+echo ========================================
+
+for %%T in (%TARGETS%) do (
+    for /f "tokens=1,2,3 delims=-" %%a in ("%%T") do (
+        set "GOOS=%%a"
+        set "GOARCH=%%b"
+        set "EXTRA=%%c"
+
+        set "GOARM="
+        if not "!EXTRA!"=="none" set "GOARM=!EXTRA!"
+
+        if "!GOARCH!"=="arm" (
+            set "ARCHNAME=armv!GOARM!"
+        ) else (
+            set "ARCHNAME=!GOARCH!"
+        )
+
+        set "EXESUF="
+        if "!GOOS!"=="windows" set "EXESUF=.exe"
+
+        set "OUTFILE=%OUTDIR%\%APPNAME%_!GOOS!_!ARCHNAME!!EXESUF!"
+
+        echo.
+        echo [build] !GOOS! / !ARCHNAME! -^> !OUTFILE!
+        go build -trimpath -ldflags "-s -w" -o "!OUTFILE!" .
+        if errorlevel 1 (
+            echo [error] build failed: !GOOS! / !ARCHNAME!
+            goto :fail
+        )
+    )
+)
+
+echo.
+echo ========================================
+echo All builds succeeded. Artifacts:
+echo ========================================
+dir /b %OUTDIR%
+goto :eof
+
+:fail
+echo.
+echo Build aborted due to errors.
+exit /b 1

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module data_send_service
+
+go 1.24

+ 364 - 0
main.go

@@ -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})
+}

+ 86 - 0
token.go

@@ -0,0 +1,86 @@
+package main
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"os"
+)
+
+// tokenCodec 使用 AES-256-GCM 把文件相对路径加密成不可读、不可篡改的分享 token。
+type tokenCodec struct {
+	gcm cipher.AEAD
+}
+
+func newTokenCodec(key []byte) (*tokenCodec, error) {
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return nil, err
+	}
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+	return &tokenCodec{gcm: gcm}, nil
+}
+
+// Seal 加密明文(相对路径),返回 base64url(无填充)字符串。
+// 每次使用随机 nonce,因此同一明文的 token 每次不同,但均可被 Open 解密。
+func (c *tokenCodec) Seal(plaintext string) string {
+	nonce := make([]byte, c.gcm.NonceSize())
+	if _, err := rand.Read(nonce); err != nil {
+		panic(err) // crypto/rand 失败属于系统级异常
+	}
+	ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+	return base64.RawURLEncoding.EncodeToString(ciphertext)
+}
+
+// Open 解密并校验 token。token 被篡改或非本服务签发时返回错误。
+func (c *tokenCodec) Open(token string) (string, error) {
+	raw, err := base64.RawURLEncoding.DecodeString(token)
+	if err != nil {
+		return "", errors.New("token 编码非法")
+	}
+	ns := c.gcm.NonceSize()
+	if len(raw) < ns {
+		return "", errors.New("token 长度非法")
+	}
+	nonce, ciphertext := raw[:ns], raw[ns:]
+	plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
+	if err != nil {
+		return "", errors.New("token 校验失败")
+	}
+	return string(plaintext), nil
+}
+
+// loadOrCreateSecret 读取 32 字节密钥;不存在则随机生成并持久化(base64 编码)。
+// 密钥文件权限尽量收紧为 0600(Windows 上仅能尽力而为)。
+func loadOrCreateSecret(path string) ([]byte, error) {
+	if data, err := os.ReadFile(path); err == nil {
+		key := make([]byte, 32)
+		n, err := base64.StdEncoding.Decode(key, data)
+		if err == nil && n == 32 {
+			return key, nil
+		}
+		// 兼容直接存放的原始 32 字节
+		if len(data) == 32 {
+			return data, nil
+		}
+		return nil, fmt.Errorf("密钥文件格式无效: %s", path)
+	} else if !os.IsNotExist(err) {
+		return nil, err
+	}
+
+	key := make([]byte, 32)
+	if _, err := rand.Read(key); err != nil {
+		return nil, err
+	}
+	encoded := []byte(base64.StdEncoding.EncodeToString(key))
+	if err := os.WriteFile(path, encoded, 0o600); err != nil {
+		return nil, err
+	}
+	return key, nil
+}