feat: initial commit
This commit is contained in:
191
pkg/server/http.go
Normal file
191
pkg/server/http.go
Normal file
@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/idle.html.gotmpl
|
||||
rawIdleTemplate []byte
|
||||
idleTemplate *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not parse idle template"))
|
||||
}
|
||||
|
||||
idleTemplate = tmpl
|
||||
}
|
||||
|
||||
func (s *Server) startHTTPServer(ctx context.Context) error {
|
||||
router := chi.NewRouter()
|
||||
|
||||
router.Get("/", s.handleHome)
|
||||
router.Post("/api/v1/cast", s.handleCast)
|
||||
router.Delete("/api/v1/cast", s.handleReset)
|
||||
router.Get("/api/v1/status", s.handleStatus)
|
||||
|
||||
server := http.Server{
|
||||
Addr: s.address,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", s.address)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
host, rawPort, err := net.SplitHostPort(listener.Addr().String())
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
port, err := strconv.ParseInt(rawPort, 10, 32)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not parse listening port '%v'", rawPort)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "listening for tcp connections", logger.F("port", port), logger.F("host", host))
|
||||
|
||||
s.port = int(port)
|
||||
|
||||
go func() {
|
||||
logger.Debug(ctx, "starting http server")
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Debug(ctx, "closing http server")
|
||||
if err := server.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close http server", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := s.resetBrowser(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CastRequest struct {
|
||||
URL string `json:"url" validate:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
|
||||
req := &CastRequest{}
|
||||
if ok := api.Bind(w, r, req); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.browser.Load(req.URL); err != nil {
|
||||
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.resetBrowser(); err != nil {
|
||||
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) resetBrowser() error {
|
||||
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
|
||||
if err := s.browser.Reset(idleURL); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
url, err := s.browser.URL()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
status, err := s.browser.Status()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
title, err := s.browser.Title()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, &StatusResponse{
|
||||
ID: s.instanceID,
|
||||
URL: url,
|
||||
Status: status.String(),
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
type templateData struct {
|
||||
IPs []string
|
||||
Port int
|
||||
ID string
|
||||
}
|
||||
|
||||
ips, err := getLANIPv4Addrs()
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d := templateData{
|
||||
ID: s.instanceID,
|
||||
IPs: ips,
|
||||
Port: s.port,
|
||||
}
|
||||
|
||||
if err := idleTemplate.Execute(w, d); err != nil {
|
||||
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}
|
43
pkg/server/mdns.go
Normal file
43
pkg/server/mdns.go
Normal file
@ -0,0 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grandcat/zeroconf"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wlynxg/anet"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MDNSService = "_arcast._http._tcp"
|
||||
MDNSDomain = "local."
|
||||
)
|
||||
|
||||
func (s *Server) startMDNServer(ctx context.Context) error {
|
||||
logger.Debug(ctx, "starting mdns server")
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ips, err := getLANIPv4Addrs()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "advertising ips", logger.F("ips", ips))
|
||||
|
||||
server, err := zeroconf.RegisterProxy(s.instanceID, MDNSService, MDNSDomain, s.port, s.instanceID, ips, []string{}, ifaces)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Debug(ctx, "closing mdns server")
|
||||
server.Shutdown()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
60
pkg/server/network.go
Normal file
60
pkg/server/network.go
Normal file
@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wlynxg/anet"
|
||||
)
|
||||
|
||||
var (
|
||||
_, lanA, _ = net.ParseCIDR("10.0.0.0/8")
|
||||
_, lanB, _ = net.ParseCIDR("172.16.0.0/12")
|
||||
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
|
||||
)
|
||||
|
||||
func getLANIPv4Addrs() ([]string, error) {
|
||||
ips := make([]string, 0)
|
||||
|
||||
addrs, err := anet.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
ipv4 := ipnet.IP.To4()
|
||||
if ipv4 == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
isLAN := lanA.Contains(ipv4) || lanB.Contains(ipv4) || lanC.Contains(ipv4)
|
||||
if !isLAN {
|
||||
continue
|
||||
}
|
||||
|
||||
ips = append(ips, ipv4.String())
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func FindPreferredLocalAddress(ips ...net.IP) (string, error) {
|
||||
localAddrs, err := anet.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, addr := range localAddrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
for _, ip := range ips {
|
||||
if ipnet.Contains(ip) {
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips[0].String(), nil
|
||||
}
|
61
pkg/server/options.go
Normal file
61
pkg/server/options.go
Normal file
@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/jaevor/go-nanoid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var newRandomInstanceID func() string
|
||||
|
||||
func init() {
|
||||
generator, err := nanoid.Standard(21)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not generate random instance id"))
|
||||
}
|
||||
|
||||
newRandomInstanceID = generator
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
InstanceID string
|
||||
Address string
|
||||
DisableServiceDiscovery bool
|
||||
}
|
||||
|
||||
type OptionFunc func(opts *Options)
|
||||
|
||||
func NewOptions(funcs ...OptionFunc) *Options {
|
||||
opts := &Options{
|
||||
InstanceID: NewRandomInstanceID(),
|
||||
Address: ":",
|
||||
DisableServiceDiscovery: false,
|
||||
}
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func WithAddress(addr string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.Address = addr
|
||||
}
|
||||
}
|
||||
|
||||
func WithInstanceID(id string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.InstanceID = id
|
||||
}
|
||||
}
|
||||
|
||||
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.DisableServiceDiscovery = disabled
|
||||
}
|
||||
}
|
||||
|
||||
func NewRandomInstanceID() string {
|
||||
return newRandomInstanceID()
|
||||
}
|
76
pkg/server/server.go
Normal file
76
pkg/server/server.go
Normal file
@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
browser browser.Browser
|
||||
|
||||
instanceID string
|
||||
address string
|
||||
port int
|
||||
disableServiceDiscovery bool
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
serverCtx, cancelServer := context.WithCancel(context.Background())
|
||||
|
||||
s.cancel = cancelServer
|
||||
s.ctx = serverCtx
|
||||
|
||||
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
|
||||
if err := s.startHTTPServer(httpServerCtx); err != nil {
|
||||
cancelHTTPServer()
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if !s.disableServiceDiscovery {
|
||||
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
|
||||
if err := s.startMDNServer(mdnsServerCtx); err != nil {
|
||||
cancelHTTPServer()
|
||||
cancelMDNSServer()
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
logger.Info(serverCtx, "service discovery disabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() error {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Wait() error {
|
||||
<-s.ctx.Done()
|
||||
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
|
||||
opts := NewOptions(funcs...)
|
||||
|
||||
return &Server{
|
||||
browser: browser,
|
||||
instanceID: opts.InstanceID,
|
||||
address: opts.Address,
|
||||
disableServiceDiscovery: opts.DisableServiceDiscovery,
|
||||
}
|
||||
}
|
101
pkg/server/templates/idle.html.gotmpl
Normal file
101
pkg/server/templates/idle.html.gotmpl
Normal file
@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcast - Idle</title>
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #e1e1e1;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
box-shadow: 2px 2px #3333331d;
|
||||
}
|
||||
|
||||
.panel p, .panel ul {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.text-centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h1>Arcast</h1>
|
||||
<p>Instance ID:</p>
|
||||
<p class="text-centered text-small">
|
||||
<code>{{ .ID }}</code>
|
||||
</p>
|
||||
<p>Addresses:</p>
|
||||
<ul class="text-italic text-small">
|
||||
{{ $port := .Port }}
|
||||
{{range .IPs}}
|
||||
<li><code>{{ . }}:{{ $port }}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
Reference in New Issue
Block a user