package learning import ( "context" "embed" "fmt" "io" "log" "net/http" "os" "strings" "sync" "time" "codeberg.org/arimelody/ari-is-learning/broadcast" "github.com/gin-gonic/gin" ) //go:embed public var publicFS embed.FS //go:embed pages var pagesFS embed.FS type ( ServiceConfig struct { TitleFilePath string } Service struct { cfg ServiceConfig ctx context.Context titleFileMutex sync.Mutex titleLastModTime time.Time titleText string titleUpdated chan string titleUpdatedBroadcast broadcast.BroadcastChannel[string] } ) func New(ctx context.Context, cfg ServiceConfig) *Service { titleUpdated := make(chan string) if _, err := os.Stat(cfg.TitleFilePath); os.IsNotExist(err) { log.Printf("Learning title file [%s] does not exist, creating one...", cfg.TitleFilePath) os.WriteFile(cfg.TitleFilePath, []byte("Untitled"), 0644) } else if err != nil { log.Fatalf("Failed to stat title file: %v", err) } srv := &Service{ cfg: cfg, ctx: ctx, titleFileMutex: sync.Mutex{}, titleLastModTime: time.Unix(0, 0), titleText: "", titleUpdated: titleUpdated, titleUpdatedBroadcast: broadcast.NewBroadcastChannel(ctx, titleUpdated), } return srv } func (srv *Service) BindRoutes(group *gin.RouterGroup) { group.GET("/public/*path", func(ctx *gin.Context) { path := strings.TrimPrefix(ctx.Request.URL.Path, "/learning/") http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path) }) group.GET("/sse", func(ctx *gin.Context) { ctx.Header("connection", "keep-alive") ctx.SSEvent("title-update", srv.titleText) titleUpdated := srv.titleUpdatedBroadcast.Subscribe() ticker := time.NewTicker(10 * time.Millisecond) ctx.Stream(func(w io.Writer) bool { select { case titleText := <-titleUpdated: ctx.SSEvent("title-update", titleText) case <-ticker.C: } return true }) srv.titleUpdatedBroadcast.Cancel(titleUpdated) }) group.POST("/title", func(ctx *gin.Context) { data, err := io.ReadAll(ctx.Request.Body) if err != nil { log.Printf("Failed to read request body: %v", err) ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) return } log.Printf("Waiting on title file lock...") srv.titleFileMutex.Lock() defer srv.titleFileMutex.Unlock() log.Printf("Got it!") file, err := os.OpenFile(srv.cfg.TitleFilePath, os.O_RDWR | os.O_CREATE | os.O_TRUNC, 0) if err != nil { log.Printf("Failed to open title file: %v", err) ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) return } if _, err := file.Write(data); err != nil { log.Printf("Failed to write to title file: %v", err) ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) return } ctx.String(http.StatusOK, http.StatusText(http.StatusOK)) }) group.GET("/hello", func(ctx *gin.Context) { http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/index.html") }) group.GET("/brb", func(ctx *gin.Context) { http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/brb.html") }) group.GET("/bye", func(ctx *gin.Context) { http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/bye.html") }) } func (srv *Service) Run(ctx context.Context) { failed := make(chan error) go func() { for { srv.titleFileMutex.Lock() stat, err := os.Stat(srv.cfg.TitleFilePath) if err != nil { failed<-fmt.Errorf("Failed to stat title file: %v", err) return } if srv.titleLastModTime.Before(stat.ModTime()) { data, err := os.ReadFile(srv.cfg.TitleFilePath) if err != nil { failed<-fmt.Errorf("Failed to read title file: %v", err) return } log.Printf( "title updated: \"%s\" -> \"%s\"", srv.titleText, strings.TrimSpace(string(data)), ) srv.titleText = strings.TrimSpace(string(data)) if !srv.titleLastModTime.IsZero() { srv.titleUpdated<-srv.titleText } srv.titleLastModTime = stat.ModTime() } srv.titleFileMutex.Unlock() time.Sleep(100 * time.Millisecond) } }() select { case err := <-failed: log.Fatalf("Learning service error: %v", err) case <-ctx.Done(): } }