feat: implement Jira relay server with SSE support
This commit is contained in:
149
internal/relay/relay.go
Normal file
149
internal/relay/relay.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const Port = 19765
|
||||
|
||||
type Server struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan string]struct{}
|
||||
currentURL string
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func New() *Server {
|
||||
return &Server{clients: make(map[chan string]struct{})}
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sse", s.handleSSE)
|
||||
mux.HandleFunc("/jira", s.handleJira)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", Port),
|
||||
Handler: mux,
|
||||
}
|
||||
go func() { _ = s.server.ListenAndServe() }()
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
if s.server != nil {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends a new Jira URL to all connected relay pages.
|
||||
func (s *Server) Broadcast(url string) {
|
||||
s.mu.Lock()
|
||||
s.currentURL = url
|
||||
clients := make([]chan string, 0, len(s.clients))
|
||||
for ch := range s.clients {
|
||||
clients = append(clients, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range clients {
|
||||
select {
|
||||
case ch <- url:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
ch := make(chan string, 2)
|
||||
s.mu.Lock()
|
||||
s.clients[ch] = struct{}{}
|
||||
current := s.currentURL
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, ch)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Send current URL immediately so relay opens Jira right on connect.
|
||||
if current != "" {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", current)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case url := <-ch:
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", url)
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = fmt.Fprint(w, relayHTML)
|
||||
}
|
||||
|
||||
// handleJira redirects to the Jira URL passed in the ?url= query param.
|
||||
// This intermediate hop keeps the navigation same-origin so the relay page
|
||||
// can reliably set w.location.href without cross-origin restrictions.
|
||||
func (s *Server) handleJira(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.Query().Get("url")
|
||||
if target == "" {
|
||||
http.Error(w, "missing url param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
const relayHTML = "" +
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Daily Timer \xe2\x80\x94 relay</title>" +
|
||||
"<style>body{background:#1a2332;color:#8899a6;font-family:system-ui,sans-serif;" +
|
||||
"display:flex;flex-direction:column;align-items:center;justify-content:center;" +
|
||||
"height:100vh;gap:8px;margin:0}p{font-size:13px;margin:0}small{opacity:.5}" +
|
||||
"#url{font-size:11px;opacity:.4;max-width:400px;word-break:break-all;text-align:center}" +
|
||||
".dot{width:8px;height:8px;border-radius:50%;background:#4caf50;display:inline-block;" +
|
||||
"margin-right:6px;animation:pulse 2s infinite}" +
|
||||
".dot.err{background:#f44336;animation:none}" +
|
||||
"button{background:#2a3b5c;color:#fff;border:none;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px;margin-top:8px;}" +
|
||||
"button:hover{background:#3a4b6c}" +
|
||||
"@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}</style></head><body>" +
|
||||
"<p><span class=\"dot\" id=\"dot\"></span>Daily Timer \xe2\x80\x94 Jira relay</p>" +
|
||||
"<p><small>\xd0\xbd\xd0\xb5 \xd0\xb7\xd0xb0\xd0\xba\xd1\x80\xd1\x8b\xd0\xb2\xd0\xb0\xd0\xb9\xd1\x82\xd0\xb5 \xd1\x8d\xd1\x82\xd1\x83 \xd0\xb2\xd0\xba\xd0\xbb\xd0\xb0\xd0\xb4\xd0\xba\xd1\x83</small></p>" +
|
||||
"<div id=\"url\"></div>" +
|
||||
"<button id=\"openBtn\">Открыть Jira</button>" +
|
||||
"<script>" +
|
||||
"var w=null;" +
|
||||
"function relayURL(jiraURL){return '/jira?url='+encodeURIComponent(jiraURL);}" +
|
||||
"function navigate(url){" +
|
||||
"document.getElementById('url').textContent=url;" +
|
||||
"var rurl=relayURL(url);" +
|
||||
"if(!w||w.closed){w=window.open(rurl,'jira-board');return;}" +
|
||||
"w.location.href=rurl;" +
|
||||
"}" +
|
||||
"document.getElementById('openBtn').onclick=function(){" +
|
||||
"navigate(document.getElementById('url').textContent);" +
|
||||
"};" +
|
||||
"var es=new EventSource('/sse');" +
|
||||
"es.onmessage=function(e){navigate(e.data);};" +
|
||||
"es.onerror=function(){document.getElementById('dot').className='dot err';};" +
|
||||
"es.onopen=function(){document.getElementById('dot').className='dot';};" +
|
||||
"</script>" +
|
||||
"</body></html>"
|
||||
Reference in New Issue
Block a user