commit
d382b0526d
7 changed files with 539 additions and 0 deletions
@ -0,0 +1,3 @@ |
|||
.ssh |
|||
sshww |
|||
warm-welcome |
@ -0,0 +1,15 @@ |
|||
ssh-warm-welcome: warm welcome pages over SSH |
|||
Copyright (C) 2022 decentral1se <d34ae261d3@sinenomine.email> |
|||
|
|||
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 <https://www.gnu.org/licenses/>. |
@ -0,0 +1,67 @@ |
|||
# ssh-warm-welcome |
|||
|
|||
<img align="right" width="150" src="./logo.png"/> |
|||
|
|||
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 |
|||
|
|||
<a><img src="https://www.gnu.org/graphics/gplv3-or-later.png"/></a> |
@ -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 |
|||
) |
@ -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= |
After Width: | Height: | Size: 140 KiB |
@ -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 |
|||
} |
Loading…
Reference in new issue