317 lines
6.8 KiB
Go
317 lines
6.8 KiB
Go
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
|
|
}
|