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.
1055 lines
26 KiB
1055 lines
26 KiB
|
4 weeks ago
|
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)
|
||
|
|
}
|