apiserver.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. // ============================================================================
  2. // apiserver.go — 内置 API 服务
  3. //
  4. // 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、
  5. // 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。
  6. // ============================================================================
  7. package main
  8. import (
  9. "archive/zip"
  10. "context"
  11. "crypto/rand"
  12. "crypto/subtle"
  13. "encoding/base64"
  14. "encoding/binary"
  15. "fmt"
  16. "io"
  17. "log"
  18. "net/http"
  19. "net/url"
  20. "os"
  21. "path/filepath"
  22. "strconv"
  23. "strings"
  24. "sync"
  25. "time"
  26. "github.com/gen2brain/beeep" // 跨平台系统通知,Windows 下为托盘气泡/Toast
  27. "github.com/gin-gonic/gin"
  28. )
  29. // notifyRequest 通知接口的请求体
  30. type notifyRequest struct {
  31. Title string `json:"title"` // 通知标题,为空时使用应用名
  32. Message string `json:"message"` // 通知正文
  33. }
  34. // mouseAlertRequest 鼠标提醒接口的请求体
  35. type mouseAlertRequest struct {
  36. Text string `json:"text"` // 提示文字,为空时使用默认文案
  37. }
  38. // ------------------------------------------------------------------------
  39. // Gin 日志环形缓冲:日志同时写控制台与内存缓冲,供「主机」页实时查看
  40. // ------------------------------------------------------------------------
  41. const apiLogCapacity = 300 // 环形缓冲最多保留的日志行数
  42. // logRing 固定容量的日志环形缓冲,实现 io.Writer
  43. type logRing struct {
  44. mu sync.Mutex
  45. lines []string
  46. }
  47. // Write 按行拆分写入缓冲,并向所有前端窗口广播 "apilog" 事件
  48. func (r *logRing) Write(p []byte) (int, error) {
  49. text := strings.TrimRight(string(p), "\r\n")
  50. r.mu.Lock()
  51. for _, line := range strings.Split(text, "\n") {
  52. if strings.TrimSpace(line) == "" {
  53. continue
  54. }
  55. r.lines = append(r.lines, line)
  56. if len(r.lines) > apiLogCapacity {
  57. r.lines = r.lines[len(r.lines)-apiLogCapacity:] // 丢弃最旧的行
  58. }
  59. if app != nil {
  60. app.Event.Emit("apilog", line) // 实时推送到「主机」页
  61. }
  62. }
  63. r.mu.Unlock()
  64. return len(p), nil
  65. }
  66. // Snapshot 返回当前缓冲的全部日志行
  67. func (r *logRing) Snapshot() []string {
  68. r.mu.Lock()
  69. defer r.mu.Unlock()
  70. out := make([]string, len(r.lines))
  71. copy(out, r.lines)
  72. return out
  73. }
  74. // Clear 清空日志缓冲
  75. func (r *logRing) Clear() {
  76. r.mu.Lock()
  77. r.lines = nil
  78. r.mu.Unlock()
  79. }
  80. var apiLogRing = &logRing{}
  81. // ------------------------------------------------------------------------
  82. // API 服务生命周期:地址变化时平滑重启(仅监听配置的地址)
  83. // ------------------------------------------------------------------------
  84. var (
  85. apiMu sync.Mutex
  86. apiServer *http.Server
  87. apiAddr string // 当前监听地址
  88. apiStartedAt time.Time // 本次启动时间
  89. apiLastErr string // 最近一次启动 / 运行错误
  90. )
  91. // StopAPIServer 停止内置 API 服务(从服务页手动关闭)。
  92. // 停止后 apiServer 置 nil、apiAddr 保留,便于再次启动或恢复。
  93. func StopAPIServer() {
  94. apiMu.Lock()
  95. old := apiServer
  96. apiServer = nil
  97. apiAddr = ""
  98. apiLastErr = ""
  99. apiMu.Unlock()
  100. if old != nil {
  101. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  102. _ = old.Shutdown(ctx) // 平滑关闭,中断不复用连接
  103. cancel()
  104. }
  105. log.Printf("API server stopped by user")
  106. fmt.Fprintf(apiLogRing, "[SYS] API 服务已停止\n")
  107. }
  108. // StartAPIServer 启动 / 重启内置 API 服务(从服务页手动启动或恢复)。
  109. // 使用配置中的地址(为空时用默认地址)。
  110. func StartAPIServer() {
  111. ensureAPIServer(loadSettings().APIAddr)
  112. }
  113. // APIStatus API 服务运行状态,供「主机」页展示
  114. type APIStatus struct {
  115. Addr string `json:"addr"` // 当前监听地址
  116. Running bool `json:"running"` // 是否正在监听
  117. StartedAt string `json:"startedAt"` // 启动时间
  118. Error string `json:"error"` // 最近错误信息
  119. }
  120. // ensureAPIServer 确保 API 服务监听在指定地址;地址变化时先关闭旧服务再启动
  121. func ensureAPIServer(addr string) {
  122. addr = strings.TrimSpace(addr)
  123. if addr == "" {
  124. addr = DefaultAPIAddr
  125. }
  126. apiMu.Lock()
  127. if apiServer != nil && apiAddr == addr && apiLastErr == "" {
  128. apiMu.Unlock()
  129. return // 地址未变且运行正常,无需重启
  130. }
  131. old := apiServer
  132. apiServer = nil
  133. apiAddr = addr
  134. apiStartedAt = time.Now()
  135. apiLastErr = ""
  136. apiMu.Unlock()
  137. if old != nil {
  138. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  139. _ = old.Shutdown(ctx) // 平滑关闭旧服务,中断不复用连接
  140. cancel()
  141. }
  142. go runAPIServer(addr)
  143. }
  144. // runAPIServer 构建路由并启动监听(出错只记录状态,不影响应用本身)
  145. func runAPIServer(addr string) {
  146. gin.SetMode(gin.ReleaseMode) // 生产模式,减少日志噪音
  147. router := gin.New()
  148. router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
  149. Output: io.MultiWriter(os.Stdout, apiLogRing), // 日志同时写控制台与环形缓冲
  150. }))
  151. router.Use(gin.Recovery()) // 捕获 panic,避免接口崩溃导致进程退出
  152. router.Use(corsMiddleware()) // 允许跨域,便于前端或其他本机工具调用
  153. router.GET("/health", func(c *gin.Context) {
  154. c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
  155. })
  156. router.GET("/upload", handleUploadPage) // 客户端上传页(手机等设备浏览器访问)
  157. router.GET("/api/qr", handleQRImage) // 生成二维码图片(供客户端上传页/桌面端展示)
  158. api := router.Group("/api")
  159. api.POST("/notify", handleNotify) // 弹出系统托盘通知
  160. api.POST("/window/show", handleShowWindow) // 显示主窗口
  161. api.POST("/window/compact", handleWindowCompact) // 窗口收拢/还原(与双击手柄等效)
  162. api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字)
  163. api.POST("/handle/flash", handleHandleFlash) // 触发拖动手柄圆点闪烁(颜色+大小变化)
  164. api.GET("/settings", handleGetSettings) // 读取配置
  165. api.POST("/settings", handleSaveSettings) // 保存配置
  166. api.GET("/stocks", handleGetStocks) // 获取股票行情
  167. api.GET("/screenshot", handleScreenshot) // 截取屏幕,返回 PNG 数据 URI
  168. api.POST("/upload", handleUpload) // 上传文件(需密码)
  169. api.GET("/download/:filename", handleDownload) // 下载已上传文件(需密码)
  170. pc := api.Group("/pc") // 主机控制接口(移植自主机服务)
  171. pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
  172. pc.GET("/restart", handleRestart) // 30 秒后重启
  173. pc.GET("/sleep", handleSleep) // 30 秒后休眠
  174. pc.GET("/lock", handleLock) // 立即锁屏
  175. pc.GET("/cancel", handleCancel) // 取消待执行命令
  176. pc.GET("/status", handlePCStatus) // 查询命令状态
  177. pc.GET("/timesync", handleTimeSync) // 同步系统时间
  178. server := &http.Server{Addr: addr, Handler: router}
  179. apiMu.Lock()
  180. apiServer = server
  181. apiMu.Unlock()
  182. log.Printf("API server listening on http://%s", addr)
  183. fmt.Fprintf(apiLogRing, "[SYS] API 服务已启动 http://%s\n", addr)
  184. if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  185. apiMu.Lock()
  186. apiLastErr = err.Error() // 端口被占用等错误只记录,不影响应用启动
  187. apiMu.Unlock()
  188. log.Printf("api server stopped: %v", err)
  189. fmt.Fprintf(apiLogRing, "[SYS] API 服务异常停止: %v\n", err)
  190. }
  191. }
  192. // getAPIStatus 返回当前 API 服务状态快照
  193. func getAPIStatus() APIStatus {
  194. apiMu.Lock()
  195. defer apiMu.Unlock()
  196. started := ""
  197. if !apiStartedAt.IsZero() {
  198. started = apiStartedAt.Format("01-02 15:04:05")
  199. }
  200. return APIStatus{
  201. Addr: apiAddr,
  202. Running: apiServer != nil,
  203. StartedAt: started,
  204. Error: apiLastErr,
  205. }
  206. }
  207. // corsMiddleware 简单放行跨域请求(服务默认仅监听 127.0.0.1,风险可控)
  208. func corsMiddleware() gin.HandlerFunc {
  209. return func(c *gin.Context) {
  210. c.Header("Access-Control-Allow-Origin", "*")
  211. c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  212. c.Header("Access-Control-Allow-Headers", "Content-Type")
  213. if c.Request.Method == http.MethodOptions {
  214. c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回
  215. return
  216. }
  217. c.Next()
  218. }
  219. }
  220. // handleNotify 通过系统通知弹出消息,例如:POST /api/notify {"message":"任务完成"}
  221. func handleNotify(c *gin.Context) {
  222. var req notifyRequest
  223. if err := c.ShouldBindJSON(&req); err != nil {
  224. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  225. return
  226. }
  227. if req.Title == "" {
  228. req.Title = "腾图工具箱" // 未指定标题时使用默认标题
  229. }
  230. if err := beeep.Notify(req.Title, req.Message, ""); err != nil {
  231. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  232. return
  233. }
  234. c.JSON(http.StatusOK, gin.H{"ok": true}) // 通知已发出
  235. }
  236. // handleMouseAlert 触发系统级鼠标提醒,例如:POST /api/mouse/alert {"text":"有新订单"}
  237. func handleMouseAlert(c *gin.Context) {
  238. var req mouseAlertRequest
  239. if err := c.ShouldBindJSON(&req); err != nil {
  240. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  241. return
  242. }
  243. triggerMouseAlert(req.Text) // 触发鼠标闪烁 + 光标下方透明文字
  244. c.JSON(http.StatusOK, gin.H{"ok": true})
  245. }
  246. // handleHandleFlash 触发拖动手柄圆点闪烁,例如:POST /api/handle/flash {"count":3}
  247. // count 为闪烁次数(可选,默认 3);前端收到 handleFlash 事件后播放颜色+大小变化动画。
  248. func handleHandleFlash(c *gin.Context) {
  249. var req struct {
  250. Count int `json:"count"`
  251. }
  252. // 允许空 body(默认 3 次闪烁)
  253. _ = c.ShouldBindJSON(&req)
  254. (&SettingsService{}).HandleFlash(req.Count)
  255. c.JSON(http.StatusOK, gin.H{"ok": true})
  256. }
  257. // handleShowWindow 显示并聚焦主窗口(从托盘恢复)
  258. func handleShowWindow(c *gin.Context) {
  259. showMainWindow()
  260. c.JSON(http.StatusOK, gin.H{"ok": true})
  261. }
  262. // handleWindowCompact 窗口收拢/还原(与双击手柄等效)。
  263. // 返回当前是否处于收拢(仅手柄)状态。
  264. func handleWindowCompact(c *gin.Context) {
  265. isCompact := (&SettingsService{}).ToggleWindowCompact()
  266. c.JSON(http.StatusOK, gin.H{"ok": true, "compact": isCompact})
  267. }
  268. // handleGetSettings 返回当前配置
  269. func handleGetSettings(c *gin.Context) {
  270. c.JSON(http.StatusOK, loadSettings())
  271. }
  272. // handleSaveSettings 保存配置并立即生效
  273. func handleSaveSettings(c *gin.Context) {
  274. var settings AppSettings
  275. if err := c.ShouldBindJSON(&settings); err != nil {
  276. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  277. return
  278. }
  279. settings = normalizeSettings(settings)
  280. settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
  281. if err := saveSettings(settings); err != nil {
  282. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  283. return
  284. }
  285. applySettings(settings)
  286. c.JSON(http.StatusOK, settings) // 返回校正后的配置
  287. }
  288. // handleGetStocks 获取股票行情;支持 ?codes=sh600519,sz000001 临时指定代码
  289. func handleGetStocks(c *gin.Context) {
  290. codes := loadSettings().StockCodes
  291. if query := c.Query("codes"); query != "" {
  292. codes = nil
  293. for _, raw := range strings.Split(query, ",") {
  294. if code := normalizeStockCode(raw); code != "" {
  295. codes = append(codes, code)
  296. }
  297. }
  298. }
  299. quotes, err := fetchStockQuotes(codes)
  300. if err != nil {
  301. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  302. return
  303. }
  304. c.JSON(http.StatusOK, gin.H{"codes": codes, "quotes": quotes})
  305. }
  306. // handleScreenshot 截取屏幕并返回 PNG 数据 URI。
  307. // 支持 ?display=N 指定显示器(0 起);缺省或 -1 截取全部屏幕。
  308. func handleScreenshot(c *gin.Context) {
  309. idx := -1
  310. if v := c.Query("display"); v != "" {
  311. if n, err := strconv.Atoi(v); err == nil {
  312. idx = n
  313. }
  314. }
  315. dataURI, err := captureScreen(idx)
  316. if err != nil {
  317. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  318. return
  319. }
  320. c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
  321. }
  322. // ------------------------------------------------------------------------
  323. // 文件上传 / 下载(需密码)
  324. //
  325. // 上传:POST /api/upload 表单字段 password(密码)、file(文件)。
  326. // 密码正确才保存到 uploadDir 目录;返回文件 URL 供二维码展示。
  327. // 下载:GET /api/download/:filename?password=xxx 校验密码后返回文件内容。
  328. // ------------------------------------------------------------------------
  329. // uploadDirPath 返回上传保存目录:配置为空时回退到用户配置目录下 uploads/。
  330. func uploadDirPath() string {
  331. dir := strings.TrimSpace(loadSettings().UploadDir)
  332. if dir != "" {
  333. return dir
  334. }
  335. cfg, err := os.UserConfigDir()
  336. if err != nil {
  337. return "." // 兜底:当前目录
  338. }
  339. return filepath.Join(cfg, "TengTuToolbox", "uploads")
  340. }
  341. // checkUploadPassword 校验上传密码(常数时间比较,防时序侧信道)。
  342. // 未设置密码时允许访问(与「空密码=不启用」语义一致)。
  343. func checkUploadPassword(pwd string) bool {
  344. expected := loadSettings().UploadPassword
  345. if strings.TrimSpace(expected) == "" {
  346. return true // 未设置密码:默认放行(用户未启用该功能)
  347. }
  348. return subtle.ConstantTimeCompare([]byte(pwd), []byte(expected)) == 1
  349. }
  350. // handleUpload 处理文件上传:密码正确才落盘,返回下载 URL 与文件信息。
  351. func handleUpload(c *gin.Context) {
  352. // 1) 校验密码(表单字段 password)
  353. pwd := c.PostForm("password")
  354. if !checkUploadPassword(pwd) {
  355. c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
  356. return
  357. }
  358. // 2) 读取上传文件
  359. fileHeader, err := c.FormFile("file")
  360. if err != nil {
  361. c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件字段 file"})
  362. return
  363. }
  364. if fileHeader.Size <= 0 {
  365. c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
  366. return
  367. }
  368. // 3) 校验文件大小:上限由配置「上传大小上限」决定(默认 512MB)
  369. maxMB := loadSettings().UploadMaxSize
  370. if maxMB <= 0 {
  371. maxMB = 512
  372. }
  373. maxUploadSize := int64(maxMB) << 20 // MB -> 字节
  374. if fileHeader.Size > maxUploadSize {
  375. c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("文件过大,上限 %dMB", maxMB)})
  376. return
  377. }
  378. // 4) 构造保存路径:仅用文件名(去路径),避免路径穿越
  379. dir := uploadDirPath()
  380. if err := os.MkdirAll(dir, 0o755); err != nil {
  381. c.JSON(http.StatusInternalServerError, gin.H{"error": "无法创建上传目录: " + err.Error()})
  382. return
  383. }
  384. origBase := filepath.Base(strings.ReplaceAll(fileHeader.Filename, "\\", "/"))
  385. if origBase == "." || origBase == "" {
  386. origBase = "upload.bin"
  387. }
  388. // 5) 落盘:存储文件名用「时间戳+随机数+原扩展名」,不可推测且天然唯一,避免覆盖与遍历。
  389. // zip 内条目保留原文件名,下载者解压后仍能看到原始文件名。
  390. storedName := newStoredName(origBase)
  391. final := filepath.Join(dir, storedName)
  392. if err := c.SaveUploadedFile(fileHeader, final); err != nil {
  393. c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败: " + err.Error()})
  394. return
  395. }
  396. // 6) 安全处理:把上传的原文件压缩为 zip(zip 内保留原文件名,文件名同样用时间戳+随机数),
  397. // 压缩成功后删除原文件,之后仅提供 zip 下载,避免可执行文件等被直接下载。
  398. zipName := newStoredName(origBase + ".zip")
  399. zipPath := filepath.Join(dir, zipName)
  400. if err := zipFile(final, zipPath, origBase); err != nil {
  401. // 压缩失败:删除原文件并报错,避免遗留未压缩文件
  402. os.Remove(final)
  403. c.JSON(http.StatusInternalServerError, gin.H{"error": "压缩文件失败: " + err.Error()})
  404. return
  405. }
  406. os.Remove(final) // 压缩成功,删除原文件
  407. // 7) 返回下载 URL(指向 zip,相对路径,前端拼完整地址)与文件信息
  408. savedName := filepath.Base(zipPath)
  409. downloadURL := "/api/download/" + url.PathEscape(savedName)
  410. fmt.Fprintf(apiLogRing, "[SYS] 文件已上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, fileHeader.Size)
  411. c.JSON(http.StatusOK, gin.H{
  412. "ok": true,
  413. "filename": origBase,
  414. "size": fileHeader.Size,
  415. "downloadUrl": downloadURL,
  416. })
  417. }
  418. // newStoredName 生成存储文件名:时间戳_6位随机数+原扩展名。
  419. // 扩展名做白名单过滤(仅保留字母数字,最长 10),防止可疑后缀。
  420. func newStoredName(orig string) string {
  421. ext := filepath.Ext(orig)
  422. ext = strings.Map(func(r rune) rune {
  423. if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' {
  424. return r
  425. }
  426. return -1
  427. }, ext)
  428. if len(ext) > 10 {
  429. ext = ext[:10]
  430. }
  431. ts := time.Now().Format("20060102_150405")
  432. rnd := fmt.Sprintf("%06d", cryptoRandInt(1000000))
  433. return fmt.Sprintf("%s_%s%s", ts, rnd, ext)
  434. }
  435. // cryptoRandInt 返回 [0, n) 的随机整数(crypto/rand),失败时回退 time 纳秒。
  436. func cryptoRandInt(n int) int {
  437. var b [8]byte
  438. if _, err := rand.Read(b[:]); err != nil {
  439. return int(time.Now().UnixNano() % int64(n))
  440. }
  441. v := int64(binary.BigEndian.Uint64(b[:]))
  442. if v < 0 {
  443. v = -v
  444. }
  445. return int(v % int64(n))
  446. }
  447. // zipFile 把 src 文件压缩为 dst zip,zip 内条目名用 name(一般取原文件名)。
  448. func zipFile(src, dst, name string) error {
  449. in, err := os.Open(src)
  450. if err != nil {
  451. return err
  452. }
  453. defer in.Close()
  454. out, err := os.Create(dst)
  455. if err != nil {
  456. return err
  457. }
  458. defer out.Close()
  459. zw := zip.NewWriter(out)
  460. defer zw.Close()
  461. w, err := zw.Create(name)
  462. if err != nil {
  463. return err
  464. }
  465. _, err = io.Copy(w, in)
  466. return err
  467. }
  468. // handleDownload 处理文件下载:密码正确才返回文件内容。
  469. func handleDownload(c *gin.Context) {
  470. if !checkUploadPassword(c.Query("password")) {
  471. c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
  472. return
  473. }
  474. name := filepath.Base(strings.ReplaceAll(c.Param("filename"), "\\", "/"))
  475. if name == "." || name == "" {
  476. c.JSON(http.StatusBadRequest, gin.H{"error": "文件名无效"})
  477. return
  478. }
  479. path := filepath.Join(uploadDirPath(), name)
  480. if _, err := os.Stat(path); err != nil {
  481. c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
  482. return
  483. }
  484. c.FileAttachment(path, name)
  485. }
  486. // ------------------------------------------------------------------------
  487. // 客户端上传页:手机/其他设备在浏览器中访问 http://<服务地址>/upload,
  488. // 输入密码并选择文件即可上传到本机,上传成功显示下载二维码。
  489. // ------------------------------------------------------------------------
  490. const clientUploadHTML = `<!DOCTYPE html>
  491. <html lang="zh-CN">
  492. <head>
  493. <meta charset="UTF-8">
  494. <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  495. <title>腾图工具箱 · 文件上传</title>
  496. <style>
  497. * { box-sizing: border-box; margin: 0; padding: 0; }
  498. body {
  499. min-height: 100vh; display: flex; align-items: center; justify-content: center;
  500. font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
  501. background: linear-gradient(160deg, #0b0e1d 0%, #1a1030 100%); color: #e9ecf5; padding: 20px;
  502. }
  503. .card {
  504. width: 100%; max-width: 400px; background: rgba(255,255,255,0.05);
  505. border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 26px 24px;
  506. box-shadow: 0 10px 40px rgba(0,0,0,0.45); backdrop-filter: blur(6px);
  507. }
  508. h1 { font-size: 20px; text-align: center; margin-bottom: 6px; }
  509. .sub { text-align: center; color: #8b93a7; font-size: 13px; margin-bottom: 20px; }
  510. label { display: block; font-size: 13px; color: #b8bfd0; margin: 14px 0 6px; }
  511. input[type=password], input[type=file] {
  512. width: 100%; padding: 11px 12px; border-radius: 10px;
  513. border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06);
  514. color: #e9ecf5; font-size: 15px;
  515. }
  516. input[type=file] { color: #8b93a7; padding: 8px; }
  517. button {
  518. width: 100%; margin-top: 20px; padding: 12px; border: 0; border-radius: 10px;
  519. background: linear-gradient(135deg, #ff5a4d, #ff2d72); color: #fff; font-size: 16px; font-weight: 600;
  520. cursor: pointer;
  521. }
  522. button:disabled { opacity: 0.6; cursor: default; }
  523. .progress { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.12); margin-top: 16px; overflow: hidden; display: none; }
  524. .progress .bar { height: 100%; width: 0; background: linear-gradient(90deg, #ff5a4d, #ff2d72); transition: width .15s; }
  525. .result { margin-top: 18px; text-align: center; display: none; }
  526. .result img { width: 200px; height: 200px; border-radius: 12px; background: #fff; padding: 8px; margin-top: 14px; }
  527. .result .fname { margin-top: 10px; font-size: 14px; word-break: break-all; }
  528. .result .dllink {
  529. display: inline-block; margin-top: 8px; padding: 9px 18px; border-radius: 8px;
  530. background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.18);
  531. color: #4f8cff; font-size: 14px; text-decoration: none; word-break: break-all; max-width: 100%;
  532. }
  533. .result .dllink:hover { background: rgba(255,255,255,0.16); }
  534. .msg { margin-top: 12px; font-size: 13px; text-align: center; color: #ff9a9a; min-height: 18px; }
  535. </style>
  536. </head>
  537. <body>
  538. <div class="card">
  539. <h1>文件上传</h1>
  540. <div class="sub">腾图工具箱 · 客户端上传</div>
  541. <label>上传密码</label>
  542. <input type="password" id="pwd" placeholder="请输入上传密码" autocomplete="off">
  543. <label>选择文件</label>
  544. <input type="file" id="file">
  545. <button id="btn">上传</button>
  546. <div class="progress"><div class="bar" id="bar"></div></div>
  547. <div class="msg" id="msg"></div>
  548. <div class="result" id="result">
  549. <div class="fname" id="fname"></div>
  550. <a class="dllink" id="dllink" href="#" target="_blank" rel="noopener"></a>
  551. <img id="qr" alt="下载二维码">
  552. </div>
  553. </div>
  554. <script>
  555. var pwd = document.getElementById('pwd');
  556. var file = document.getElementById('file');
  557. var btn = document.getElementById('btn');
  558. var bar = document.getElementById('bar');
  559. var msg = document.getElementById('msg');
  560. var result = document.getElementById('result');
  561. var qr = document.getElementById('qr');
  562. var fname = document.getElementById('fname');
  563. var dllink = document.getElementById('dllink');
  564. function showMsg(t, err) { msg.textContent = t; msg.style.color = err ? '#ff9a9a' : '#3ddc84'; }
  565. btn.addEventListener('click', function () {
  566. var f = file.files && file.files[0];
  567. if (!f) { showMsg('请先选择文件', true); return; }
  568. btn.disabled = true; btn.textContent = '上传中…';
  569. result.style.display = 'none'; showMsg('', false);
  570. var progress = document.querySelector('.progress'); progress.style.display = 'block'; bar.style.width = '0%';
  571. var fd = new FormData(); fd.append('password', pwd.value.trim()); fd.append('file', f);
  572. var xhr = new XMLHttpRequest();
  573. xhr.open('POST', '/api/upload');
  574. xhr.upload.onprogress = function (e) { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
  575. xhr.onload = function () {
  576. btn.disabled = false; btn.textContent = '上传';
  577. var data = {};
  578. try { data = JSON.parse(xhr.responseText); } catch (_) {}
  579. if (xhr.status >= 200 && xhr.status < 300 && data.ok) {
  580. var dl = '/api/download/' + encodeURIComponent(data.filename) + '?password=' + encodeURIComponent(pwd.value.trim());
  581. var full = location.origin + dl;
  582. fname.textContent = data.filename + '(' + data.size + ' 字节)';
  583. dllink.href = full;
  584. dllink.textContent = full; // 下载地址
  585. qr.src = '/api/qr?text=' + encodeURIComponent(full) + '&size=256';
  586. result.style.display = 'block';
  587. showMsg('上传成功', false);
  588. } else {
  589. showMsg(data.error || ('上传失败(HTTP ' + xhr.status + ')'), true);
  590. }
  591. };
  592. xhr.onerror = function () { btn.disabled = false; btn.textContent = '上传'; showMsg('网络错误,上传失败', true); };
  593. xhr.send(fd);
  594. });
  595. </script>
  596. </body>
  597. </html>
  598. `
  599. // handleUploadPage 返回客户端上传页 HTML(供手机等设备访问)
  600. func handleUploadPage(c *gin.Context) {
  601. c.Header("Content-Type", "text/html; charset=utf-8")
  602. c.String(http.StatusOK, clientUploadHTML)
  603. }
  604. // handleQRImage 生成二维码图片:GET /api/qr?text=xxx&size=300
  605. // 返回真实 PNG 图片字节(供客户端上传页 <img src> 直接加载)。
  606. func handleQRImage(c *gin.Context) {
  607. text := c.Query("text")
  608. if text == "" {
  609. c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 text 参数"})
  610. return
  611. }
  612. size, _ := strconv.Atoi(c.Query("size"))
  613. if size <= 0 {
  614. size = 300
  615. }
  616. uri, err := GenerateQRCode(text, size)
  617. if err != nil {
  618. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  619. return
  620. }
  621. // GenerateQRCode 返回 data URI,剥离前缀后得到原始 PNG 字节
  622. const prefix = "data:image/png;base64,"
  623. if strings.HasPrefix(uri, prefix) {
  624. if raw, derr := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, prefix)); derr == nil {
  625. c.Data(http.StatusOK, "image/png", raw)
  626. return
  627. }
  628. }
  629. c.String(http.StatusOK, uri)
  630. }