package main import ( "context" "errors" "flag" "fmt" "io/ioutil" "log" "os" "os/signal" "path/filepath" "sort" "strconv" "strings" "syscall" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" lm "github.com/charmbracelet/wish/logging" "github.com/gliderlabs/ssh" ) const help = `ssh-warm-welcome: warm welcome pages over SSH Options: -p port for ssh server (default: 1312) -h output help ` var portFlag int var helpFlag bool var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "├" return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) }() selectedTitleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "├" return lipgloss.NewStyle().Underline(true).BorderStyle(b).Padding(0, 1) }() infoStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Left = "┤" return titleStyle.Copy().BorderStyle(b) }() ) func main() { handleCliFlags() if helpFlag { fmt.Printf(help) os.Exit(0) } if err := validatePages(); err != nil { log.Fatalf(err.Error()) } s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf("%s:%d", "0.0.0.0", portFlag)), wish.WithHostKeyPath(".ssh/term_info_ed25519"), wish.WithMiddleware( bm.Middleware(teaHandler), lm.Middleware(), ), ) if err != nil { log.Fatalln(err) } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.Printf("warm welcome waiting on port :%d", portFlag) go func() { if err = s.ListenAndServe(); err != nil { log.Fatalln(err) } }() <-done log.Println("turning off now...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { log.Fatalln(err) } } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { pages, err := gatherPages() if err != nil { log.Fatalf(err.Error()) } model, err := initModel(pages) if err != nil { log.Fatalf("unable to initialise model? (%s)", err.Error()) } return model, []tea.ProgramOption{tea.WithAltScreen()} } func handleCliFlags() { flag.IntVar(&portFlag, "p", 1312, "port for ssh server") flag.BoolVar(&helpFlag, "h", false, "output help") flag.Parse() } type page struct { name string path string contents string rendered string } type pages map[int]page func sortByConvention(files []os.FileInfo) { sort.Slice(files, func(i, j int) bool { if files[i].Name() == "welcome.md" { return true // always top of the list } return files[i].Name() < files[j].Name() }) } func validatePages() error { warmWelcomeDir, err := filepath.Abs("warm-welcome") if err != nil { return err } if _, err := os.Stat(warmWelcomeDir); os.IsNotExist(err) { return errors.New("'warm-welcome' directory missing from current working directory?") } files, err := ioutil.ReadDir(warmWelcomeDir) if err != nil { return fmt.Errorf("unable to list files in %s (%s)", warmWelcomeDir, err.Error()) } hasWelcome := false for _, file := range files { if file.Name() == "welcome.md" { hasWelcome = true } } if !hasWelcome { return errors.New("welcome.md is missing from warm-welcome directory (required)?") } return nil } func gatherPages() (pages, error) { pages := make(map[int]page) warmWelcomeDir, err := filepath.Abs("warm-welcome") if err != nil { return pages, err } files, err := ioutil.ReadDir(warmWelcomeDir) if err != nil { return pages, fmt.Errorf("unable to list files in %s (%s)", warmWelcomeDir, err.Error()) } sortByConvention(files) for idx, file := range files { filePath := filepath.Join(warmWelcomeDir, file.Name()) contents, err := ioutil.ReadFile(filePath) if err != nil { return pages, fmt.Errorf("unable to read %s (%s)", filePath, err.Error()) } rendered, err := glamour.Render(string(contents), "dark") if err != nil { return pages, err } pages[idx] = page{ name: file.Name(), path: filePath, contents: string(contents), rendered: rendered, } } return pages, nil } type model struct { pages map[int]page pageIndex int viewport viewport.Model ready bool } func initModel(pgs pages) (model, error) { return model{pages: pgs}, nil } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10": idx, err := strconv.Atoi(msg.String()) if err == nil { if _, ok := m.pages[idx-1]; ok { m.pageIndex = idx - 1 m.viewport.SetContent(m.pages[m.pageIndex].rendered) m.viewport.GotoTop() } } return m, nil default: m.viewport.Update(msg) } case tea.WindowSizeMsg: headerHeight := lipgloss.Height(m.headerView()) footerHeight := lipgloss.Height(m.footerView()) + lipgloss.Height(m.helpView()) verticalMarginHeight := headerHeight + footerHeight if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) m.viewport.YPosition = headerHeight m.viewport.SetContent(m.pages[m.pageIndex].rendered) m.ready = true } else { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - verticalMarginHeight } } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, tea.Batch(cmd) } func (m model) View() string { if !m.ready { return "\n initializing..." } header := m.headerView() viewp := m.viewport.View() footer := m.footerView() return fmt.Sprintf("%s\n%s\n%s\n%s", header, viewp, footer, m.helpView()) } func (m model) helpView() string { return "↑/↓: scroll pager • 1/2/3... choose page • q: quit" } func (m model) headerView() string { indices := make([]int, 0, len(m.pages)) for idx := range m.pages { indices = append(indices, idx) } sort.Ints(indices) var pagesTotalWidth int var header []string for _, idx := range indices { var pageRender string if idx == m.pageIndex { pageRender = selectedTitleStyle.Render(m.pages[idx].name) } else { pageRender = titleStyle.Render(m.pages[idx].name) } header = append(header, pageRender) pagesTotalWidth += lipgloss.Width(pageRender) } line := strings.Repeat("─", max(0, m.viewport.Width-pagesTotalWidth)) header = append(header, line) return lipgloss.JoinHorizontal(lipgloss.Center, header...) } func (m model) footerView() string { info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) return lipgloss.JoinHorizontal(lipgloss.Center, line, info) } func max(a, b int) int { if a > b { return a } return b }