feat(shared-sqs): remove auth from UI, add public /ui/api/ routes (v0.1.5)
- Public routes /ui/api/* without bearer token for demo - UI loads dashboard immediately, no login required - Admin API /admin/* still requires bearer token - For demo purposes only — auth will be restored later
This commit is contained in:
@@ -39,6 +39,18 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
|
|||||||
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||||
|
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||||
|
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||||
|
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||||
|
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||||
|
ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
}
|
||||||
|
|
||||||
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
||||||
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+101
-98
@@ -4,139 +4,142 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"shared-sqs/app/admin"
|
"shared-sqs/app/admin"
|
||||||
"shared-sqs/app/auth"
|
"shared-sqs/app/auth"
|
||||||
"shared-sqs/app/interfaces"
|
sqs "shared-sqs/app/gosqs"
|
||||||
sqs "shared-sqs/app/gosqs"
|
"shared-sqs/app/interfaces"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
"shared-sqs/app/ui"
|
"shared-sqs/app/ui"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New — создаёт HTTP router с tenant auth и admin API
|
// New — создаёт HTTP router с tenant auth и admin API
|
||||||
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// /health — публичный, без auth
|
// /health — публичный, без auth
|
||||||
r.HandleFunc("/health", health).Methods("GET")
|
r.HandleFunc("/health", health).Methods("GET")
|
||||||
|
|
||||||
// Admin API — Bearer token auth, регистрируется через AdminHandler
|
// Admin API — Bearer token auth, регистрируется через AdminHandler
|
||||||
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||||
adminHandler.RegisterRoutes(r)
|
adminHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
// UI console — встроенный SPA, публичный доступ
|
// UI public API — без auth, для встроенной console
|
||||||
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
adminHandler.RegisterPublicRoutes(r)
|
||||||
|
|
||||||
// SQS API — tenant auth middleware
|
// UI console — встроенный SPA, публичный доступ
|
||||||
sqsRouter := r.NewRoute().Subrouter()
|
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||||
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
|
|
||||||
sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST")
|
|
||||||
sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST")
|
|
||||||
sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST")
|
|
||||||
sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST")
|
|
||||||
|
|
||||||
return r
|
// SQS API — tenant auth middleware
|
||||||
|
sqsRouter := r.NewRoute().Subrouter()
|
||||||
|
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
|
||||||
|
sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST")
|
||||||
|
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
|
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
|
||||||
protocol := resolveProtocol(req)
|
protocol := resolveProtocol(req)
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case AwsJsonProtocol:
|
case AwsJsonProtocol:
|
||||||
w.Header().Set("x-amzn-RequestId", body.GetRequestId())
|
w.Header().Set("x-amzn-RequestId", body.GetRequestId())
|
||||||
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
|
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
if body.GetResult() == nil {
|
if body.GetResult() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := json.NewEncoder(w).Encode(body.GetResult())
|
err := json.NewEncoder(w).Encode(body.GetResult())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
|
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
|
||||||
http.Error(w, "General Error", http.StatusInternalServerError)
|
http.Error(w, "General Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
case AwsQueryProtocol:
|
case AwsQueryProtocol:
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
result, err := xml.Marshal(body)
|
result, err := xml.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
|
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
|
||||||
http.Error(w, "General Error", http.StatusInternalServerError)
|
http.Error(w, "General Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
_, _ = w.Write(result)
|
_, _ = w.Write(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routingTableV1 — только SQS actions (SNS удалён)
|
// routingTableV1 — только SQS actions (SNS удалён)
|
||||||
var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
|
var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
|
||||||
"CreateQueue": sqs.CreateQueueV1,
|
"CreateQueue": sqs.CreateQueueV1,
|
||||||
"ListQueues": sqs.ListQueuesV1,
|
"ListQueues": sqs.ListQueuesV1,
|
||||||
"GetQueueAttributes": sqs.GetQueueAttributesV1,
|
"GetQueueAttributes": sqs.GetQueueAttributesV1,
|
||||||
"SetQueueAttributes": sqs.SetQueueAttributesV1,
|
"SetQueueAttributes": sqs.SetQueueAttributesV1,
|
||||||
"SendMessage": sqs.SendMessageV1,
|
"SendMessage": sqs.SendMessageV1,
|
||||||
"ReceiveMessage": sqs.ReceiveMessageV1,
|
"ReceiveMessage": sqs.ReceiveMessageV1,
|
||||||
"ChangeMessageVisibility": sqs.ChangeMessageVisibilityV1,
|
"ChangeMessageVisibility": sqs.ChangeMessageVisibilityV1,
|
||||||
"DeleteMessage": sqs.DeleteMessageV1,
|
"DeleteMessage": sqs.DeleteMessageV1,
|
||||||
"GetQueueUrl": sqs.GetQueueUrlV1,
|
"GetQueueUrl": sqs.GetQueueUrlV1,
|
||||||
"PurgeQueue": sqs.PurgeQueueV1,
|
"PurgeQueue": sqs.PurgeQueueV1,
|
||||||
"DeleteQueue": sqs.DeleteQueueV1,
|
"DeleteQueue": sqs.DeleteQueueV1,
|
||||||
"SendMessageBatch": sqs.SendMessageBatchV1,
|
"SendMessageBatch": sqs.SendMessageBatchV1,
|
||||||
"DeleteMessageBatch": sqs.DeleteMessageBatchV1,
|
"DeleteMessageBatch": sqs.DeleteMessageBatchV1,
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(w http.ResponseWriter, req *http.Request) {
|
func health(w http.ResponseWriter, req *http.Request) {
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
fmt.Fprint(w, "OK")
|
fmt.Fprint(w, "OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
action := extractAction(req)
|
action := extractAction(req)
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"action": action,
|
"action": action,
|
||||||
"url": req.URL,
|
"url": req.URL,
|
||||||
}).Debug("Handling URL request")
|
}).Debug("Handling URL request")
|
||||||
jsonFn, ok := routingTableV1[action]
|
jsonFn, ok := routingTableV1[action]
|
||||||
if ok {
|
if ok {
|
||||||
statusCode, responseBody := jsonFn(req)
|
statusCode, responseBody := jsonFn(req)
|
||||||
encodeResponse(w, req, statusCode, responseBody)
|
encodeResponse(w, req, statusCode, responseBody)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Warnf("Bad Request - Action: %s", action)
|
log.Warnf("Bad Request - Action: %s", action)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
io.WriteString(w, "Bad Request")
|
io.WriteString(w, "Bad Request")
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwsProtocol int
|
type AwsProtocol int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AwsJsonProtocol AwsProtocol = iota
|
AwsJsonProtocol AwsProtocol = iota
|
||||||
AwsQueryProtocol AwsProtocol = iota
|
AwsQueryProtocol AwsProtocol = iota
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol)
|
// extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol)
|
||||||
func extractAction(req *http.Request) string {
|
func extractAction(req *http.Request) string {
|
||||||
protocol := resolveProtocol(req)
|
protocol := resolveProtocol(req)
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case AwsJsonProtocol:
|
case AwsJsonProtocol:
|
||||||
action := req.Header.Get("X-Amz-Target")
|
action := req.Header.Get("X-Amz-Target")
|
||||||
return strings.Split(action, ".")[1]
|
return strings.Split(action, ".")[1]
|
||||||
case AwsQueryProtocol:
|
case AwsQueryProtocol:
|
||||||
return req.FormValue("Action")
|
return req.FormValue("Action")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveProtocol — определяет протокол по Content-Type
|
// resolveProtocol — определяет протокол по Content-Type
|
||||||
func resolveProtocol(req *http.Request) AwsProtocol {
|
func resolveProtocol(req *http.Request) AwsProtocol {
|
||||||
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
|
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
|
||||||
return AwsJsonProtocol
|
return AwsJsonProtocol
|
||||||
}
|
}
|
||||||
return AwsQueryProtocol
|
return AwsQueryProtocol
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,22 +317,8 @@ tbody tr { cursor: pointer; }
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ===== LOGIN ===== -->
|
<!-- ===== APP SHELL (всегда видим, без логина) ===== -->
|
||||||
<div id="login-page" class="login-page">
|
<div id="app">
|
||||||
<div class="login-box">
|
|
||||||
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
|
||||||
<h1>SQS CONSOLE</h1>
|
|
||||||
<div id="login-error" class="login-error"></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="token-input">Admin Token</label>
|
|
||||||
<input type="password" id="token-input" placeholder="sqs-admin-..." autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== APP SHELL ===== -->
|
|
||||||
<div id="app" class="hidden">
|
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
@@ -340,7 +326,6 @@ tbody tr { cursor: pointer; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-user">
|
<div class="navbar-user">
|
||||||
<span>admin</span>
|
<span>admin</span>
|
||||||
<button class="btn-logout" onclick="doLogout()">Выход</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -394,61 +379,23 @@ tbody tr { cursor: pointer; }
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ===== STATE =====
|
// ===== STATE =====
|
||||||
let TOKEN = '';
|
|
||||||
let BASE = '';
|
let BASE = '';
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let currentTenantId = null;
|
let currentTenantId = null;
|
||||||
|
|
||||||
// ===== AUTH =====
|
// ===== INIT =====
|
||||||
// doLogin — проверяет токен через /admin/health и сохраняет в sessionStorage
|
// Запуск — сразу показываем dashboard без логина
|
||||||
function doLogin() {
|
(function init() {
|
||||||
const token = document.getElementById('token-input').value.trim();
|
|
||||||
if (!token) return;
|
|
||||||
// Определяем base URL — тот же origin что и UI
|
|
||||||
BASE = window.location.origin;
|
BASE = window.location.origin;
|
||||||
TOKEN = token;
|
showDashboard();
|
||||||
api('/admin/health').then(data => {
|
|
||||||
sessionStorage.setItem('sqs_token', token);
|
|
||||||
document.getElementById('login-page').classList.add('hidden');
|
|
||||||
document.getElementById('app').classList.remove('hidden');
|
|
||||||
showDashboard();
|
|
||||||
}).catch(err => {
|
|
||||||
document.getElementById('login-error').textContent = 'Неверный токен';
|
|
||||||
TOKEN = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// doLogout — очищает сессию и возвращает на логин
|
|
||||||
function doLogout() {
|
|
||||||
TOKEN = '';
|
|
||||||
sessionStorage.removeItem('sqs_token');
|
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
|
||||||
document.getElementById('app').classList.add('hidden');
|
|
||||||
document.getElementById('login-page').classList.remove('hidden');
|
|
||||||
document.getElementById('token-input').value = '';
|
|
||||||
document.getElementById('login-error').textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Авто-логин из sessionStorage
|
|
||||||
(function autoLogin() {
|
|
||||||
const saved = sessionStorage.getItem('sqs_token');
|
|
||||||
if (saved) {
|
|
||||||
document.getElementById('token-input').value = saved;
|
|
||||||
doLogin();
|
|
||||||
}
|
|
||||||
// Enter на поле токена
|
|
||||||
document.getElementById('token-input').addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') doLogin();
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ===== API HELPER =====
|
// ===== API HELPER =====
|
||||||
// api — выполняет запрос к admin API с bearer token
|
// api — выполняет запрос к публичному UI API (без auth)
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
return fetch(BASE + path, {
|
return fetch(BASE + '/ui/api' + path, {
|
||||||
...opts,
|
...opts,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + TOKEN,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(opts.headers || {})
|
...(opts.headers || {})
|
||||||
}
|
}
|
||||||
@@ -472,7 +419,7 @@ function showDashboard() {
|
|||||||
|
|
||||||
// loadDashboard — загружает данные и обновляет DOM
|
// loadDashboard — загружает данные и обновляет DOM
|
||||||
function loadDashboard() {
|
function loadDashboard() {
|
||||||
Promise.all([api('/admin/health'), api('/admin/tenants')])
|
Promise.all([api('/health'), api('/tenants')])
|
||||||
.then(([health, tenants]) => renderDashboard(health, tenants))
|
.then(([health, tenants]) => renderDashboard(health, tenants))
|
||||||
.catch(err => console.error('Dashboard load error:', err));
|
.catch(err => console.error('Dashboard load error:', err));
|
||||||
}
|
}
|
||||||
@@ -557,7 +504,7 @@ function showTenant(id) {
|
|||||||
|
|
||||||
// loadTenant — загружает тенанта и его очереди
|
// loadTenant — загружает тенанта и его очереди
|
||||||
function loadTenant(id) {
|
function loadTenant(id) {
|
||||||
Promise.all([api('/admin/tenants/' + id), api('/admin/tenants/' + id + '/queues')])
|
Promise.all([api('/tenants/' + id), api('/tenants/' + id + '/queues')])
|
||||||
.then(([tenant, queues]) => renderTenant(tenant, queues))
|
.then(([tenant, queues]) => renderTenant(tenant, queues))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Tenant load error:', err);
|
console.error('Tenant load error:', err);
|
||||||
@@ -643,7 +590,7 @@ function closeModal() {
|
|||||||
document.getElementById('modal-create').classList.add('hidden');
|
document.getElementById('modal-create').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTenant — POST /admin/tenants, показывает credentials
|
// createTenant — POST /tenants, показывает credentials
|
||||||
function createTenant() {
|
function createTenant() {
|
||||||
const name = document.getElementById('ct-name').value.trim();
|
const name = document.getElementById('ct-name').value.trim();
|
||||||
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
|
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
|
||||||
@@ -651,7 +598,7 @@ function createTenant() {
|
|||||||
document.getElementById('ct-error').textContent = 'Укажите имя';
|
document.getElementById('ct-error').textContent = 'Укажите имя';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api('/admin/tenants', {
|
api('/tenants', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: name, max_queues: maxQ })
|
body: JSON.stringify({ name: name, max_queues: maxQ })
|
||||||
}).then(data => {
|
}).then(data => {
|
||||||
@@ -670,10 +617,10 @@ function closeCredsModal() {
|
|||||||
document.getElementById('modal-creds').classList.add('hidden');
|
document.getElementById('modal-creds').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTenant — DELETE /admin/tenants/{id} с подтверждением
|
// deleteTenant — DELETE /tenants/{id} с подтверждением
|
||||||
function deleteTenant(id, name) {
|
function deleteTenant(id, name) {
|
||||||
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
|
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
|
||||||
api('/admin/tenants/' + id, { method: 'DELETE' })
|
api('/tenants/' + id, { method: 'DELETE' })
|
||||||
.then(() => loadDashboard())
|
.then(() => loadDashboard())
|
||||||
.catch(err => alert('Ошибка удаления: ' + err.message));
|
.catch(err => alert('Ошибка удаления: ' + err.message));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user