cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit ee0cc33c9f9cd3f5285b3a6dd895f1146b5466f9
parent c6b62e6d161ec21f47225127b1850b0e54db2f7a
Author: cblgh <cblgh@cblgh.org>
Date:   Thu,  7 Dec 2023 20:37:18 +0100

add new feature: multiple admins

* add-admin tool is used to bootstrap the process
* route /admin will be used for administration on the web instead of on
  the server

wip admin view and web actions

flesh out admin reset of user password in web

Diffstat:
Acmd/add-admin/main.go | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/admin-add-user/main.go | 2+-
Mcmd/admin-reset/main.go | 35++++-------------------------------
Mdatabase/database.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/admin.html | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhtml/head.html | 7++++++-
Mserver/server.go | 200++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
7 files changed, 440 insertions(+), 44 deletions(-)

diff --git a/cmd/add-admin/main.go b/cmd/add-admin/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "cerca/database" + "flag" + "fmt" + "os" +) + +func inform(msg string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } else { + fmt.Printf("%s\n", msg) + } +} + +func complain(msg string, args ...interface{}) { + if len(args) > 0 { + inform(msg, args) + } else { + inform(msg) + } + os.Exit(0) +} + +func main() { + var username string + var forumDomain string + var dbPath string + flag.StringVar(&forumDomain, "url", "https://forum.merveilles.town", "root url to forum, referenced in output") + flag.StringVar(&username, "username", "", "username who should be made admin") + flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") + flag.Parse() + + usage := `usage + add-admin --username <username to make admin> --url <rool url to forum> + add-admin --help for more information + ` + + adminRoute := fmt.Sprintf("%s/admin", forumDomain) + + if username == "" { + complain(usage) + } + + // check if database exists! we dont wanna create a new db in this case ':) + if !database.CheckExists(dbPath) { + complain("couldn't find database at %s", dbPath) + } + + db := database.InitDB(dbPath) + + userid, err := db.GetUserID(username) + if err != nil { + complain("username %s not in database", username) + } + inform("Attempting to make %s (id %d) admin...", username, userid) + err = db.AddAdmin(userid) + if err != nil { + complain("Something went wrong: %s", err) + } + inform("Successfully added %s (id %d) as an admin", username, userid) + inform("Please visit %s for all your administration needs (changing usernames, resetting passwords, deleting user accounts)", adminRoute) +} diff --git a/cmd/admin-add-user/main.go b/cmd/admin-add-user/main.go @@ -80,7 +80,7 @@ func main() { db := database.InitDB(dbPath) newPassword := crypto.GeneratePassword() - info := createUser(username, newPassword, &db) + _ = createUser(username, newPassword, &db) loginRoute := fmt.Sprintf("%s/login", forumDomain) resetRoute := fmt.Sprintf("%s/reset", forumDomain) diff --git a/cmd/admin-reset/main.go b/cmd/admin-reset/main.go @@ -1,7 +1,6 @@ package main import ( - "cerca/crypto" "cerca/database" "cerca/util" "flag" @@ -55,9 +54,6 @@ func main() { if username == "" { complain(usage) } - if !keypairFlag && !passwordFlag { - complain("nothing to reset, exiting") - } // check if database exists! we dont wanna create a new db in this case ':) if !database.CheckExists(dbPath) { @@ -66,35 +62,12 @@ func main() { db := database.InitDB(dbPath) ed := util.Describe("admin reset") + newPassword, err := db.ResetPassword(userid) - userid, err := db.GetUserID(username) if err != nil { - complain("username %s not in database", username) - } - - // generate new password for user and set it in the database - if passwordFlag { - newPassword := crypto.GeneratePassword() - passwordHash, err := crypto.HashPassword(newPassword) - ed.Check(err, "hash new password") - db.UpdateUserPasswordHash(userid, passwordHash) - - inform("successfully updated %s's password hash", username) - inform("new temporary password %s", newPassword) + complain("reset password failed (%w)", err) } - // generate a new keypair for user and update user's pubkey record with new pubkey - if keypairFlag { - kp, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - kpBytes, err := kp.Marshal() - ed.Check(err, "marshal keypair") - pubkey, err := kp.PublicString() - ed.Check(err, "get pubkey string") - err = db.SetPubkey(userid, pubkey) - ed.Check(err, "set new pubkey in database") - - inform("successfully changed %s's stored public key", username) - inform("new keypair\n%s", string(kpBytes)) - } + inform("successfully updated %s's password hash", username) + inform("new temporary password %s", newPassword) } diff --git a/database/database.go b/database/database.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "cerca/crypto" "errors" "fmt" "html/template" @@ -63,6 +64,11 @@ func createTables(db *sql.DB) { ); `, ` + CREATE TABLE IF NOT EXISTS admins( + id INTEGER PRIMARY KEY + ); + `, + ` CREATE TABLE IF NOT EXISTS registrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, userid INTEGER, @@ -410,3 +416,115 @@ func (d DB) AddRegistration(userid int, verificationLink string) error { } return nil } + +func (d DB) ResetPassword(userid int) (string, error) { + ed := util.Describe("reset password") + exists, err := d.CheckUserExists(userid) + if !exists { + return "", errors.New(fmt.Sprintf("reset password: userid %d did not exist", userid)) + } else if err != nil { + return "", fmt.Errorf("reset password encountered an error (%w)", err) + } + // generate new password for user and set it in the database + newPassword := crypto.GeneratePassword() + passwordHash, err := crypto.HashPassword(newPassword) + if err != nil { + return "", ed.Eout(err, "hash password") + } + d.UpdateUserPasswordHash(userid, passwordHash) + return newPassword, nil +} + +type User struct { + Name string + ID int +} + +func (d DB) AddAdmin(userid int) error { + ed := util.Describe("add admin") + // make sure the id exists + exists, err := d.CheckUserExists(userid) + if !exists { + return errors.New(fmt.Sprintf("add admin: userid %d did not exist", userid)) + } + if err != nil { + return ed.Eout(err, "CheckUserExists had an error") + } + isAdminAlready, err := d.IsUserAdmin(userid) + if isAdminAlready { + return errors.New(fmt.Sprintf("userid %d was already an admin", userid)) + } + if err != nil { + // some kind of error, let's bubble it up + return ed.Eout(err, "IsUserAdmin") + } + // insert into table, we gots ourselves a new sheriff in town [|:D + stmt := `INSERT INTO admins (id) VALUES (?)` + _, err = d.db.Exec(stmt, userid) + if err != nil { + return ed.Eout(err, "inserting new admin") + } + return nil +} +func (d DB) IsUserAdmin (userid int) (bool, error) { + stmt := `SELECT 1 FROM admins WHERE id = ?` + return d.existsQuery(stmt, userid) +} + +func (d DB) GetAdmins() []User { + ed := util.Describe("get admins") + query := `SELECT u.name, a.id + FROM users u + INNER JOIN admins a ON u.id = a.id + ORDER BY u.name + ` + stmt, err := d.db.Prepare(query) + ed.Check(err, "prep stmt") + defer stmt.Close() + + rows, err := stmt.Query() + util.Check(err, "run query") + defer rows.Close() + + var user User + var admins []User + for rows.Next() { + if err := rows.Scan(&user.Name, &user.ID); err != nil { + ed.Check(err, "scanning loop") + } + admins = append(admins, user) + } + return admins +} +func (d DB) GetUsers(includeAdmin bool) []User { + ed := util.Describe("get users") + query := `SELECT u.name, u.id + FROM users u + %s + ORDER BY u.name + ` + + if includeAdmin { + query = fmt.Sprintf(query, "") // do nothing + } else { + query = fmt.Sprintf(query, "WHERE u.id NOT IN (select id from admins)") // do nothing + } + + stmt, err := d.db.Prepare(query) + ed.Check(err, "prep stmt") + defer stmt.Close() + + rows, err := stmt.Query() + util.Check(err, "run query") + defer rows.Close() + + var user User + var users []User + for rows.Next() { + if err := rows.Scan(&user.Name, &user.ID); err != nil { + ed.Check(err, "scanning loop") + } + users = append(users, user) + } + return users +} diff --git a/html/admin.html b/html/admin.html @@ -0,0 +1,57 @@ +{{ template "head" . }} +<main> + <h1>{{ .Title }}</h1> + <!-- { if .IsAdmin } --> + <section> + <p> + Does someone wish admittance? You can <button>Add new user</button>. + </p> + <p> + If you want to stop being an admin, you can <button>Step down</button>. + </p> + </section> + {{ if .LoggedIn }} + <section> + <h2> Admins </h2> + {{ if len .Data.Admins | eq 0 }} + <p> there are no admins; chaos reigns </p> + {{ else }} + {{ $userID := .LoggedInID }} <!-- do some kind of extra styling to indicate "hey this is you!" --> + {{ range $index, $user := .Data.Admins }} + <p> {{ $user.Name }} ({{ $user.ID }}) </p> + {{ end }} + {{ end }} + </section> + + <section> + <h2> Users </h2> + {{ if len .Data.Users | eq 0 }} + <p> there are no other users </p> + {{ else }} + <table> + {{ range $index, $user := .Data.Users }} + <form method="POST"> + <input type="hidden" name="userid" value="{{$user.ID}}"> + <tr> + <td>{{ $user.Name }} ({{ $user.ID }})</td> + <td> + <!-- TODO only have one form and use select's form attribute to target that form --> + <select name="admin-action" action="/admin/" id="select-{{$user.ID}}"> + <option selected value="reset-password">Reset password</option> + <option value="remove-account">Remove account</option> + <option value="make-admin">Make admin</option> + </select> + </td> + <td> + <button type="submit">Submit</button> + </td> + </tr> + </form> + {{ end }} + </table> + {{ end }} + </section> + + {{ end }} +</main> +{{ template "footer" . }} diff --git a/html/head.html b/html/head.html @@ -30,9 +30,11 @@ font-size: 1rem; } button { - text-decoration: underline; cursor: pointer; } + button, select { + margin-bottom: 0; + } #logo { width: 48px; height: 48px; @@ -194,6 +196,9 @@ {{ if .QuickNav }} <li><a href="#bottom">{{ "Bottom" | translate }}</a></li> {{end}} + {{ if .IsAdmin }} + <li><a href="/admin">admin</a></li> + {{end}} <li><a href="/about">{{ "About" | translate }}</a></li> {{ if .HasRSS }} <li><a href="/rss.xml">rss</a></li> diff --git a/server/server.go b/server/server.go @@ -6,6 +6,7 @@ import ( "fmt" "html/template" "log" + "strconv" "net" "net/http" "net/url" @@ -35,7 +36,8 @@ import ( type TemplateData struct { Data interface{} QuickNav bool - LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? + LoggedIn bool + IsAdmin bool HasRSS bool LoggedInID int ForumName string @@ -82,6 +84,12 @@ type ThreadData struct { ThreadURL string } +type AdminsData struct { + Admins []database.User + Users []database.User + IsAdmin bool +} + type RequestHandler struct { db *database.DB session *session.Session @@ -161,6 +169,35 @@ func (h RequestHandler) IsLoggedIn(req *http.Request) (bool, int) { return true, userid } +// TODO (2023-12-10): any vulns with this approach? could a user forge a session cookie with the user id of an admin? +func (h RequestHandler) IsAdmin(req *http.Request) (bool, int) { + ed := util.Describe("IsAdmin") + userid, err := h.session.Get(req) + err = ed.Eout(err, "getting userid from session cookie") + if err != nil { + dump(err) + return false, -1 + } + + // make sure the user from the cookie actually exists + userExists, err := h.db.CheckUserExists(userid) + if err != nil { + dump(ed.Eout(err, "check userid in db")) + return false, -1 + } else if !userExists { + return false, -1 + } + // make sure the user id is actually an admin + userIsAdmin, err := h.db.IsUserAdmin(userid) + if err != nil { + dump(ed.Eout(err, "IsUserAdmin in db")) + return false, -1 + } else if !userIsAdmin { + return false, -1 + } + return true, userid +} + // establish closure over config + translator so that it's present in templates during render func generateTemplates(config types.Config, translator i18n.Translator) (*template.Template, error) { // only read logo contents once when generating @@ -224,6 +261,7 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa "register", "register-success", "thread", + "admin", "password-reset", "change-password", "change-password-success", @@ -265,9 +303,147 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat } } + +func (h RequestHandler) renderGenericMessage(res http.ResponseWriter, req *http.Request, incomingData GenericMessageData) { + loggedIn, _ := h.IsLoggedIn(req) + data := TemplateData{ + Data: incomingData, + // the following two fields are defaults that usually are not set and which are cumbersome to set each time since + // they don't really matter / vary across invocations + HasRSS: h.config.RSS.URL != "", + LoggedIn: loggedIn, + } + h.renderView(res, "generic-message", data) + return +} + +func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request) { + loggedIn, _ := h.IsLoggedIn(req) + if req.Method == "POST" && loggedIn { + // perform action + // get results + // render simple page saying "user <x> was removed but their posts were kept" + } +} + +func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserid int) { + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + if req.Method == "GET" || !loggedIn || !isAdmin { + // redirect to index + IndexRedirect(res, req) + return + } + // TODO (2023-12-10): introduce 2-quorom + err := h.db.AddAdmin(targetUserid) + if err != nil { + // TODO (2023-12-09): bubble up error to visible page as feedback for admin + errMsg := fmt.Sprintf("make admin failed (%w)\n", err) + fmt.Println(errMsg) + data := GenericMessageData{ + Title: "Make admin", + Message: errMsg, + } + h.renderGenericMessage(res, req, data) + return + } + // adminUsername, _ := h.db.GetUsername(adminUserid) + username, _ := h.db.GetUsername(targetUserid) + // TODO (2023-12-12): h.db.LogModerationAction(adminUserid, targerUserid, fmt.Sprintf("%s made %s an admin", + // adminUsername, username)) + + // output copy-pastable credentials page for admin to send to the user + data := GenericMessageData{ + Title: "Make admin success", + Message: fmt.Sprintf("User %s is now a fellow admin user!", username), + LinkMessage: "Go back to the", + LinkText: "admin view", + Link: "/admin", + } + h.renderGenericMessage(res, req, data) + return +} + +func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserid int) { + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + if req.Method == "GET" || !loggedIn || !isAdmin { + // redirect to index + IndexRedirect(res, req) + return + } + newPassword, err := h.db.ResetPassword(targetUserid) + if err != nil { + // TODO (2023-12-09): bubble up error to visible page as feedback for admin + errMsg := fmt.Sprintf("reset password failed (%w)\n", err) + fmt.Println(errMsg) + data := GenericMessageData{ + Title: "Admin reset password", + Message: errMsg, + } + h.renderGenericMessage(res, req, data) + return + } + // adminUsername, _ := h.db.GetUsername(adminUserid) + // TODO (2023-12-12): h.db.LogModerationAction(adminUserid, targerUserid, fmt.Sprintf("%s changed reset a user's password", adminUsername)) + + username, _ := h.db.GetUsername(targetUserid) + + // output copy-pastable credentials page for admin to send to the user + data := GenericMessageData{ + Title: "Password reset successful!", + Message: fmt.Sprintf("Instructions: %s's password was reset to: %s. After logging in, please change your password by going to /reset", username, newPassword), + LinkMessage: "Go back to the", + LinkText: "admin view", + Link: "/admin", + } + h.renderGenericMessage(res, req, data) + return +} +// TODO (2023-12-10): introduce 2-quorum for consequential actions like +// * make admin +// * remove account +// * (later: demote admin) +// note: only make a 2-quorum if there are actually 2 admins +func (h *RequestHandler) AdminRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, userid := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + + if req.Method == "POST" && loggedIn && isAdmin { + action := req.PostFormValue("admin-action") + useridString := req.PostFormValue("userid") + targetUserId, err := strconv.Atoi(useridString) + util.Check(err, "convert user id string to a plain userid") + + switch action { + case "reset-password": + h.AdminResetUserPassword(res, req, targetUserId) + case "make-admin": + h.AdminMakeUserAdmin(res, req, targetUserId) + fmt.Println("make admin!") + case "remove-account": + fmt.Println("gone with the account!") + } + return + } + if req.Method == "GET" && loggedIn { + if !isAdmin { + // TODO (2023-12-10): redirect to /admins + IndexRedirect(res, req) + return + } + admins := h.db.GetAdmins() + normalUsers := h.db.GetUsers(false) // do not include admins + data := AdminsData{Admins: admins, Users: normalUsers} + view := TemplateData{Title: "Forum Administration", Data: &data, HasRSS: false, LoggedIn: loggedIn, LoggedInID: userid} + h.renderView(res, "admin", view) + } +} + func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) { threadid, ok := util.GetURLPortion(req, 2) loggedIn, userid := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) if !ok { title := h.translator.Translate("ErrThread404") @@ -275,7 +451,7 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) Title: title, Message: h.translator.Translate("ErrThread404Message"), } - h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) + h.renderGenericMessage(res, req, data) return } @@ -311,7 +487,7 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) thread[i].Content = template.HTML(content) } data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} - view := TemplateData{Data: &data, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} + view := TemplateData{Data: &data, IsAdmin: isAdmin, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} if len(thread) > 0 { data.Title = thread[0].ThreadTitle view.Title = data.Title @@ -325,7 +501,7 @@ func (h RequestHandler) ErrorRoute(res http.ResponseWriter, req *http.Request, s Title: title, Message: fmt.Sprintf(h.translator.Translate("ErrGeneric404Message"), status), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) } func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { @@ -336,6 +512,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } loggedIn, _ := h.IsLoggedIn(req) var mostRecentPost bool + isAdmin, _ := h.IsAdmin(req) params := req.URL.Query() if q, exists := params["sort"]; exists { @@ -344,7 +521,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } // show index listing threads := h.db.ListThreads(mostRecentPost) - view := TemplateData{Data: IndexData{threads}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} + view := TemplateData{Data: IndexData{threads}, IsAdmin: isAdmin, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -458,7 +635,7 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. Link: "/reset", LinkText: h.translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) } _, uid := h.IsLoggedIn(req) @@ -551,7 +728,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request LinkMessage: h.translator.Translate("RegisterLinkMessage"), LinkText: h.translator.Translate("Index"), } - h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Register")}) + h.renderGenericMessage(res, req, data) return } @@ -655,7 +832,7 @@ func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) LinkMessage: "Generic link messsage", LinkText: "with link", } - h.renderView(res, "generic-message", TemplateData{Data: data}) + h.renderGenericMessage(res, req, data) } func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { @@ -683,7 +860,7 @@ func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reque LinkMessage: h.translator.Translate("NewThreadLinkMessage"), LinkText: h.translator.Translate("LogIn"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) return } h.renderView(res, "new-thread", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) @@ -699,7 +876,7 @@ func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reque Title: h.translator.Translate("NewThreadCreateError"), Message: h.translator.Translate("NewThreadCreateErrorMessage"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("ThreadNew")}) + h.renderGenericMessage(res, req, data) return } // update the rss feed @@ -733,7 +910,7 @@ func (h *RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Requ renderErr := func(msg string) { fmt.Println(msg) genericErr.Message = msg - h.renderView(res, "generic-message", TemplateData{Data: genericErr, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) + h.renderGenericMessage(res, req, genericErr) } if !loggedIn || !ok { @@ -855,6 +1032,7 @@ func NewServer(allowlist []string, sessionKey, dir string, config types.Config) /* note: be careful with trailing slashes; go's default handler is a bit sensitive */ // TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes s.ServeMux.HandleFunc("/reset/", handler.ResetPasswordRoute) + s.ServeMux.HandleFunc("/admin", handler.AdminRoute) s.ServeMux.HandleFunc("/about", handler.AboutRoute) s.ServeMux.HandleFunc("/logout", handler.LogoutRoute) s.ServeMux.HandleFunc("/login", handler.LoginRoute)