150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
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>"
|