package handlers import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "io" "io/fs" "log" "net" "net/http" "net/http/httputil" "net/url" "strconv" "strings" "sync" "time" "teraclone" "teraclone/internal/config" "teraclone/internal/mock" "teraclone/internal/service" "teraclone/internal/store" ) type DeviceHandler struct { templates *template.Template static http.Handler app *service.AppService apiTarget *url.URL sessionTarget *url.URL apiProxy *httputil.ReverseProxy streamProxy *httputil.ReverseProxy apiClient *http.Client sessions map[string]browserSession mu sync.RWMutex } type browserSession struct { Username string WorkspaceID string BackendToken string Role string Permissions []string MustChangePassword bool ClientID string } type browserSessionContextKey struct{} type backendAuthSession struct { SessionToken string `json:"session_token"` Actor string `json:"actor"` Role string `json:"role"` Permissions []string `json:"permissions"` MustChangePassword bool `json:"must_change_password"` } type backendErrorResponse struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } type apiResponse struct { OK bool `json:"ok"` Message string `json:"message"` Mock bool `json:"mock"` Data interface{} `json:"data,omitempty"` } type loginPageData struct { AppName string Error string } const sessionCookieName = "teraclone_session" func NewDeviceHandler(appService *service.AppService, apiUpstream string) (*DeviceHandler, error) { return NewDeviceHandlerWithSessionUpstream(appService, apiUpstream, "http://127.0.0.1:8081") } func NewDeviceHandlerWithSessionUpstream(appService *service.AppService, apiUpstream string, sessionUpstream string) (*DeviceHandler, error) { webRoot, err := teraclone.EmbeddedWebRoot() if err != nil { return nil, err } tmpl, err := template.New("").Funcs(template.FuncMap{ "eq": func(a, b string) bool { return a == b }, "contains": func(items []string, target string) bool { for _, item := range items { if item == target { return true } } return false }, }).ParseFS(webRoot, "templates/*.html") if err != nil { return nil, err } if _, err := tmpl.ParseFS(webRoot, "templates/partials/*.html"); err != nil { return nil, err } if _, err := tmpl.ParseFS(webRoot, "templates/auth/*.html"); err != nil { return nil, err } staticRoot, err := fs.Sub(webRoot, "static") if err != nil { return nil, err } apiTarget, err := url.Parse(strings.TrimSpace(apiUpstream)) if err != nil || apiTarget.Scheme != "http" || apiTarget.Host == "" || apiTarget.User != nil { return nil, errors.New("TERACLONE_API_UPSTREAM must be an unauthenticated http URL") } sessionTarget, err := url.Parse(strings.TrimSpace(sessionUpstream)) if err != nil || sessionTarget.Scheme != "http" || sessionTarget.Host == "" || sessionTarget.User != nil { return nil, errors.New("TERACLONE_SESSION_UPSTREAM must be an unauthenticated http URL") } handler := &DeviceHandler{ templates: tmpl, static: http.FileServer(http.FS(staticRoot)), app: appService, apiTarget: apiTarget, sessionTarget: sessionTarget, apiClient: &http.Client{Timeout: 15 * time.Second}, sessions: make(map[string]browserSession), } proxy := httputil.NewSingleHostReverseProxy(apiTarget) director := proxy.Director proxy.Director = func(request *http.Request) { clientIP, publicProtocol, publicPort := browserClientMetadata(request) if cookie, cookieErr := request.Cookie(sessionCookieName); cookieErr == nil && cookie.Value != "" { *request = *request.WithContext(context.WithValue(request.Context(), browserSessionContextKey{}, cookie.Value)) } token, authenticated := handler.backendToken(request) director(request) request.Host = apiTarget.Host request.Header.Del("Cookie") request.Header.Set("X-Tera-Client-IP", clientIP) request.Header.Set("X-Tera-Client-Protocol", publicProtocol) request.Header.Set("X-Tera-Client-Port", publicPort) if authenticated { request.Header.Set("Authorization", "Bearer "+token) } } proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, proxyErr error) { log.Printf("backend proxy error: %v", proxyErr) writeJSON(response, http.StatusBadGateway, apiResponse{OK: false, Message: "백엔드 API에 연결할 수 없습니다."}) } proxy.ModifyResponse = func(response *http.Response) error { if response.StatusCode != http.StatusUnauthorized { return nil } browserToken, _ := response.Request.Context().Value(browserSessionContextKey{}).(string) handler.invalidateBrowserSession(browserToken) response.Header.Add("Set-Cookie", expiredSessionCookie(secureBrowserRequest(response.Request)).String()) return nil } handler.apiProxy = proxy streamProxy := httputil.NewSingleHostReverseProxy(sessionTarget) streamDirector := streamProxy.Director streamProxy.Director = func(request *http.Request) { publicHost := request.Host streamDirector(request) // tera-sessiond performs same-origin validation when no explicit origin // allow-list is configured. Preserve the browser-facing Host while the // request URL still routes to the loopback session daemon. request.Host = publicHost request.Header.Del("Cookie") request.Header.Del("Authorization") } streamProxy.ErrorHandler = func(response http.ResponseWriter, request *http.Request, proxyErr error) { log.Printf("serial stream proxy error: %v", proxyErr) if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") { http.Error(response, "serial stream upstream unavailable", http.StatusBadGateway) return } writeJSON(response, http.StatusBadGateway, apiResponse{OK: false, Message: "시리얼 세션 스트림에 연결할 수 없습니다."}) } handler.streamProxy = streamProxy return handler, nil } func (h *DeviceHandler) RegisterRoutes(mux *http.ServeMux) { mux.Handle("/static/", http.StripPrefix("/static/", h.static)) mux.HandleFunc("/api/v1/", h.handleAPIProxy) mux.HandleFunc("/login/backend-status", h.handleLoginBackendStatus) mux.HandleFunc("/login/bootstrap-time", h.handleBootstrapTime) mux.HandleFunc("/login/captcha", h.handleLoginCaptcha) mux.HandleFunc("/port-debug/readiness", h.handlePortDebugReadiness) mux.HandleFunc("/login", h.handleLogin) mux.HandleFunc("/logout", h.handleLogout) mux.HandleFunc("/", h.handlePage) } func (h *DeviceHandler) handlePortDebugReadiness(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } if _, ok := h.requireAPIAuth(w, r); !ok { return } w.Header().Set("Cache-Control", "no-store") ctx, cancel := context.WithTimeout(r.Context(), 1500*time.Millisecond) defer cancel() endpoint := h.sessionTarget.ResolveReference(&url.URL{Path: "/readyz"}) request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { writePortDebugReadiness(w, false) return } request.Header.Set("Accept", "application/json") response, err := h.apiClient.Do(request) if err != nil { writePortDebugReadiness(w, false) return } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) writePortDebugReadiness(w, response.StatusCode == http.StatusOK) } func writePortDebugReadiness(w http.ResponseWriter, ready bool) { state := "starting" if ready { state = "ready" } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(struct { Ready bool `json:"ready"` State string `json:"state"` }{Ready: ready, State: state}) } func (h *DeviceHandler) handleLoginBackendStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "no-store") ctx, cancel := context.WithTimeout(r.Context(), 1500*time.Millisecond) defer cancel() endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/startup"}) request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { writeLoginBackendStatus(w, false) return } response, err := h.apiClient.Do(request) if err != nil { writeLoginBackendStatus(w, false) return } var state loginStartupStatus decodeErr := json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&state) response.Body.Close() if decodeErr != nil || response.StatusCode != http.StatusOK { writeLoginBackendStatus(w, false) return } if !state.Ready || state.State != "ready" { state.Ready = false writeStartupResponse(w, state) return } endpoint = h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/login-policy"}) request, err = http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { writeLoginBackendStatus(w, false) return } request.Header.Set("Accept", "application/json") response, err = h.apiClient.Do(request) if err != nil { writeLoginBackendStatus(w, false) return } defer response.Body.Close() ready := response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices var policy struct { CaptchaEnabled bool `json:"captcha_enabled"` } if err := json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&policy); err != nil { ready = false } state.Ready = ready state.CaptchaEnabled = policy.CaptchaEnabled if !ready { state.State = "starting" } writeStartupResponse(w, state) } func writeLoginBackendStatus(w http.ResponseWriter, ready bool, captcha ...bool) { state := "starting" if ready { state = "ready" } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(struct { Ready bool `json:"ready"` State string `json:"state"` CaptchaEnabled bool `json:"captcha_enabled"` }{Ready: ready, State: state, CaptchaEnabled: len(captcha) > 0 && captcha[0]}) } // Only this fixed, unauthenticated challenge endpoint is exposed before login. // Never proxy browser cookies, Authorization or client-supplied forwarding headers. func (h *DeviceHandler) handleLoginCaptcha(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "no-store") ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/captcha"}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) if err != nil { http.Error(w, "challenge unavailable", http.StatusBadGateway) return } clientIP, _, _ := browserClientMetadata(r) request.Header.Set("X-Forwarded-For", clientIP) response, err := h.apiClient.Do(request) if err != nil { http.Error(w, "challenge unavailable", http.StatusBadGateway) return } defer response.Body.Close() w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(response.StatusCode) _, _ = io.Copy(w, io.LimitReader(response.Body, 1<<20)) } func (h *DeviceHandler) handlePage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } session, ok := h.requirePageAuth(w, r) if !ok { return } if session.Role == "port_operator" && !portOperatorPageAllowed(r.URL.Path) { http.Error(w, "이 계정은 대시보드, 포트 상태, 시리얼 포트 메뉴만 사용할 수 있습니다.", http.StatusForbidden) return } if strings.HasPrefix(r.URL.Path, "/admin/address-filter/ipv6") { http.Redirect(w, r, "/admin/address-filter/ipv4", http.StatusSeeOther) return } if r.URL.Path == "/admin/groups" { http.Redirect(w, r, "/admin/users", http.StatusSeeOther) return } page, ok := mock.ResolvePage(r.URL.Path) if !ok { http.NotFound(w, r) return } page.CurrentUser = session.Username page.WorkspaceID = session.WorkspaceID page = mock.ApplyRole(page, session.Role) if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { log.Printf("sqlite page visit error: %v", err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = h.templates.ExecuteTemplate(w, "layout", page) } func (h *DeviceHandler) handleUsersPage(w http.ResponseWriter, r *http.Request, username string) { page, ok := mock.ResolvePage(r.URL.Path) if !ok { http.NotFound(w, r) return } users, err := h.app.ListUsers() if err != nil { http.Error(w, "failed to load users", http.StatusInternalServerError) return } groups, err := h.app.ListGroups() if err != nil { http.Error(w, "failed to load groups", http.StatusInternalServerError) return } page.Blocks = buildUserBlocksWithGroups(users, groups) page.CurrentUser = username if session, ok := h.authenticatedSession(r); ok { page.WorkspaceID = session.WorkspaceID page = mock.ApplyRole(page, session.Role) } if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { log.Printf("sqlite page visit error: %v", err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := h.templates.ExecuteTemplate(w, "layout", page); err != nil { log.Printf("template execute error: %v", err) } } func (h *DeviceHandler) handleGroupsPage(w http.ResponseWriter, r *http.Request, username string) { page, ok := mock.ResolvePage(r.URL.Path) if !ok { http.NotFound(w, r) return } groups, err := h.app.ListGroups() if err != nil { http.Error(w, "failed to load groups", http.StatusInternalServerError) return } page.Blocks = buildGroupBlocks(groups) page.CurrentUser = username if session, ok := h.authenticatedSession(r); ok { page.WorkspaceID = session.WorkspaceID page = mock.ApplyRole(page, session.Role) } if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { log.Printf("sqlite page visit error: %v", err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := h.templates.ExecuteTemplate(w, "layout", page); err != nil { log.Printf("template execute error: %v", err) } } func (h *DeviceHandler) handleLogin(w http.ResponseWriter, r *http.Request) { if session, ok := h.authenticatedSession(r); ok { target := redirectTarget(r, "/") if session.MustChangePassword { target = "/password" } http.Redirect(w, r, target, http.StatusSeeOther) return } switch r.Method { case http.MethodGet: h.renderLogin(w, "") case http.MethodPost: username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") backendSession, err := h.loginBackend(r, username, password) if err != nil { h.renderLogin(w, err.Error()) return } token, err := generateSessionToken() if err != nil { http.Error(w, "failed to create session", http.StatusInternalServerError) return } // Public UI identity only; independent of both authentication tokens. // A fresh identity on every login prevents old account tabs being restored. workspaceID, err := generateSessionToken() if err != nil { http.Error(w, "failed to create workspace", http.StatusInternalServerError) return } clientID := desktopClientID(r) var replacedBackendTokens []string h.mu.Lock() for browserToken, existing := range h.sessions { if existing.ClientID == "" || existing.ClientID != clientID { continue } delete(h.sessions, browserToken) if existing.BackendToken != "" { replacedBackendTokens = append(replacedBackendTokens, existing.BackendToken) } } h.sessions[token] = browserSession{ Username: username, WorkspaceID: workspaceID, BackendToken: backendSession.SessionToken, Role: backendSession.Role, Permissions: append([]string(nil), backendSession.Permissions...), MustChangePassword: backendSession.MustChangePassword, ClientID: clientID, } h.mu.Unlock() for _, replacedToken := range replacedBackendTokens { h.logoutBackend(r.Context(), replacedToken) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: secureBrowserRequest(r), SameSite: http.SameSiteLaxMode, }) target := "/" if backendSession.MustChangePassword { target = "/password" } http.Redirect(w, r, target, http.StatusSeeOther) default: http.NotFound(w, r) } } func (h *DeviceHandler) handleLogout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { h.mu.Lock() session, ok := h.sessions[cookie.Value] delete(h.sessions, cookie.Value) h.mu.Unlock() if ok { h.logoutBackend(r.Context(), session.BackendToken) } } http.SetCookie(w, expiredSessionCookie(secureBrowserRequest(r))) http.Redirect(w, r, "/login", http.StatusSeeOther) } func (h *DeviceHandler) renderLogin(w http.ResponseWriter, errorMessage string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := h.templates.ExecuteTemplate(w, "login", loginPageData{ AppName: "TERACLONE", Error: errorMessage, }); err != nil { http.Error(w, "failed to render login", http.StatusInternalServerError) } } func (h *DeviceHandler) requirePageAuth(w http.ResponseWriter, r *http.Request) (browserSession, bool) { session, ok := h.authenticatedSession(r) if ok { return session, true } if r.URL.Path == "/login" { return browserSession{}, true } http.Redirect(w, r, redirectTarget(r, "/login"), http.StatusSeeOther) return browserSession{}, false } func portOperatorPageAllowed(path string) bool { if path == "/" || path == "/port-status" || path == "/password" || path == "/logs/ports" { return true } return strings.HasPrefix(path, "/ports/") && path != "/ports/restart" } func (h *DeviceHandler) requireAPIAuth(w http.ResponseWriter, r *http.Request) (string, bool) { username, ok := h.authenticatedUser(r) if ok { return username, true } writeJSON(w, http.StatusUnauthorized, apiResponse{ OK: false, Message: "로그인이 필요합니다.", Mock: false, }) return "", false } func (h *DeviceHandler) authenticatedUser(r *http.Request) (string, bool) { session, ok := h.authenticatedSession(r) return session.Username, ok } func (h *DeviceHandler) authenticatedSession(r *http.Request) (browserSession, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { return browserSession{}, false } h.mu.RLock() session, ok := h.sessions[cookie.Value] h.mu.RUnlock() return session, ok } func (h *DeviceHandler) invalidateBrowserSession(browserToken string) { if strings.TrimSpace(browserToken) == "" { return } h.mu.Lock() delete(h.sessions, browserToken) h.mu.Unlock() } func expiredSessionCookie(secure bool) *http.Cookie { return &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, } } func directClientIP(r *http.Request) string { address := strings.TrimSpace(r.RemoteAddr) host, _, err := net.SplitHostPort(address) if err != nil { host = strings.Trim(address, "[]") } if ip := net.ParseIP(host); ip != nil { return ip.String() } return strings.ToLower(host) } func browserClientMetadata(r *http.Request) (string, string, string) { address := directClientIP(r) protocol := "http" port := "80" if r.TLS != nil { protocol = "https" port = "443" } if _, parsedPort, err := net.SplitHostPort(r.Host); err == nil && parsedPort != "" { port = parsedPort } if peer := net.ParseIP(address); peer != nil && peer.IsLoopback() { forwardedIP := net.ParseIP(r.Header.Get("X-Tera-Client-IP")) forwardedProtocol := r.Header.Get("X-Tera-Client-Protocol") forwardedPort, err := strconv.Atoi(r.Header.Get("X-Tera-Client-Port")) if forwardedIP != nil && (forwardedProtocol == "http" || forwardedProtocol == "https") && err == nil && forwardedPort >= 1 && forwardedPort <= 65535 { address, protocol, port = forwardedIP.String(), forwardedProtocol, strconv.Itoa(forwardedPort) } } return address, protocol, port } func desktopClientID(r *http.Request) string { address, _, _ := browserClientMetadata(r) return address } func secureBrowserRequest(r *http.Request) bool { _, protocol, _ := browserClientMetadata(r) return protocol == "https" } func (h *DeviceHandler) backendToken(r *http.Request) (string, bool) { session, ok := h.authenticatedSession(r) if !ok || session.BackendToken == "" { return "", false } return session.BackendToken, true } func (h *DeviceHandler) handleAPIProxy(w http.ResponseWriter, r *http.Request) { if _, ok := h.backendToken(r); !ok { writeJSON(w, http.StatusUnauthorized, apiResponse{OK: false, Message: "로그인이 필요합니다."}) return } if isDirectStreamPath(r.URL.Path) { h.streamProxy.ServeHTTP(w, r) return } h.apiProxy.ServeHTTP(w, r) } func isDirectStreamPath(path string) bool { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) != 7 || parts[0] != "api" || parts[1] != "v1" || parts[2] != "direct" || parts[4] != "sessions" || parts[6] != "stream" { return false } channel := parts[3] if len(channel) != 4 || channel[:2] != "CH" { return false } number, err := strconv.Atoi(channel[2:]) return err == nil && number >= 1 && number <= 48 && parts[5] != "" } func (h *DeviceHandler) loginBackend(browserRequest *http.Request, username, password string) (backendAuthSession, error) { if username == "" || password == "" { return backendAuthSession{}, errors.New("아이디와 비밀번호를 입력하세요.") } loginFields := map[string]string{ "username": username, "password": password, "captcha_id": browserRequest.FormValue("captcha_id"), "captcha_answer": browserRequest.FormValue("captcha_answer"), } if stamp := browserRequest.FormValue("browser_time"); stamp != "" { loginFields["browser_time"] = stamp } body, err := json.Marshal(loginFields) if err != nil { return backendAuthSession{}, err } endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/login"}) request, err := http.NewRequestWithContext(browserRequest.Context(), http.MethodPost, endpoint.String(), bytes.NewReader(body)) if err != nil { return backendAuthSession{}, err } request.Header.Set("Content-Type", "application/json") clientIP, protocol, port := browserClientMetadata(browserRequest) request.Header.Set("X-Tera-Client-IP", clientIP) request.Header.Set("X-Forwarded-For", clientIP) request.Header.Set("X-Tera-Client-Protocol", protocol) request.Header.Set("X-Tera-Client-Port", port) response, err := h.apiClient.Do(request) if err != nil { return backendAuthSession{}, errors.New("백엔드 API가 실행 중인지 확인하세요.") } defer response.Body.Close() limited := io.LimitReader(response.Body, 1<<20) if response.StatusCode < 200 || response.StatusCode >= 300 { var payload backendErrorResponse _ = json.NewDecoder(limited).Decode(&payload) if payload.Error.Code == "captcha_failed" { return backendAuthSession{}, errors.New("자동입력방지 값이 틀렸거나 만료되었습니다. 새 이미지의 숫자를 입력하세요.") } if payload.Error.Message != "" { return backendAuthSession{}, errors.New(payload.Error.Message) } return backendAuthSession{}, errors.New("아이디 또는 비밀번호가 올바르지 않습니다.") } var session backendAuthSession if err := json.NewDecoder(limited).Decode(&session); err != nil { return backendAuthSession{}, fmt.Errorf("백엔드 로그인 응답 해석 실패: %w", err) } if session.SessionToken == "" || session.Actor == "" { return backendAuthSession{}, errors.New("백엔드 로그인 응답이 올바르지 않습니다.") } return session, nil } func (h *DeviceHandler) logoutBackend(ctx context.Context, token string) { if strings.TrimSpace(token) == "" { return } endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/logout"}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) if err != nil { return } request.Header.Set("Authorization", "Bearer "+token) response, err := h.apiClient.Do(request) if err == nil { _ = response.Body.Close() } } func (h *DeviceHandler) handleRecentActivity(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } records, err := h.app.RecentActivity(30) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{ OK: false, Message: "최근 활동 조회에 실패했습니다.", Mock: config.MockMode, }) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "최근 활동을 조회했습니다.", Mock: config.MockMode, Data: records, }) } func (h *DeviceHandler) handleUsers(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } switch r.Method { case http.MethodGet: users, err := h.app.ListUsers() if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{ OK: false, Message: "사용자 목록을 불러오지 못했습니다.", Mock: false, }) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "사용자 목록을 조회했습니다.", Mock: false, Data: users, }) case http.MethodPost: var input service.CreateUserInput if err := decodeUserPayload(r, &input); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) return } user, err := h.app.CreateUser(input) if err != nil { writeUserError(w, err) return } _ = h.app.RecordMockAction("create-user", "/admin/users") writeJSON(w, http.StatusCreated, apiResponse{ OK: true, Message: "사용자를 추가했습니다.", Mock: false, Data: user, }) default: http.NotFound(w, r) } } func (h *DeviceHandler) handleUserByID(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } id, err := parseUserID(r.URL.Path) if err != nil { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: user, err := h.app.GetUser(id) if err != nil { writeUserError(w, err) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "사용자 정보를 조회했습니다.", Mock: false, Data: user, }) case http.MethodPut: var input service.UpdateUserInput if err := decodeUserPayload(r, &input); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) return } input.ID = id user, err := h.app.UpdateUser(input) if err != nil { writeUserError(w, err) return } _ = h.app.RecordMockAction("update-user", "/admin/users") writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "사용자 정보를 수정했습니다.", Mock: false, Data: user, }) case http.MethodDelete: if err := h.app.DeleteUser(id); err != nil { writeUserError(w, err) return } _ = h.app.RecordMockAction("delete-user", "/admin/users") writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "사용자를 삭제했습니다.", Mock: false, }) default: http.NotFound(w, r) } } func (h *DeviceHandler) handleGroups(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } switch r.Method { case http.MethodGet: groups, err := h.app.ListGroups() if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{ OK: false, Message: "그룹 목록을 불러오지 못했습니다.", Mock: false, }) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "그룹 목록을 조회했습니다.", Mock: false, Data: groups, }) case http.MethodPost: var input service.CreateGroupInput if err := decodeGroupPayload(r, &input); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) return } group, err := h.app.CreateGroup(input) if err != nil { writeGroupError(w, err) return } _ = h.app.RecordMockAction("create-group", "/admin/groups") writeJSON(w, http.StatusCreated, apiResponse{ OK: true, Message: "그룹을 추가했습니다.", Mock: false, Data: group, }) default: http.NotFound(w, r) } } func (h *DeviceHandler) handleGroupByID(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } id, err := parseGroupID(r.URL.Path) if err != nil { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: group, err := h.app.GetGroup(id) if err != nil { writeGroupError(w, err) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "그룹 정보를 조회했습니다.", Mock: false, Data: group, }) case http.MethodPut: var input service.UpdateGroupInput if err := decodeGroupPayload(r, &input); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) return } input.ID = id group, err := h.app.UpdateGroup(input) if err != nil { writeGroupError(w, err) return } _ = h.app.RecordMockAction("update-group", "/admin/groups") writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "그룹 정보를 수정했습니다.", Mock: false, Data: group, }) case http.MethodDelete: if err := h.app.DeleteGroup(id); err != nil { writeGroupError(w, err) return } _ = h.app.RecordMockAction("delete-group", "/admin/groups") writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "그룹을 삭제했습니다.", Mock: false, }) default: http.NotFound(w, r) } } func buildUserBlocks(users []service.User) []mock.ContentBlock { rows := make([]mock.TableRow, 0, len(users)) for _, user := range users { rows = append(rows, mock.TableRow{ Cells: []string{ user.StatusLabel, user.Username, user.Group, user.AccessSummary, }, Actions: []mock.RowAction{ {Label: "수정", Action: "user-edit", Variant: "primary", Target: strconv.FormatInt(user.ID, 10)}, {Label: "삭제", Action: "user-delete", Variant: "danger", Target: strconv.FormatInt(user.ID, 10)}, }, }) } return []mock.ContentBlock{ { Kind: "table", Title: "사용자 계정", Table: &mock.TableData{ Columns: []string{"상태", "사용자 이름", "그룹", "포트 권한"}, Rows: rows, EmptyMessage: "등록된 사용자가 없습니다.", PrimaryLabel: "사용자 추가", }, }, { Kind: "modal", Title: "사용자 추가", Modal: &mock.ModalData{ Root: "user-create", Title: "사용자 추가", Message: "사용자 생성과 수정을 같은 팝업에서 처리합니다.", Fields: []mock.FormField{ {Type: "hidden", Name: "id", Value: ""}, {Label: "사용자 이름", Name: "username", Type: "text", Value: "", Required: true}, {Label: "비밀번호", Name: "password", Type: "password", Value: "", Placeholder: "수정 시 비우면 유지됩니다."}, { Label: "그룹", Name: "group", Type: "select", Value: "administrator", Options: []mock.Option{ {Label: "administrator", Value: "administrator", Selected: true}, {Label: "operator", Value: "operator"}, {Label: "guest", Value: "guest"}, }, }, { Label: "상태", Name: "enabled", Type: "select", Value: "true", Options: []mock.Option{ {Label: "사용", Value: "true", Selected: true}, {Label: "중지", Value: "false"}, }, }, }, Buttons: []mock.ActionButton{ {Label: "확인", Action: "submit-user-form", Variant: "primary"}, {Label: "취소", Action: "cancel-modal", Variant: "secondary"}, }, }, }, } } func buildUserBlocksWithGroups(users []service.User, groups []service.Group) []mock.ContentBlock { groupOptions := make([]mock.Option, 0, len(groups)) accessByGroup := make(map[string]string, len(groups)) defaultGroup := "" for index, group := range groups { if defaultGroup == "" || group.Name == "administrator" { defaultGroup = group.Name } groupOptions = append(groupOptions, mock.Option{ Label: group.Name, Value: group.Name, Selected: index == 0 || group.Name == "administrator", }) accessByGroup[group.Name] = group.PermissionSummary } rows := make([]mock.TableRow, 0, len(users)) for _, user := range users { accessSummary := user.AccessSummary if summary, ok := accessByGroup[user.Group]; ok { accessSummary = summary } rows = append(rows, mock.TableRow{ Cells: []string{ user.StatusLabel, user.Username, user.Group, accessSummary, }, Actions: []mock.RowAction{ {Label: "수정", Action: "user-edit", Variant: "primary", Target: strconv.FormatInt(user.ID, 10)}, {Label: "삭제", Action: "user-delete", Variant: "danger", Target: strconv.FormatInt(user.ID, 10)}, }, }) } return []mock.ContentBlock{ { Kind: "table", Title: "사용자 계정", Table: &mock.TableData{ Columns: []string{"상태", "사용자 이름", "그룹", "포트 권한"}, Rows: rows, EmptyMessage: "등록된 사용자가 없습니다.", PrimaryLabel: "사용자 추가", }, }, { Kind: "modal", Title: "사용자 추가", Modal: &mock.ModalData{ Root: "user-create", Title: "사용자 추가", Message: "사용자 생성과 수정은 같은 팝업에서 처리합니다.", Fields: []mock.FormField{ {Type: "hidden", Name: "id", Value: ""}, {Label: "사용자 이름", Name: "username", Type: "text", Value: "", Required: true}, {Label: "비밀번호", Name: "password", Type: "password", Value: "", Placeholder: "수정 시 비우면 유지됩니다."}, { Label: "그룹", Name: "group", Type: "select", Value: defaultGroup, Options: groupOptions, }, { Label: "상태", Name: "enabled", Type: "select", Value: "true", Options: []mock.Option{ {Label: "사용", Value: "true", Selected: true}, {Label: "중지", Value: "false"}, }, }, }, Buttons: []mock.ActionButton{ {Label: "확인", Action: "submit-user-form", Variant: "primary"}, {Label: "취소", Action: "cancel-modal", Variant: "secondary"}, }, }, }, } } func buildGroupBlocks(groups []service.Group) []mock.ContentBlock { rows := make([]mock.TableRow, 0, len(groups)) for _, group := range groups { rows = append(rows, mock.TableRow{ Cells: []string{ group.Name, group.Description, group.PermissionSummary, }, Actions: []mock.RowAction{ {Label: "수정", Action: "group-edit", Variant: "primary", Target: strconv.FormatInt(group.ID, 10)}, {Label: "삭제", Action: "group-delete", Variant: "danger", Target: strconv.FormatInt(group.ID, 10)}, }, }) } return []mock.ContentBlock{ { Kind: "table", Title: "그룹", Table: &mock.TableData{ Columns: []string{"그룹 이름", "설명", "권한"}, Rows: rows, EmptyMessage: "등록된 그룹이 없습니다.", PrimaryLabel: "그룹 추가", }, }, } } func parseUserID(path string) (int64, error) { idText := strings.TrimPrefix(path, "/api/users/") if idText == "" || strings.Contains(idText, "/") { return 0, errors.New("invalid user id") } return strconv.ParseInt(idText, 10, 64) } func parseGroupID(path string) (int64, error) { idText := strings.TrimPrefix(path, "/api/groups/") if idText == "" || strings.Contains(idText, "/") { return 0, errors.New("invalid group id") } return strconv.ParseInt(idText, 10, 64) } func decodeUserPayload(r *http.Request, dest interface{}) error { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "application/json") { return json.NewDecoder(r.Body).Decode(dest) } if err := r.ParseForm(); err != nil { return err } switch payload := dest.(type) { case *service.CreateUserInput: payload.Username = strings.TrimSpace(r.FormValue("username")) payload.Password = r.FormValue("password") payload.Group = strings.TrimSpace(r.FormValue("group")) payload.Enabled = parseEnabledValue(r.FormValue("enabled")) case *service.UpdateUserInput: payload.Username = strings.TrimSpace(r.FormValue("username")) payload.Password = r.FormValue("password") payload.Group = strings.TrimSpace(r.FormValue("group")) payload.Enabled = parseEnabledValue(r.FormValue("enabled")) default: return errors.New("unsupported payload") } return nil } func decodeGroupPayload(r *http.Request, dest interface{}) error { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "application/json") { return json.NewDecoder(r.Body).Decode(dest) } if err := r.ParseForm(); err != nil { return err } permissions := append([]string(nil), r.Form["permissions"]...) switch payload := dest.(type) { case *service.CreateGroupInput: payload.Name = strings.TrimSpace(r.FormValue("name")) payload.Description = strings.TrimSpace(r.FormValue("description")) payload.Permissions = permissions case *service.UpdateGroupInput: payload.Name = strings.TrimSpace(r.FormValue("name")) payload.Description = strings.TrimSpace(r.FormValue("description")) payload.Permissions = permissions default: return errors.New("unsupported payload") } return nil } func parseEnabledValue(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "false", "0", "off", "disabled": return false default: return true } } func writeUserError(w http.ResponseWriter, err error) { switch { case err == nil: return case store.IsNotFoundError(err): writeJSON(w, http.StatusNotFound, apiResponse{ OK: false, Message: "사용자를 찾을 수 없습니다.", Mock: false, }) case store.IsUniqueConstraintError(err): writeJSON(w, http.StatusConflict, apiResponse{ OK: false, Message: "같은 사용자 이름이 이미 존재합니다.", Mock: false, }) default: writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) } } func writeGroupError(w http.ResponseWriter, err error) { switch { case err == nil: return case store.IsNotFoundError(err): writeJSON(w, http.StatusNotFound, apiResponse{ OK: false, Message: "그룹을 찾을 수 없습니다.", Mock: false, }) case store.IsUniqueConstraintError(err): writeJSON(w, http.StatusConflict, apiResponse{ OK: false, Message: "같은 그룹 이름이 이미 존재합니다.", Mock: false, }) case store.IsDependencyError(err): writeJSON(w, http.StatusConflict, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) default: writeJSON(w, http.StatusBadRequest, apiResponse{ OK: false, Message: err.Error(), Mock: false, }) } } func generateSessionToken() (string, error) { buffer := make([]byte, 32) if _, err := rand.Read(buffer); err != nil { return "", err } return hex.EncodeToString(buffer), nil } func redirectTarget(r *http.Request, fallback string) string { if next := strings.TrimSpace(r.URL.Query().Get("next")); next != "" && strings.HasPrefix(next, "/") { return next } return fallback } func writeJSON(w http.ResponseWriter, status int, payload apiResponse) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) }