commit d382b0526df64ff808f755ed1021b966d7770d98 Author: decentral1se Date: Tue Mar 22 13:22:41 2022 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4370f28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.ssh +sshww +warm-welcome diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c463c0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ssh-warm-welcome: warm welcome pages over SSH +Copyright (C) 2022 decentral1se + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3f58dc --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# ssh-warm-welcome + + + +SSH as a publishing interface :tada: + +`ssh-warm-welcome` serves a [TUI] for reading markdown documents via a built-in +SSH server. In the context of a [pubnix] or [femserver] it can be a convenient +way to publish documentation to a general public via the terminal instead of +the browser. It's designed to be easy to use for beginners: no user accounts or +SSH keys are required, simply use your standard `/usr/bin/ssh` command to +connect & start reading. + +Here's a little demo video: [`vvvvvvaria.org/~decentral1se/sshww/demo.mp4`](https://vvvvvvaria.org/~decentral1se/sshww/demo.mp4) + +[pubnix]: https://tilde.town/~cmccabe/online-communities.html +[femserver]: https://gendersec.tacticaltech.org/wiki/index.php/Servers:_From_autonomous_servers_to_feminist_servers +[TUI]: https://en.wikipedia.org/wiki/Text-based_user_interface + +## Quickstart + +Download `ssh-warm-welcome` like so. + +(only `x86_64` is available right now but we can build more if you need it) + +```bash +curl https://vvvvvvaria.org/~decentral1se/sshww/sshww -o sshww +chmod +x sshww +``` + +Write your warm welcome home page in markdown: + +``` +mkdir -p warm-welcome +echo "# hello, world!\n\nwelcome, welcome" > warm-welcome/welcome.md +``` + +Then turn on the built-in SSH server: + +``` +./sshww +2022/04/03 00:11:39 warm welcome waiting on port :1312 +``` + +And from another terminal, connect with your SSH client: + +``` +ssh -p 1312 localhost +``` + +That's it! + +## Configure + +* `sshww` reads markdown files from a folder named `warm-welcome` in the + current working directory. Any file it finds there will be included as a menu + item in the warm welcome environment. `welcome.md` is the only required file, + everything else is optional. Up to 10 markdown files are supported. + +## Acknowledgements + +* [`egonelbre/gophers`](https://github.com/egonelbre/gophers) for the rad logo +* [`charm.sh`](https://github.com/charmbracelet) for new-wave command line glamour + +## License + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93b7456 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module decentral1se/sshww + +go 1.18 + +require ( + github.com/charmbracelet/bubbles v0.10.3 + github.com/charmbracelet/bubbletea v0.20.0 + github.com/charmbracelet/glamour v0.5.0 + github.com/charmbracelet/lipgloss v0.4.0 + github.com/charmbracelet/wish v0.3.0 + github.com/gliderlabs/ssh v0.3.3 +) + +require ( + github.com/alecthomas/chroma v0.10.0 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/keygen v0.2.1 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/microcosm-cc/bluemonday v1.0.17 // indirect + github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/yuin/goldmark v1.4.4 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect + golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect + golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b75666d --- /dev/null +++ b/go.sum @@ -0,0 +1,99 @@ +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= +github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= +github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= +github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/keygen v0.2.1 h1:H1yYTVe6qIDz+UILYXo6q+qLQNkvyXXA5KEhzyuEfzg= +github.com/charmbracelet/keygen v0.2.1/go.mod h1:kFQ3Cvop12fXWX1K29vxDxV9x8ujG4wBSXq//GySSSk= +github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= +github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= +github.com/charmbracelet/wish v0.3.0 h1:DsQ7wC5BfiQz07iOGiWB4GWJM53O4BPt3koO1Gqxw5w= +github.com/charmbracelet/wish v0.3.0/go.mod h1:KB8u7Bp4a/akXmGVqy+R/2OhMxgNoeJ+3lDgSgBrog4= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/gliderlabs/ssh v0.3.3 h1:mBQ8NiOgDkINJrZtoizkC3nDNYgSaWtxyem6S2XHBtA= +github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= +github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= +github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..a53a504 Binary files /dev/null and b/logo.png differ diff --git a/sshww.go b/sshww.go new file mode 100644 index 0000000..beb2f87 --- /dev/null +++ b/sshww.go @@ -0,0 +1,316 @@ +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 +}