package handlers import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "html/template" "io/fs" "log" "net/http" "strconv" "strings" "sync" "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 sessions map[string]string mu sync.RWMutex } 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) (*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 } return &DeviceHandler{ templates: tmpl, static: http.FileServer(http.FS(staticRoot)), app: appService, sessions: make(map[string]string), }, nil } func (h *DeviceHandler) RegisterRoutes(mux *http.ServeMux) { mux.Handle("/static/", http.StripPrefix("/static/", h.static)) mux.HandleFunc("/login", h.handleLogin) mux.HandleFunc("/logout", h.handleLogout) mux.HandleFunc("/api/status", h.handleAPIStatus) mux.HandleFunc("/api/network", h.handleAPINetwork) mux.HandleFunc("/api/network/apply", h.handleMockPost("네트워크 설정 모의 적용이 완료되었습니다.")) mux.HandleFunc("/api/system", h.handleAPISystem) mux.HandleFunc("/api/system/reboot", h.handleMockPost("장치 재시작 요청을 모의 처리했습니다.")) mux.HandleFunc("/api/logs/system", h.handleAPISystemLogs) mux.HandleFunc("/api/logs/ports", h.handleAPIPortLogs) mux.HandleFunc("/api/activity/recent", h.handleRecentActivity) mux.HandleFunc("/api/users", h.handleUsers) mux.HandleFunc("/api/users/", h.handleUserByID) mux.HandleFunc("/api/groups", h.handleGroups) mux.HandleFunc("/api/groups/", h.handleGroupByID) mux.HandleFunc("/api/mock/action", h.handleMockAction) mux.HandleFunc("/", h.handlePage) } func (h *DeviceHandler) handlePage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } username, ok := h.requirePageAuth(w, r) if !ok { return } if r.URL.Path == "/admin/users" { h.handleUsersPage(w, r, username) return } if r.URL.Path == "/admin/groups" { h.handleGroupsPage(w, r, username) return } page, ok := mock.ResolvePage(r.URL.Path) if !ok { http.NotFound(w, r) return } page.CurrentUser = username 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") _ = 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 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") 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 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") 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 currentUser, ok := h.authenticatedUser(r); ok { http.Redirect(w, r, redirectTarget(r, "/"), http.StatusSeeOther) _ = currentUser return } switch r.Method { case http.MethodGet: h.renderLogin(w, "") case http.MethodPost: username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") user, err := h.app.AuthenticateUser(username, password) if err != nil { h.renderLogin(w, "아이디 또는 비밀번호가 올바르지 않습니다.") return } token, err := generateSessionToken() if err != nil { http.Error(w, "failed to create session", http.StatusInternalServerError) return } h.mu.Lock() h.sessions[token] = user.Username h.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", 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() delete(h.sessions, cookie.Value) h.mu.Unlock() } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) 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") 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) (string, bool) { username, ok := h.authenticatedUser(r) if ok { return username, true } if r.URL.Path == "/login" { return "", true } http.Redirect(w, r, redirectTarget(r, "/login"), http.StatusSeeOther) return "", false } 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) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { return "", false } h.mu.RLock() username, ok := h.sessions[cookie.Value] h.mu.RUnlock() return username, ok } func (h *DeviceHandler) handleAPIStatus(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "상태 더미 데이터를 반환했습니다.", Mock: config.MockMode, Data: mock.StatusAPIData(), }) } func (h *DeviceHandler) handleAPINetwork(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "네트워크 더미 데이터를 반환했습니다.", Mock: config.MockMode, Data: mock.NetworkAPIData(), }) } func (h *DeviceHandler) handleAPISystem(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "시스템 더미 데이터를 반환했습니다.", Mock: config.MockMode, Data: mock.SystemAPIData(), }) } func (h *DeviceHandler) handleAPISystemLogs(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "시스템 로그 더미 데이터를 반환했습니다.", Mock: config.MockMode, Data: mock.SystemLogsAPIData(), }) } func (h *DeviceHandler) handleAPIPortLogs(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: "포트 로그 더미 데이터를 반환했습니다.", Mock: config.MockMode, Data: mock.PortLogsAPIData(), }) } 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) handleMockAction(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } if r.Method != http.MethodPost { http.NotFound(w, r) return } action := r.FormValue("action") if action == "" { action = "mock-action" } if err := h.app.RecordMockAction(action, r.Referer()); err != nil { log.Printf("sqlite mock action error: %v", err) } message := "모의 동작을 처리했습니다." switch { case strings.Contains(action, "save"): message = "모의 저장이 완료되었습니다." case strings.Contains(action, "apply"): message = "모의 적용이 완료되었습니다." case strings.Contains(action, "reboot"): message = "모의 재시작 요청이 접수되었습니다." case strings.Contains(action, "download"): message = "모의 다운로드 요청을 처리했습니다." case strings.Contains(action, "refresh"): message = "모의 새로고침이 완료되었습니다." case strings.Contains(action, "delete"): message = "모의 삭제가 완료되었습니다." case strings.Contains(action, "add"): message = "모의 추가가 완료되었습니다." } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: message, Mock: config.MockMode, Data: map[string]string{ "action": action, }, }) } func (h *DeviceHandler) handleMockPost(message string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAPIAuth(w, r); !ok { return } if r.Method != http.MethodPost { http.NotFound(w, r) return } writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: message, Mock: config.MockMode, }) } } 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) }