plugin_handler.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package admin
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "mime"
  10. "net/http"
  11. "os"
  12. "path/filepath"
  13. "strconv"
  14. "strings"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  17. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  18. "github.com/Wei-Shaw/sub2api/internal/service"
  19. "github.com/gin-gonic/gin"
  20. )
  21. const pluginUISessionTTL = 30 * time.Minute
  22. // PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
  23. type PluginHandler struct {
  24. manager *service.PluginManager
  25. }
  26. func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
  27. return &PluginHandler{manager: manager}
  28. }
  29. func (h *PluginHandler) List(c *gin.Context) {
  30. plugins, err := h.manager.List(c.Request.Context())
  31. if err != nil {
  32. response.ErrorFrom(c, err)
  33. return
  34. }
  35. response.Success(c, plugins)
  36. }
  37. func (h *PluginHandler) Get(c *gin.Context) {
  38. id, ok := pluginIDParam(c)
  39. if !ok {
  40. return
  41. }
  42. plugin, err := h.manager.Get(c.Request.Context(), id)
  43. if err != nil {
  44. response.ErrorFrom(c, err)
  45. return
  46. }
  47. response.Success(c, plugin)
  48. }
  49. func (h *PluginHandler) Upload(c *gin.Context) {
  50. maxBytes := h.manager.MaxUploadBytes()
  51. c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
  52. file, header, err := c.Request.FormFile("plugin")
  53. if err != nil {
  54. response.BadRequest(c, "请选择有效的 .s2plugin 文件")
  55. return
  56. }
  57. defer func() { _ = file.Close() }()
  58. if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
  59. response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
  60. return
  61. }
  62. var installedBy *int64
  63. if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
  64. userID := subject.UserID
  65. installedBy = &userID
  66. }
  67. plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
  68. if err != nil {
  69. response.BadRequest(c, err.Error())
  70. return
  71. }
  72. response.Created(c, plugin)
  73. }
  74. type pluginEnableRequest struct {
  75. AcceptUntested bool `json:"accept_untested"`
  76. RolloutPercent int `json:"rollout_percent"`
  77. }
  78. func (h *PluginHandler) Enable(c *gin.Context) {
  79. id, ok := pluginIDParam(c)
  80. if !ok {
  81. return
  82. }
  83. request := pluginEnableRequest{RolloutPercent: 100}
  84. if err := c.ShouldBindJSON(&request); err != nil {
  85. response.BadRequest(c, "启用参数无效")
  86. return
  87. }
  88. plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
  89. if err != nil {
  90. response.BadRequest(c, err.Error())
  91. return
  92. }
  93. response.Success(c, plugin)
  94. }
  95. func (h *PluginHandler) Disable(c *gin.Context) {
  96. id, ok := pluginIDParam(c)
  97. if !ok {
  98. return
  99. }
  100. plugin, err := h.manager.Disable(c.Request.Context(), id)
  101. if err != nil {
  102. response.ErrorFrom(c, err)
  103. return
  104. }
  105. response.Success(c, plugin)
  106. }
  107. func (h *PluginHandler) Delete(c *gin.Context) {
  108. id, ok := pluginIDParam(c)
  109. if !ok {
  110. return
  111. }
  112. if err := h.manager.Delete(c.Request.Context(), id); err != nil {
  113. response.ErrorFrom(c, err)
  114. return
  115. }
  116. response.Success(c, gin.H{"message": "插件已卸载"})
  117. }
  118. func (h *PluginHandler) GetConfig(c *gin.Context) {
  119. id, ok := pluginIDParam(c)
  120. if !ok {
  121. return
  122. }
  123. configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
  124. if err != nil {
  125. response.ErrorFrom(c, err)
  126. return
  127. }
  128. c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
  129. }
  130. func (h *PluginHandler) SaveConfig(c *gin.Context) {
  131. id, ok := pluginIDParam(c)
  132. if !ok {
  133. return
  134. }
  135. decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
  136. decoder.UseNumber()
  137. var value any
  138. if err := decoder.Decode(&value); err != nil {
  139. response.BadRequest(c, "插件配置必须是有效 JSON")
  140. return
  141. }
  142. if err := decoder.Decode(&struct{}{}); err != io.EOF {
  143. response.BadRequest(c, "插件配置只能包含一个 JSON 值")
  144. return
  145. }
  146. raw, err := json.Marshal(value)
  147. if err != nil {
  148. response.BadRequest(c, "插件配置无法序列化")
  149. return
  150. }
  151. saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
  152. if err != nil {
  153. response.BadRequest(c, err.Error())
  154. return
  155. }
  156. c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
  157. }
  158. func (h *PluginHandler) Test(c *gin.Context) {
  159. id, ok := pluginIDParam(c)
  160. if !ok {
  161. return
  162. }
  163. result, err := h.manager.Test(c.Request.Context(), id)
  164. if err != nil {
  165. response.BadRequest(c, err.Error())
  166. return
  167. }
  168. response.Success(c, result)
  169. }
  170. func (h *PluginHandler) CreateUISession(c *gin.Context) {
  171. id, ok := pluginIDParam(c)
  172. if !ok {
  173. return
  174. }
  175. assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
  176. if err != nil {
  177. response.ErrorFrom(c, err)
  178. return
  179. }
  180. bridgeToken, err := randomPluginToken()
  181. if err != nil {
  182. response.InternalError(c, "创建插件 UI Bridge 失败")
  183. return
  184. }
  185. response.Success(c, gin.H{
  186. "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
  187. "bridge_token": bridgeToken,
  188. "ui_bridge_version": 1,
  189. "expires_at": expires,
  190. })
  191. }
  192. // ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
  193. func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
  194. token := strings.TrimSpace(c.Param("token"))
  195. pluginID, err := h.manager.ResolveUIAssetToken(token)
  196. if err != nil {
  197. c.Status(http.StatusGone)
  198. return
  199. }
  200. relative := strings.TrimPrefix(c.Param("path"), "/")
  201. data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
  202. if err != nil {
  203. if errors.Is(err, os.ErrNotExist) {
  204. c.Status(http.StatusNotFound)
  205. return
  206. }
  207. c.Status(http.StatusNotFound)
  208. return
  209. }
  210. contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
  211. if contentType == "" {
  212. contentType = "application/octet-stream"
  213. }
  214. c.Header("Cache-Control", "private, no-store")
  215. c.Header("Referrer-Policy", "no-referrer")
  216. c.Header("X-Content-Type-Options", "nosniff")
  217. c.Header("X-Frame-Options", "SAMEORIGIN")
  218. // sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
  219. // 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
  220. c.Header("Cross-Origin-Resource-Policy", "cross-origin")
  221. c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
  222. c.Data(http.StatusOK, contentType, data)
  223. }
  224. func pluginIDParam(c *gin.Context) (int64, bool) {
  225. id, err := strconv.ParseInt(c.Param("id"), 10, 64)
  226. if err != nil || id <= 0 {
  227. response.BadRequest(c, "插件 ID 无效")
  228. return 0, false
  229. }
  230. return id, true
  231. }
  232. func randomPluginToken() (string, error) {
  233. buffer := make([]byte, 32)
  234. if _, err := rand.Read(buffer); err != nil {
  235. return "", err
  236. }
  237. return hex.EncodeToString(buffer), nil
  238. }