general_api.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "runtime"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "vocat/internal/auth"
  14. "vocat/internal/buildinfo"
  15. "vocat/internal/i18n"
  16. "vocat/internal/loghub"
  17. "vocat/internal/store"
  18. )
  19. func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
  20. cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
  21. if s.routeSMSAPI(w, r, cleanPath) {
  22. return true
  23. }
  24. if s.routeProxyAPI(w, r, cleanPath) {
  25. return true
  26. }
  27. if s.routeSettingsAPI(w, r, cleanPath) {
  28. return true
  29. }
  30. switch cleanPath {
  31. case "logs/history":
  32. s.handleLogHistory(w, r)
  33. case "logs/stream":
  34. s.handleLogStream(w, r)
  35. case "system/info":
  36. s.handleSystemInfo(w, r)
  37. case "system/update/check":
  38. s.handleUpdateCheck(w, r)
  39. case "system/update/apply":
  40. s.handleUpdateApply(w, r)
  41. case "settings/password":
  42. s.handlePasswordChange(w, r)
  43. case "settings/preferences":
  44. s.handleUIPreferences(w, r)
  45. default:
  46. return false
  47. }
  48. return true
  49. }
  50. const uiPreferencesSettingKey = "ui.preferences"
  51. // loadUILanguage primes the process-level UI language (internal/i18n) from the
  52. // persisted preference so backend-generated strings are translated correctly
  53. // even before the first preferences request arrives after a restart.
  54. func (s *Server) loadUILanguage(ctx context.Context) {
  55. setting, err := s.store.AppSetting(ctx, uiPreferencesSettingKey)
  56. if err != nil {
  57. return
  58. }
  59. var document struct {
  60. Language string `json:"language"`
  61. }
  62. if json.Unmarshal(setting.Value, &document) == nil {
  63. i18n.Set(document.Language)
  64. }
  65. }
  66. // handleUIPreferences reads and writes UI preferences such as the interface
  67. // language. Preferences live in the database so they stay consistent across
  68. // the browsers and devices of the single administrator.
  69. func (s *Server) handleUIPreferences(w http.ResponseWriter, r *http.Request) {
  70. switch r.Method {
  71. case http.MethodGet:
  72. s.writeUIPreferences(w, r)
  73. case http.MethodPut:
  74. var request struct {
  75. Language string `json:"language"`
  76. }
  77. if err := s.decodeJSON(w, r, &request); err != nil {
  78. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  79. return
  80. }
  81. language := strings.ToLower(strings.TrimSpace(request.Language))
  82. if language != "en" && language != "zh" {
  83. writeError(w, http.StatusBadRequest, "invalid_language", "language must be \"en\" or \"zh\"")
  84. return
  85. }
  86. raw, err := json.Marshal(map[string]string{"language": language})
  87. if err != nil {
  88. writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
  89. return
  90. }
  91. if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{
  92. Key: uiPreferencesSettingKey,
  93. Value: raw,
  94. }); err != nil {
  95. s.writeStoreError(w, err)
  96. return
  97. }
  98. s.writeUIPreferences(w, r)
  99. default:
  100. w.Header().Set("Allow", "GET, PUT")
  101. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  102. }
  103. }
  104. func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
  105. language := "en"
  106. setting, err := s.store.AppSetting(r.Context(), uiPreferencesSettingKey)
  107. switch {
  108. case errors.Is(err, store.ErrNotFound):
  109. case err != nil:
  110. s.writeStoreError(w, err)
  111. return
  112. default:
  113. var document struct {
  114. Language string `json:"language"`
  115. }
  116. if json.Unmarshal(setting.Value, &document) == nil &&
  117. (document.Language == "en" || document.Language == "zh") {
  118. language = document.Language
  119. }
  120. }
  121. // Keep the process-level UI language in sync so backend-generated strings
  122. // (status text, errors, hints) translate to match the SPA.
  123. i18n.Set(language)
  124. writeJSON(w, http.StatusOK, map[string]any{
  125. "data": map[string]any{"language": language},
  126. })
  127. }
  128. func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
  129. if !requireMethod(w, r, http.MethodGet) {
  130. return
  131. }
  132. limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
  133. if err != nil || limit < 1 {
  134. limit = 500
  135. }
  136. if limit > 2000 {
  137. limit = 2000
  138. }
  139. minimum := logLevel(r.URL.Query().Get("level"))
  140. search := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("search")))
  141. // History is served from the persisted log_events table so it reflects the
  142. // configured retention policy and survives restarts (the in-memory hub only
  143. // backs the live stream).
  144. entries := []loghub.Entry{}
  145. if s.store != nil {
  146. events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
  147. if err != nil {
  148. s.writeStoreError(w, err)
  149. return
  150. }
  151. for _, event := range events {
  152. if storedLogLevel(event.Level) < minimum {
  153. continue
  154. }
  155. entry := storedLogToEntry(event)
  156. if search != "" && !storedLogContains(entry, search) {
  157. continue
  158. }
  159. entries = append(entries, entry)
  160. }
  161. // ListLogEvents is newest-first; present chronologically.
  162. for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
  163. entries[i], entries[j] = entries[j], entries[i]
  164. }
  165. }
  166. writeJSON(w, http.StatusOK, map[string]any{
  167. "data": map[string]any{"logs": entries},
  168. })
  169. }
  170. func storedLogLevel(value string) slog.Level {
  171. switch strings.ToLower(strings.TrimSpace(value)) {
  172. case "error":
  173. return slog.LevelError
  174. case "warn", "warning":
  175. return slog.LevelWarn
  176. case "debug":
  177. return slog.LevelDebug
  178. default:
  179. return slog.LevelInfo
  180. }
  181. }
  182. func storedLogToEntry(event store.LogEvent) loghub.Entry {
  183. var fields map[string]any
  184. if len(event.Fields) > 0 {
  185. if err := json.Unmarshal(event.Fields, &fields); err != nil {
  186. fields = nil
  187. }
  188. }
  189. return loghub.Entry{
  190. Time: event.Time,
  191. Level: event.Level,
  192. Message: event.Message,
  193. Caller: event.Caller,
  194. Fields: fields,
  195. }
  196. }
  197. func storedLogContains(entry loghub.Entry, search string) bool {
  198. if strings.Contains(strings.ToLower(entry.Message), search) ||
  199. strings.Contains(strings.ToLower(entry.Caller), search) {
  200. return true
  201. }
  202. for key, value := range entry.Fields {
  203. if strings.Contains(strings.ToLower(key), search) ||
  204. strings.Contains(strings.ToLower(fmt.Sprint(value)), search) {
  205. return true
  206. }
  207. }
  208. return false
  209. }
  210. func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
  211. if !requireMethod(w, r, http.MethodGet) {
  212. return
  213. }
  214. if s.logs == nil {
  215. writeError(w, http.StatusServiceUnavailable, "log_stream_unavailable", "live log stream is unavailable")
  216. return
  217. }
  218. controller := http.NewResponseController(w)
  219. if err := controller.SetWriteDeadline(time.Time{}); err != nil {
  220. s.logger.Debug("stream write deadline is controlled by the HTTP server", "error", err)
  221. }
  222. w.Header().Set("Content-Type", "text/event-stream")
  223. w.Header().Set("Cache-Control", "no-cache, no-store")
  224. w.Header().Set("Connection", "keep-alive")
  225. w.Header().Set("X-Accel-Buffering", "no")
  226. w.WriteHeader(http.StatusOK)
  227. if _, err := w.Write([]byte("event: connected\ndata: {}\n\n")); err != nil {
  228. return
  229. }
  230. if err := controller.Flush(); err != nil {
  231. return
  232. }
  233. minimum := logLevel(r.URL.Query().Get("level"))
  234. entries, cancel := s.logs.Subscribe(128)
  235. defer cancel()
  236. heartbeat := time.NewTicker(20 * time.Second)
  237. defer heartbeat.Stop()
  238. encoder := json.NewEncoder(w)
  239. for {
  240. select {
  241. case <-r.Context().Done():
  242. return
  243. case <-heartbeat.C:
  244. if _, err := w.Write([]byte(": keepalive\n\n")); err != nil {
  245. return
  246. }
  247. if err := controller.Flush(); err != nil {
  248. return
  249. }
  250. case entry, ok := <-entries:
  251. if !ok {
  252. return
  253. }
  254. if logLevel(entry.Level) < minimum {
  255. continue
  256. }
  257. if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
  258. return
  259. }
  260. if err := encoder.Encode(entry); err != nil {
  261. return
  262. }
  263. if _, err := w.Write([]byte("\n")); err != nil {
  264. return
  265. }
  266. if err := controller.Flush(); err != nil {
  267. return
  268. }
  269. }
  270. }
  271. }
  272. func logLevel(value string) slog.Level {
  273. switch strings.ToLower(strings.TrimSpace(value)) {
  274. case "error":
  275. return slog.LevelError
  276. case "warn", "warning":
  277. return slog.LevelWarn
  278. case "debug":
  279. return slog.LevelDebug
  280. default:
  281. return slog.LevelInfo
  282. }
  283. }
  284. func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
  285. if !requireMethod(w, r, http.MethodGet) {
  286. return
  287. }
  288. writeJSON(w, http.StatusOK, map[string]any{
  289. "data": map[string]any{
  290. "version": buildinfo.Version,
  291. "build_time": buildinfo.BuildTime,
  292. "config": "VOCAT_CONFIG and environment",
  293. "os": runtime.GOOS,
  294. "architecture": runtime.GOARCH,
  295. "uptime": formatDuration(time.Since(s.startedAt)),
  296. },
  297. })
  298. }
  299. func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
  300. if !requireMethod(w, r, http.MethodGet) {
  301. return
  302. }
  303. writeJSON(w, http.StatusOK, map[string]any{
  304. "data": map[string]any{
  305. "available": false,
  306. "version": buildinfo.Version,
  307. "message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
  308. },
  309. })
  310. }
  311. // handleUpdateApply deliberately performs no update. Without a configured,
  312. // trusted update channel the product never downloads or executes code, so an
  313. // apply request is acknowledged as a safe no-op rather than acted on.
  314. func (s *Server) handleUpdateApply(w http.ResponseWriter, r *http.Request) {
  315. if !requireMethod(w, r, http.MethodPost) {
  316. return
  317. }
  318. writeJSON(w, http.StatusOK, map[string]any{
  319. "data": map[string]any{
  320. "applied": false,
  321. "message": i18n.T("未配置受信任的软件更新源;未执行任何更新。"),
  322. },
  323. })
  324. }
  325. func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
  326. if !requireMethod(w, r, http.MethodPost) {
  327. return
  328. }
  329. var request struct {
  330. OldPassword string `json:"old_password"`
  331. NewPassword string `json:"new_password"`
  332. ConfirmPassword string `json:"confirm_password"`
  333. }
  334. if err := s.decodeJSON(w, r, &request); err != nil {
  335. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  336. return
  337. }
  338. if request.NewPassword != request.ConfirmPassword {
  339. writeError(w, http.StatusBadRequest, "password_mismatch", "new password and confirmation do not match")
  340. return
  341. }
  342. sessionToken, ok := s.sessionToken(w, r)
  343. if !ok {
  344. return
  345. }
  346. session, err := s.auth.Authenticate(r.Context(), sessionToken)
  347. if err != nil {
  348. writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
  349. return
  350. }
  351. if err := s.auth.ChangePassword(
  352. r.Context(),
  353. session.Principal.Username,
  354. request.OldPassword,
  355. request.NewPassword,
  356. ); err != nil {
  357. switch {
  358. case errors.Is(err, auth.ErrInvalidCredentials):
  359. writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
  360. case strings.Contains(err.Error(), "between 12 and 1024"):
  361. writeError(w, http.StatusBadRequest, "weak_password", err.Error())
  362. case strings.Contains(err.Error(), "must differ"):
  363. writeError(w, http.StatusBadRequest, "password_reused", err.Error())
  364. default:
  365. s.logger.Error("password change failed", "error", err)
  366. writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
  367. }
  368. return
  369. }
  370. s.clearAuthCookies(w)
  371. writeJSON(w, http.StatusOK, map[string]any{
  372. "data": map[string]any{"changed": true, "reauthentication_required": true},
  373. })
  374. }
  375. func formatDuration(duration time.Duration) string {
  376. if duration < 0 {
  377. duration = 0
  378. }
  379. days := int(duration / (24 * time.Hour))
  380. duration %= 24 * time.Hour
  381. hours := int(duration / time.Hour)
  382. duration %= time.Hour
  383. minutes := int(duration / time.Minute)
  384. if days > 0 {
  385. return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
  386. }
  387. if hours > 0 {
  388. return fmt.Sprintf("%dh %dm", hours, minutes)
  389. }
  390. return fmt.Sprintf("%dm", minutes)
  391. }