Compare commits

...

160 Commits

Author SHA1 Message Date
vilmibm ca33731826 no, c 2023-11-04 05:11:36 +00:00
vilmibm 81c9dede67 tweak 2023-11-04 05:09:33 +00:00
vilmibm 4958407856 add Makefile 2023-11-04 05:08:46 +00:00
vilmibm 9bb456bc17 add newline to keyfile if needed 2023-11-01 20:59:49 +00:00
vilmibm 69ad6a384a idk 2023-11-01 20:36:53 +00:00
vilmibm 4284fb4048 logger 2023-11-01 03:42:43 +00:00
vilmibm 100643d8fc further the rearranging 2023-11-01 03:25:26 +00:00
vilmibm 0764534fed rearrange things, add locking writer 2023-11-01 02:44:22 +00:00
vilmibm 69666edefa success note 2023-10-26 22:36:48 +00:00
vilmibm add129826a assume prompts are required 2023-10-26 22:32:30 +00:00
vilmibm 90808c1ce0 bug fixes, ready for external qa 2023-10-25 19:24:01 +00:00
vilmibm 17d39483fb fix logging 2023-10-25 17:52:04 +00:00
vilmibm 418e4a4a14 logging 2023-10-25 17:42:46 +00:00
vilmibm 79dc987c61 minor 2023-10-25 08:44:20 +00:00
vilmibm ba1a1319e3 sigh 2023-10-25 08:42:31 +00:00
vilmibm cbc868ae35 oops 2023-10-25 08:34:58 +00:00
vilmibm 940779876c oops 2023-10-25 08:34:32 +00:00
vilmibm f53b2721cb use helper 2023-10-25 08:33:25 +00:00
vilmibm b1ff57ba58 fill in emailtouser 2023-10-25 08:24:23 +00:00
vilmibm be5020ad28 WIP retooling bc of permission issue 2023-10-25 04:39:13 +00:00
vilmibm 9bea4257c1 notes 2023-10-25 03:52:54 +00:00
vilmibm 2a07a0e200 mark code used 2023-10-25 03:03:36 +00:00
vilmibm 7255ee691e minor 2023-10-25 02:48:01 +00:00
vilmibm 44686ad536 add new appendkeyfile helper 2023-10-25 02:41:16 +00:00
vilmibm 529e14158a forgot to add these 2023-10-25 02:41:02 +00:00
vilmibm bf244101e6 finish code validation, start on key handling 2023-10-25 01:40:37 +00:00
vilmibm 6fa11aba8e code processing, TODOs 2023-10-24 19:23:35 +00:00
vilmibm 5c2142f6e7 auth code db methods 2023-10-24 19:17:16 +00:00
vilmibm 92faddd079 refactor prompting, start on redeemCode 2023-10-24 19:10:13 +00:00
vilmibm 6950ba7109 send auth code email 2023-10-24 18:58:34 +00:00
vilmibm a3b13d21b3 creating auth codes in db 2023-10-24 06:22:21 +00:00
vilmibm 5c6f4cce19 ref 2023-10-24 05:16:51 +00:00
vilmibm 2a966bf842 ask db about users 2023-10-24 05:15:04 +00:00
vilmibm 880511a79a add color and email prompt 2023-10-21 04:58:30 +00:00
vilmibm 0884ba2ff6 fix glaring upsetting error in signup copy 2023-10-18 00:48:47 +00:00
vilmibm d0b79f3e7c start on bare bones help SSH command 2023-10-18 00:48:37 +00:00
vilmibm 69ec0b4e4b Merge pull request 'add search, scrolling, forward/backward navigation' (#2) from equa/town:trunk into trunk
Reviewed-on: #2
2023-07-20 16:27:40 +00:00
equa 1e7a016dca add search function 2023-07-12 10:33:27 -04:00
equa 0472c24199 basic email validation
the code for the panes is getting messy. since we have one window we
constantly reuse, and especially now that we have weird state-dependent
validation. probably good to rework?
2023-07-11 22:08:22 -04:00
equa a124b27021 review: make application scrollable; add S to go back to previous entry 2023-07-11 16:41:35 -04:00
vilmibm edf4f68932 stub help command 2023-06-07 07:12:32 +00:00
vilmibm 2fbedb75c2 have wire guy suggest nodding 2023-03-16 22:33:06 +00:00
vilmibm 1ca0893c2e minor 2023-03-16 21:38:54 +00:00
vilmibm ccc357591e fix SignupURL in stats 2023-03-16 07:09:28 +00:00
vilmibm 852a104300 annoying text input things 2023-03-16 07:04:32 +00:00
vilmibm bf2f2e3790 minor 2023-03-16 06:43:58 +00:00
vilmibm c5590cac95 dump script for user db 2023-03-16 06:41:22 +00:00
vilmibm f624483614 oops 2023-03-16 06:17:06 +00:00
vilmibm 1bec2349cb set created and admin when inserting user 2023-03-16 06:15:12 +00:00
vilmibm e12d92735d fix note import 2023-03-15 08:25:06 +00:00
vilmibm 3638685d24 add note 2023-03-15 08:16:16 +00:00
vilmibm cb0d574a76 ok 2023-03-15 08:13:44 +00:00
vilmibm 7291a43e68 oops 2023-03-15 08:11:55 +00:00
vilmibm 869eaa5f3b add dumps script 2023-03-15 07:53:36 +00:00
vilmibm 5876b0ebe3 Merge pull request 'fix spaces in passwords' (#1) from equa/town:trunk into trunk
Reviewed-on: #1
2023-03-14 21:57:09 +00:00
equa d5e6960573 fix spaces in passwords
sometimes they showed up in the beginning/ends of passwords. there's no
actual reason to have them otherwise so i'm just removing them
2023-03-14 13:53:26 -04:00
vilmibm 02c5079e31 use raw email value as a placeholder 2023-03-11 23:50:00 +00:00
vilmibm 2f6164635f have wire guy respond to email 2023-03-10 06:22:23 +00:00
vilmibm 3d877ea184 switch to crypto/rand 2023-03-10 03:21:26 +00:00
vilmibm 9353e3f414 fix email 2023-03-10 03:21:12 +00:00
vilmibm 9b1143e18d fix issue where msgScroll did not scroll 2023-03-10 03:21:01 +00:00
vilmibm 96d487ede2 wording 2023-03-10 03:20:50 +00:00
vilmibm d82c633ee5 mention review timeframe 2023-03-09 08:22:28 +00:00
vilmibm 76bf2643a6 README cleanup 2023-03-09 06:47:32 +00:00
vilmibm 8fb6208dbf cleanup some TODOs 2023-03-09 06:42:18 +00:00
vilmibm b789865943 set Code when inserting 2023-03-09 06:39:20 +00:00
vilmibm c43adc49fb email accepted users 2023-03-09 06:33:31 +00:00
vilmibm cee8b75bad WIP on sending external email 2023-03-07 01:06:46 +00:00
vilmibm 09269126d8 CoC prompt 2023-03-06 21:00:59 +00:00
vilmibm d407e26917 mark invites as used 2023-03-06 20:44:19 +00:00
vilmibm 57115b1c11 welcome working end to end 2023-03-06 20:34:29 +00:00
vilmibm 92807f9b6b call registeruser from welcome 2023-03-06 19:33:36 +00:00
vilmibm 55eb4b7010 WIP insert helper 2023-03-06 18:18:24 +00:00
vilmibm c4c02533e7 town db scripting 2023-03-06 03:07:35 +00:00
vilmibm 8d531936a1 WIP inserting users 2023-03-06 03:04:27 +00:00
vilmibm c0a8c50dbb WIP towndb 2023-03-04 22:19:27 +00:00
vilmibm df4eeaba13 stuff, but also things 2023-03-04 01:15:09 +00:00
vilmibm 015b28ba6a actually sudo as user as intended 2023-03-04 01:02:52 +00:00
vilmibm 6a1fcbcf32 add welcome message 2023-03-04 00:51:40 +00:00
vilmibm 9444954bc6 golf and also securing 2023-03-04 00:33:01 +00:00
vilmibm 872d2ade23 oops 2023-03-04 00:22:37 +00:00
vilmibm c3dc5ae0ed createUser first pass 2023-03-04 00:20:18 +00:00
vilmibm 21e51a829f double validate username 2023-03-04 00:18:45 +00:00
vilmibm e5cf8a5521 WIP keyfile stuff 2023-03-04 00:15:29 +00:00
vilmibm 2acc042fe7 first pass on keyfile helper 2023-03-03 22:01:21 +00:00
vilmibm 6741079152 call adduser, usermod 2023-03-03 21:39:36 +00:00
vilmibm 3940fe58ae WIP user creation 2023-03-03 21:29:13 +00:00
vilmibm 4847c19eaf this logging was of little value and caused sadness 2023-03-03 21:18:23 +00:00
vilmibm 28ac63f256 finish form part, next make user account 2023-03-03 21:14:22 +00:00
vilmibm f28da14d98 pubkey validation 2023-03-01 05:22:09 +00:00
vilmibm cec7ee4a82 check for email fqdn 2023-03-01 00:33:08 +00:00
vilmibm add10cb754 email collection 2023-03-01 00:10:45 +00:00
vilmibm 9442ecb55e more username validation 2023-02-28 23:44:34 +00:00
vilmibm 8716140b40 accept username 2023-02-28 23:18:11 +00:00
vilmibm 9de98bf2ab move stats into its own library 2023-02-28 23:17:59 +00:00
vilmibm 5a41d99ff9 WIP username input 2023-02-28 19:30:07 +00:00
vilmibm 128b53edbe switching over to just survey 2023-02-28 05:50:55 +00:00
vilmibm d5aff6fc83 generate invites on acceptance 2023-02-26 23:02:51 +00:00
vilmibm c0d205b447 ignore binary 2023-02-26 21:39:16 +00:00
vilmibm 33ea591651 WIP on welcome tool 2023-02-26 08:57:46 +00:00
vilmibm e339fa8cb6 notes 2023-02-24 23:08:48 +00:00
vilmibm b3d1b25131 write up README for welcome command 2023-02-24 23:02:29 +00:00
vilmibm 84cc13bf07 repurpose package 2023-02-24 20:59:07 +00:00
vilmibm fca0ecae4e finish review for now; start on welcome 2023-02-24 20:55:16 +00:00
vilmibm ced73adb77 remove some comments 2023-02-23 22:37:16 +00:00
vilmibm 5e20b0569e tweaks 2023-02-23 22:25:15 +00:00
vilmibm 6ec2a52db8 approval/rejection 2023-02-23 22:20:39 +00:00
vilmibm 29ba24d36c WIP clean email modal 2023-02-23 07:40:31 +00:00
vilmibm c1f041e9d8 more determinism 2023-02-23 07:31:38 +00:00
vilmibm 7543c2c4cd fixes 2023-02-23 07:30:03 +00:00
vilmibm 7ecb79793f render notes 2023-02-23 07:19:30 +00:00
vilmibm 46ee8932c1 can add signup notes 2023-02-23 06:20:00 +00:00
vilmibm 8ecfe7a940 WIP adding signup notes 2023-02-23 00:14:38 +00:00
vilmibm df41bb4df2 retooling review for sql 2023-02-23 00:04:42 +00:00
vilmibm 717c1b93f1 mention /quit in legend and add host for final scene 2023-02-22 21:52:33 +00:00
vilmibm 8e6b2edbc8 ignore more binaries 2023-02-21 06:14:50 +00:00
vilmibm 05ac83019c fixes, readme 2023-02-21 06:13:51 +00:00
vilmibm a464159a21 remove extra crap 2023-02-21 06:00:10 +00:00
vilmibm 86dc9cef2d port to sceneManager, IRC feedback 2023-02-21 05:57:27 +00:00
vilmibm cb83223ab8 working 2023-02-21 03:56:19 +00:00
vilmibm f579c811f3 retooling for sql 2023-02-20 08:43:14 +00:00
vilmibm 0b940cdf1c go get sqlite3 2023-02-18 03:46:52 +00:00
vilmibm e7ff5606f1 WIP on notating signups 2023-02-17 06:06:37 +00:00
vilmibm 41e5463756 WIP 2023-02-17 05:53:05 +00:00
vilmibm 7e8f8cb0fc WIP on accept/reject 2023-02-16 05:48:26 +00:00
vilmibm b03e4f069e skip through applications 2023-02-16 05:08:31 +00:00
vilmibm 327e0551b9 WIP 2023-02-14 07:33:53 +00:00
vilmibm 5682d3dafd start on review tool 2023-02-14 04:48:12 +00:00
vilmibm efbde68f9d TODOs from IRC feedback 2023-02-14 04:15:10 +00:00
vilmibm df1a18dae3 todos 2023-02-13 06:15:19 +00:00
vilmibm 45dd8efbae unfuck input collection 2023-02-13 06:13:07 +00:00
vilmibm 462e8772ec save answers to file, some cleanup (broken) 2023-02-13 01:28:45 +00:00
vilmibm 4a881d3b8b refactor stuff 2023-02-12 00:28:20 +00:00
vilmibm 336622ca62 remove some old stuff 2023-02-10 20:07:10 +00:00
vilmibm ea484671ed have hosts talk when no answer 2023-02-10 20:05:06 +00:00
vilmibm 4dbeb6984f record user inputs 2023-02-10 19:47:09 +00:00
vilmibm 39396eba4e some color/presentation tweaks 2023-02-10 00:22:04 +00:00
vilmibm d7adcbf11f WIP 2023-02-05 22:58:22 +00:00
vilmibm 6b86087c0e WIP start writing this for real 2023-02-03 05:03:03 +00:00
vilmibm cf99807126 stuff n things 2023-02-03 03:48:40 +00:00
vilmibm 65228979e9 add soem more cmd categories 2022-11-16 20:27:23 +00:00
vilmibm 75d64bf7b6 simplify this and make it parse new format 2022-10-30 23:09:06 +00:00
vilmibm b1f7e9842d notes on new functionality 2022-10-29 19:20:10 +00:00
vilmibm e668db7e15 properly seed rand. accept comments. fix u.Name 2022-10-27 18:52:17 +00:00
vilmibm 4a2d31e5fd finish first pass on submitting commands 2022-10-08 04:44:15 +00:00
vilmibm 571c37b089 WIP 2022-07-31 21:24:50 +00:00
vilmibm 122af44a9e upgrade to go 1.18 2022-07-31 13:43:40 +00:00
vilmibm a2e5c81752 WIP working on contrib 2022-07-31 13:43:29 +00:00
vilmibm 77049703a4 add gitignore 2022-07-31 12:05:50 +00:00
vilmibm 54174c7194 stuff 2022-07-31 12:05:43 +00:00
vilmibm a92c972d43 make use of IsAdmin 2022-07-31 08:57:45 +00:00
vilmibm 362ca0fa19 add visit 2022-07-30 13:51:51 +00:00
vilmibm 7e22317ae9 copy paste stats 2022-07-30 13:47:47 +00:00
vilmibm 1bdd9249fc copy paste launcher 2022-07-30 13:45:37 +00:00
vilmibm 9464d393e3 fix var 2022-07-30 13:42:33 +00:00
vilmibm 8138733bd1 move request code 2022-07-30 13:39:48 +00:00
vilmibm 470ebb5507 fix username spoofing 2022-07-21 18:11:41 +00:00
vilmibm b10bdd756a move request stuff here 2021-04-27 16:14:15 +00:00
vilmibm d29fabfa52 start on dedicated 'request' command 2021-04-24 20:35:02 +00:00
vilmibm c965c578bf sigh 2021-03-23 22:07:50 +00:00
55 changed files with 4930 additions and 14 deletions

15
.gitignore vendored 100644
View File

@ -0,0 +1,15 @@
*.swp
bin/
cmd/launcher/launcher
cmd/request/request
cmd/contrib/contrib
cmd/visit/visit
cmd/review/review
cmd/stats/stats
external/cmd/signup/signup
external/cmd/welcome/welcome
external/cmd/help/help
external/cmd/helpers/emailtouser/emailtouser
external/cmd/helpers/createkeyfile/createkeyfile
external/cmd/helpers/registeruser/registeruser
external/cmd/helpers/appendkeyfile/appendkeyfile

59
Makefile 100644
View File

@ -0,0 +1,59 @@
all: cmds external
install: all
cp bin/launcher /usr/local/bin/town
cp bin/stats /town/bin/
cp bin/contrib /town/bin/
cp bin/request /town/bin/
cp bin/appendkeyfile /town/bin/
cp bin/createkeyfile /town/bin/
cp bin/emailtouser /town/bin/
cp bin/registeruser /town/bin/
cp bin/help /town/bin/external/
cp bin/welcome /town/bin/external/
cp bin/signup /town/bin/external/
clean:
rm -rf bin
external: bin/help bin/welcome bin/signup exthelpers
bin/help: external/cmd/help/main.go bin
go build -o bin/help ./external/cmd/help
bin/welcome: external/cmd/welcome/main.go bin
go build -o bin/welcome ./external/cmd/welcome
bin/signup: external/cmd/signup/main.go bin
go build -o bin/signup ./external/cmd/signup
exthelpers: bin/appendkeyfile bin/createkeyfile bin/emailtouser bin/registeruser
bin/appendkeyfile: external/cmd/helpers/appendkeyfile/main.go bin
go build -o bin/appendkeyfile ./external/cmd/helpers/appendkeyfile
bin/createkeyfile: external/cmd/helpers/createkeyfile/main.go bin
go build -o bin/createkeyfile ./external/cmd/helpers/createkeyfile
bin/emailtouser: external/cmd/helpers/emailtouser/main.go bin
go build -o bin/emailtouser ./external/cmd/helpers/emailtouser
bin/registeruser: external/cmd/helpers/registeruser/main.go bin
go build -o bin/registeruser ./external/cmd/helpers/registeruser
cmds: bin/launcher bin/stats bin/contrib bin/request
bin/launcher: cmd/launcher/main.go bin
go build -o bin/launcher ./cmd/launcher
bin/stats: cmd/stats/main.go bin
go build -o bin/stats ./cmd/stats
bin/contrib: cmd/contrib/main.go bin
go build -o bin/contrib ./cmd/contrib
bin/request: cmd/request/main.go bin
go build -o bin/request ./cmd/request
bin:
mkdir -p bin

View File

@ -1,5 +1,22 @@
it might end up being more but for now this repository houses common tooling for tide.town. the
primary impetus is to share some common Go code i want to use in a variety of helper programs (stuff
for querying the town about users and statistics and things).
# town
it might house non-go code eventually, we'll see.
This repository contains custom commands and helper packages for town stuff.
commands:
- `launcher` (invoked on town as `town`), a launcher for various town things (including user contributed commands)
- `request` (invokved on town as `town request-gitea` or `town request-gemini`), a helper command for requesting certain featuers be enabled for your town account
- `stats` (invoked as `town stats`), a command that prints out information about the town and its users in JSON.
- `visit` an experimental command for "visiting" a user.
- `signup` command that powers `ssh join@tilde.town`
- `welcome` command that powers `ssh welcome@tilde.town`
- `review` a TUI for town admins to review signups
There are also sundry helpers and scripts under `cmd/`.
A lot of this behavior (for example, `stats`) is exposed as a library. if you want to make some stuff for town and want to work in Go feel free to import this and use it.
## TODO
- [ ] consider bringing the launcher's index of commands into this git repo so it can be tracked
- [ ] add a Makefile

4
TODO
View File

@ -1,2 +1,4 @@
- [ ] admin check for a user struct
- [x] admin check for a user struct
- [x] gitea requesting
- [x] gemini requesting
- [ ] get user stats/info

View File

@ -0,0 +1,67 @@
# contrib (town contrib)
the point of this command is to request to an admin that a particular executable path be exposed via the town launcher.
## mockup
```
town contrib ~/bin/mycoolexe
? How should the new command be called? [mycoolexe]
? What is a one line description of this program? cool dingus
? What category is this program in?
artistic
game
utility
> social
misc
? Press e to open your editor and write a longer description of this program...
FYI! you will be the maintainer of this command and only you (or an admin) will be able to make updates to it once it is approved.
v created request for command mycoolexe. if too much time passes without addition, bother vilmibm.
```
```
# uses uid to guard updates
town contrib -u mycoolexe ~/bin/newpath/mycoolexe
...updated path for mycoolexe to ~/bin/newpath/mycoolexe
? Want to edit other stuff (description, category, etc) [yN] n
```
```
# uses uid to guard updates
town contrib -d mycoolexe
? To confirm deletion of mycoolexe, type its name: mycoolexe
v deleted mycoolexe
```
```
# enforces admin access
town contrib --admin ~someone//bin/something
? How should the new command be called? [something]
? What is a one line description of this program? cool dingus
? Who is the maintainer? [vilmibm] someone
? What category is this program in?
artistic
game
utility
> social
misc
? Press e to open your editor and write a longer description of this program...
```
in addition to this new interface, i want to change how commands are tracked. the script + yaml file is very cumbersome and confusing and has the bad race condition of the two files not being written at the same time. it means i hate adding commands.
- sqlite3. a little overkill.
- json file. a little underkill.
what am I doing. should there even be a system like this? what about just letting anyone add anything? i think we're big enough that we shouldn't go moderation less and i can also help guarantee things are runnable and maintainable, this way.
i have a gut feeling that involving SQL here is bad. I like the flat file approach for requesting that something be added to core; those are easy to review and tweak, but after that, what to do? right now the town launcher consumes a directory of flat files to create the command hierarchy. i could keep that code the same. the ultimate issue is having to have the corresponding executable. right now, that might be a bash wrapper that i write; it could be a compiled executable; it could be a script someone else writes; as of now, it doesn't seem like it can be a symlink which i think is good.
several of the wrapper scripts are just calling the executable path and passing args; i think i should just do that from go.
so we'll have: /town/requests/contrib to store requests to add. once added, they will go to /town/commands/contrib as a yaml file with no need for a corresponding executable.
competing with this, i want a `town wild` command that can call any executable in a world writeable dir. because it's funny

260
cmd/contrib/main.go 100644
View File

@ -0,0 +1,260 @@
package main
import (
"errors"
"fmt"
"math/rand"
"os"
"os/user"
"path"
"path/filepath"
"time"
"git.tilde.town/tildetown/town/email"
tuser "git.tilde.town/tildetown/town/user"
"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
const (
contribRequestPath = "/town/requests/contrib"
submitOp = "submit"
delOp = "del"
updateOp = "update"
)
type contribOpts struct {
CmdName string
Force bool // instantly install, do not make a request
Operation string // submit, delete, update
ExecPath string
}
type contrib struct {
CmdName string
ExecPath string
Maintainer string
MaintainerUID string
Category string
ShortDesc string
LongDesc string
Comments string
}
func runContrib(opts *contribOpts) error {
var action func(*contribOpts) error
switch opts.Operation {
case submitOp:
action = submit
case delOp:
action = delete
case updateOp:
action = update
}
return action(opts)
}
func validExec(execPath string) error {
if !path.IsAbs(execPath) {
return fmt.Errorf("'%s' needs to be an absolute path", execPath)
}
fi, err := os.Stat(execPath)
if err != nil {
return fmt.Errorf("could not stat '%s'; does it exist? error: %w", execPath, err)
}
if fi.Mode()&0001 == 0 {
return fmt.Errorf("'%s' is not executable", execPath)
}
return nil
}
func submit(opts *contribOpts) error {
rand.Seed(time.Now().UTC().UnixNano())
var cmdName string
var category string
var shortDesc string
var longDesc string
var comments string
u, err := user.Current()
if err != nil {
return fmt.Errorf("that's my purse. I don't know you! %w", err)
}
// TODO should commands be markable as for admins only? i feel like that can be a parallel, more manual system.
isAdmin, err := tuser.IsAdmin(u)
if err != nil {
return fmt.Errorf("that's my purse. I don't know you! %w", err)
}
if opts.Force && !isAdmin {
return errors.New("must be admin to use --force")
}
if err := validExec(opts.ExecPath); err != nil {
return err
}
_, defaultName := filepath.Split(opts.ExecPath)
if err := survey.AskOne(&survey.Input{
Message: "what is the command's name?",
Help: "for example if it's called 'cats' it will be invoked when users run 'town cats'",
Default: defaultName}, &cmdName); err != nil {
return err
}
categories := []string{
"art",
"social",
"game",
"utility",
"programming",
"community",
"misc",
}
var choice int
if err := survey.AskOne(&survey.Select{
Message: "what category best describes this command?",
Options: categories,
}, &choice); err != nil {
return err
}
category = categories[choice]
if err := survey.AskOne(&survey.Input{
Message: "in one brief sentence, what does this command do?",
}, &shortDesc); err != nil {
return err
}
if err := survey.AskOne(&survey.Editor{
Message: "yr editor is gonna open. in there please write a long description for this command. not like, novel long, but maybe a sentence or two and some examples."}, &longDesc); err != nil {
return nil
}
if err := survey.AskOne(&survey.Multiline{
Message: "any comments for the admins? this won't be public, it's just to give admins any additional context about this command.",
}, &comments); err != nil {
return nil
}
// TODO be able to set a maintainer other than caller. this might only be if an admin.
// TODO would be fun if it was a Select using a user list -- extract that from stats cmd
c := contrib{
CmdName: cmdName,
Category: category,
ShortDesc: shortDesc,
LongDesc: longDesc,
ExecPath: opts.ExecPath,
// for later validation against file owner
Maintainer: u.Username,
MaintainerUID: u.Uid,
Comments: comments,
}
bs, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("failed to serialize contrib: %w", err)
}
fname := fmt.Sprintf("%d.yml", time.Now().Unix())
f, err := os.Create(path.Join(contribRequestPath, fname))
if err != nil {
return fmt.Errorf("failed to open contrib file for writing: %w", err)
}
if _, err = f.Write(bs); err != nil {
return fmt.Errorf("failed to write contrib file: %w", err)
}
err = email.SendLocalEmail("vilmibm", fmt.Sprintf("contrib: %s", cmdName), string(bs))
if err != nil {
return fmt.Errorf("could not email vilmibm. ping them on IRC or something. the error was: %w", err)
}
fmt.Printf("submitted %s for review! thank you~\n", cmdName)
return nil
}
func update(opts *contribOpts) error {
return nil
}
func delete(opts *contribOpts) error {
return nil
}
func rootCmd() *cobra.Command {
var updateName string
var delName string
var force bool
rc := &cobra.Command{
Use: "contrib [path to executable]",
Short: "Submit new commands to the town launcher",
RunE: func(cmd *cobra.Command, args []string) error {
// submit - requires path arg
// update - requres path arg
// delete - no arg needed
if updateName != "" && delName != "" {
return errors.New("-u and -d are mutually exclusive")
}
var cmdName string
operation := "submit"
if updateName != "" {
operation = "update"
cmdName = updateName
} else if delName != "" {
operation = "delete"
cmdName = delName
}
if (operation == "update" || operation == "submit") && len(args) == 0 {
return errors.New("path to executable required when submitting or updating")
}
if operation == "delete" && len(args) > 0 {
return fmt.Errorf("no arguments expected when deleting; got %d", len(args))
}
if len(args) > 1 {
return fmt.Errorf("at most one argument is accepted; got %d", len(args))
}
var execPath string
if len(args) == 1 {
execPath = args[0]
}
opts := &contribOpts{
Operation: operation,
Force: force,
ExecPath: execPath,
CmdName: cmdName,
}
return runContrib(opts)
},
// TODO longer example
}
rc.Flags().StringVarP(&updateName, "update", "u", "", "Name of command to update")
rc.Flags().StringVarP(&delName, "delete", "d", "", "Name of command to delete")
// TODO hiding this until i decide i want it or not
//rc.Flags().BoolVarP(&force, "force", "f", false, "skip request, just install the command")
return rc
}
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}

BIN
cmd/dumps/dumps 100755

Binary file not shown.

204
cmd/dumps/main.go 100644
View File

@ -0,0 +1,204 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"git.tilde.town/tildetown/town/models"
"git.tilde.town/tildetown/town/signup"
"git.tilde.town/tildetown/town/stats"
"git.tilde.town/tildetown/town/towndb"
"github.com/AlecAivazis/survey/v2"
)
// this is basically a pile of scripts. no hope is to be found here. this is
// dirty, one off code stored in case any useful patterns are worth extracting
// or for copypasta fodder.
func confirmContinue(msg string) {
var serr error
var conf bool
if serr = survey.AskOne(&survey.Confirm{
Message: msg,
Default: false,
}, &conf); serr != nil {
os.Exit(2)
}
if !conf {
os.Exit(1)
}
}
type jsonSignup struct {
Created float64
Email string
Username string
Reasons string
Plans string
Referral string
Socials string
Notes string
}
func main() {
db, err := towndb.ConnectDB()
if err != nil {
panic(err)
}
lol, err := os.ReadFile("/town/var/users.json")
if err != nil {
panic(err)
}
td, err := stats.Stats()
if err != nil {
panic(err)
}
lines := strings.Split(string(lol), "\n")
errs := []error{}
signups := make([]jsonSignup, len(lines))
for i, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
continue
}
s := jsonSignup{}
err := json.Unmarshal([]byte(l), &s)
if err != nil {
fmt.Printf("%s %s", l, err.Error())
errs = append(errs, err)
} else {
signups[i] = s
}
}
if len(errs) > 0 {
confirmContinue(fmt.Sprintf("%d errors found deserializing; continue?", len(errs)))
}
ttbirth, err := time.Parse("2006-01-02 3:04pm", "2014-10-11 11:49pm")
if err != nil {
panic(err)
}
me := towndb.TownUser{
Created: ttbirth,
Username: "vilmibm",
Emails: []string{"vilmibm@protonmail.com"},
State: towndb.StateActive,
IsAdmin: true,
}
err = me.Insert(db)
if err != nil {
panic(err)
}
notFound := []jsonSignup{}
found := []jsonSignup{}
for _, su := range signups {
fl := len(found)
for _, u := range td.Users {
if su.Username == u.Username {
found = append(found, su)
break
}
}
if len(found) == fl {
notFound = append(notFound, su)
}
}
if len(notFound) > 0 {
confirmContinue(fmt.Sprintf("%d of those were not found. continue?", len(notFound)))
}
for _, su := range found {
var emails []string
if su.Email != "" {
emails = []string{su.Email}
}
u := towndb.TownUser{
Created: time.Unix(int64(su.Created), 0),
Emails: emails,
Username: su.Username,
State: towndb.StateActive,
}
if err = u.Insert(db); err != nil {
confirmContinue(fmt.Sprintf("%#v led to error %s; continue?", u, err.Error()))
}
if su.Notes != "" {
note := towndb.AdminNote{
Created: time.Time{},
AuthorID: me.ID,
Content: su.Notes,
UserID: u.ID,
}
if err = note.Insert(db); err != nil {
confirmContinue(fmt.Sprintf("%#v led to error %s; continue?", note, err.Error()))
}
}
}
}
func importSignups() {
lol, err := os.ReadFile("/town/var/signups.json")
if err != nil {
panic(err)
}
db, err := signup.ConnectDB()
if err != nil {
panic(err)
}
lines := strings.Split(string(lol), "\n")
errs := []error{}
signups := make([]jsonSignup, len(lines))
for i, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
continue
}
s := jsonSignup{}
err := json.Unmarshal([]byte(l), &s)
if err != nil {
fmt.Printf("%s %s", l, err.Error())
errs = append(errs, err)
} else {
signups[i] = s
}
}
if len(errs) > 0 {
confirmContinue(fmt.Sprintf("%d errors found deserializing; continue?", len(errs)))
}
for _, s := range signups {
ts := models.TownSignup{
Created: time.Unix(int64(s.Created), 0),
Email: s.Email,
How: s.Referral,
Why: s.Reasons + "\n" + s.Plans,
Links: s.Socials,
}
if err = ts.Insert(db); err != nil {
confirmContinue(fmt.Sprintf("%#v led to error %s; continue?", ts, err.Error()))
}
if s.Notes != "" {
note := models.SignupNote{
Created: time.Now(),
Author: "IMPORT",
Content: s.Notes,
SignupID: ts.ID,
}
if err = note.Insert(db); err != nil {
confirmContinue(fmt.Sprintf("%#v led to error %s; continue?", ts, err.Error()))
}
}
}
}

View File

@ -0,0 +1,50 @@
This is an implementation of an idea we discussed a while ago; a launcher for town-specific
commands.
The idea is to put town commands in one of three places:
- /town/launcher/core
- /town/launcher/contrib
- /town/launcher/admin
and pair each command with a corresponding .yml file.
For example, the `aup` command is a simple wrapper around elinks that opens our code of conduct. I
put the executable `aup` in /town/launcher/core and matched it with /town/launcher/aup.yml. The
purpose of the yaml file is to provide documentation for your executable, so `aup.yml` looks like:
```yaml
shortDesc: View the town's Acceptable Use Policy
longDesc: |
This command will open our code of conduct, a type of document that evokes the Acceptable Use
Policies that governed servers like this in the past. It will open the elinks browser to a
page on the wiki.
examples: |
$ town aup # open the aup
$ town aup --rainbow # open the aup with rainbow colors
maintainer: vilmibm
```
and using the launcher is like:
$ town aup
$ town aup --rainbow
$ town writo
$ town admin ban vilmibm
You can see all the commands with `town help` as well as their descriptions; `town help
aup` would show you the docs from `aup.yml`.
I'd love feedback on this approach while I wrap up this implementation. I can put it up on
git.tilde.town if anyone desires to collaborate (and let me know if you want a git.tilde.town
account).
Remaining TODOs:
- [ ] make tab completion available for common shells
- [ ] document / script submitting a tool for inclusion in contrib
- [x] make little wrappers for things like `mail` and `chat`
- [x] fix arg passing
- [x] test with a command that makes use of stdin/stdout
- [x] add all existing commands to the buckets
- [x] add to users' paths

View File

@ -0,0 +1,139 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
/* TODO
I've finished writing the "contrib" half of `town contrib`. but what about the other half? this is two pieces:
1. as an admin, reviewing what's been submitted and:
- discarding
- clarifying
- accepting
2. as a user, running `town` in order to discover and run contrib'ed commands.
for 2, I think it's fine for now to just let the usage for town get long. as far as how to manage this, i think moving to a folder of yaml files that have an execPath and then dynamically building the command list up from the yaml files works. that way, the contrib yaml can just be copied over (renaming to command name and stripping comments) to the accepted path (/town/commands/contrib or whatever).
for 1, it can just be manually copying the files but that could easily lead to brainrot and errors, so i want automation there.
how about:
town contrib --review?
it would:
- list all the commands that have been contribed so dupes can be looked at
- iterate through each with:
- edit
- approve
- reject
- pass
- contact (email)
*/
const binroot = "/town/commands"
var rootCmd = &cobra.Command{
Use: "town",
Short: "Run commands unique to tilde.town",
// TODO Long example showing how to contribute a command
}
func parseCommands(targetCmd *cobra.Command, path string) error {
binPath := filepath.Join(binroot, path)
files, err := ioutil.ReadDir(binPath)
if err != nil {
return fmt.Errorf("failed to list directory %s: %s", binPath, err)
}
for _, file := range files {
if strings.HasSuffix(file.Name(), "yml") {
parseCommand(targetCmd, filepath.Join(binPath, file.Name()))
}
}
return nil
}
type cmdDoc struct {
CmdName string
ExecPath string
Maintainer string
Category string
ShortDesc string
LongDesc string
}
func parseCommand(targetCmd *cobra.Command, yamlPath string) {
yamlBytes, err := ioutil.ReadFile(yamlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "could not read %s; skipping...\n", yamlPath)
return
}
var doc cmdDoc
err = yaml.Unmarshal(yamlBytes, &doc)
if err != nil {
fmt.Fprintf(os.Stderr, "could not parse %s; skipping...\n", yamlPath)
return
}
parsedCmd := &cobra.Command{
Use: doc.CmdName,
RunE: execWrapper(doc.ExecPath),
DisableFlagParsing: true,
}
if doc.ShortDesc != "" {
parsedCmd.Short = doc.ShortDesc
}
if doc.LongDesc != "" {
parsedCmd.Long = doc.LongDesc
}
parsedCmd.Long += fmt.Sprintf("\nMaintained by %s; reach out to them via mail or chat with questions", doc.Maintainer)
targetCmd.AddCommand(parsedCmd)
}
func execWrapper(executablePath string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
execCmd := exec.Command(executablePath, args...)
execCmd.Stderr = os.Stderr
execCmd.Stdout = os.Stdout
execCmd.Stdin = os.Stdin
return execCmd.Run()
}
}
func cli() int {
files, err := ioutil.ReadDir(binroot)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list directory %s: %s", binroot, err)
}
for _, file := range files {
if strings.HasSuffix(file.Name(), "yml") {
parseCommand(rootCmd, filepath.Join(binroot, file.Name()))
}
}
// I feel like the example/documentation yaml can be frontmatter for non-binary files. to start
// i'll just do the accompanying yaml file.
rootCmd.Execute()
return 0
}
func main() {
os.Exit(cli())
}

View File

@ -0,0 +1,60 @@
package main
import (
"errors"
"fmt"
"os"
"os/user"
"git.tilde.town/tildetown/town/request"
townUser "git.tilde.town/tildetown/town/user"
)
func _main(args []string) error {
currentUser, err := user.Current()
if err != nil {
return err
}
ok, err := townUser.IsAdmin(currentUser)
if err != nil {
return err
}
if !ok {
return errors.New("must be a town admin")
}
errs := []error{}
err = request.ProcessGitea(request.RequestPath)
if err != nil {
errs = append(errs, err)
}
err = request.ProcessGemini(request.RequestPath)
if err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
errMsg := "errors encountered during request processing: "
for _, e := range errs {
errMsg += e.Error() + " "
}
return fmt.Errorf("errors encountered during request processing: %s", errMsg)
}
return nil
}
func main() {
retcode := 0
err := _main(os.Args)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
retcode = 1
}
os.Exit(retcode)
}

View File

@ -0,0 +1,11 @@
THIS COMMAND is for reviewing applications made to tilde.town. it's for admins to run.
Signups are read from /town/var/signups/signups.db . Approved signups create rows in /town/var/invites/invites.db .
# verbs in the TUI:
- skip
- approve
- reject
- notate (leave a note for other admins)
- jump to random signup

View File

@ -0,0 +1,63 @@
package main
import (
"errors"
"fmt"
"os"
"git.tilde.town/tildetown/town/email"
"git.tilde.town/tildetown/town/invites"
)
const emailText = `hello!
You applied to https://tilde.town at some point and your application has been approved ^_^
Your invite code is: %s
To redeem your code, please open a terminal and run:
ssh welcome@tilde.town
You'll fill in details like your desired username and SSH public key.
This page has information on what SSH is and how to use it, including how to create an ssh key pair which you'll need to access your town account: https://tilde.town/wiki/getting-started/ssh.html
If you end up stuck, e-mail root@tilde.town with any questions.
See you on the server,
~vilmibm`
func loadPassword() (string, error) {
f, err := os.Open("/town/docs/smtp.pw")
if err != nil {
return "", fmt.Errorf("could not open smtp password file: %w", err)
}
pw := make([]byte, 100)
n, err := f.Read(pw)
if err != nil {
return "", fmt.Errorf("could not read smtp password file: %w", err)
}
if n == 0 {
return "", errors.New("smtp password file was empty")
}
return string(pw[0:n]), nil
}
func sendInviteEmail(invite invites.Invite) error {
pw, err := loadPassword()
if err != nil {
return fmt.Errorf("could not read password: %w", err)
}
body := fmt.Sprintf(emailText, invite.Code)
mailer := email.NewExternalMailer(pw)
return mailer.Send(
invite.Email,
"your tilde.town application was accepted",
body)
}

471
cmd/review/main.go 100644
View File

@ -0,0 +1,471 @@
package main
import (
"bytes"
"database/sql"
"errors"
"fmt"
"math/rand"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
"time"
"git.tilde.town/tildetown/town/invites"
"git.tilde.town/tildetown/town/models"
"git.tilde.town/tildetown/town/signup"
tuser "git.tilde.town/tildetown/town/user"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func getTitle() string {
titles := []string{
"yo bum rush the show",
"can i kick it?",
"super nintendo sega genesis",
"birthdays was the worst days",
"where were you when we were getting high?",
"it's real time, real time, time to get real",
}
return titles[rand.Intn(len(titles))]
}
type reviewer struct {
db *sql.DB
adminName string
}
func newReviewer(db *sql.DB, adminName string) *reviewer {
return &reviewer{db: db, adminName: adminName}
}
func (r *reviewer) Review(s *models.TownSignup, decision models.SignupDecision) error {
s.DecisionTime = time.Now()
s.Decision = decision
s.DecidedBy = r.adminName
return s.Review(r.db)
}
func (r *reviewer) AddNote(s *models.TownSignup, content string) error {
note := &models.SignupNote{
Author: r.adminName,
Content: content,
SignupID: s.ID,
}
return note.Insert(r.db)
}
func renderSignup(s models.TownSignup) string {
out := ""
pairs := [][]string{
{"submitted", s.Created.Format("2006-01-02 15:04")},
{"e-mail", s.Email},
{"how found / referral", s.How},
{"why like town / plans", s.Why},
{"links", s.Links},
}
for _, v := range pairs {
out += fmt.Sprintf("[-:-:b]%s[-:-:-]\n", v[0])
out += strings.TrimSpace(v[1])
out += "\n\n"
}
return out
}
func renderNotes(s models.TownSignup) string {
out := ""
for _, note := range s.Notes {
out += fmt.Sprintf(`%s said on %s:
%s`, note.Author, note.Created.Format("2006-01-02 15:04"), note.Content)
out += "\n\n"
}
return out
}
func searchSignups(signups []*models.TownSignup) (int, error) {
escapeNuls := func(str string) string {
return strings.ReplaceAll(str, "\000", " ")
}
buf := new(bytes.Buffer)
for ix, signup := range signups {
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Email))
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.How))
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Links))
fmt.Fprintf(buf, "%d\t%s\000", ix, escapeNuls(signup.Why))
}
cmd := exec.Command("fzf", "--read0", "--delimiter=\t", "--tac", "--with-nth=2..")
cmd.Stdin = buf
cmd.Stderr = os.Stderr
out, err := cmd.Output()
if err != nil {
return -1, err
}
if len(out) == 0 {
return -1, nil
}
s := strings.Split(string(out[:]), "\t")[0]
n, err := strconv.Atoi(s)
if err != nil {
return -1, err
}
return n, nil
}
func _main() error {
inviteDB, err := invites.ConnectDB()
if err != nil {
return fmt.Errorf("could not connect to invites database: %w", err)
}
signupDB, err := signup.ConnectDB()
if err != nil {
return fmt.Errorf("could not connect to signups database: %w", err)
}
u, err := user.Current()
if err != nil {
return fmt.Errorf("that's my purse. I don't know you! %w", err)
}
isAdmin, err := tuser.IsAdmin(u)
if err != nil {
return fmt.Errorf("that's my purse. I don't know you! %w", err)
}
if !isAdmin {
return errors.New("this command can only be run by a town admin")
}
r := newReviewer(signupDB, u.Username)
rand.Seed(time.Now().Unix())
su := models.TownSignup{}
signups, err := su.All(signupDB)
if err != nil {
return fmt.Errorf("could not fetch signups: %w", err)
}
signupIx := 0
title := tview.NewTextView()
title.SetText(getTitle())
title.SetTextAlign(tview.AlignCenter)
title.SetTextColor(tcell.ColorPurple)
title.SetBackgroundColor(tcell.ColorBlack)
appView := tview.NewTextView()
appView.SetScrollable(true)
appView.SetDynamicColors(true)
legend := tview.NewTextView()
legend.SetText("s/S: next/prev r: random F: find A: approve R: reject N: notate Q: quit")
legend.SetTextColor(tcell.ColorPurple)
legend.SetTextAlign(tview.AlignCenter)
legend.SetBackgroundColor(tcell.ColorBlack)
count := tview.NewTextView()
count.SetDynamicColors(true)
updateCount := func() {
count.SetText(fmt.Sprintf("[-:-:b]%d of %d[-:-:-]", signupIx+1, len(signups)))
if len(signups) == 0 {
count.SetText("")
}
}
updateCount()
notesView := tview.NewTextView()
notesView.SetDynamicColors(true)
notesView.SetBorder(true).SetBorderColor(tcell.ColorPurple)
bottomFlex := tview.NewFlex()
bottomFlex.SetDirection(tview.FlexColumn)
bottomFlex.AddItem(count, 0, 1, false)
bottomFlex.AddItem(legend, 0, 10, false)
innerFlex := tview.NewFlex()
innerFlex.SetDirection(tview.FlexColumn)
innerFlex.AddItem(appView, 0, 2, true)
innerFlex.AddItem(notesView, 0, 1, true)
mainFlex := tview.NewFlex()
mainFlex.SetDirection(tview.FlexRow)
mainFlex.AddItem(title, 1, -1, false)
mainFlex.AddItem(innerFlex, 0, 1, false)
mainFlex.AddItem(bottomFlex, 1, -1, false)
// set scrollable
mainFlex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
appView.InputHandler()(event, func(p tview.Primitive) {})
return nil
})
pages := tview.NewPages()
errorModal := tview.NewModal()
errorModal.AddButtons([]string{"damn"})
errorModal.SetDoneFunc(func(ix int, _ string) {
pages.SwitchToPage("main")
})
render := func() {
if len(signups) == 0 {
appView.SetText("no signups")
return
}
currSignup := signups[signupIx]
err := currSignup.RefreshNotes(signupDB)
if err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to add note: %s", err.Error()))
pages.SwitchToPage("error")
}
appView.SetText(renderSignup(*currSignup))
notesView.SetText(renderNotes(*currSignup))
}
render()
notate := tview.NewForm()
notate.AddTextArea("note", "", 80, 10, 1000, func(string) {})
notate.AddButton("submit", func() {
fi := notate.GetFormItemByLabel("note").(*tview.TextArea)
err = r.AddNote(signups[signupIx], fi.GetText())
if err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to add note: %s", err.Error()))
pages.SwitchToPage("error")
return
}
render()
pages.SwitchToPage("main")
})
notate.AddButton("cancel", func() {
pages.SwitchToPage("main")
})
reviewModal := tview.NewFlex().SetDirection(tview.FlexRow)
providedEmailView := tview.NewTextView()
providedEmailView.SetTitle("provided email input")
reviewForm := tview.NewForm()
decisionFI := tview.NewDropDown().SetLabel("decision")
decisionFI.SetOptions([]string{"accepted", "rejected"}, func(_ string, _ int) {})
cleanEmailInput := tview.NewInputField()
cleanEmailInput.SetLabel("clean email ")
cleanEmailInput.SetAcceptanceFunc(func(tx string, _ rune) bool { return len(tx) > 0 })
reviewForm.AddFormItem(decisionFI)
reviewForm.AddFormItem(cleanEmailInput)
reviewForm.AddButton("submit", func() {
currSignup := signups[signupIx]
cleanEmail := cleanEmailInput.GetText()
currSignup.CleanEmail = cleanEmail
decision := models.SignupRejected
_, d := decisionFI.GetCurrentOption()
if d == "accepted" {
decision = models.SignupAccepted
}
err := r.Review(currSignup, decision)
if err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to submit review: %s", err.Error()))
pages.SwitchToPage("error")
return
}
newSignups := []*models.TownSignup{}
for ix, s := range signups {
if ix != signupIx {
newSignups = append(newSignups, s)
}
}
signups = newSignups
if len(signups) > 0 {
if signupIx >= len(signups) {
signupIx = 0
}
}
updateCount()
render()
if decision == models.SignupAccepted {
invite := &invites.Invite{
Email: currSignup.CleanEmail,
}
if err = invite.Insert(inviteDB); err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to create invite: %s", err.Error()))
pages.SwitchToPage("error")
}
if err = sendInviteEmail(*invite); err != nil {
errorModal.SetText(fmt.Sprintf("error! failed to send welcome email: %s", err.Error()))
pages.SwitchToPage("error")
}
}
pages.SwitchToPage("main")
})
reviewForm.AddButton("cancel", func() {
pages.SwitchToPage("main")
})
reviewModal.AddItem(tview.NewTextView().SetText("provided email input"), 1, 1, false)
reviewModal.AddItem(providedEmailView, 0, 1, false)
reviewModal.AddItem(reviewForm, 0, 1, true)
pages.AddPage("main", mainFlex, true, true)
pages.AddPage("error", errorModal, false, false)
pages.AddPage("notate", notate, true, false)
pages.AddPage("review", reviewModal, true, false)
app := tview.NewApplication()
app.SetRoot(pages, true)
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
currPage, _ := pages.GetFrontPage()
if currPage == "notate" || currPage == "review" {
return event
}
switch event.Rune() {
case 's':
signupIx++
if signupIx == len(signups) {
signupIx = 0
}
updateCount()
render()
return nil
case 'S':
signupIx--
if signupIx < 0 {
signupIx = len(signups) - 1
}
updateCount()
render()
return nil
case 'r':
if len(signups) > 0 {
signupIx = rand.Intn(len(signups))
updateCount()
render()
}
// TODO: there's a bunch of messy state management.
// should we generate this pane functionally?
case 'A':
if len(signups) == 0 {
return nil
}
emailVal := signups[signupIx].Email
providedEmailView.SetText(emailVal)
cleanEmailInput.SetLabel("clean email ")
cleanEmailInput.SetText("")
/*
TODO the placeholder doesn't appear to become the default text which is
what I wanted it to do. Just taking this out so the blank box beckons
input. Also, it seems like the AcceptanceFunc didn't work since a blank
value got through.
cleanEmailInput.SetPlaceholder(
strings.TrimSpace(strings.ReplaceAll(emailVal, "\n", " ")))
*/
cleanEmailInput.SetChangedFunc(func(text string) {
if strings.Contains(emailVal, text) {
cleanEmailInput.SetLabel("clean email ")
} else {
cleanEmailInput.SetLabel("[red]clean email :(")
}
})
decisionFI.SetCurrentOption(0)
pages.SwitchToPage("review")
app.SetFocus(cleanEmailInput)
return nil
case 'R':
if len(signups) == 0 {
return nil
}
emailVal := signups[signupIx].Email
providedEmailView.SetText(emailVal)
cleanEmailInput.SetLabel("clean email ")
cleanEmailInput.SetText("")
/*
TODO the placeholder doesn't appear to become the default text which is
what I wanted it to do. Just taking this out so the blank box beckons
input. Also, it seems like the AcceptanceFunc didn't work since a blank
value got through.
cleanEmailInput.SetPlaceholder(
strings.TrimSpace(strings.ReplaceAll(emailVal, "\n", " ")))
*/
cleanEmailInput.SetChangedFunc(func(text string) {
if strings.Contains(emailVal, text) {
cleanEmailInput.SetLabel("clean email ")
} else {
cleanEmailInput.SetLabel("[red]clean email :(")
}
})
decisionFI.SetCurrentOption(1)
pages.SwitchToPage("review")
app.SetFocus(cleanEmailInput)
return nil
case 'N':
if len(signups) == 0 {
return nil
}
pages.SwitchToPage("notate")
return nil
case 'F':
app.Suspend(func() {
ix, err := searchSignups(signups)
if err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
// no match or interrupt. who cares
switch exiterr.ExitCode() {
case 1: case 130:
return
}
}
errorModal.SetText(fmt.Sprintf("error! failed to search: %s", err.Error()))
pages.SwitchToPage("error")
} else if ix >= 0 {
signupIx = ix
}
})
updateCount()
render()
return nil
case 'Q':
app.Stop()
}
return event
})
return app.Run()
}
func main() {
err := _main()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@ -0,0 +1,11 @@
# townstats
Code for dumping information about tilde.town in the [Tilde Data Protocol](http://protocol.club/~datagrok/beta-wiki/tdp.html).
# author
vilmibm, based on python work by [datagrok](https://datagrok.org)
# license
gplv3+

28
cmd/stats/main.go 100644
View File

@ -0,0 +1,28 @@
// townstats returns information about tilde.town in the tilde data protcol format
// It was originally a Python script written by Michael F. Lamb <https://datagrok.org>
// License: GPLv3+
// TDP is defined at http://protocol.club/~datagrok/beta-wiki/tdp.html
// Usage: stats > /var/www/html/tilde.json
package main
import (
"encoding/json"
"fmt"
"log"
"git.tilde.town/tildetown/town/stats"
)
func main() {
systemData, err := stats.Stats()
if err != nil {
log.Fatal(err)
}
data, err := json.Marshal(systemData)
if err != nil {
log.Fatalf("Failed to marshal JSON: %s", err)
}
fmt.Printf("%s\n", data)
}

143
cmd/visit/main.go 100644
View File

@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/glamour"
)
func main() {
var err error
if len(os.Args) > 2 {
fmt.Println("expected zero or one arguments")
}
if len(os.Args) > 1 {
arg := os.Args[1]
switch arg {
case "-r", "--random":
err = visitRandomUser()
default:
err = visitUsername(os.Args[1])
}
} else {
err = visitPrompt()
}
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
func visitRandomUser() error {
rand.Seed(time.Now().UnixNano())
usernames, err := getUsernames()
if err != nil {
return err
}
return visitUser(usernames[rand.Intn(len(usernames))])
}
func visitUsername(username string) error {
usernames, err := getUsernames()
if err != nil {
return err
}
for _, name := range usernames {
if name == username {
return visitUser(username)
}
}
return fmt.Errorf("no such user: %s", username)
}
func visitPrompt() error {
usernames, err := getUsernames()
if err != nil {
return err
}
username := ""
prompt := &survey.Select{
Message: "Choose a townie to visit!",
Options: usernames,
}
survey.AskOne(prompt, &username)
return visitUser(username)
}
func visitUser(username string) error {
files, err := ioutil.ReadDir(filepath.Join("/home", username))
if err != nil {
return fmt.Errorf("user is not accepting visitors (could not read user's home directory: %w)", err)
}
for _, file := range files {
if !isReadme(file.Name()) {
continue
}
path := filepath.Join("/home", username, file.Name())
data, err := ioutil.ReadFile(path)
if err != nil {
break
}
if isMarkdown(file.Name()) {
r, _ := glamour.NewTermRenderer(glamour.WithAutoStyle())
out, err := r.RenderBytes(data)
if err == nil {
fmt.Println(string(out))
return nil
}
}
fmt.Println(string(data))
return nil
}
// TODO handle .plan and .project files
fmt.Println("TODO non-readme fallback")
return nil
}
func isReadme(filename string) bool {
fn := strings.ToLower(filename)
return strings.HasPrefix(fn, "readme") || strings.HasPrefix(fn, "welcome")
}
func isMarkdown(filename string) bool {
fn := strings.ToLower(filename)
return strings.HasSuffix(fn, ".md") || strings.HasSuffix(fn, ".markdown")
}
func getUsernames() ([]string, error) {
cmd := exec.Command("/bin/bash", "-c", "town stats | jq -r '.users|map(.username)'")
output, err := cmd.Output()
if err != nil {
return nil, err
}
usernames := []string{}
err = json.Unmarshal(output, &usernames)
if err != nil {
return nil, err
}
return usernames, nil
}

47
codes/codes.go 100644
View File

@ -0,0 +1,47 @@
package codes
import (
"crypto/rand"
"encoding/base64"
"math/big"
"strings"
)
const codeLen = 32
func NewCode(email string) string {
charset := "abcdefghijklmnopqrztuvwxyz"
charset += strings.ToUpper(charset)
charset += "0123456789"
charset += "`~!@#$%^&*()-=_+[]{}|;:,./<>?"
code := []byte{}
max := big.NewInt(int64(len(charset)))
for len(code) < codeLen {
ix, err := rand.Int(rand.Reader, max)
if err != nil {
// TODO this is bad but I'm just kind of hoping it doesn't happen...often
panic(err)
}
code = append(code, charset[ix.Int64()])
}
code = append(code, ' ')
eb := []byte(email)
for x := 0; x < len(eb); x++ {
code = append(code, eb[x])
}
return base64.StdEncoding.EncodeToString(code)
}
func Decode(code string) ([]string, error) {
decoded, err := base64.StdEncoding.DecodeString(code)
if err != nil {
return nil, err
}
return strings.Split(string(decoded), " "), nil
}

View File

@ -2,10 +2,18 @@ package email
import (
"bytes"
"crypto/tls"
"fmt"
"net/smtp"
"os/exec"
)
const (
from = "root@tilde.town"
SMTPHost = "smtp.zoho.com"
SMTPPort = 465
)
func SendLocalEmail(username, subject, body string) error {
cmd := exec.Command("/usr/sbin/sendmail", username)
cmd.Stdin = bytes.NewBufferString(fmt.Sprintf("Subject: %s\n\n%s", subject, body))
@ -16,3 +24,76 @@ func SendLocalEmail(username, subject, body string) error {
return nil
}
type ExternalMailer struct {
Password string
}
func NewExternalMailer(pw string) *ExternalMailer {
if pw == "" {
panic("why?")
}
return &ExternalMailer{
Password: pw,
}
}
func (m *ExternalMailer) Send(address, subject, body string) error {
headers := map[string]string{
"From": from,
"To": address,
"Subject": subject,
}
message := ""
for k, v := range headers {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + body
auth := smtp.PlainAuth("", from, m.Password, SMTPHost)
server := fmt.Sprintf("%s:%d", SMTPHost, SMTPPort)
tlsconf := &tls.Config{
InsecureSkipVerify: true,
ServerName: server,
}
conn, err := tls.Dial("tcp", server, tlsconf)
if err != nil {
return err
}
c, err := smtp.NewClient(conn, SMTPHost)
if err != nil {
return err
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("auth failed for smtp: %w", err)
}
if err = c.Mail(from); err != nil {
return err
}
if err = c.Rcpt(address); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
_, err = w.Write([]byte(message))
if err != nil {
return err
}
w.Close()
c.Quit()
return nil
}

1
external/README.md vendored 100644
View File

@ -0,0 +1 @@
this folder contains the things that external people can access via ssh: join@tilde.town, welcome@tilde.town, and help@tilde.town

15
external/cmd/help/README.md vendored 100644
View File

@ -0,0 +1,15 @@
# help
another ssh command:
```
ssh help@tilde.town
hey what's up?
> i'm a user and i need to reset my key
i tried to sign up and it just didn't work
i have a concern or complaint about the town
```
the goal with this is to formalize the "i forgot my key" process and get us off of using an external email inbox which i find irritating.

60
external/cmd/help/email.go vendored 100644
View File

@ -0,0 +1,60 @@
package main
import (
"errors"
"fmt"
"os"
"git.tilde.town/tildetown/town/email"
)
const emailText = `hello!
You (hopefully) requested to add a new public key to your tilde.town account.
If you didn't, feel free to ignore this email (or report it to an admin).
If you did, here is your auth code: %s
To use this code, please open a terminal and run:
ssh help@tilde.town
Follow the instructions there to add your new key and restore access to your account.
best,
~vilmibm`
func loadPassword() (string, error) {
f, err := os.Open("/town/docs/smtp_help.pw")
if err != nil {
return "", fmt.Errorf("could not open smtp password file: %w", err)
}
pw := make([]byte, 100)
n, err := f.Read(pw)
if err != nil {
return "", fmt.Errorf("could not read smtp password file: %w", err)
}
if n == 0 {
return "", errors.New("smtp password file was empty")
}
return string(pw[0:n]), nil
}
func sendAuthCodeEmail(ac AuthCode) error {
pw, err := loadPassword()
if err != nil {
return err
}
body := fmt.Sprintf(emailText, ac.Code)
mailer := email.NewExternalMailer(pw)
return mailer.Send(
ac.Email,
"Adding a new tilde.town public key",
body)
}

404
external/cmd/help/main.go vendored 100644
View File

@ -0,0 +1,404 @@
package main
import (
"bytes"
"database/sql"
"errors"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"strings"
"git.tilde.town/tildetown/town/codes"
"git.tilde.town/tildetown/town/external/lockingwriter"
"git.tilde.town/tildetown/town/sshkey"
"github.com/charmbracelet/lipgloss"
_ "github.com/mattn/go-sqlite3"
"github.com/mattn/go-tty"
)
// TODO consider local-only help command for renaming, email mgmt, deleting account
// TODO put colorscheme, prompting stuff into own packages for use in the other commands. would be good to get off of survey.
// TODO use new lockingwriter for logging in the other external commands
func connectDB() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "/town/var/codes/codes.db?mode=rw")
if err != nil {
return nil, err
}
return db, nil
}
type colorScheme struct {
Header func(string) string
Subtitle func(string) string
Prompt func(string) string
Email func(string) string
Option func(string) string
Error func(string) string
}
func newColorScheme() colorScheme {
s2r := func(s lipgloss.Style) func(string) string {
return s.Render
}
c := func(s string) lipgloss.Color {
return lipgloss.Color(s)
}
s := lipgloss.NewStyle
return colorScheme{
Header: s2r(s().Bold(true).Foreground(c("#E0B0FF"))),
Subtitle: s2r(s().Italic(true).Foreground(c("gray"))),
Email: s2r(s().Bold(true).Underline(true)),
Prompt: s2r(s().Bold(true).Foreground(c("#00752d"))),
Option: s2r(s().Bold(true).Foreground(c("#38747a"))),
Error: s2r(s().Bold(true).Foreground(c("#f43124"))),
}
}
type Prompter struct {
cs colorScheme
tty *tty.TTY
}
func NewPrompter(tty *tty.TTY, cs colorScheme) *Prompter {
return &Prompter{
cs: cs,
tty: tty,
}
}
func (p *Prompter) String(prompt string) (string, error) {
// TODO assumes blank is no bueno
var err error
var answer string
for answer == "" {
fmt.Println("")
fmt.Println(p.cs.Prompt(prompt))
fmt.Println(p.cs.Subtitle("(press enter to submit)"))
answer, err = p.tty.ReadString()
if err != nil {
return "", fmt.Errorf("couldn't collect input: %w", err)
}
}
return answer, nil
}
func (p *Prompter) Select(prompt string, opts []string) (int, error) {
fmt.Println()
fmt.Println(p.cs.Prompt(prompt))
fmt.Println(p.cs.Subtitle("(pick an option using the corresponding number)"))
chosen := -1
for chosen < 0 {
fmt.Println()
for ix, o := range opts {
fmt.Printf("%s: %s\n", p.cs.Option(fmt.Sprintf("%d", ix+1)), o)
}
r, err := p.tty.ReadRune()
if err != nil {
return -1, fmt.Errorf("could not collect answer for '%s': %w", prompt, err)
}
c, err := strconv.Atoi(string(r))
if err != nil {
fmt.Println()
fmt.Printf("I could not understand '%s'. Try again, please.\n", string(r))
continue
}
if c > len(opts) || c == 0 {
fmt.Println()
fmt.Printf("%s is not an option. Try again, please.\n", string(r))
continue
}
chosen = c - 1
}
fmt.Println("")
return chosen, nil
}
func _main(cs colorScheme) error {
lw := lockingwriter.New()
l := log.New(lw, "help: ", log.Ldate|log.Ltime|log.LUTC|log.Lshortfile|log.Lmsgprefix)
db, err := connectDB()
if err != nil {
return fmt.Errorf("could not connect to database. please let root@tilde.town know about this.")
}
fmt.Println(cs.Header("Hi, you have reached the tilde town help desk."))
fmt.Println()
fmt.Println("Please check out the options below.")
fmt.Printf("If none of them apply to your case, you can email %s. \n", cs.Email("root@tilde.town"))
tty, err := tty.Open()
if err != nil {
return fmt.Errorf("could not open tty: %w", err)
}
defer tty.Close()
p := NewPrompter(tty, cs)
options := []string{
"I need to request that a new SSH key be added to my account.",
"I have a code from my e-mail to redeem for a new SSH key",
"I just want out of here",
}
c, err := p.Select("What do you need help with?", options)
defer func() {
}()
switch c {
case 0:
return collectEmail(l, db, cs, p)
case 1:
return redeemCode(l, db, cs, p)
case 2:
return nil
}
return nil
}
func emailToUsername(email string) (string, error) {
cmd := exec.Command("sudo", "/town/bin/emailtouser", email)
stderrBuff := bytes.NewBuffer([]byte{})
stdoutBuff := bytes.NewBuffer([]byte{})
cmd.Stderr = stderrBuff
cmd.Stdout = stdoutBuff
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("emailtouser failed with '%s': %w", stderrBuff.String(), err)
}
return strings.TrimSpace(stdoutBuff.String()), nil
}
func collectEmail(l *log.Logger, db *sql.DB, cs colorScheme, p *Prompter) error {
fmt.Println(cs.Header("We can send a authorization code to an email associated with your town account."))
email, err := p.String("email to send reset code to?")
if err != nil {
return err
}
fmt.Println()
fmt.Println(cs.Header("thanks!"))
fmt.Println()
fmt.Printf("If %s is associated with a town account we'll email an authorization code.\n", cs.Email(email))
mustHave := []string{"@", "."}
found := 0
for _, s := range mustHave {
if strings.Contains(email, s) {
found++
}
}
if found != len(mustHave) {
l.Printf("corrupt email '%s'", email)
return nil
}
if _, err = emailToUsername(email); err != nil {
l.Printf("no user for '%s'", email)
return nil
}
code := codes.NewCode(email)
ac := &AuthCode{
Code: code,
Email: email,
}
if err = ac.Insert(db); err != nil {
l.Printf("database error: %s", err.Error())
return errors.New("the database was sad")
}
if err = sendAuthCodeEmail(*ac); err != nil {
l.Printf("mail send error: %s", err.Error())
return errors.New("email sending failed")
}
return nil
}
func redeemCode(l *log.Logger, db *sql.DB, cs colorScheme, p *Prompter) error {
fmt.Println(cs.Header("redeem an auth code and add a new public key"))
c, err := p.String("paste your auth code:")
if err != nil {
l.Printf("failed to prompt: %s", err.Error())
fmt.Println(cs.Error("sorry, I couldn't read that."))
return nil
}
parts, err := codes.Decode(c)
if err != nil {
l.Printf("failed to decode auth code: %s", err.Error())
fmt.Println(cs.Error("sorry, that doesn't look like an auth code..."))
return nil
}
code := &AuthCode{
Code: c,
Email: parts[1],
}
err = code.Hydrate(db)
if err != nil {
l.Printf("hydrate failed: %s", err.Error())
return errors.New("the database is sad")
}
if code.Used {
fmt.Println(cs.Error("That code has already been redeemed. You'll have to request a new one."))
return nil
}
username, err := emailToUsername(code.Email)
if err != nil {
l.Printf("could not find user: %s", err.Error())
fmt.Println(cs.Error("That code doesn't seem to match an account."))
return nil
}
fmt.Println()
fmt.Printf("hi, ~%s", username)
key, err := p.String("paste your new public key:")
if err != nil {
l.Printf("failed to prompt: %s", err.Error())
fmt.Println(cs.Error("sorry, I couldn't read that."))
return nil
}
valid, err := sshkey.ValidKey(key)
if err != nil {
return fmt.Errorf("failed to validate key: %w", err)
}
if !valid {
errMsg := fmt.Sprintf("that key is invalid: %s", err.Error())
fmt.Println(cs.Error(errMsg))
return nil
}
cmd := exec.Command("sudo", "--user", username, "/town/bin/appendkeyfile")
cmd.Stdin = bytes.NewBufferString(key + "\n")
stdoutBuff := bytes.NewBuffer([]byte{})
cmd.Stdout = stdoutBuff
stderrBuff := bytes.NewBuffer([]byte{})
cmd.Stderr = stderrBuff
if err = cmd.Run(); err != nil {
l.Printf("appendkeyfile failed with '%s', '%s': %s", stderrBuff.String(), stdoutBuff.String(), err.Error())
return errors.New("adding to keys file failed")
}
err = code.MarkUsed(db)
if err != nil {
l.Printf("failed to mark used: %s", err.Error())
return errors.New("database was sad")
}
fmt.Println()
fmt.Println("new key added! you should be able to use it to log in.")
return nil
}
func main() {
cs := newColorScheme()
err := _main(cs)
defer func() {
fmt.Println()
fmt.Println(cs.Header("bye~"))
}()
if err != nil {
fmt.Println(
cs.Error(fmt.Sprintf("sorry, something went wrong: %s", err.Error())))
fmt.Println("Please let an admin know by emailing a copy of this error to root@tilde.town")
os.Exit(1)
}
}
type AuthCode struct {
ID int64
Code string
Email string
Used bool
}
func (c *AuthCode) Insert(db *sql.DB) error {
stmt, err := db.Prepare(`
INSERT INTO auth_codes (code, email)
VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(c.Code, c.Email)
if err != nil {
return err
}
liid, err := result.LastInsertId()
if err != nil {
return err
}
c.ID = liid
return nil
}
func (c *AuthCode) Hydrate(db *sql.DB) error {
stmt, err := db.Prepare(`
SELECT id, used
FROM auth_codes
WHERE code = ? AND email = ?`)
if err != nil {
return err
}
defer stmt.Close()
return stmt.QueryRow(c.Code, c.Email).Scan(&c.ID, &c.Used)
}
func (c *AuthCode) MarkUsed(db *sql.DB) error {
if c.ID == 0 {
return errors.New("not hydrated")
}
stmt, err := db.Prepare(`
UPDATE auth_codes SET used = 1 WHERE id = ?`)
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(c.ID)
if err != nil {
return err
}
var rowsAffected int64
if rowsAffected, err = result.RowsAffected(); err != nil {
return err
}
if rowsAffected == 0 {
return errors.New("no rows affected")
}
return nil
}

View File

@ -0,0 +1,59 @@
package main
/*
The purpose of this command is to be run via sudo as an arbitrary user by the "help" user. It is invoked as part of the "i need to add a new public key" flow from "ssh help@tilde.town".
It's based on the createkeyfile helper and heavily copy pasta'd. They should probably share code or be a single command but I wanted to keep things simple for now.
*/
import (
"fmt"
"os"
"os/user"
"path"
)
const keyfileName = "authorized_keys2"
func quit(msg string, code int) {
// TODO print to stderr
fmt.Println(msg)
os.Exit(code)
}
func main() {
u, err := user.Current()
if err != nil {
quit(err.Error(), 2)
}
sshPath := path.Join("/home", u.Username, ".ssh")
keyfilePath := path.Join(sshPath, keyfileName)
f, err := os.OpenFile(keyfilePath, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
quit(fmt.Sprintf("failed to open %s: %s", keyfilePath, err.Error()), 5)
}
defer f.Close()
stdin := make([]byte, 90000) // arbitrary limit
n, err := os.Stdin.Read(stdin)
if err != nil {
quit(err.Error(), 6)
} else if n == 0 {
quit("nothing passed on STDIN", 7)
}
stdin = stdin[0:n]
n, err = f.Write(stdin)
if err != nil {
quit(err.Error(), 9)
} else if n == 0 {
quit("wrote nothing to keyfile", 10)
}
}

View File

@ -0,0 +1,110 @@
package main
/*
The purpose of this command is to be run as a new user. It initializes their ssh authorized_keys2 file, which allows them to ssh in for the first time.
This is an isolated command because creating a file and chowning it normally requires root permissions. We don't want to run the welcome command as root, so we give it `sudo` permission to run this one command as any user. The keyfile path is hardcoded, so if someone were to assume `welcome`'s identity, it could of course cause havoc but not delete
This is a port of the old createkeyfile.py script from the former admin system.
There are two functional changes:
1. It also creates `.ssh`. I can't remember if there was a reason the old script didn't do that as there is no record one way or the other. But having this command make `.ssh` means one fewer thing in the sudoers file.
2. It guards against overwriting of both .ssh and authorized_keys2. This is solely to limit the effect of a security breach. In the old admin system keys were managed via the django admin, meaning this script was used to add keys to authorized_keys2. these days we just edit authorized_keys2 directly, so this new command should only ever be used to initialize .ssh for a new user.
*/
import (
"fmt"
"os"
"os/user"
"path"
"strings"
)
const keyfileName = "authorized_keys2"
func quit(msg string, code int) {
fmt.Println(msg)
os.Exit(code)
}
func main() {
username := os.Args[1]
if username == "" {
quit("expected username as argument", 1)
}
u, err := user.Current()
if err != nil {
quit(err.Error(), 2)
}
if u.Username != username {
quit("that's my purse; I don't know you", 3)
}
sshPath := path.Join("/home", u.Username, ".ssh")
keyfilePath := path.Join(sshPath, keyfileName)
if err = os.Mkdir(sshPath, os.FileMode(0700)); err != nil {
quit(err.Error(), 4)
}
f, err := os.OpenFile(keyfilePath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600)
if err != nil {
quit(fmt.Sprintf("failed to open %s: %s", keyfilePath, err.Error()), 5)
}
defer f.Close()
stdin := make([]byte, 90000) // arbitrary limit
n, err := os.Stdin.Read(stdin)
if err != nil {
quit(err.Error(), 6)
} else if n == 0 {
quit("nothing passed on STDIN", 7)
}
stdin = stdin[0:n]
if !strings.HasPrefix(string(stdin), "########## GREETINGS! ##########") {
// TODO further validation?
quit(fmt.Sprintf("file contents look wrong: %s", string(stdin)), 8)
}
_, err = f.Write(stdin)
if err != nil {
quit(err.Error(), 9)
}
_, err = f.WriteString("\n")
}
/*
The old script, in full:
#!/usr/bin/env python3
"""this script allows django to add public keys for a user. it's in its own
script so that a specific command can be added to the ttadmin user's sudoers
file."""
import sys
KEYFILE_PATH = '/home/{}/.ssh/authorized_keys2'
def main(argv):
username = argv[1]
with open(KEYFILE_PATH.format(username), 'w') as f:
f.write(sys.stdin.read())
if __name__ == '__main__':
exit(main(sys.argv))
*/

View File

@ -0,0 +1,40 @@
package main
import (
"errors"
"fmt"
"os"
"git.tilde.town/tildetown/town/towndb"
)
func _main(args []string) error {
if len(args) < 2 {
return errors.New("need email")
}
email := args[1]
db, err := towndb.ConnectDB()
if err != nil {
return err
}
user, err := towndb.UserForEmail(db, email)
if err != nil {
return err
}
if user == nil {
return errors.New("email does not correspond to user")
}
fmt.Print(user.Username)
return nil
}
func main() {
if err := _main(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
os.Exit(1)
}
}

View File

@ -0,0 +1,79 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
// _ "github.com/mattn/go-sqlite3"
"git.tilde.town/tildetown/town/towndb"
)
// this command adds a new user to /town/var/town.db. it's meant to be invoked
// by the welcome binary upon successfully creating a new user account
func main() {
tdb, err := towndb.ConnectDB()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
userData, err := parseInput(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(2)
}
if err = validateInput(userData); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(3)
}
if err = userData.Insert(tdb); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(4)
}
}
func validateInput(userData towndb.TownUser) error {
if userData.Username == "" {
return errors.New("no username")
}
if len(userData.Emails) == 0 {
return errors.New("no email set")
}
for _, e := range userData.Emails {
if e == "" {
return errors.New("blank email in email list")
}
}
if userData.IsAdmin {
return errors.New("please stop")
}
if userData.State != towndb.StateActive {
return errors.New("bad state")
}
return nil
}
func parseInput(stdin *os.File) (u towndb.TownUser, err error) {
var n int
input := make([]byte, 3000) // arbitrary
if n, err = stdin.Read(input); err != nil {
return
}
if n == 0 {
err = errors.New("nothing passed on input")
return
}
err = json.Unmarshal(input[0:n], &u)
return
}

88
external/cmd/signup/README.md vendored 100644
View File

@ -0,0 +1,88 @@
# town signup
The point of this command is to enable signing up for tilde.town via an ssh connection. It is designed to be run when `join@tilde.town` is SSH'd to.
## to-dos
- [x] finish this command
- [x] interactive guts
- [x] logging
- [x] write answers to disk
- [x] take out sidebar
- [x] add /help
- [x] make copy clearer (that you say whatever and *then* type verb)
- [x] enter to send
- [ ] splash screen - put off
- [ ] easter egg commands - put off
- [ ] inactivity timer(?) - put off
- [x] review tool
- [x] actual account creation
- [ ] backlog
- [ ] get a manual dump from psql of json
- [ ] convert into files in the review directory
## configuration
On disk assumptions:
- `/town/var/signups` exists and is owned by user `join` and group `admin`
- `/town/var/signups/log` exists and is owned by user `join` and group `admin`
Assumes the following has been run:
```bash
sqlite3 /town/var/signups/signups.db < /town/src/town/sql/create_signups_table.sql
sudo chown join:admin /town/var/signups/signups.db
```
It assumes, in `sshd_config`:
```
Match User join
ForceCommand /town/src/town/cmd/signup/signup
PubkeyAuthentication no
KbdInteractiveAuthentication no
PasswordAuthentication yes
PermitEmptyPasswords yes
DisableForwarding yes
```
and in `/etc/pam.d/sshd`:
```
auth [success=done default=ignore] pam_succeed_if.so user ingroup join
```
## initial thoughts
I need a script for how this interaction should go. It should feel more organic and fluid than just filling out a form. It shouldn't, however, take more than 10 minutes to get through stuff. The end result is to collect:
- an email address
- how a user found out about the town
- if they have a referral from an existing member
- what interests them about the town
- any links to personal websites or social media
A given is that the applicant is interacting with a "host" that guides them through the process. Should there be more than one host?
How many rooms should there be? 1? more?
in terms of data collection, I intend to just save the transcript of their interaction instead of more structured data. The only real structured data are email and referral.
another idea/thought/dream:
the whole point of this is to spatialize a form. it's a form! a set of questions and corresponding textarea elements. but in order to trigger self reflection -- as well as a feeling of being seen by another (in a good way) -- i want to turn the form into a shape. i'm thinking about this like the room in Eclipse Penumbra:
(For the Hollow Head was drug paraphenalia you could walk into. The building itself was the syringe, or the hookah, or the sniff-tube)
so, spatialized, every room is a question. the rooms take shape as a linear script the user moves through by:
1. answering a question
2. executing a verb
and finally at the end, a verb to confirm submission.
## author
vilmibm

352
external/cmd/signup/main.go vendored 100644
View File

@ -0,0 +1,352 @@
package main
import (
"bytes"
"database/sql"
"fmt"
"io"
"log"
"os"
"path"
"strings"
"time"
"git.tilde.town/tildetown/town/models"
"git.tilde.town/tildetown/town/signup"
"github.com/MakeNowJust/heredoc/v2"
"github.com/gdamore/tcell/v2"
_ "github.com/mattn/go-sqlite3"
"github.com/rivo/tview"
)
const (
maxInputLength = 10000
logDir = "/town/var/signups/log"
)
type scene struct {
Name string
Description string
Host *character
SorryMsg string
Input *bytes.Buffer
OnAdvance func(*scene)
OnMsg func(*scene, *tview.TextView, string)
}
func newScene(name, desc, sorryMsg string, host *character, onAdvance func(*scene), onMsg func(*scene, *tview.TextView, string)) *scene {
return &scene{
Name: name,
Description: desc,
SorryMsg: sorryMsg,
Host: host,
Input: bytes.NewBuffer([]byte{}),
OnAdvance: onAdvance,
OnMsg: onMsg,
}
}
type sceneManager struct {
scenes []*scene
Current *scene
index int
output io.Writer
Save func()
}
func (m *sceneManager) Advance() bool {
if m.Current.Name == "done" {
return false
}
if m.Current.Input.Len() == 0 {
fmt.Fprintln(m.output, m.Current.Host.Say(m.Current.SorryMsg))
return false
}
m.Current.OnAdvance(m.Current)
m.index++
m.Current = m.scenes[m.index]
if m.Current.Name == "done" {
m.Save()
}
fmt.Fprintln(m.output, heredoc.Doc(`
[purple]----------[-:-:-]
`))
fmt.Fprintln(m.output, m.Current.Description)
return true
}
func newSceneManager(output io.Writer, scenes []*scene) *sceneManager {
return &sceneManager{
output: output,
scenes: scenes,
Current: scenes[0],
}
}
type character struct {
Name string
Description string
}
func newCharacter(name, description string) *character {
return &character{
Name: name,
Description: description,
}
}
func (c *character) Say(msg string) string {
verb := "says"
if c.Name == "you" {
verb = "say"
}
return fmt.Sprintf(`[-:-:b]%s %s:[-:-:-]
%s
`,
c.Name,
verb,
strings.TrimSpace(msg))
}
func main() {
logFile := path.Join(logDir, fmt.Sprintf("%d", time.Now().Unix()))
logF, err := os.Create(logFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
logger := log.New(logF, "", log.Ldate|log.Ltime)
db, err := signup.ConnectDB()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
err = _main(logger, db)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
}
func _main(l *log.Logger, db *sql.DB) error {
pages := tview.NewPages()
mainFlex := tview.NewFlex()
input := tview.NewTextArea()
input.SetBorder(true).SetBorderColor(tcell.ColorPaleTurquoise)
input.SetTitle("press enter to send")
input.SetMaxLength(2000)
title := tview.NewTextView()
title.SetDynamicColors(true)
title.SetTextAlign(tview.AlignCenter)
title.SetText("[purple]the tilde town sign up portal[-]")
footer := tview.NewTextView()
footer.SetText("type /help and press enter to get help. type /quit and press enter to leave.")
msgScroll := tview.NewTextView()
msgScroll.SetDynamicColors(true)
msgScroll.SetBackgroundColor(tcell.ColorBlack)
msgScroll.SetTextColor(tcell.ColorWhite)
mainFlex.SetDirection(tview.FlexRow)
mainFlex.AddItem(title, 1, -1, false)
mainFlex.AddItem(msgScroll, 0, 1, false)
mainFlex.AddItem(input, 4, -1, true)
mainFlex.AddItem(footer, 1, -1, false)
pages.AddPage("main", mainFlex, true, true)
app := tview.NewApplication()
app.SetRoot(pages, true)
player := newCharacter("you", "it's you. how are you?")
su := &models.TownSignup{ID: -1}
save := func() {
su.Created = time.Now()
err := su.Insert(db)
if err != nil {
l.Printf("failed to write to db: %s", err.Error())
l.Printf("dumping values: %#v", su)
return
}
}
scenes := []*scene{
newScene("start", heredoc.Doc(`
You open your eyes.
You're in some kind of workshop.
Wires and computers in various state of disrepair are strewn across
tables and shelves. It smells faintly of burnt cedar.
The wires and components before you slowly drag
themselves into the shape of a small humanoid.
[-:-:b]wire guy says:[-:-:-]
hello, welcome to the application for membership in tilde town.
first, please let me know what a good [-:-:b]email address[-:-:-] is for you?
just say it out loud. as many times as you need. to get it right.
once you've told me your email you can [-:-:b]/nod[-:-:-] to move on.
`),
"i'm sorry, before going further could you share an email with me?",
newCharacter("wire guy", "a lil homonculus made of discarded computer cables"),
func(s *scene) { su.Email = string(s.Input.Bytes()) },
func(s *scene, tv *tview.TextView, msg string) {
// TODO could check and see if it's email shaped and admonish if not
trimmed := strings.TrimSpace(msg)
fmt.Fprintln(tv, s.Host.Say(fmt.Sprintf("I heard '%s'. Is that right? if so, /nod", trimmed)))
}),
newScene("how", heredoc.Doc(`
The workshop fades away. You hear the sound of a dial up modem
in the distance.
Trees spring up out of the ground around you: birches, oaks, maples,
firs, yews, pines, cypresses complete with tiny swamps around their trunks,
junipers, redwoods, cedars, towering palms waving gently in a breeze, eucalyptus,
banyan. the smell is riotous like a canvas with all the colors splashed on. birds
start to sing.
a shrike alights on a branch in front of you.
[-:-:b]the shrike says:[-:-:-]
phweeturpff. how did you find out about the town? did anyone refer you?
just say your answer out loud. when you've said what you want, [-:-:b]/nod[-:-:-]
to continue.
`),
"phweeturpff",
newCharacter("the shrike", "a little grey bird. it has a pretty song."),
func(s *scene) { su.How = string(s.Input.Bytes()) }, nil),
newScene("what", heredoc.Doc(`
You sink down into soft mossy floor of the forest. You find yourself floating in darkness.
At the far reaches of your vision you can make out a faint neon grid. Around you
float pieces of consumer electronic appliances from 1980s Earth. A VCR approaches
you and speaks, flapping its tape slot cover with each word.
[-:-:b]the vcr says:[-:-:-]
welcome! thank you for coming this far. just two questions left. what about
tilde town interests you? what kind of stuff might you want to get up to here?
as usual, just say your answer. when you're satisfied, please [-:-:b]/nod[-:-:-]
`),
"hmm did you say something?",
newCharacter("the vcr", "a black and grey VCR from 1991"),
func(s *scene) { su.Why = string(s.Input.Bytes()) }, nil),
newScene("link", heredoc.Doc(`
You realize your eyes have been shut. You open them and, in an instant,
the neon grid and polygons are gone. You're in a convenience store. Outside
it's dark besides a single pool of light coming from a street lamp. it's illuminating
some litter and a rusty, blue 1994 pontiac grand am.
The shelves around you are stocked with products you've never heard of before like
Visible Pants, Petty Burgers, Gentle Rice, Boo Sponge, Power Banjo, Superware, Kneephones,
and Diet Coagulator. A mop is mopping the floor and turns to you.
[-:-:b]the mop says:[-:-:-]
swishy slop. last question. where online can we get to know you? do you have a personal
website or social media presence? we'll take whatever.
say some links and words out loud, you know the drill.
when you're happy you can submit this whole experience with a [-:-:b]/nod[-:-:-]
`),
"just the one last thing please",
newCharacter("the mop", "a greying mop with a wooden handle."),
func(s *scene) { su.Links = string(s.Input.Bytes()) }, nil),
newScene("done", heredoc.Doc(`
thank you for applying to tilde.town!
please be on the look out for an email from [-:-:b]root@tilde.town[-:-:-].
it's almost certain that it will end up in your spam filter, unfortunately.
every application is reviewed by a human. it can take up to 30 days for
an application to be reviewed. not every application is approved.
you can [-:-:b]/quit[-:-:-] now
ok bye have a good one~
`),
"",
newCharacter("the æther", "the very air around you"),
nil, nil),
}
sm := newSceneManager(msgScroll, scenes)
sm.Save = save
handleInput := func(msg string) {
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
if strings.HasPrefix(msg, "/") {
split := strings.Split(msg, " ")
if len(split) > 0 {
msg = split[0]
}
switch strings.TrimPrefix(msg, "/") {
case "help":
fmt.Fprintln(msgScroll, sm.Current.Host.Say(`some artificial beings will guide you through applying to tilde.town.
type things, then press enter. to take an action, put a "/" before a word.
for example typing:
/nod
and pressing enter will cause you to nod. some other verbs: /quit /look`))
case "quit":
app.Stop()
case "look":
fmt.Fprintln(msgScroll, "")
fmt.Fprintln(msgScroll, sm.Current.Description)
case "nod":
if !sm.Advance() {
fmt.Fprintln(msgScroll, "you nod, but nothing happens.")
fmt.Fprintln(msgScroll)
}
}
return
}
if sm.Current.Input.Len() > maxInputLength {
fmt.Fprintln(msgScroll,
sm.Current.Host.Say(
"sorry I've heard more than I can remember :( maybe it's time to move on"))
return
}
fmt.Fprintln(msgScroll, player.Say(msg))
fmt.Fprintln(sm.Current.Input, msg)
if sm.Current.OnMsg != nil {
sm.Current.OnMsg(sm.Current, msgScroll, msg)
}
msgScroll.ScrollToEnd()
}
defer db.Close()
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEnter:
handleInput(input.GetText())
input.SetText("", false)
return nil
}
return event
})
app.SetAfterDrawFunc(func(_ tcell.Screen) {
fmt.Fprintln(msgScroll, sm.Current.Description)
app.SetAfterDrawFunc(nil)
})
return app.Run()
}

41
external/cmd/signup/thoughts.md vendored 100644
View File

@ -0,0 +1,41 @@
# New commands for signups, rough draft
## town-signup
this binary is run by OpenSSH when a user anywhere in the world runs `ssh join@tilde.town`. It should:
- collect information about an application from a user
- allow them to edit their responses before submitting
- write a yaml file of their responses to disk
## review-signups
this binary is run by town admins in order to review, approve, notate, and reject applications. It should:
- iterate over signups
- allow fuzzy finding a particular signup
- allow approval using create-user
- allow rejection
- just move signup to archived rejected signup directory
- allow notating an application, ie:
- lock the yaml file for writing
- add notes to the yaml file that can be seen by other admins
- print info about historical signups
## create-user
this binary is called by `review-signups` to take a yaml application and create a user on disk. It should:
- create the user idempotently
- `adduser`
- `usermod` to set group
- calling `add-key` for user
- move the yaml file to an archive directory of approved signups
## user-key
this binary helps manage keys for users; basically automating the listing, adding, and removing of public keys for a user.
- `user-key add <username> <keyfile`
- `user-key list <username>`
- `user-key remove <username>`

79
external/cmd/welcome/README.md vendored 100644
View File

@ -0,0 +1,79 @@
# welcome command
this command is used to exchange a town invite token for a user account. it is
responsible for:
1. accepting and validating an invite token generated by the `review` command
2. accepting and validating a new user's username choice (ie enforcing rules and checking for dupes)
3. accepting and validating a user's email for use in account recovery (defaulting to an email embedded in the invite token)
4. accepting and validating a display name (PUT OFF)
5. Confirming that a user agrees to our CoC
6. accepting and validating a user's public ssh key
upon receipt of these things a user account is created. if it fails, the user
is told about the failure and told to email root@tilde.town for guidance; us
admins get a local mail about the problem.
upon successful creation, `welcome` prints a message on STDOUT suggesting how to log in then quits.
It is risky to let `welcome` create users but no riskier at a high level than the Django admin we had. I can re-use the sudoers trick I did there for the `welcome` user.
## an invite token
an invite token consists of two pieces that are then base64 encoded. the first piece is a random string of 30 characters (alphanumeric and symbols except space) and the second is an email address the invite was sent to; they are separated by a space.
## sudoers config
something like:
```
welcome ALL=(ALL)NOPASSWD:/usr/sbin/adduser,/usr/sbin/usermod,/town/bin/createkeyfile,/town/bin/generate_welcome_present.sh,/town/bin/registeruser
```
I'd like to consolidate adduser/usermod calls into a single "createuser" helper. I'd also like to move the welcome present generation into `welcome`. TODO.
## user creation flow
once we accept what we need from the user accepting an invite, the flow looks like:
1. create user account
a. run `adduser`, set shell and displayname
b. add user to town group
2. write authorized keys
a. create `~/.ssh`
b. write `~/.ssh/authorized_keys2` and put their key in there
c. write blank `~/.ssh/authorized_keys` with note about adding custom keys
3. generate welcome gift
4. alert hooks (more of a future idea; but it would be nice to have a "WELCOME NEW USER!" in the mailing list / IRC / etc)
## creating keyfiles
A frustrating hurdle is that `welcome`, just like `ttadmin`, has to write a keyfile that is perms 600 for the new user. This is annoying as shit and requires running `sudo` as the new user. In the old python code:
```python
def write_authorized_keys(self):
# Write out authorized_keys file
# Why is this a call out to a python script? There's no secure way with
# sudoers to allow this code to write to a file; if this code was to be
# compromised, the ability to write arbitrary files with sudo is a TKO.
# By putting the ssh key file creation into its own script, we can just
# give sudo access for that one command to this code.
#
# We could put the other stuff from here into that script and then only
# grant sudo for the script, but then we're moving code out of this
# virtual-env contained, maintainable thing into a script. it's my
# preference to have the script be as minimal as possible.
with TemporaryFile(dir="/tmp") as fp:
fp.write(self.generate_authorized_keys().encode('utf-8'))
fp.seek(0)
error = _guarded_run(['sudo',
'--user={}'.format(self.username),
'/town/src/tildetown-admin/scripts/create_keyfile.py',
self.username],
stdin=fp)
if error:
logger.error(error)
```
this warrants porting `create_keyfile.py` to a new Go program that can live at `/town/bin/create_keyfile` or wherever.

169
external/cmd/welcome/form.go vendored 100644
View File

@ -0,0 +1,169 @@
package main
import (
"errors"
"fmt"
"net/mail"
"regexp"
"strings"
"git.tilde.town/tildetown/town/invites"
"git.tilde.town/tildetown/town/sshkey"
"git.tilde.town/tildetown/town/stats"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/lipgloss"
)
func surveyIconSet(icons *survey.IconSet) {
icons.Question.Text = "~"
icons.Question.Format = "magenta:b"
}
func promptCode() (code string, err error) {
err = survey.AskOne(&survey.Input{
Message: "invite code?",
}, &code,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet))
code = strings.TrimSpace(code)
return
}
func confirmCoC() (conf bool, err error) {
err = survey.AskOne(
&survey.Confirm{
Message: "do you agree to the above CoC?",
Default: true,
}, &conf,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet))
return
}
func confirmContinue() (conf bool, err error) {
err = survey.AskOne(
&survey.Confirm{
Message: "Does the above look ok?",
}, &conf,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet))
return
}
type asker struct {
UserData *newUserData
Style lipgloss.Style
Invite invites.Invite
TownData stats.TildeData
}
func (a *asker) Ask() (err error) {
if err = a.promptUsername(); err != nil {
return err
}
if err = a.promptEmail(); err != nil {
return err
}
if err = a.promptKey(); err != nil {
return err
}
s := a.Style.SetString(
fmt.Sprintf(`ok! your account is about to be created with the following details:
username: %s
email: %s
pubkey: %s`, a.UserData.Username, a.UserData.Email, a.UserData.PubKey)).Bold(true).MaxWidth(80)
fmt.Println(s)
return nil
}
func (a *asker) promptUsername() (err error) {
// copied from /etc/adduser.conf
usernameRE := regexp.MustCompile(`^[a-z][-a-z0-9_]*$`)
err = survey.AskOne(
&survey.Input{
Message: "desired username?",
Default: a.UserData.Username,
}, &a.UserData.Username,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet),
survey.WithValidator(func(val interface{}) error {
un := val.(string)
if len(un) > 32 {
return fmt.Errorf("username '%s' is too long", un)
}
return nil
}),
survey.WithValidator(func(val interface{}) error {
un := val.(string)
if !usernameRE.MatchString(un) {
return errors.New("usernames must start with a letter and only contain letters, nubers, - or _")
}
return nil
}),
survey.WithValidator(func(val interface{}) error {
un := val.(string)
for _, v := range a.TownData.Users {
if v.Username == un {
return fmt.Errorf("username '%s' is already in use", un)
}
}
return nil
}))
return
}
func (a *asker) promptEmail() (err error) {
err = survey.AskOne(
&survey.Input{
Message: "e-mail (for account recovery only)?",
Default: a.UserData.Email,
}, &a.UserData.Email,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet),
survey.WithValidator(func(val interface{}) error {
email := val.(string)
_, err := mail.ParseAddress(email)
if err != nil {
return fmt.Errorf("'%s' doesn't look like an email: %w", email, err)
}
if !strings.Contains(email, ".") {
return fmt.Errorf("'%s' doesn't look like an email: domain not fully qualified", email)
}
return nil
}))
return
}
func (a *asker) promptKey() (err error) {
err = survey.AskOne(
&survey.Input{
Message: "SSH public key?",
Default: a.UserData.PubKey,
}, &a.UserData.PubKey,
survey.WithValidator(survey.Required),
survey.WithIcons(surveyIconSet),
survey.WithValidator(func(v interface{}) error {
key := v.(string)
valid, err := sshkey.ValidKey(key)
if err != nil {
return fmt.Errorf("failed to validate key: %w", err)
}
if !valid {
return errors.New("that doesn't seem like a valid SSH key. try another public key?")
}
return nil
}))
return
}

227
external/cmd/welcome/main.go vendored 100644
View File

@ -0,0 +1,227 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"git.tilde.town/tildetown/town/invites"
"git.tilde.town/tildetown/town/stats"
"git.tilde.town/tildetown/town/towndb"
"github.com/charmbracelet/lipgloss"
_ "embed"
)
// TODO mark on user table what signup id led to the account for forensics
// TODO add logging like the signup tool has
// TODO consider merging adduser, usermod, and createkeyfile into single createuser helper to limit sudoers list
// TODO add alerts for new users (mailing list post, irc post, etc(?))
//go:embed welcome.txt
var welcomeArt string
type newUserData struct {
Username string
DisplayName string
Email string
PubKey string
}
func defaultStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{
Light: "#7D19BD",
Dark: "#E0B0FF",
})
}
func _main() error {
inviteDB, err := invites.ConnectDB()
if err != nil {
return err
}
s := defaultStyle().SetString(welcomeArt)
fmt.Println(s)
code, err := promptCode()
if err != nil {
return err
}
invite, err := invites.Get(inviteDB, code)
if err != nil {
return fmt.Errorf("could not look up invite code: %w", err)
}
if invite.Used {
return errors.New("that invite code has already been used.")
}
s = s.SetString("thanks!! just gotta collect some information now and then your account will be ready.")
fmt.Println(s)
townData, err := stats.Stats()
if err != nil {
return err
}
data := &newUserData{
Email: invite.Email,
}
a := &asker{
UserData: data,
Invite: *invite,
TownData: townData,
Style: defaultStyle(),
}
if err = a.Ask(); err != nil {
return err
}
conf, err := confirmContinue()
if err != nil {
return err
}
if !conf {
for !conf {
if err = a.Ask(); err != nil {
return err
}
if conf, err = confirmContinue(); err != nil {
return err
}
}
}
s = s.SetString("town users all agree to our code of conduct: https://tilde.town/wiki/conduct.html")
fmt.Println(s)
coc, err := confirmCoC()
if err != nil {
return err
}
if !coc {
s = s.SetString("bummer. have a good one.")
fmt.Println(s)
return nil
}
s = s.SetString("cool, awesome, thank you, going to make your account now...")
fmt.Println(s)
if err = createUser(*data); err != nil {
s = s.SetString(fmt.Sprintf(`augh, I'm sorry. the account creation failed.
Please email root@tilde.town and paste the following error:
%s
Your invite code has not been marked as used and you're welcome to try again,
though if there is a system issue you might need to wait for word from an admin.`, err.Error()))
fmt.Println(s)
return nil
}
s = s.SetString(fmt.Sprintf(`OK! your user account has been created.
welcome, ~%[1]s <3
This program is going to exit and you are now free to ssh to town as yourself:
ssh %[1]s@tilde.town
if your public key isn't found by ssh, you'll need to explain to ssh how to find it with:
ssh -i "replace with path to private key file" %[1]s@tilde.town
for help with ssh, see: https://tilde.town/wiki/getting-started/ssh.html
if you end up very stuck, you can email root@tilde.town .`, data.Username))
fmt.Println(s)
if err = invite.MarkUsed(inviteDB); err != nil {
return fmt.Errorf("could not mark invite as used: %w", err)
}
return nil
}
func createUser(data newUserData) (err error) {
cmd := exec.Command("sudo", "/usr/sbin/adduser", "--quiet", "--disabled-password", data.Username)
if err = cmd.Run(); err != nil {
return fmt.Errorf("adduser failed: %w", err)
}
cmd = exec.Command("sudo", "/usr/sbin/usermod", "-a", "-G", "town", data.Username)
if err = cmd.Run(); err != nil {
return fmt.Errorf("usermod failed: %w", err)
}
cmd = exec.Command("sudo", "--user", data.Username, "/town/bin/createkeyfile", data.Username)
cmd.Stdin = bytes.NewBufferString(keyfileText(data))
stdoutBuff := bytes.NewBuffer([]byte{})
cmd.Stdout = stdoutBuff
if err = cmd.Run(); err != nil {
return fmt.Errorf("createkeyfile failed with '%s': %w", string(stdoutBuff.Bytes()), err)
}
cmd = exec.Command("sudo", "/town/bin/generate_welcome_present.sh", data.Username)
if err = cmd.Run(); err != nil {
// TODO log this. no reason to bail out.
}
tu := towndb.TownUser{
Username: data.Username,
Created: time.Now(),
Emails: []string{
data.Email,
},
State: towndb.StateActive,
}
var out []byte
if out, err = json.Marshal(tu); err != nil {
return fmt.Errorf("could not serialize user data: %w", err)
}
cmd = exec.Command("sudo", "/town/bin/registeruser")
stderrBuff := bytes.NewBuffer([]byte{})
cmd.Stderr = stderrBuff
cmd.Stdin = bytes.NewBuffer(out)
if err = cmd.Run(); err != nil {
return fmt.Errorf("register user failed with '%s': %w", string(stderrBuff.Bytes()), err)
}
return nil
}
func keyfileText(data newUserData) string {
pkey := data.PubKey
if !strings.HasSuffix(pkey, "\n") {
pkey += "\n"
}
header := `########## GREETINGS! ##########
# This file was automatically generated by tilde.town when
# your account was created. You can edit it if you want, but we
# recommend adding stuff to ~/.ssh/authorized_keys instead.`
return fmt.Sprintf("%s\n%s", header, pkey)
}
func main() {
err := _main()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@ -0,0 +1,19 @@
_ ( ) . '
| | .
_ | | __ __ _ _ _ _ _|_ __ __ :
| | |_|/ |/ / / \_/ |/ |/ | |/ | / \_ / /\ __ ___!__ _,__ ___,_
\/ \/ |__/|__/\___/\__/ | | |_/|__/ |_/\__/ / / o\/ \ / /\ /__/ \ /__\__\
/ \\ \/_____ /_*\ | |[^][^| | |
_ / /\ | | |_|__| .: |__|__|
o | | | || | {^} | |
_|_ | | __| _ _|_ __ _ _ [] [] | | | |. . A ._ . .
| | |/ / | |/ | / \_| | |_/ |/ | _ _ |___|__|__D_| . H / \ {^}
|_/|_/|__/\_/|_/|__/o|_/\__/ \/ \/ | |_/ | |[@] | | . . . | |/ \ |
_|_|_____| . . |^| \ |
. . v v . | | \|.
. v v . / O \ /|
|_ u _| . . / _ \ / |
we're glad you're here || | || / |_| \/ |
| | |.

View File

@ -0,0 +1,64 @@
package lockingwriter
import (
"os"
"time"
"github.com/gofrs/flock"
)
// for now this package defines a writer for use with log.New(). It it intended to be used from the external ssh applications. This logger uses a file lock to allow all the various ssh applications to log to the same file.
// the correct way to do this is to send log events to a daemon
// but i'm trying to cut a corner
type LockingWriter struct {
path string
}
const (
fp = "/town/var/log/external.log"
lp = "/town/var/log/log.lock"
)
func New() *LockingWriter {
f, err := os.OpenFile(fp, os.O_EXCL|os.O_CREATE|os.O_WRONLY, 0660)
if err != nil && !os.IsExist(err) {
panic(err)
}
f.Close()
return &LockingWriter{
path: fp,
}
}
func (l *LockingWriter) Write(p []byte) (n int, err error) {
fl := flock.New(lp)
var locked bool
for !locked {
locked, err = fl.TryLock()
if err != nil {
return
}
time.Sleep(time.Second)
}
var f *os.File
f, err = os.OpenFile(l.path, os.O_APPEND|os.O_WRONLY, 0660)
if err != nil {
return
}
defer f.Close()
defer fl.Unlock()
n, err = f.Write(p)
if err != nil {
panic(err)
}
return
//return f.Write(p)
}

43
go.mod
View File

@ -1,3 +1,44 @@
module git.tilde.town/tildetown/town
go 1.14
go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.5
github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/charmbracelet/glamour v0.5.0
github.com/charmbracelet/lipgloss v0.6.0
github.com/gdamore/tcell/v2 v2.5.3
github.com/gofrs/flock v0.8.1
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-tty v0.0.5
github.com/rivo/tview v0.0.0-20230130130022-4a1b7a76c01c
github.com/spf13/cobra v1.5.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/microcosm-cc/bluemonday v1.0.17 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/yuin/goldmark v1.4.4 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect
golang.org/x/text v0.3.7 // indirect
)

118
go.sum 100644
View File

@ -0,0 +1,118 @@
github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ=
github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A=
github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
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/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/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
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/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
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.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
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/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-tty v0.0.5 h1:s09uXI7yDbXzzTTfw3zonKFzwGkyYlgU3OMjqA0ddz4=
github.com/mattn/go-tty v0.0.5/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
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/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.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/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/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/tview v0.0.0-20230130130022-4a1b7a76c01c h1:zIYU4PjQJ4BnYryMmpyizt1Un13V0ToCMXvC05DK8xc=
github.com/rivo/tview v0.0.0-20230130130022-4a1b7a76c01c/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/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-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

112
invites/invites.go 100644
View File

@ -0,0 +1,112 @@
package invites
import (
"database/sql"
"errors"
"time"
"git.tilde.town/tildetown/town/codes"
_ "github.com/mattn/go-sqlite3"
)
const (
dsn = "/town/var/invites/invites.db?mode=rw"
)
type Invite struct {
ID int64
Created time.Time
Code string
Email string
Used bool
}
func (i *Invite) Insert(db *sql.DB) error {
stmt, err := db.Prepare(`
INSERT INTO invites (code, email) VALUES (?, ?)
`)
if err != nil {
return err
}
i.Code = codes.NewCode(i.Email)
_, err = stmt.Exec(i.Code, i.Email)
if err != nil {
return err
}
defer stmt.Close()
return nil
}
func ConnectDB() (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}
func Get(db *sql.DB, code string) (*Invite, error) {
inv := &Invite{
Code: code,
}
var created string
var used int
stmt, err := db.Prepare(`
SELECT id, created, email, used
FROM invites WHERE code = ?`)
if err != nil {
return nil, err
}
row := stmt.QueryRow(code)
if err != nil {
return nil, err
}
defer stmt.Close()
err = row.Scan(
&inv.ID,
&created,
&inv.Email,
&used,
)
if err != nil {
return nil, err
}
inv.Created, err = time.Parse("2006-01-02T15:04", created)
if err != nil {
return inv, err
}
inv.Used = used > 0
return inv, nil
}
func (i *Invite) MarkUsed(db *sql.DB) (err error) {
var stmt *sql.Stmt
var result sql.Result
var rowsAffected int64
if stmt, err = db.Prepare(`UPDATE invites SET used = 1 WHERE id = ?`); err != nil {
return
}
if result, err = stmt.Exec(i.ID); err != nil {
return
}
if rowsAffected, err = result.RowsAffected(); err != nil {
return
}
if rowsAffected == 0 {
err = errors.New("no rows affected")
}
return
}

View File

@ -1,7 +0,0 @@
package main
import "git.tilde.town/tildetown/town/email"
func main() {
email.SendLocalEmail("vilmibm", "testing hi", "this is a body")
}

195
models/models.go 100644
View File

@ -0,0 +1,195 @@
// shared database related code
package models
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// TODO consider splitting this stuff out into packages
type SignupNote struct {
ID int64
Created time.Time
Author string
Content string
SignupID int64
}
func (n *SignupNote) Insert(db *sql.DB) error {
n.Created = time.Now()
stmt, err := db.Prepare(`
INSERT INTO notes (created, author, content, signupid)
VALUES (
?, ?, ?, ?
)`)
if err != nil {
return err
}
result, err := stmt.Exec(
n.Created.Unix(),
n.Author,
n.Content,
n.SignupID)
if err != nil {
return err
}
defer stmt.Close()
liid, err := result.LastInsertId()
if err != nil {
return err
}
n.ID = liid
return nil
}
type SignupDecision string
const (
SignupAccepted SignupDecision = "accepted"
SignupRejected SignupDecision = "rejected"
)
type TownSignup struct {
ID int64
Created time.Time
Email string
How string
Why string
Links string
Notes []SignupNote
Decision SignupDecision
DecisionTime time.Time
DecidedBy string
CleanEmail string
}
func (s *TownSignup) Insert(db *sql.DB) error {
stmt, err := db.Prepare(`
INSERT INTO signups (created, email, how, why, links) VALUES(
?, ?, ?, ?, ?
) RETURNING id
`)
if err != nil {
return err
}
result, err := stmt.Exec(s.Created.Unix(), s.Email, s.How, s.Why, s.Links)
if err != nil {
return err
}
defer stmt.Close()
liid, err := result.LastInsertId()
if err != nil {
return err
}
s.ID = liid
return nil
}
func (s *TownSignup) RefreshNotes(db *sql.DB) error {
stmt, err := db.Prepare(`
SELECT created, author, content
FROM notes
WHERE signupid = ?
ORDER BY created ASC`)
if err != nil {
return err
}
rows, err := stmt.Query(s.ID)
if err != nil {
return err
}
defer stmt.Close()
s.Notes = []SignupNote{}
for rows.Next() {
note := &SignupNote{}
var timestamp int64
err = rows.Scan(
&timestamp,
&note.Author,
&note.Content,
)
if err != nil {
return err
}
note.Created = time.Unix(timestamp, 0)
s.Notes = append(s.Notes, *note)
}
return nil
}
func (s *TownSignup) Review(db *sql.DB) error {
stmt, err := db.Prepare(`
UPDATE signups SET
decision = ?,
decision_time = ?,
decided_by = ?,
clean_email = ?
WHERE id = ?`)
if err != nil {
return err
}
_, err = stmt.Exec(
s.Decision,
s.DecisionTime.Unix(),
s.DecidedBy,
s.CleanEmail,
s.ID)
if err != nil {
return err
}
stmt.Close()
return nil
}
func (s *TownSignup) All(db *sql.DB) ([]*TownSignup, error) {
// TODO notes; circle back once can author them
rows, err := db.Query(`
SELECT
id, created, email, how, why, links
FROM signups WHERE decision IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*TownSignup{}
for rows.Next() {
su := &TownSignup{}
var timestamp int64
if err = rows.Scan(
&su.ID,
&timestamp,
&su.Email,
&su.How,
&su.Why,
&su.Links,
); err != nil {
return nil, err
}
su.Created = time.Unix(timestamp, 0)
out = append(out, su)
}
return out, nil
}

69
request/gemini.go 100644
View File

@ -0,0 +1,69 @@
package request
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
email "git.tilde.town/tildetown/town/email"
)
const geminiHomeDocBase = "/home/gemini/users"
func ProcessGemini(requestRootPath string) error {
rp := filepath.Join(requestRootPath, "gemini")
files, err := ioutil.ReadDir(rp)
if err != nil {
return fmt.Errorf("failed to list directory %s: %w", rp, err)
}
usernames := []string{}
for _, file := range files {
usernames = append(usernames, file.Name())
}
if len(usernames) == 0 {
return nil
}
for _, username := range usernames {
err := linkGemini(username)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to process gemini request for %s: %s\n", username, err.Error())
}
os.Remove(filepath.Join(rp, username))
}
return nil
}
func linkGemini(username string) error {
pgPath := filepath.Join("/home", username, "public_gemini")
if !pathExists(pgPath) {
return fmt.Errorf("public_gemini missing for %s", username)
}
geminiPath := filepath.Join(geminiHomeDocBase, username)
if !pathExists(geminiPath) {
err := os.Symlink(pgPath, geminiPath)
if err != nil {
return fmt.Errorf("failed to link public_gemini for %s: %w", username, err)
}
}
body := fmt.Sprintf(`hi %s!
you requested a gemini space on the town. this space has been activated and anything you do in your public_gemini directory should now be reflected by the server.
if you did _not_ request this, please let an admin know.`, username)
return email.SendLocalEmail(username, "gemini", body)
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

188
request/gitea.go 100644
View File

@ -0,0 +1,188 @@
package request
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"os"
"os/user"
"path/filepath"
"syscall"
"time"
email "git.tilde.town/tildetown/town/email"
)
const pwLetters = "!@#$%^&*()[]{}:;,.<>/?abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890"
func ProcessGitea(rp string) error {
apiToken := os.Getenv("GITEA_TOKEN")
if apiToken == "" {
return errors.New("need GITEA_TOKEN")
}
gtPath := filepath.Join(RequestPath, "gitea")
files, err := ioutil.ReadDir(gtPath)
if err != nil {
return err
}
usernames := []string{}
for _, file := range files {
sysInfo := file.Sys()
uid := sysInfo.(*syscall.Stat_t).Uid
user, err := user.LookupId(fmt.Sprintf("%d", uid))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get owner of file named '%s': %s", file.Name(), err)
continue
}
usernames = append(usernames, user.Username)
}
if len(usernames) == 0 {
return nil
}
for _, username := range usernames {
exists, err := giteaUserExists(apiToken, username)
if err != nil {
fmt.Fprintf(os.Stderr, "unable to check for existing account for %s: %s\n", username, err)
continue
}
if !exists {
password, err := createGiteaUser(apiToken, username)
if err != nil {
fmt.Fprintf(os.Stderr, "unable to create account for %s: %s\n", username, err)
continue
}
err = sendGiteaEmail(username, password)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to send email to %s; reach out manually: %s\n", username, err)
}
}
os.Remove(filepath.Join(gtPath, username))
}
return nil
}
func createGiteaUser(apiToken, username string) (string, error) {
client := &http.Client{}
password := genGiteaPassword()
// TODO using local email sucks for obvious reasons but it'll have to do for now. ideally password
// resets can be set local to the server, so the thing to change is not the local email but the
// ability for gitea to send mail internally.
createPayload := struct {
Email string
FullName string `json:"full_name"`
Login string `json:"login_name"`
MustChangePassword bool `json:"must_change_password"`
Password string
SendNotify bool `json:"send_notify"`
Username string
SourceId int `json:"source_id"`
}{
Email: fmt.Sprintf("%s@tilde.town", username),
FullName: username,
Login: username,
MustChangePassword: true,
Password: password,
SendNotify: false,
Username: username,
SourceId: 0,
}
body, err := json.Marshal(createPayload)
if err != nil {
return "", err
}
req, err := giteaAPIReq(apiToken, "POST", "admin/users", bytes.NewBuffer(body))
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
if resp.StatusCode != 201 {
lol, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("DBG %#v\n", string(lol))
return "", fmt.Errorf("failed to create user for %s; error code %d", username, resp.StatusCode)
}
return password, nil
}
func giteaUserExists(apiToken, username string) (bool, error) {
client := &http.Client{}
req, err := giteaAPIReq(apiToken, "GET", "users/"+username, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
if resp.StatusCode == 200 {
return true, nil
} else if resp.StatusCode == 404 {
return false, nil
} else {
return false, fmt.Errorf("unexpected response code: %d", resp.StatusCode)
}
}
func giteaAPIReq(apiToken, method, path string, body *bytes.Buffer) (*http.Request, error) {
if body == nil {
body = bytes.NewBufferString("")
}
basePath := "https://git.tilde.town/api/v1/"
req, err := http.NewRequest(method, basePath+path, body)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", fmt.Sprintf("token %s", apiToken))
req.Header.Add("Content-Type", "application/json")
return req, nil
}
func sendGiteaEmail(username, password string) error {
body := fmt.Sprintf(`hi %s!
you requested a git.tilde.town account and now you have one~
please log in with username %s and password %s
you'll be prompted to change the password.
if you did _not_ request this, please let an admin know.
`, username, username, password)
return email.SendLocalEmail(username, "gitea", body)
}
func genGiteaPassword() string {
rand.Seed(time.Now().UnixNano())
b := make([]byte, 20)
for i := range b {
b[i] = pwLetters[rand.Intn(len(pwLetters))]
}
// Bootleg, but hack to ensure we meet complexity requirement
return string(b) + "!" + "1" + "A"
}

View File

@ -0,0 +1,3 @@
package request
const RequestPath = "/town/requests"

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
dbpath="/town/var/invites/invites.db"
srcpath="/town/src/town"
rm -f "$dbpath"
sqlite3 < "${srcpath}/sql/create_invites_db.sql" "$dbpath"
chown welcome:admin "$dbpath"
chmod o-r "$dbpath"
chmod g+w "$dbpath"

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
dbpath="/town/var/signups/signups.db"
srcpath="/town/src/town"
rm -f "$dbpath"
sqlite3 < "${srcpath}/sql/create_signups_db.sql" "$dbpath"
chown join:admin "$dbpath"
chmod o-r "$dbpath"
chmod g+w "$dbpath"

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
dbpath="/town/var/town.db"
srcpath="/town/src/town"
rm -f "$dbpath"
sqlite3 < "${srcpath}/sql/create_town_db.sql" "$dbpath"
chown root:admin "$dbpath"
chmod o-r "$dbpath"
chmod g+w "$dbpath"

18
signup/signup.go 100644
View File

@ -0,0 +1,18 @@
package signup
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
const dsn = "/town/var/signups/signups.db?mode=rw"
func ConnectDB() (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS auth_codes (
id INTEGER PRIMARY KEY,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
code TEXT,
email TEXT,
used INTEGER DEFAULT 0
);

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
code TEXT,
email TEXT,
used INTEGER DEFAULT 0
);

View File

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
-- from user
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
email TEXT,
how TEXT,
why TEXT,
links TEXT,
-- admin provided
decision_time TEXT,
decision TEXT,
decided_by TEXT,
clean_email TEXT
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
signupid INTEGER,
author TEXT,
content TEXT,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
FOREIGN KEY (signupid) REFERENCES signups(signupid)
);

View File

@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
username TEXT UNIQUE,
state TEXT,
admin INTEGER DEFAULT 0
);
-- TODO address /should/ be unique but leaving it duplicable for now since i can think of some cases where there might be >1 account for the same human
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY,
address TEXT,
userid INTEGER,
FOREIGN KEY (userid) REFERENCES users(userid)
);
CREATE TABLE IF NOT EXISTS user_notes (
noteid INTEGER,
userid INTEGER,
PRIMARY KEY (noteid, userid),
FOREIGN KEY (noteid) REFERENCES notes(noteid),
FOREIGN KEY (userid) REFERENCES users(userid)
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
author INTEGER,
content TEXT,
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
FOREIGN KEY (author) REFERENCES users(author)
);

23
sshkey/sshkey.go 100644
View File

@ -0,0 +1,23 @@
package sshkey
import (
"bytes"
"errors"
"os/exec"
)
const skgpath = "/usr/bin/ssh-keygen"
func ValidKey(key string) (valid bool, err error) {
cmd := exec.Command(skgpath, "-l", "-f", "-")
cmd.Stdin = bytes.NewBuffer([]byte(key))
err = cmd.Run()
if err == nil {
valid = true
} else if errors.Is(&exec.ExitError{}, err) {
err = nil
}
return
}

335
stats/stats.go 100644
View File

@ -0,0 +1,335 @@
package stats
import (
"bufio"
"bytes"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const defaultIndexPath = "/etc/skel/public_html/index.html"
const description = `an intentional digital community for creating and sharing
works of art, peer education, and technological anachronism. we are
non-commercial, donation supported, and committed to rejecting false
technological progress in favor of empathy and sustainable computing.`
type NewsEntry struct {
Title string `json:"title"` // Title of entry
Pubdate string `json:"pubdate"` // Human readable date
Content string `json:"content"` // HTML of entry
}
type User struct {
Username string `json:"username"` // Username of user
PageTitle string `json:"title"` // Title of user's HTML page, if they have one
Mtime int64 `json:"mtime"` // Timestamp representing the last time a user's index.html was modified
// Town additions
DefaultPage bool `json:"default"` // Whether or not user has updated their default index.html
}
type TildeData struct {
Name string `json:"name"` // Name of the server
URL string `json:"url"` // URL of the server's homepage
SignupURL string `json:"signup_url"` // URL for server's signup page
WantUsers bool `json:"want_users"` // Whether or not new users are being accepted
AdminEmail string `json:"admin_email"` // Email for server admin
Description string `json:"description"` // Description of server
UserCount int `json:"user_count"` // Total number of users on server sorted by last activity time
Users []*User `json:"users"`
// Town Additions
LiveUserCount int `json:"live_user_count"` // Users who have changed their index.html
ActiveUserCount int `json:"active_user_count"` // Users with an active session
ActiveUsers []string `json:"active_users"` // Usernames of logged in users
GeneratedAt string `json:"generated_at"` // When this was generated in '%Y-%m-%d %H:%M:%S' format
GeneratedAtSec int64 `json:"generated_at_sec"` // When this was generated in seconds since epoch
Uptime string `json:"uptime"` // output of `uptime -p`
News []NewsEntry `json:"news"` // Collection of town news entries
}
func getEnvDefault(key, def string) string {
result := os.Getenv(key)
if result == "" {
result = def
}
return result
}
func homesDir() string { return getEnvDefault("HOMES_DIR", "/home") }
func getNews() (entries []NewsEntry, err error) {
inMeta := true
inContent := false
current := NewsEntry{}
blankLineRe := regexp.MustCompile(`^ *\n$`)
newsPath := getEnvDefault("NEWS_PATH", "/town/news.posts")
newsFile, err := os.Open(newsPath)
if err != nil {
return entries, fmt.Errorf("unable to read news file: %s", err)
}
defer newsFile.Close()
scanner := bufio.NewScanner(newsFile)
for scanner.Scan() {
newsLine := scanner.Text()
if strings.HasPrefix(newsLine, "#") || newsLine == "" || blankLineRe.FindStringIndex(newsLine) != nil {
continue
} else if strings.HasPrefix(newsLine, "--") {
entries = append(entries, current)
current = NewsEntry{}
inMeta = true
inContent = false
} else if inMeta {
kv := strings.SplitN(newsLine, ":", 2)
if kv[0] == "pubdate" {
current.Pubdate = strings.TrimSpace(kv[1])
} else if kv[0] == "title" {
current.Title = strings.TrimSpace(kv[1])
}
if current.Pubdate != "" && current.Title != "" {
inMeta = false
inContent = true
}
} else if inContent {
current.Content += fmt.Sprintf("\n%v", strings.TrimSpace(newsLine))
} else {
panic("news post parsing should never reach this point")
}
}
return entries, nil
}
func indexPathFor(username string) (string, error) {
potentialPaths := []string{"index.html", "index.htm"}
indexPath := ""
errs := []error{}
for _, p := range potentialPaths {
fullPath := path.Join(homesDir(), username, "public_html", p)
_, staterr := os.Stat(fullPath)
if staterr != nil {
errs = append(errs, staterr)
} else {
indexPath = fullPath
break
}
}
if indexPath == "" {
return "", fmt.Errorf("failed to locate index file for %v; tried %v; encountered errors: %v", username, potentialPaths, errs)
}
return indexPath, nil
}
func pageTitleFor(username string) string {
pageTitleRe := regexp.MustCompile(`<title[^>]*>(.*)</title>`)
indexPath, err := indexPathFor(username)
if err != nil {
return ""
}
content, err := ioutil.ReadFile(indexPath)
if err != nil {
return ""
}
titleMatch := pageTitleRe.FindStringSubmatch(string(content))
if len(titleMatch) < 2 {
return ""
}
return titleMatch[1]
}
func systemUsers() map[string]bool {
systemUsers := map[string]bool{
"ubuntu": true,
"ttadmin": true,
"root": true,
}
envSystemUsers := os.Getenv("SYSTEM_USERS")
if envSystemUsers != "" {
for _, username := range strings.Split(envSystemUsers, ",") {
systemUsers[username] = true
}
}
return systemUsers
}
func mtimeFor(username string) int64 {
path := path.Join(homesDir(), username, "public_html")
var maxMtime int64 = 0
_ = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if maxMtime < info.ModTime().Unix() {
maxMtime = info.ModTime().Unix()
}
return nil
})
return maxMtime
}
func detectDefaultPageFor(username string, defaultHTML []byte) bool {
indexPath, err := indexPathFor(username)
if err != nil {
return false
}
indexFile, err := os.Open(indexPath)
if err != nil {
return false
}
defer indexFile.Close()
indexHTML, err := ioutil.ReadAll(indexFile)
if err != nil {
return false
}
return bytes.Equal(indexHTML, defaultHTML)
}
func getDefaultHTML() ([]byte, error) {
indexPath := os.Getenv("DEFAULT_INDEX_PATH")
if indexPath == "" {
indexPath = defaultIndexPath
}
defaultIndexFile, err := os.Open(indexPath)
if err != nil {
return []byte{}, fmt.Errorf("could not open default index: %s", err)
}
defer defaultIndexFile.Close()
defaultIndexHTML, err := ioutil.ReadAll(defaultIndexFile)
if err != nil {
return []byte{}, fmt.Errorf("could not read default index: %s", err)
}
return defaultIndexHTML, nil
}
type byMtime []*User
func (x byMtime) Len() int { return len(x) }
func (x byMtime) Less(i, j int) bool { return x[i].Mtime > x[j].Mtime } // because we want DESC
func (x byMtime) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
func getUsers() (users []*User, err error) {
// For the purposes of this program, we discover users via:
// - presence in /home/
// - absence in systemUsers list (sourced from source code and potentially augmented by an environment variable)
// We formally used passwd parsing. This is definitely more "correct" and I'm
// not opposed to going back to that; going back to parsing /home is mainly to
// get this new version going.
defaultIndexHTML, err := getDefaultHTML()
if err != nil {
return users, err
}
out, err := exec.Command("ls", homesDir()).Output()
if err != nil {
return users, fmt.Errorf("could not run ls: %s", err)
}
scanner := bufio.NewScanner(bytes.NewReader(out))
systemUsers := systemUsers()
for scanner.Scan() {
username := scanner.Text()
if systemUsers[username] {
continue
}
user := User{
Username: username,
PageTitle: pageTitleFor(username),
Mtime: mtimeFor(username),
DefaultPage: detectDefaultPageFor(username, defaultIndexHTML),
}
users = append(users, &user)
}
return users, nil
}
func liveUserCount(users []*User) int {
count := 0
for _, u := range users {
if !u.DefaultPage {
count++
}
}
return count
}
func activeUsers() ([]string, error) {
out, err := exec.Command("sh", "-c", "who | cut -d' ' -f1 | sort -u").Output()
if err != nil {
return nil, fmt.Errorf("failed to get active user count: %w", err)
}
scanner := bufio.NewScanner(bytes.NewReader(out))
usernames := []string{}
for scanner.Scan() {
usernames = append(usernames, strings.TrimSpace(scanner.Text()))
}
return usernames, nil
}
func getUptime() (string, error) {
out, err := exec.Command("uptime").Output()
if err != nil {
return "", fmt.Errorf("could not run uptime: %s", err)
}
return strings.TrimSpace(string(out)), nil
}
func Stats() (TildeData, error) {
users, err := getUsers()
if err != nil {
return TildeData{}, fmt.Errorf("could not get user list: %s", err)
}
activeUsernames, err := activeUsers()
if err != nil {
return TildeData{}, fmt.Errorf("could not count active users: %s", err)
}
news, err := getNews()
if err != nil {
return TildeData{}, fmt.Errorf("could not get news: %s", err)
}
uptime, err := getUptime()
if err != nil {
return TildeData{}, fmt.Errorf("could not determine uptime: %s", err)
}
sort.Sort(byMtime(users))
return TildeData{
Name: "tilde.town",
URL: "https://tilde.town",
SignupURL: "https://tilde.town/signup",
WantUsers: true,
AdminEmail: "root@tilde.town",
Description: description,
UserCount: len(users),
Users: users,
LiveUserCount: liveUserCount(users),
ActiveUserCount: len(activeUsernames),
ActiveUsers: activeUsernames,
Uptime: uptime,
News: news,
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
GeneratedAtSec: time.Now().Unix(),
}, nil
}

176
towndb/towndb.go 100644
View File

@ -0,0 +1,176 @@
package towndb
import (
"database/sql"
"errors"
"time"
_ "github.com/mattn/go-sqlite3"
)
const dsn = "/town/var/town.db?mode=rw"
type UserState string
const (
StateActive = "active"
StateTempBan = "temp_banned"
StateBan = "banned"
StateDeleted = "deleted" // some users request deletion
)
type AdminNote struct {
ID int64
Created time.Time
AuthorID int64
Content string
UserID int64
}
func (n *AdminNote) Insert(db *sql.DB) error {
var (
err error
stmt *sql.Stmt
result sql.Result
liid int64
)
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
stmt, err = tx.Prepare(`
INSERT INTO notes (created, author, content)
VALUES (?, ?, ?)`)
if err != nil {
return err
}
result, err = stmt.Exec(
n.Created.Unix(),
n.AuthorID,
n.Content)
if err != nil {
return err
}
liid, err = result.LastInsertId()
if err != nil {
return err
}
n.ID = liid
stmt, err = tx.Prepare(`
INSERT INTO user_notes (noteid, userid) VALUES (?, ?)`)
if err != nil {
return err
}
_, err = stmt.Exec(n.ID, n.UserID)
if err != nil {
return err
}
return tx.Commit()
}
type TownUser struct {
ID int64
Created time.Time
Emails []string
Username string
Notes []AdminNote
State UserState
IsAdmin bool
}
func (u *TownUser) Insert(db *sql.DB) (err error) {
var tx *sql.Tx
var stmt *sql.Stmt
var result sql.Result
var liid int64
if tx, err = db.Begin(); err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if stmt, err = tx.Prepare(`
INSERT INTO users (created, username, state, admin)
VALUES (?, ?, ?, ?)`); err != nil {
return err
}
if result, err = stmt.Exec(
u.Created.Unix(),
u.Username,
u.State,
u.IsAdmin); err != nil {
return err
}
if liid, err = result.LastInsertId(); err != nil {
return err
}
u.ID = liid
if len(u.Emails) > 0 {
for _, e := range u.Emails {
if stmt, err = tx.Prepare(`
INSERT INTO emails (address, userid)
VALUES (?, ?)`); err != nil {
return err
}
if result, err = stmt.Exec(e, u.ID); err != nil {
return err
}
}
}
return tx.Commit()
}
// UserForEmail returns the user associated with an email or nil if no matching user is found
func UserForEmail(db *sql.DB, address string) (*TownUser, error) {
stmt, err := db.Prepare(`
SELECT u.id, u.username FROM users u
JOIN emails e ON e.userid = u.id
WHERE e.address = ?
`)
if err != nil {
return nil, err
}
defer stmt.Close()
row := stmt.QueryRow(address)
u := &TownUser{}
if err = row.Scan(&u.ID, &u.Username); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return u, nil
}
func ConnectDB() (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -7,7 +7,7 @@ import (
const adminGroup = "admin"
func IsAdmin(u user.User) (bool, error) {
func IsAdmin(u *user.User) (bool, error) {
adminGroup, err := user.LookupGroup(adminGroup)
if err != nil {
return false, fmt.Errorf("failed to get admin group info: %w", err)