Warm welcome pages over SSH ☺️
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

316 lines
6.8 KiB

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
}