diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..2ff1588 --- /dev/null +++ b/internal/relay/relay.go @@ -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 = "" + + "Daily Timer \xe2\x80\x94 relay" + + "" + + "

Daily Timer \xe2\x80\x94 Jira relay

" + + "

\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

" + + "
" + + "" + + "" + + ""