From 008acd2f243957a28ecdaa867592a8b798a89687 Mon Sep 17 00:00:00 2001 From: Kevin Lyda <kevin@lyda.ie> Date: Mon, 19 May 2025 19:19:41 +0100 Subject: [PATCH] A bunch of improvements * Better onboarding - can pull ssh keys from github-like sites. * Cleaned up help. * Reviewed command status. 42 need work. --- NOTES.md | 79 ++++++++++++++++----------------- batch/batch.go | 47 ++++++++++++++++---- key/key.go | 34 +++++++++++++++ main.go | 2 + repl/accounts.go | 89 +++++++++++++++++++++++-------------- repl/command.go | 111 +++++++++++++++++++++++++++++++---------------- repl/folders.go | 1 + repl/messages.go | 33 ++++++++++---- repl/show.go | 5 ++- this/this.go | 14 ++++++ 10 files changed, 287 insertions(+), 128 deletions(-) diff --git a/NOTES.md b/NOTES.md index dc077cc..0272fb0 100644 --- a/NOTES.md +++ b/NOTES.md @@ -13,7 +13,7 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented [here](https://marc.vos.net/books/vms/help/mail/). ## sqlite - + * sqlite trigger tracing: `.trace stdout --row --profile --stmt --expanded --plain --close` * https://sqlite.org/cli.html * https://www.sqlitetutorial.net/ @@ -21,55 +21,50 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented ## Things to do * Run [godoc](http://localhost:6060/) and then review where the help text is lacking. - * Implement each command. - * Next: folder commands - MODIFY - * Messages edit: CHANGE, REPLY - * Moving messages: COPY, MOVE - * Mark messages: MARK, UNMARK - * ~~Compound commands: SET and SHOW - make HELP work for them.~~ - * Mail: MAIL, FORWARD, RESPOND - * Review each command and fully implement it. + * Missing [MAIL] [RESPOND] [SET DEFAULT_EXPIRE] [SET READNEW] [SET NOREADNEW] [SET NOSHOWNEW] [SET NOPROMPT_EXPIRE] [SET EXPIRE_LIMIT] [SET PROMPT_EXPIRE] [SET SHOWNEW] [SHOW NEW] [SEARCH] * Run this.Skew.Safe() before... each command? each write? - * Handle broadcast messages - have bulletin watch a directory and - display files from it. Then have them delete the file if it's older - than 5 minutes (allow for failure) + * Handle broadcast messages - create a broadcast table and add an expiration column. * Database * trigger to limit values for 'visibility'? * Add commands: - * ~~A way to add / delete ssh keys.~~ * Commands for a local mail system? * Commands to connect to Mattermost or mastodon? - * ~~Commands to manage users.~~ * Make a spreadsheet for signups. + * Make sure ssh key is fully unique. + +Polishing. Review each command and put a + next to each as it is +fully done. + +Top level: + + ADD +BACK BULLETIN CHANGE COPY CREATE + +Ctrl-C +CURRENT DELETE DIRECTORY +EXIT +FIRST + +Folders FORWARD +HELP INDEX +Keypad +LAST + MAIL +MARK MODIFY MOVE +NEXT PRINT + +QUIT READ REMOVE REPLY RESPOND SEARCH + SEEN SELECT +SET +SHOW +SSH +UNMARK + UNSEEN +USER + +SET: + + ACCESS +ALWAYS BRIEF DEFAULT_EXPIRE + EXPIRE_LIMIT FOLDER +NOALWAYS NOBRIEF + NONOTIFY NOPROMPT_EXPIRE NOREADNEW NOSHOWNEW + NOSYSTEM NOTIFY PROMPT_EXPIRE READNEW + SHOWNEW SYSTEM + +SHOW: + + +FLAGS FOLDER -NEW +PRIVILEGES USER +VERSION + +SSH: + + +ADD +DELETE +FETCH +LIST + +USER: -Done: - - * ~~Editor - need an embedded editor~~ Implemented using tview; good enough - * An EDT inspired [editor](https://sourceforge.net/projects/edt-text-editor/) - * [gkilo](https://github.com/vcnovaes/gkilo) - * This [kilo editor](https://viewsourcecode.org/snaptoken/kilo/) tutorial - * Using giu, a [text-editor](https://serge-hulne.medium.com/coding-a-simple-text-editor-in-go-using-giu-quick-and-dirty-b9b97ab41e4a) (needs cgo, no) - * [bubbletea](https://github.com/charmbracelet/bubbletea) seems to be the tui that's winning - * Another option is tview - [simpler](https://github.com/rivo/tview). - * ~~Implement batch jobs~~ - * ~~Have install populate the database with some test data.~~ - * ~~reboot~~ - * ~~expire~~ - * ~~Add a pager~~ - * ~~SHOW VERSION~~ - * ~~Check db version; notify user if it changes; refuse to write to db if it has.~~ - * ~~this.Folder should be a storage.Folder.~~ - * ~~Add some of the early announcements from the sources - see the - conversion branch - to the GENERAL folder.~~ - * ~~Move to a storage layer.~~ - * ~~Cleanup help output.~~ - * ~~Remove the node/cluster/newsgroup/mailing-list related flags.~~ - * ~~Remove BBOARD references.~~ - * ~~format with `par w72j1`~~ - * ~~Handle MARK for SELECT and DIRECTORY.~~ - * ~~Remove all file related things. Which means no need for most - (all?) /EDIT flags~~ - * ~~Stop the seeded messages from being deleted by the expire batch command.~~ + +ADD +ADMIN +DELETE +DISABLE +ENABLE LIST + +MOD +NAME +NOADMIN +NOMOD ## Module links diff --git a/batch/batch.go b/batch/batch.go index f17eb5c..0cb34b6 100644 --- a/batch/batch.go +++ b/batch/batch.go @@ -14,6 +14,7 @@ import ( "git.lyda.ie/kevin/bulletin/ask" "git.lyda.ie/kevin/bulletin/key" "git.lyda.ie/kevin/bulletin/storage" + "git.lyda.ie/kevin/bulletin/users" "github.com/adrg/xdg" ) @@ -125,23 +126,53 @@ the first user.`) // Install crontab. bulletin, err := os.Executable() - if err != nil { - panic(err) // TODO: cleanup error handling. - } + ask.CheckErr(err) crontab := &strings.Builder{} template.Must(template.New("crontab").Parse(crontabTemplate)). Execute(crontab, map[string]string{"Bulletin": bulletin}) fmt.Printf("Adding this to crontab:\n\n%s\n", crontab.String()) err = installCrontab(crontab.String()) - if err != nil { - panic(err) // TODO: cleanup error handling. - } + ask.CheckErr(err) // Mark that install has happened. err = touch(touchfile) - if err != nil { - panic(err) // TODO: cleanup error handling. + ask.CheckErr(err) + + return 0 +} + +// NewUser creates a new user based on command line arguments. +func NewUser(args []string) int { + // Make sure we have enough args. + if len(args) != 3 { + fmt.Println("ERROR: Must supply login, site nickname and site username.") + return 1 } + // Create a user if missing. + login := strings.ToUpper(args[0]) + err := users.ValidLogin(login) + ask.CheckErr(err) + store, err := storage.Open() + ask.CheckErr(err) + q := storage.New(store.DB) + ctx := storage.Context() + u, err := q.GetUser(ctx, login) + if u.Login == "" { + u, err = q.AddUser(ctx, storage.AddUserParams{ + Login: login, + }) + ask.CheckErr(err) + } + if u.Login == "" { + fmt.Println("ERROR: Failed to make user.") + return 1 + } + + response := key.Fetch(u.Login, args[1], args[2]) + fmt.Println(response) + if strings.HasPrefix(response, "ERROR") { + return 1 + } return 0 } diff --git a/key/key.go b/key/key.go index 634b254..77f267b 100644 --- a/key/key.go +++ b/key/key.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path" "strings" @@ -190,3 +191,36 @@ func Delete(public string) error { return nil } + +// Fetch fetches keys and adds them. +func Fetch(login, nickname, username string) string { + sites := map[string]string{ + "codeberg": "https://codeberg.org/%s.keys", + "gitlab": "https://gitlab.com/%s.keys", + "github": "https://github.com/%s.keys", + } + site := sites[strings.ToLower(nickname)] + if site == "" { + return fmt.Sprintln("ERROR: site nickname unknown.") + } + url := fmt.Sprintf(site, username) + resp, err := http.Get(url) + if err != nil { + return fmt.Sprintf("ERROR: Failed to fetch ssh keys (%s).\n", err) + } + scanner := bufio.NewScanner(resp.Body) + keys := 0 + for scanner.Scan() { + keyline := string(bytes.TrimSpace(scanner.Bytes())) + Add(login, keyline) + keys++ + } + switch keys { + case 0: + return fmt.Sprintln("No keys added.") + case 1: + return fmt.Sprintln("Key is added.") + default: + return fmt.Sprintf("%d keys added.\n", keys) + } +} diff --git a/main.go b/main.go index 06ba61b..8d48d46 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,8 @@ func main() { exitcode = batch.Expire() case "install": exitcode = batch.Install() + case "new-user": + exitcode = batch.NewUser(cmd.Args().Slice()) default: fmt.Println("ERROR: can only run batch commands as SYSTEM.") exitcode = 1 diff --git a/repl/accounts.go b/repl/accounts.go index da8a3d8..6d6abeb 100644 --- a/repl/accounts.go +++ b/repl/accounts.go @@ -1,10 +1,7 @@ package repl import ( - "bufio" - "bytes" "fmt" - "net/http" "strings" "git.lyda.ie/kevin/bulletin/ask" @@ -23,12 +20,29 @@ func ActionUser(cmd *dclish.Command) error { // ActionUserAdd handles the `USER ADD` command. func ActionUserAdd(cmd *dclish.Command) error { + ctx := storage.Context() login := strings.ToUpper(cmd.Args[0]) - err := users.ValidLogin(login) + u, err := users.ValidExistingLogin(this.Q, login) if err != nil { fmt.Printf("ERROR: %s.\n", err) return nil } + if u.Login != "" { + fmt.Println("ERROR: User already exists.") + return nil + } + u, err = this.Q.AddUser(ctx, storage.AddUserParams{ + Login: login, + Name: cmd.Args[1], + }) + if err != nil { + fmt.Printf("ERROR: %s.\n", err) + return nil + } + if u.Login == "" { + fmt.Println("ERROR: Failed to make user; unknown reason.") + return nil + } return nil } @@ -183,6 +197,35 @@ func ActionUserNomod(cmd *dclish.Command) error { return actionUserMod(cmd, 0, "not a moderator") } +// ActionUserName handles the `USER LIST` command. +func ActionUserName(cmd *dclish.Command) error { + if len(cmd.Args) == 2 && this.User.Admin == 0 { + fmt.Println("ERROR: You are not an admin.") + return nil + } + login := this.User.Login + name := cmd.Args[0] + if len(cmd.Args) == 2 { + login = strings.ToUpper(cmd.Args[0]) + name = cmd.Args[1] + _, err := users.ValidExistingLogin(this.Q, login) + if err != nil { + fmt.Printf("ERROR: %s.\n", err) + return nil + } + } + ctx := storage.Context() + err := this.Q.UpdateUserName(ctx, + storage.UpdateUserNameParams{ + Login: login, + Name: name, + }) + if err != nil { + fmt.Printf("ERROR: Failed to update user name (%s).\n", err) + } + return nil +} + // ActionSSH handles the `SSH` command. func ActionSSH(cmd *dclish.Command) error { fmt.Println(cmd.Description) @@ -282,36 +325,18 @@ func ActionSSHDelete(cmd *dclish.Command) error { // ActionSSHFetch handles the `SSH FETCH` command. func ActionSSHFetch(cmd *dclish.Command) error { - sites := map[string]string{ - "codeberg": "https://codeberg.org/%s.keys", - "gitlab": "https://gitlab.com/%s.keys", - "github": "https://github.com/%s.keys", - } - siteTemplate := sites[strings.ToLower(cmd.Args[0])] - if siteTemplate == "" { - fmt.Println("ERROR: site nickname unknown.") - return nil - } - site := fmt.Sprintf(siteTemplate, cmd.Args[1]) - resp, err := http.Get(site) - if err != nil { - fmt.Printf("ERROR: Failed to fetch ssh keys (%s).\n", err) + login := this.User.Login + sitename := cmd.Args[0] + username := cmd.Args[1] + if len(cmd.Args) == 3 && this.User.Admin == 0 { + fmt.Println("ERROR: You are not an admin.") return nil } - scanner := bufio.NewScanner(resp.Body) - keys := 0 - for scanner.Scan() { - keyline := string(bytes.TrimSpace(scanner.Bytes())) - key.Add(this.User.Login, keyline) - keys++ - } - switch keys { - case 0: - fmt.Println("No keys added.") - case 1: - fmt.Println("Key is added.") - default: - fmt.Printf("%d keys added.\n", keys) + if len(cmd.Args) == 3 { + login = cmd.Args[0] + sitename = cmd.Args[1] + username = cmd.Args[2] } + fmt.Print(key.Fetch(login, sitename, username)) return nil } diff --git a/repl/command.go b/repl/command.go index 06026a5..b2b37b9 100644 --- a/repl/command.go +++ b/repl/command.go @@ -1013,61 +1013,97 @@ message.`, The following actions are available: ADD ADMIN DELETE DISABLE ENABLE LIST - MOD NOADMIN NOMOD + MOD NAME NOADMIN NOMOD `, Action: ActionUser, Commands: dclish.Commands{ "ADD": { - Description: ` Creates a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserAdd, + Description: ` Creates a user. The name must be in quotes unless it's a single name. + + Format: + USER ADD login name`, + MinArgs: 2, + MaxArgs: 2, + Action: ActionUserAdd, }, "DELETE": { - Description: ` Removes a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserDelete, + Description: ` Removes a user. + + Format: + USER DELETE login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserDelete, }, "ENABLE": { - Description: ` Enables a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserEnable, + Description: ` Enables a user. + + Format: + USER ENABLE login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserEnable, }, "DISABLE": { - Description: ` Disables a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserDisable, + Description: ` Disables a user. + + Format: + USER DISABLE login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserDisable, }, "ADMIN": { - Description: ` Makes a user an admin.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserAdmin, + Description: ` Makes a user an admin. + + Format: + USER ADMIN login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserAdmin, }, "NOADMIN": { - Description: ` Removes the admin bit from a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserNoadmin, + Description: ` Removes the admin bit from a user. + + Format: + USER NOADMIN login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserNoadmin, }, "LIST": { Description: ` Lists all the users. Must be an admin.`, Action: ActionUserList, }, + "NAME": { + Description: ` + Updates the name for the user. Update name for another user if an + admin. The name should be in quotes. For example, to set your + account's name to Grace Hoper, run USER NAME "Grace Hopper" + + Format: + USER NAME [login] name`, + MinArgs: 1, + MaxArgs: 2, + Action: ActionUserName, + }, "MOD": { - Description: ` Makes a user an mod.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserMod, + Description: ` Makes a user an mod. + + Format: + USER MOD login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserMod, }, "NOMOD": { - Description: ` Removes the mod bit from a user.`, - MinArgs: 1, - MaxArgs: 1, - Action: ActionUserNomod, + Description: ` Removes the mod bit from a user. + + Format: + USER NOMOD login`, + MinArgs: 1, + MaxArgs: 1, + Action: ActionUserNomod, }, }, }, @@ -1108,7 +1144,9 @@ The following commands are available: }, "FETCH": { Description: ` - Fetches ssh keys for a user from a site like github. + Fetches ssh keys for a user from a site like github. Only an admin + can specify the login this will apply to - the three argument version + of this command. The following sites are supported: @@ -1121,10 +1159,10 @@ The following commands are available: +---------------+--------------------------------+ Format: - SSH FETCH site-nickname site-username`, + SSH FETCH [login] site-nickname site-username`, Action: ActionSSHFetch, MinArgs: 2, - MaxArgs: 2, + MaxArgs: 3, }, "LIST": { Description: ` Prints a list of ssh keys for a user. Only an admin can list ssh keys @@ -1146,7 +1184,6 @@ characteristics of the BULLETIN Utility. The following options are available: - NOPROMPT_EXPIRE NOPROMPT_EXPIRE NOPROMPT_EXPIRE NOPROMPT_EXPIRE ACCESS ALWAYS BRIEF DEFAULT_EXPIRE EXPIRE_LIMIT FOLDER NOALWAYS NOBRIEF NONOTIFY NOPROMPT_EXPIRE NOREADNEW NOSHOWNEW diff --git a/repl/folders.go b/repl/folders.go index 8c4b29d..5eb22c9 100644 --- a/repl/folders.go +++ b/repl/folders.go @@ -16,6 +16,7 @@ import ( // ActionIndex handles the `INDEX` command. This lists all the folders. func ActionIndex(_ *dclish.Command) error { + // TODO: Handle flags! rows, err := folders.ListFolder() if err != nil { return err diff --git a/repl/messages.go b/repl/messages.go index 40eaee1..6c9ff65 100644 --- a/repl/messages.go +++ b/repl/messages.go @@ -174,10 +174,10 @@ func ActionBack(_ *dclish.Command) error { if err != nil { return err } - // TODO: pager needs to report if the whole message was read - // and only increment if not. - pager.Pager(msg.String()) - this.MsgID = msgid + if pager.Pager(msg.String()) { + this.MsgID = msgid + folders.MarkSeen([]int64{msgid}) + } return nil } @@ -195,6 +195,13 @@ func ActionFirst(_ *dclish.Command) error { return nil } this.MsgID = msgid + msg, err := folders.ReadMessage(this.User.Login, this.Folder.Name, msgid) + if err != nil { + return err + } + if pager.Pager(msg.String()) { + folders.MarkSeen([]int64{msgid}) + } return nil } @@ -206,6 +213,13 @@ func ActionLast(_ *dclish.Command) error { return nil } this.MsgID = msgid + msg, err := folders.ReadMessage(this.User.Login, this.Folder.Name, msgid) + if err != nil { + return err + } + if pager.Pager(msg.String()) { + folders.MarkSeen([]int64{msgid}) + } return nil } @@ -220,10 +234,11 @@ func ActionNext(_ *dclish.Command) error { if err != nil { return err } - // TODO: pager needs to report if the whole message was read - // and only increment if not. pager.Pager(msg.String()) - this.MsgID = msgid + if pager.Pager(msg.String()) { + folders.MarkSeen([]int64{msgid}) + this.MsgID = msgid + } return nil } @@ -242,7 +257,7 @@ func ActionPrint(cmd *dclish.Command) error { for _, msgid := range msgids { msg, err := folders.ReadMessage(this.User.Login, this.Folder.Name, msgid) if err != nil { - fmt.Printf("Message %d not found.\n") + fmt.Printf("Message %d not found.\n", msgid) } else { fmt.Print(msg.String()) } @@ -290,6 +305,7 @@ func ActionForward(cmd *dclish.Command) error { // ActionSeen handles the `SEEN` command. func ActionSeen(cmd *dclish.Command) error { + // TODO: review help. var err error msgids := []int64{this.MsgID} if len(cmd.Args) == 1 { @@ -307,6 +323,7 @@ func ActionSeen(cmd *dclish.Command) error { // ActionUnseen handles the `UNSEEN` command. func ActionUnseen(cmd *dclish.Command) error { + // TODO: review help. var err error msgids := []int64{this.MsgID} if len(cmd.Args) == 1 { diff --git a/repl/show.go b/repl/show.go index 80481f8..e4c090e 100644 --- a/repl/show.go +++ b/repl/show.go @@ -65,7 +65,10 @@ func ActionShowFolder(cmd *dclish.Command) error { full := false if cmd.Flags["/FULL"].Value == "true" { - // TODO: Check permissions. + if this.User.Admin == 0 { + fmt.Println("ERROR: You are not an admin.") + return nil + } full = true } fmt.Printf("Settings for %s.\n", folder.Name) diff --git a/this/this.go b/this/this.go index 0a14a07..922426b 100644 --- a/this/this.go +++ b/this/this.go @@ -12,6 +12,7 @@ package this import ( "errors" "fmt" + "strings" "git.lyda.ie/kevin/bulletin/ask" "git.lyda.ie/kevin/bulletin/storage" @@ -37,6 +38,7 @@ var MsgID int64 // StartThis starts a session. func StartThis(login string) error { // Validate the login name. + login = strings.ToUpper(login) err := users.ValidLogin(login) if err != nil { return err @@ -70,6 +72,18 @@ func StartThis(login string) error { } else { User.LastLogin, _ = Q.UpdateUserLastLogin(ctx, User.Login) } + if User.Name == "" { + fmt.Printf("Welcome new user %s\n", User.Login) + name, err := ask.GetLine("please enter your name: ") + if err != nil { + return err + } + User.Name = name + Q.UpdateUserName(ctx, storage.UpdateUserNameParams{ + Name: User.Name, + Login: User.Login, + }) + } if User.Disabled == 1 { return errors.New("User is disabled") } -- GitLab