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.
1341 lines
38 KiB
1341 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)
|
|
}
|
|
|