| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- package server
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "runtime"
- "strconv"
- "strings"
- "time"
- "vocat/internal/auth"
- "vocat/internal/buildinfo"
- "vocat/internal/i18n"
- "vocat/internal/loghub"
- "vocat/internal/store"
- )
- func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
- cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
- if s.routeSMSAPI(w, r, cleanPath) {
- return true
- }
- if s.routeProxyAPI(w, r, cleanPath) {
- return true
- }
- if s.routeSettingsAPI(w, r, cleanPath) {
- return true
- }
- switch cleanPath {
- case "logs/history":
- s.handleLogHistory(w, r)
- case "logs/stream":
- s.handleLogStream(w, r)
- case "system/info":
- s.handleSystemInfo(w, r)
- case "system/update/check":
- s.handleUpdateCheck(w, r)
- case "system/update/apply":
- s.handleUpdateApply(w, r)
- case "settings/password":
- s.handlePasswordChange(w, r)
- case "settings/preferences":
- s.handleUIPreferences(w, r)
- default:
- return false
- }
- return true
- }
- const uiPreferencesSettingKey = "ui.preferences"
- // loadUILanguage primes the process-level UI language (internal/i18n) from the
- // persisted preference so backend-generated strings are translated correctly
- // even before the first preferences request arrives after a restart.
- func (s *Server) loadUILanguage(ctx context.Context) {
- setting, err := s.store.AppSetting(ctx, uiPreferencesSettingKey)
- if err != nil {
- return
- }
- var document struct {
- Language string `json:"language"`
- }
- if json.Unmarshal(setting.Value, &document) == nil {
- i18n.Set(document.Language)
- }
- }
- // handleUIPreferences reads and writes UI preferences such as the interface
- // language. Preferences live in the database so they stay consistent across
- // the browsers and devices of the single administrator.
- func (s *Server) handleUIPreferences(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- s.writeUIPreferences(w, r)
- case http.MethodPut:
- var request struct {
- Language string `json:"language"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- language := strings.ToLower(strings.TrimSpace(request.Language))
- if language != "en" && language != "zh" {
- writeError(w, http.StatusBadRequest, "invalid_language", "language must be \"en\" or \"zh\"")
- return
- }
- raw, err := json.Marshal(map[string]string{"language": language})
- if err != nil {
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- return
- }
- if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{
- Key: uiPreferencesSettingKey,
- Value: raw,
- }); err != nil {
- s.writeStoreError(w, err)
- return
- }
- s.writeUIPreferences(w, r)
- default:
- w.Header().Set("Allow", "GET, PUT")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
- language := "en"
- setting, err := s.store.AppSetting(r.Context(), uiPreferencesSettingKey)
- switch {
- case errors.Is(err, store.ErrNotFound):
- case err != nil:
- s.writeStoreError(w, err)
- return
- default:
- var document struct {
- Language string `json:"language"`
- }
- if json.Unmarshal(setting.Value, &document) == nil &&
- (document.Language == "en" || document.Language == "zh") {
- language = document.Language
- }
- }
- // Keep the process-level UI language in sync so backend-generated strings
- // (status text, errors, hints) translate to match the SPA.
- i18n.Set(language)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{"language": language},
- })
- }
- func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
- if err != nil || limit < 1 {
- limit = 500
- }
- if limit > 2000 {
- limit = 2000
- }
- minimum := logLevel(r.URL.Query().Get("level"))
- search := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("search")))
- // History is served from the persisted log_events table so it reflects the
- // configured retention policy and survives restarts (the in-memory hub only
- // backs the live stream).
- entries := []loghub.Entry{}
- if s.store != nil {
- events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- for _, event := range events {
- if storedLogLevel(event.Level) < minimum {
- continue
- }
- entry := storedLogToEntry(event)
- if search != "" && !storedLogContains(entry, search) {
- continue
- }
- entries = append(entries, entry)
- }
- // ListLogEvents is newest-first; present chronologically.
- for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
- entries[i], entries[j] = entries[j], entries[i]
- }
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{"logs": entries},
- })
- }
- func storedLogLevel(value string) slog.Level {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "error":
- return slog.LevelError
- case "warn", "warning":
- return slog.LevelWarn
- case "debug":
- return slog.LevelDebug
- default:
- return slog.LevelInfo
- }
- }
- func storedLogToEntry(event store.LogEvent) loghub.Entry {
- var fields map[string]any
- if len(event.Fields) > 0 {
- if err := json.Unmarshal(event.Fields, &fields); err != nil {
- fields = nil
- }
- }
- return loghub.Entry{
- Time: event.Time,
- Level: event.Level,
- Message: event.Message,
- Caller: event.Caller,
- Fields: fields,
- }
- }
- func storedLogContains(entry loghub.Entry, search string) bool {
- if strings.Contains(strings.ToLower(entry.Message), search) ||
- strings.Contains(strings.ToLower(entry.Caller), search) {
- return true
- }
- for key, value := range entry.Fields {
- if strings.Contains(strings.ToLower(key), search) ||
- strings.Contains(strings.ToLower(fmt.Sprint(value)), search) {
- return true
- }
- }
- return false
- }
- func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- if s.logs == nil {
- writeError(w, http.StatusServiceUnavailable, "log_stream_unavailable", "live log stream is unavailable")
- return
- }
- controller := http.NewResponseController(w)
- if err := controller.SetWriteDeadline(time.Time{}); err != nil {
- s.logger.Debug("stream write deadline is controlled by the HTTP server", "error", err)
- }
- w.Header().Set("Content-Type", "text/event-stream")
- w.Header().Set("Cache-Control", "no-cache, no-store")
- w.Header().Set("Connection", "keep-alive")
- w.Header().Set("X-Accel-Buffering", "no")
- w.WriteHeader(http.StatusOK)
- if _, err := w.Write([]byte("event: connected\ndata: {}\n\n")); err != nil {
- return
- }
- if err := controller.Flush(); err != nil {
- return
- }
- minimum := logLevel(r.URL.Query().Get("level"))
- entries, cancel := s.logs.Subscribe(128)
- defer cancel()
- heartbeat := time.NewTicker(20 * time.Second)
- defer heartbeat.Stop()
- encoder := json.NewEncoder(w)
- for {
- select {
- case <-r.Context().Done():
- return
- case <-heartbeat.C:
- if _, err := w.Write([]byte(": keepalive\n\n")); err != nil {
- return
- }
- if err := controller.Flush(); err != nil {
- return
- }
- case entry, ok := <-entries:
- if !ok {
- return
- }
- if logLevel(entry.Level) < minimum {
- continue
- }
- if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
- return
- }
- if err := encoder.Encode(entry); err != nil {
- return
- }
- if _, err := w.Write([]byte("\n")); err != nil {
- return
- }
- if err := controller.Flush(); err != nil {
- return
- }
- }
- }
- }
- func logLevel(value string) slog.Level {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "error":
- return slog.LevelError
- case "warn", "warning":
- return slog.LevelWarn
- case "debug":
- return slog.LevelDebug
- default:
- return slog.LevelInfo
- }
- }
- func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "version": buildinfo.Version,
- "build_time": buildinfo.BuildTime,
- "config": "VOCAT_CONFIG and environment",
- "os": runtime.GOOS,
- "architecture": runtime.GOARCH,
- "uptime": formatDuration(time.Since(s.startedAt)),
- },
- })
- }
- func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "available": false,
- "version": buildinfo.Version,
- "message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
- },
- })
- }
- // handleUpdateApply deliberately performs no update. Without a configured,
- // trusted update channel the product never downloads or executes code, so an
- // apply request is acknowledged as a safe no-op rather than acted on.
- func (s *Server) handleUpdateApply(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "applied": false,
- "message": i18n.T("未配置受信任的软件更新源;未执行任何更新。"),
- },
- })
- }
- func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- var request struct {
- OldPassword string `json:"old_password"`
- NewPassword string `json:"new_password"`
- ConfirmPassword string `json:"confirm_password"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- if request.NewPassword != request.ConfirmPassword {
- writeError(w, http.StatusBadRequest, "password_mismatch", "new password and confirmation do not match")
- return
- }
- sessionToken, ok := s.sessionToken(w, r)
- if !ok {
- return
- }
- session, err := s.auth.Authenticate(r.Context(), sessionToken)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- return
- }
- if err := s.auth.ChangePassword(
- r.Context(),
- session.Principal.Username,
- request.OldPassword,
- request.NewPassword,
- ); err != nil {
- switch {
- case errors.Is(err, auth.ErrInvalidCredentials):
- writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
- case strings.Contains(err.Error(), "between 12 and 1024"):
- writeError(w, http.StatusBadRequest, "weak_password", err.Error())
- case strings.Contains(err.Error(), "must differ"):
- writeError(w, http.StatusBadRequest, "password_reused", err.Error())
- default:
- s.logger.Error("password change failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- }
- return
- }
- s.clearAuthCookies(w)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{"changed": true, "reauthentication_required": true},
- })
- }
- func formatDuration(duration time.Duration) string {
- if duration < 0 {
- duration = 0
- }
- days := int(duration / (24 * time.Hour))
- duration %= 24 * time.Hour
- hours := int(duration / time.Hour)
- duration %= time.Hour
- minutes := int(duration / time.Minute)
- if days > 0 {
- return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
- }
- if hours > 0 {
- return fmt.Sprintf("%dh %dm", hours, minutes)
- }
- return fmt.Sprintf("%dm", minutes)
- }
|