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:
“Naeel”
2026-04-09 18:23:17 +03:00
parent 12b3bb9bf3
commit 70c276b4e7
3 changed files with 127 additions and 165 deletions
+12
View File
@@ -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
View File
@@ -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
} }
+14 -67
View File
@@ -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));
} }