You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1342 lines
38 KiB

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(response.Request.TLS != nil).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/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/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
}
writeLoginBackendStatus(w, ready, policy.CaptchaEnabled)
}
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
}
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: r.TLS != nil,
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(r.TLS != nil))
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" {
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 desktopClientID(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) {
protocol := "http"
port := "80"
if r.TLS != nil {
protocol = "https"
port = "443"
}
if _, parsedPort, err := net.SplitHostPort(r.Host); err == nil && parsedPort != "" {
port = parsedPort
}
return desktopClientID(r), protocol, port
}
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("아이디와 비밀번호를 입력하세요.")
}
body, err := json.Marshal(map[string]string{
"username": username, "password": password,
"captcha_id": browserRequest.FormValue("captcha_id"),
"captcha_answer": browserRequest.FormValue("captcha_answer"),
})
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)
}