Compare commits
160 Commits
Author | SHA1 | Date |
---|---|---|
vilmibm | ca33731826 | |
vilmibm | 81c9dede67 | |
vilmibm | 4958407856 | |
vilmibm | 9bb456bc17 | |
vilmibm | 69ad6a384a | |
vilmibm | 4284fb4048 | |
vilmibm | 100643d8fc | |
vilmibm | 0764534fed | |
vilmibm | 69666edefa | |
vilmibm | add129826a | |
vilmibm | 90808c1ce0 | |
vilmibm | 17d39483fb | |
vilmibm | 418e4a4a14 | |
vilmibm | 79dc987c61 | |
vilmibm | ba1a1319e3 | |
vilmibm | cbc868ae35 | |
vilmibm | 940779876c | |
vilmibm | f53b2721cb | |
vilmibm | b1ff57ba58 | |
vilmibm | be5020ad28 | |
vilmibm | 9bea4257c1 | |
vilmibm | 2a07a0e200 | |
vilmibm | 7255ee691e | |
vilmibm | 44686ad536 | |
vilmibm | 529e14158a | |
vilmibm | bf244101e6 | |
vilmibm | 6fa11aba8e | |
vilmibm | 5c2142f6e7 | |
vilmibm | 92faddd079 | |
vilmibm | 6950ba7109 | |
vilmibm | a3b13d21b3 | |
vilmibm | 5c6f4cce19 | |
vilmibm | 2a966bf842 | |
vilmibm | 880511a79a | |
vilmibm | 0884ba2ff6 | |
vilmibm | d0b79f3e7c | |
vilmibm | 69ec0b4e4b | |
equa | 1e7a016dca | |
equa | 0472c24199 | |
equa | a124b27021 | |
vilmibm | edf4f68932 | |
vilmibm | 2fbedb75c2 | |
vilmibm | 1ca0893c2e | |
vilmibm | ccc357591e | |
vilmibm | 852a104300 | |
vilmibm | bf2f2e3790 | |
vilmibm | c5590cac95 | |
vilmibm | f624483614 | |
vilmibm | 1bec2349cb | |
vilmibm | e12d92735d | |
vilmibm | 3638685d24 | |
vilmibm | cb0d574a76 | |
vilmibm | 7291a43e68 | |
vilmibm | 869eaa5f3b | |
vilmibm | 5876b0ebe3 | |
equa | d5e6960573 | |
vilmibm | 02c5079e31 | |
vilmibm | 2f6164635f | |
vilmibm | 3d877ea184 | |
vilmibm | 9353e3f414 | |
vilmibm | 9b1143e18d | |
vilmibm | 96d487ede2 | |
vilmibm | d82c633ee5 | |
vilmibm | 76bf2643a6 | |
vilmibm | 8fb6208dbf | |
vilmibm | b789865943 | |
vilmibm | c43adc49fb | |
vilmibm | cee8b75bad | |
vilmibm | 09269126d8 | |
vilmibm | d407e26917 | |
vilmibm | 57115b1c11 | |
vilmibm | 92807f9b6b | |
vilmibm | 55eb4b7010 | |
vilmibm | c4c02533e7 | |
vilmibm | 8d531936a1 | |
vilmibm | c0a8c50dbb | |
vilmibm | df4eeaba13 | |
vilmibm | 015b28ba6a | |
vilmibm | 6a1fcbcf32 | |
vilmibm | 9444954bc6 | |
vilmibm | 872d2ade23 | |
vilmibm | c3dc5ae0ed | |
vilmibm | 21e51a829f | |
vilmibm | e5cf8a5521 | |
vilmibm | 2acc042fe7 | |
vilmibm | 6741079152 | |
vilmibm | 3940fe58ae | |
vilmibm | 4847c19eaf | |
vilmibm | 28ac63f256 | |
vilmibm | f28da14d98 | |
vilmibm | cec7ee4a82 | |
vilmibm | add10cb754 | |
vilmibm | 9442ecb55e | |
vilmibm | 8716140b40 | |
vilmibm | 9de98bf2ab | |
vilmibm | 5a41d99ff9 | |
vilmibm | 128b53edbe | |
vilmibm | d5aff6fc83 | |
vilmibm | c0d205b447 | |
vilmibm | 33ea591651 | |
vilmibm | e339fa8cb6 | |
vilmibm | b3d1b25131 | |
vilmibm | 84cc13bf07 | |
vilmibm | fca0ecae4e | |
vilmibm | ced73adb77 | |
vilmibm | 5e20b0569e | |
vilmibm | 6ec2a52db8 | |
vilmibm | 29ba24d36c | |
vilmibm | c1f041e9d8 | |
vilmibm | 7543c2c4cd | |
vilmibm | 7ecb79793f | |
vilmibm | 46ee8932c1 | |
vilmibm | 8ecfe7a940 | |
vilmibm | df41bb4df2 | |
vilmibm | 717c1b93f1 | |
vilmibm | 8e6b2edbc8 | |
vilmibm | 05ac83019c | |
vilmibm | a464159a21 | |
vilmibm | 86dc9cef2d | |
vilmibm | cb83223ab8 | |
vilmibm | f579c811f3 | |
vilmibm | 0b940cdf1c | |
vilmibm | e7ff5606f1 | |
vilmibm | 41e5463756 | |
vilmibm | 7e8f8cb0fc | |
vilmibm | b03e4f069e | |
vilmibm | 327e0551b9 | |
vilmibm | 5682d3dafd | |
vilmibm | efbde68f9d | |
vilmibm | df1a18dae3 | |
vilmibm | 45dd8efbae | |
vilmibm | 462e8772ec | |
vilmibm | 4a881d3b8b | |
vilmibm | 336622ca62 | |
vilmibm | ea484671ed | |
vilmibm | 4dbeb6984f | |
vilmibm | 39396eba4e | |
vilmibm | d7adcbf11f | |
vilmibm | 6b86087c0e | |
vilmibm | cf99807126 | |
vilmibm | 65228979e9 | |
vilmibm | 75d64bf7b6 | |
vilmibm | b1f7e9842d | |
vilmibm | e668db7e15 | |
vilmibm | 4a2d31e5fd | |
vilmibm | 571c37b089 | |
vilmibm | 122af44a9e | |
vilmibm | a2e5c81752 | |
vilmibm | 77049703a4 | |
vilmibm | 54174c7194 | |
vilmibm | a92c972d43 | |
vilmibm | 362ca0fa19 | |
vilmibm | 7e22317ae9 | |
vilmibm | 1bdd9249fc | |
vilmibm | 9464d393e3 | |
vilmibm | 8138733bd1 | |
vilmibm | 470ebb5507 | |
vilmibm | b10bdd756a | |
vilmibm | d29fabfa52 | |
vilmibm | c965c578bf |
|
@ -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
|
|
@ -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
|
25
README.md
25
README.md
|
@ -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
4
TODO
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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())
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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+
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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.
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
||||
*/
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
|
@ -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>`
|
|
@ -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.
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
_ ( ) . '
|
||||
| | .
|
||||
_ | | __ __ _ _ _ _ _|_ __ __ :
|
||||
| | |_|/ |/ / / \_/ |/ |/ | |/ | / \_ / /\ __ ___!__ _,__ ___,_
|
||||
\/ \/ |__/|__/\___/\__/ | | |_/|__/ |_/\__/ / / o\/ \ / /\ /__/ \ /__\__\
|
||||
/ \\ \/_____ /_*\ | |[^][^| | |
|
||||
_ / /\ | | |_|__| .: |__|__|
|
||||
o | | | || | {^} | |
|
||||
_|_ | | __| _ _|_ __ _ _ [] [] | | | |. . A ._ . .
|
||||
| | |/ / | |/ | / \_| | |_/ |/ | _ _ |___|__|__D_| . H / \ {^}
|
||||
|_/|_/|__/\_/|_/|__/o|_/\__/ \/ \/ | |_/ | |[@] | | . . . | |/ \ |
|
||||
_|_|_____| . . |^| \ |
|
||||
. . v v . | | \|.
|
||||
. v v . / O \ /|
|
||||
|_ u _| . . / _ \ / |
|
||||
we're glad you're here || | || / |_| \/ |
|
||||
| | |.
|
||||
|
||||
|
|
@ -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
43
go.mod
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
|
@ -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
|
||||
}
|
7
main.go
7
main.go
|
@ -1,7 +0,0 @@
|
|||
package main
|
||||
|
||||
import "git.tilde.town/tildetown/town/email"
|
||||
|
||||
func main() {
|
||||
email.SendLocalEmail("vilmibm", "testing hi", "this is a body")
|
||||
}
|
|
@ -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(
|
||||
×tamp,
|
||||
¬e.Author,
|
||||
¬e.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,
|
||||
×tamp,
|
||||
&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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package request
|
||||
|
||||
const RequestPath = "/town/requests"
|
|
@ -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"
|
|
@ -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"
|
|
@ -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"
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
|
@ -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)
|
||||
);
|
|
@ -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)
|
||||
);
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue