commit c5ff0dce3893a461e17b9ce0f7fbf4fb61c67b3e
parent 87fa52bd7d03ff40b867d1fe8c522903226d4cdb
Author: cblgh <cblgh@cblgh.org>
Date: Tue, 19 Dec 2023 22:29:57 +0100
add 2-quorum system for consequential decisions
squashed commit messages:
* the absolute basics of the full quorum / proposal vetos+confirmations are IN!!
* fix modlog rendering for final confirm/veto action on a pending proposal
* impose 1 week before self-confirmations in db & ui, fix pending proposal rendering
* extract moderation routes into server/moderation.go
* refactor and condense moderation route quorum logic
* only allow one pending proposal per action+recipient, handle vetoes+confirmations of old proposals
Diffstat:
8 files changed, 1042 insertions(+), 634 deletions(-)
diff --git a/constants/constants.go b/constants/constants.go
@@ -1,15 +1,21 @@
package constants
+import "time"
const (
MODLOG_RESETPW = iota
- MODLOG_ADMIN_VETO
- MODLOG_ADMIN_MAKE
- MODLOG_REMOVE_USER
- MODLOG_ADMIN_ADD_USER
- MODLOG_ADMIN_DEMOTE
+ MODLOG_ADMIN_VETO // vetoing a proposal
+ MODLOG_ADMIN_MAKE // make an admin
+ MODLOG_REMOVE_USER // remove a user
+ MODLOG_ADMIN_ADD_USER // add a new user
+ MODLOG_ADMIN_DEMOTE // demote an admin back to a normal user
+ MODLOG_ADMIN_CONFIRM // confirming a proposal
+ MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN
+ MODLOG_ADMIN_PROPOSE_MAKE_ADMIN
+ MODLOG_ADMIN_PROPOSE_REMOVE_USER
/* NOTE: when adding new values, only add them after already existing values! otherwise the existing variables will
- * receive new values */
- // MODLOG_DELETE_VETO
- // MODLOG_DELETE_PROPOSE
- // MODLOG_DELETE_CONFIRM
+ * receive new values which affects the stored values in table moderation_log */
)
+
+const PROPOSAL_VETO = false
+const PROPOSAL_CONFIRM = true
+const PROPOSAL_SELF_CONFIRMATION_WAIT = time.Hour * 24 * 7 /* 1 week */
diff --git a/database/database.go b/database/database.go
@@ -90,18 +90,40 @@ func createTables(db *sql.DB) {
id INTEGER PRIMARY KEY
);
`,
+ /* add optional columns: quorumuser quorum_action (confirm, veto)? */
`
CREATE TABLE IF NOT EXISTS moderation_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actingid INTEGER NOT NULL,
recipientid INTEGER,
- action INTEGER,
- time DATE,
+ action INTEGER NOT NULL,
+ time DATE NOT NULL,
FOREIGN KEY (actingid) REFERENCES users(id),
FOREIGN KEY (recipientid) REFERENCES users(id)
);
`,
+ `
+ CREATE TABLE IF NOT EXISTS quorum_decisions (
+ userid INTEGER NOT NULL,
+ decision BOOL NOT NULL,
+ modlogid INTEGER NOT NULL,
+
+ FOREIGN KEY (modlogid) REFERENCES moderation_log(id)
+ );
+ `,
+ `
+ CREATE TABLE IF NOT EXISTS moderation_proposals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ proposerid INTEGER NOT NULL,
+ recipientid INTEGER NOT NULL,
+ action INTEGER NOT NULL,
+ time DATE NOT NULL,
+
+ FOREIGN KEY (proposerid) REFERENCES users(id),
+ FOREIGN KEY (recipientid) REFERENCES users(id)
+ );
+ `,
`
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -430,20 +452,6 @@ func (d DB) UpdateUserPasswordHash(userid int, newhash string) {
util.Check(err, "changing user %d's description to %s", userid, newhash)
}
-// there are a bunch of places that reference a user's id, so i don't want to break all of those
-//
-// i also want to avoid big invisible holes in a conversation's history
-
-// remove user performs the following operation:
-// 1. checks to see if the DELETED USER exists; otherwise create it and remember its id
-//
-// 2. if it exists, we swap out the userid for the DELETED_USER in tables:
-// - table threads authorid
-// - table posts authorid
-// - table moderation_log actingid or recipientid
-//
-// the entry in registrations correlating to userid is removed
-
func (d DB) GetSystemUserid() int {
ed := util.Describe("get system user id")
systemUserid, err := d.GetUserID(SYSTEM_USER_NAME)
@@ -454,75 +462,6 @@ func (d DB) GetSystemUserid() int {
return systemUserid
}
-// if allowing deletion of post contents as well when removing account,
-// userid should be used to get all posts from table posts and change the contents
-// to say _deleted_
-func (d DB) RemoveUser(userid int) (finalErr error) {
- ed := util.Describe("remove user")
- // there is a single user we call the "deleted user", and we make sure this deleted user exists on startup
- // they will take the place of the old user when they remove their account.
- deletedUserID, err := d.GetUserID(DELETED_USER_NAME)
- if err != nil {
- log.Fatalln(ed.Eout(err, "get deleted user id"))
- }
- // create a transaction spanning all our removal-related ops
- tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
- rollbackOnErr:= func(incomingErr error) {
- if incomingErr != nil {
- _ = tx.Rollback()
- log.Println(incomingErr, "rolling back")
- finalErr = incomingErr
- return
- }
- }
- rollbackOnErr(ed.Eout(err, "start transaction"))
-
- // create prepared statements performing the required removal operations for tables that reference a userid as a
- // foreign key: threads, posts, moderation_log, and registrations
- threadsStmt, err := tx.Prepare("UPDATE threads SET authorid = ? WHERE authorid = ?")
- rollbackOnErr(ed.Eout(err, "prepare threads stmt"))
- defer threadsStmt.Close()
-
- postsStmt, err := tx.Prepare(`UPDATE posts SET content = "_deleted_", authorid = ? WHERE authorid = ?`)
- rollbackOnErr(ed.Eout(err, "prepare posts stmt"))
- defer postsStmt.Close()
-
- modlogStmt1, err := tx.Prepare("UPDATE moderation_log SET recipientid = ? WHERE recipientid = ?")
- rollbackOnErr(ed.Eout(err, "prepare modlog stmt #1"))
- defer modlogStmt1.Close()
-
- modlogStmt2, err := tx.Prepare("UPDATE moderation_log SET actingid = ? WHERE actingid = ?")
- rollbackOnErr(ed.Eout(err, "prepare modlog stmt #2"))
- defer modlogStmt2.Close()
-
- stmtReg, err := tx.Prepare("DELETE FROM registrations where userid = ?")
- rollbackOnErr(ed.Eout(err, "prepare registrations stmt"))
- defer stmtReg.Close()
-
- // and finally: removing the entry from the user's table itself
- stmtUsers, err := tx.Prepare("DELETE FROM users where id = ?")
- rollbackOnErr(ed.Eout(err, "prepare users stmt"))
- defer stmtUsers.Close()
-
- _, err = threadsStmt.Exec(deletedUserID, userid)
- rollbackOnErr(ed.Eout(err, "exec threads stmt"))
- _, err = postsStmt.Exec(deletedUserID, userid)
- rollbackOnErr(ed.Eout(err, "exec posts stmt"))
- _, err = modlogStmt1.Exec(deletedUserID, userid)
- rollbackOnErr(ed.Eout(err, "exec modlog #1 stmt"))
- _, err = modlogStmt2.Exec(deletedUserID, userid)
- rollbackOnErr(ed.Eout(err, "exec modlog #2 stmt"))
- _, err = stmtReg.Exec(userid)
- rollbackOnErr(ed.Eout(err, "exec registration stmt"))
- _, err = stmtUsers.Exec(userid)
- rollbackOnErr(ed.Eout(err, "exec users stmt"))
-
- err = tx.Commit()
- ed.Check(err, "commit transaction")
- finalErr = nil
- return
-}
-
func (d DB) AddRegistration(userid int, verificationLink string) error {
ed := util.Describe("add registration")
stmt := `INSERT INTO registrations (userid, host, link, time) VALUES (?, ?, ?, ?)`
@@ -538,171 +477,7 @@ func (d DB) AddRegistration(userid int, verificationLink string) error {
return nil
}
-func (d DB) AddModerationLog(actingid, recipientid, action int) error {
- ed := util.Describe("add moderation log")
- t := time.Now()
- // we have a recipient
- var err error
- if recipientid > 0 {
- stmt := `INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`
- _, err = d.Exec(stmt, actingid, recipientid, action, t)
- } else {
- // we are not listing a recipient
- stmt := `INSERT INTO moderation_log (actingid, action, time) VALUES (?, ?, ?)`
- _, err = d.Exec(stmt, actingid, action, t)
- }
- if err = ed.Eout(err, "exec prepared statement"); err != nil {
- return err
- }
- return nil
-}
-
-type ModerationEntry struct {
- ActingUsername, RecipientUsername string
- Action int
- Time time.Time
-}
-func (d DB) GetModerationLogs () []ModerationEntry {
- ed := util.Describe("moderation log")
- query := `SELECT uact.name, urecp.name, m.action, m.time
- FROM moderation_LOG m
- LEFT JOIN users uact ON uact.id = m.actingid
- LEFT JOIN users urecp ON urecp.id = m.recipientid
- ORDER BY time DESC`
-
- 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 entry ModerationEntry
- var logs []ModerationEntry
- for rows.Next() {
- var actingUsername, recipientUsername sql.NullString
- if err := rows.Scan(&actingUsername, &recipientUsername, &entry.Action, &entry.Time); err != nil {
- ed.Check(err, "scanning loop")
- }
- if actingUsername.Valid {
- entry.ActingUsername = actingUsername.String
- }
- if recipientUsername.Valid {
- entry.RecipientUsername = recipientUsername.String
- }
- logs = append(logs, entry)
- }
- return logs
-}
-
-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) DemoteAdmin(userid int) error {
- ed := util.Describe("demote admin")
- // make sure the id exists
- exists, err := d.CheckUserExists(userid)
- if !exists {
- return errors.New(fmt.Sprintf("demote admin: userid %d did not exist", userid))
- }
- if err != nil {
- return ed.Eout(err, "CheckUserExists had an error")
- }
- isAdmin, err := d.IsUserAdmin(userid)
- if !isAdmin {
- return errors.New(fmt.Sprintf("demote admin: userid %d was not an admin", userid))
- }
- if err != nil {
- // some kind of error, let's bubble it up
- return ed.Eout(err, "IsUserAdmin")
- }
- // all checks are done: perform the removal
- stmt := `DELETE FROM admins WHERE id = ?`
- _, 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
-}
+/* for moderation operations and queries, see database/moderation.go */
func (d DB) GetUsers(includeAdmin bool) []User {
ed := util.Describe("get users")
@@ -736,3 +511,22 @@ func (d DB) GetUsers(includeAdmin bool) []User {
}
return users
}
+
+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
+}
+
diff --git a/database/moderation.go b/database/moderation.go
@@ -0,0 +1,449 @@
+package database
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log"
+ "time"
+
+ "cerca/util"
+ "cerca/constants"
+
+ _ "github.com/mattn/go-sqlite3"
+
+)
+
+// there are a bunch of places that reference a user's id, so i don't want to break all of those
+//
+// i also want to avoid big invisible holes in a conversation's history
+//
+// remove user performs the following operation:
+// 1. checks to see if the DELETED USER exists; otherwise create it and remember its id
+//
+// 2. if it exists, we swap out the userid for the DELETED_USER in tables:
+// - table threads authorid
+// - table posts authorid
+// - table moderation_log actingid or recipientid
+//
+// the entry in registrations correlating to userid is removed
+// if allowing deletion of post contents as well when removing account,
+// userid should be used to get all posts from table posts and change the contents
+// to say _deleted_
+func (d DB) RemoveUser(userid int) (finalErr error) {
+ ed := util.Describe("remove user")
+ // there is a single user we call the "deleted user", and we make sure this deleted user exists on startup
+ // they will take the place of the old user when they remove their account.
+ deletedUserID, err := d.GetUserID(DELETED_USER_NAME)
+ if err != nil {
+ log.Fatalln(ed.Eout(err, "get deleted user id"))
+ }
+ // create a transaction spanning all our removal-related ops
+ tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
+ rollbackOnErr:= func(incomingErr error) {
+ if incomingErr != nil {
+ _ = tx.Rollback()
+ log.Println(incomingErr, "rolling back")
+ finalErr = incomingErr
+ return
+ }
+ }
+ rollbackOnErr(ed.Eout(err, "start transaction"))
+
+ // create prepared statements performing the required removal operations for tables that reference a userid as a
+ // foreign key: threads, posts, moderation_log, and registrations
+ threadsStmt, err := tx.Prepare("UPDATE threads SET authorid = ? WHERE authorid = ?")
+ rollbackOnErr(ed.Eout(err, "prepare threads stmt"))
+ defer threadsStmt.Close()
+
+ postsStmt, err := tx.Prepare(`UPDATE posts SET content = "_deleted_", authorid = ? WHERE authorid = ?`)
+ rollbackOnErr(ed.Eout(err, "prepare posts stmt"))
+ defer postsStmt.Close()
+
+ modlogStmt1, err := tx.Prepare("UPDATE moderation_log SET recipientid = ? WHERE recipientid = ?")
+ rollbackOnErr(ed.Eout(err, "prepare modlog stmt #1"))
+ defer modlogStmt1.Close()
+
+ modlogStmt2, err := tx.Prepare("UPDATE moderation_log SET actingid = ? WHERE actingid = ?")
+ rollbackOnErr(ed.Eout(err, "prepare modlog stmt #2"))
+ defer modlogStmt2.Close()
+
+ stmtReg, err := tx.Prepare("DELETE FROM registrations where userid = ?")
+ rollbackOnErr(ed.Eout(err, "prepare registrations stmt"))
+ defer stmtReg.Close()
+
+ // and finally: removing the entry from the user's table itself
+ stmtUsers, err := tx.Prepare("DELETE FROM users where id = ?")
+ rollbackOnErr(ed.Eout(err, "prepare users stmt"))
+ defer stmtUsers.Close()
+
+ _, err = threadsStmt.Exec(deletedUserID, userid)
+ rollbackOnErr(ed.Eout(err, "exec threads stmt"))
+ _, err = postsStmt.Exec(deletedUserID, userid)
+ rollbackOnErr(ed.Eout(err, "exec posts stmt"))
+ _, err = modlogStmt1.Exec(deletedUserID, userid)
+ rollbackOnErr(ed.Eout(err, "exec modlog #1 stmt"))
+ _, err = modlogStmt2.Exec(deletedUserID, userid)
+ rollbackOnErr(ed.Eout(err, "exec modlog #2 stmt"))
+ _, err = stmtReg.Exec(userid)
+ rollbackOnErr(ed.Eout(err, "exec registration stmt"))
+ _, err = stmtUsers.Exec(userid)
+ rollbackOnErr(ed.Eout(err, "exec users stmt"))
+
+ err = tx.Commit()
+ ed.Check(err, "commit transaction")
+ finalErr = nil
+ return
+}
+
+func (d DB) AddModerationLog(actingid, recipientid, action int) error {
+ ed := util.Describe("add moderation log")
+ t := time.Now()
+ // we have a recipient
+ var err error
+ if recipientid > 0 {
+ stmt := `INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`
+ _, err = d.Exec(stmt, actingid, recipientid, action, t)
+ } else {
+ // we are not listing a recipient
+ stmt := `INSERT INTO moderation_log (actingid, action, time) VALUES (?, ?, ?)`
+ _, err = d.Exec(stmt, actingid, action, t)
+ }
+ if err = ed.Eout(err, "exec prepared statement"); err != nil {
+ return err
+ }
+ return nil
+}
+
+type ModerationEntry struct {
+ ActingUsername, RecipientUsername, QuorumUsername string
+ QuorumDecision bool
+ Action int
+ Time time.Time
+}
+
+func (d DB) GetModerationLogs () []ModerationEntry {
+ ed := util.Describe("moderation log")
+ query := `SELECT uact.name, urecp.name, uquorum.name, q.decision, m.action, m.time
+ FROM moderation_LOG m
+
+ LEFT JOIN users uact ON uact.id = m.actingid
+ LEFT JOIN users urecp ON urecp.id = m.recipientid
+
+ LEFT JOIN quorum_decisions q ON q.modlogid = m.id
+ LEFT JOIN users uquorum ON uquorum.id = q.userid
+
+ ORDER BY time DESC`
+
+ 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 logs []ModerationEntry
+ for rows.Next() {
+ var entry ModerationEntry
+ var actingUsername, recipientUsername, quorumUsername sql.NullString
+ var quorumDecision sql.NullBool
+ if err := rows.Scan(&actingUsername, &recipientUsername, &quorumUsername, &quorumDecision, &entry.Action, &entry.Time); err != nil {
+ ed.Check(err, "scanning loop")
+ }
+ if actingUsername.Valid {
+ entry.ActingUsername = actingUsername.String
+ }
+ if recipientUsername.Valid {
+ entry.RecipientUsername = recipientUsername.String
+ }
+ if quorumUsername.Valid {
+ entry.QuorumUsername = quorumUsername.String
+ }
+ if quorumDecision.Valid {
+ entry.QuorumDecision = quorumDecision.Bool
+ }
+ logs = append(logs, entry)
+ }
+ return logs
+}
+
+func (d DB) ProposeModerationAction(proposerid, recipientid, action int) (finalErr error) {
+ ed := util.Describe("propose mod action")
+
+ t := time.Now()
+ tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
+ ed.Check(err, "open transaction")
+
+ rollbackOnErr:= func(incomingErr error) {
+ if incomingErr != nil {
+ _ = tx.Rollback()
+ log.Println(incomingErr, "rolling back")
+ finalErr = incomingErr
+ return
+ }
+ }
+
+ // start tx
+ propRecipientId := -1
+ // there should only be one pending proposal of each type for any given recipient
+ // so let's check to make sure that's true!
+ stmt, err := tx.Prepare("SELECT recipientid FROM moderation_proposals WHERE action = ?")
+ err = stmt.QueryRow(action).Scan(&propRecipientId)
+ if err == nil && propRecipientId != -1 {
+ finalErr = tx.Commit()
+ return
+ }
+ // there was no pending proposal of the proposed action for recipient - onwards!
+
+ // add the proposal
+ stmt, err = tx.Prepare("INSERT INTO moderation_proposals (proposerid, recipientid, time, action) VALUES (?, ?, ?, ?)")
+ rollbackOnErr(ed.Eout(err, "prepare proposal stmt"))
+ _, err = stmt.Exec(proposerid, recipientid, t, action)
+ rollbackOnErr(ed.Eout(err, "insert into proposals table"))
+
+ // TODO (2023-12-18): hmm how do we do this properly now? only have one constant per action
+ // {demote, make admin, remove user} but vary translations for these three depending on if there is also a decision or not?
+
+ // add moderation log that user x proposed action y for recipient z
+ stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
+ rollbackOnErr(ed.Eout(err, "prepare modlog stmt"))
+ _, err = stmt.Exec(proposerid, recipientid, action, t)
+ rollbackOnErr(ed.Eout(err, "insert into modlog"))
+
+ err = tx.Commit()
+ ed.Check(err, "commit transaction")
+ return
+}
+
+type ModProposal struct {
+ ActingUsername, RecipientUsername string
+ ActingID, RecipientID int
+ ProposalID, Action int
+ Time time.Time
+}
+
+func (d DB) GetProposedActions() []ModProposal {
+ ed := util.Describe("get moderation proposals")
+ stmt, err := d.db.Prepare(`SELECT mp.id, proposerid, up.name, recipientid, ur.name, action, mp.time
+ FROM moderation_proposals mp
+ INNER JOIN users up on mp.proposerid = up.id
+ INNER JOIN users ur on mp.recipientid = ur.id
+ ORDER BY time DESC
+ ;`)
+ defer stmt.Close()
+ ed.Check(err, "prepare stmt")
+ rows, err := stmt.Query()
+ ed.Check(err, "perform query")
+ defer rows.Close()
+ var proposals []ModProposal
+ for rows.Next() {
+ var prop ModProposal
+ if err = rows.Scan(&prop.ProposalID, &prop.ActingID, &prop.ActingUsername, &prop.RecipientID, &prop.RecipientUsername, &prop.Action, &prop.Time); err != nil {
+ ed.Check(err, "error scanning in row data")
+ }
+ proposals = append(proposals, prop)
+ }
+ return proposals
+}
+
+// finalize a proposal by either confirming or vetoing it, logging the requisite information and then finally executing
+// the proposed action itself
+func (d DB) FinalizeProposedAction(proposalid, adminid int, decision bool) (finalErr error) {
+ ed := util.Describe("finalize proposed mod action")
+
+ t := time.Now()
+ tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
+ ed.Check(err, "open transaction")
+
+ rollbackOnErr:= func(incomingErr error) {
+ if incomingErr != nil {
+ _ = tx.Rollback()
+ log.Println(incomingErr, "rolling back")
+ finalErr = incomingErr
+ return
+ }
+ }
+
+ /* start tx */
+ // make sure the proposal is still there (i.e. nobody has beat us to acting on it yet)
+ stmt, err := tx.Prepare("SELECT 1 FROM moderation_proposals WHERE id = ?")
+ rollbackOnErr(ed.Eout(err, "prepare proposal existence stmt"))
+ existence := -1
+ err = stmt.QueryRow(proposalid).Scan(&existence)
+ // proposal id did not exist (it was probably already acted on!)
+ if err != nil {
+ _ = tx.Commit()
+ return
+ }
+ // retrieve the proposal & populate with our dramatis personae
+ var proposerid, recipientid, proposalAction int
+ var proposalDate time.Time
+ stmt, err = tx.Prepare(`SELECT proposerid, recipientid, action, time from moderation_proposals WHERE id = ?`)
+ err = stmt.QueryRow(proposalid).Scan(&proposerid, &recipientid, &proposalAction, &proposalDate)
+ rollbackOnErr(ed.Eout(err, "retrieve proposal vals"))
+
+ timeSelfConfirmOK := proposalDate.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT)
+ // TODO (2024-01-07): render err message in admin view?
+ // self confirms are not allowed at this point in time, exit early without performing any changes
+ if decision == constants.PROPOSAL_CONFIRM && !time.Now().After(timeSelfConfirmOK) {
+ err = tx.Commit()
+ ed.Check(err, "commit transaction")
+ finalErr = nil
+ return
+ }
+
+ // convert proposed action (semantically different for the sake of logs) from the finalized action
+ var action int
+ switch proposalAction {
+ case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
+ action = constants.MODLOG_ADMIN_DEMOTE
+ case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
+ action = constants.MODLOG_REMOVE_USER
+ case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
+ action = constants.MODLOG_ADMIN_MAKE
+ default:
+ ed.Check(errors.New("unknown proposal action"), "convertin proposalAction into action")
+ }
+
+ // remove proposal from proposal table as it has been executed as desired
+ stmt, err = tx.Prepare("DELETE FROM moderation_proposals WHERE id = ?")
+ rollbackOnErr(ed.Eout(err, "prepare proposal removal stmt"))
+ _, err = stmt.Exec(proposalid)
+ rollbackOnErr(ed.Eout(err, "remove proposal from table"))
+
+ // add moderation log
+ stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
+ rollbackOnErr(ed.Eout(err, "prepare modlog stmt"))
+ // the admin who proposed the action will be logged as the one performing it
+ // get the modlog so we can reference it in the quorum_decisions table. this will be used to augment the moderation
+ // log view with quorum info
+ result, err := stmt.Exec(proposerid, recipientid, action, t)
+ rollbackOnErr(ed.Eout(err, "insert into modlog"))
+ modlogid, err := result.LastInsertId()
+ rollbackOnErr(ed.Eout(err, "get last insert id"))
+
+ // update the quorum decisions table so that we can use its info to augment the moderation log view
+ stmt, err = tx.Prepare(`INSERT INTO quorum_decisions (userid, decision, modlogid) VALUES (?, ?, ?)`)
+ rollbackOnErr(ed.Eout(err, "prepare quorum insertion stmt"))
+ // decision = confirm or veto => values true or false
+ _, err = stmt.Exec(adminid, decision, modlogid)
+ rollbackOnErr(ed.Eout(err, "execute quorum insertion"))
+
+ err = tx.Commit()
+ ed.Check(err, "commit transaction")
+
+ // the decision was to veto the proposal: there's nothing more to do! except return outta this function ofc ofc
+ if decision == constants.PROPOSAL_VETO {
+ return
+ }
+ // perform the actual action; would be preferable to do this in the transaction somehow
+ // but hell no am i copying in those bits here X)
+ switch proposalAction {
+ case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
+ err = d.DemoteAdmin(recipientid)
+ ed.Check(err, "remove user", recipientid)
+ case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
+ err = d.RemoveUser(recipientid)
+ ed.Check(err, "remove user", recipientid)
+ case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
+ d.AddAdmin(recipientid)
+ ed.Check(err, "add admin", recipientid)
+ }
+ return // return finalError = null
+}
+
+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) DemoteAdmin(userid int) error {
+ ed := util.Describe("demote admin")
+ // make sure the id exists
+ exists, err := d.CheckUserExists(userid)
+ if !exists {
+ return errors.New(fmt.Sprintf("demote admin: userid %d did not exist", userid))
+ }
+ if err != nil {
+ return ed.Eout(err, "CheckUserExists had an error")
+ }
+ isAdmin, err := d.IsUserAdmin(userid)
+ if !isAdmin {
+ return errors.New(fmt.Sprintf("demote admin: userid %d was not an admin", userid))
+ }
+ if err != nil {
+ // some kind of error, let's bubble it up
+ return ed.Eout(err, "IsUserAdmin")
+ }
+ // all checks are done: perform the removal
+ stmt := `DELETE FROM admins WHERE id = ?`
+ _, 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) QuorumActivated () bool {
+ admins := d.GetAdmins()
+ return len(admins) >= 2
+}
+
+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
+}
diff --git a/html/admin-add-user.html b/html/admin-add-user.html
@@ -0,0 +1,22 @@
+{{ template "head" . }}
+<main>
+ <h1> {{ .Title }}</h1>
+ <p>Register a new user account. After registering the account you will be given a generated password and
+ instructions to pass onto the user.</p>
+
+ <form method="post">
+ <label for="username">{{ "Username" | translate | capitalize }}:</label>
+ <input type="text" required id="username" name="username">
+ <div>
+ <input type="submit" value='{{ "Register" | translate | capitalize }}'>
+ </div>
+ </form>
+
+ {{ if .Data.ErrorMessage }}
+ <div>
+ <p><b> {{ .Data.ErrorMessage }} </b></p>
+ </div>
+ {{ end }}
+
+</main>
+{{ template "footer" . }}
diff --git a/html/admin.html b/html/admin.html
@@ -18,23 +18,58 @@
</p>
</section>
{{ if .LoggedIn }}
+ {{ $userID := .LoggedInID }}
<section>
<h2> Admins </h2>
{{ if len .Data.Admins | eq 0 }}
<p> there are no admins; chaos reigns </p>
{{ else }}
- {{ $userID := .LoggedInID }}
+ <table>
{{ range $index, $user := .Data.Admins }}
- <form method="POST" id="demote-admin-{{$user.ID}}" action="/demote-admin">
- <input type="hidden" name="userid" value="{{ $user.ID }}">
- </form>
- <p> {{ $user.Name }} ({{ $user.ID }})
- {{ if eq $userID $user.ID }} <i>(this is you!)</i>
- {{ else }}<button type="submit" form="demote-admin-{{$user.ID}}">Demote</button>{{ end }}
- </p>
+ <tr>
+ <form method="POST" id="demote-admin-{{$user.ID}}" action="/demote-admin">
+ <input type="hidden" name="userid" value="{{ $user.ID }}">
+ </form>
+ <td>{{ $user.Name }} ({{ $user.ID }}) </td>
+ <td>
+ {{ if eq $userID $user.ID }} <i>(you!)</i>
+ {{ else }}<button type="submit" form="demote-admin-{{$user.ID}}">Demote</button>{{ end }}
+ </td>
+ </tr>
{{ end }}
+ </table>
{{ end }}
</section>
+ <section>
+ <h2> Pending actions</h2>
+ <p>Two admins are required for <i>making a user an admin</i>, <i>demoting an existing admin</i>, or <i>removing a user</i>. The
+ first proposes the action, the second confirms (or vetos) it. If enough time elapses without a veto, the
+ proposer may confirm their own proposal.</p>
+
+ {{ if len .Data.Proposals | eq 0}}
+ <p><i>there are no pending proposals</i></p>
+ {{ else }}
+ <table>
+ <tr>
+ <th>Proposal</th>
+ <th colspan="3">Date self-proposals become valid</th>
+ </tr>
+ {{ range $index, $proposal := .Data.Proposals }}
+ <tr>
+ <form method="POST" id="confirm-{{$proposal.ID}}" action="/proposal-confirm">
+ <input type="hidden" name="proposalid" value="{{ $proposal.ID }}">
+ </form>
+ <form method="POST" id="veto-{{$proposal.ID}}" action="/proposal-veto">
+ <input type="hidden" name="proposalid" value="{{ $proposal.ID }}">
+ </form>
+ <td> {{ $proposal.Action | tohtml }} </td>
+ <td> {{ $proposal.Time | formatDateTime }} </td>
+ <td><button type="submit" form="veto-{{$proposal.ID}}">Veto</button></td>
+ <td><button {{ if not $proposal.TimePassed }} disabled title="a week must pass before self-confirmations are ok" {{ end }} type="submit" form="confirm-{{$proposal.ID}}">Confirm</button></td>
+ </tr>
+ {{ end }}
+ </table>
+ {{ end }}
<section>
<h2> Users </h2>
@@ -43,7 +78,7 @@
{{ else }}
<table>
{{ range $index, $user := .Data.Users }}
- {{ if ne $user.Name "deleted user" }}
+ {{ if and (ne $user.Name "CERCA_CMD") (ne $user.Name "deleted user") }}
<form method="POST">
<input type="hidden" name="userid" value="{{$user.ID}}">
<tr>
diff --git a/i18n/i18n.go b/i18n/i18n.go
@@ -29,18 +29,18 @@ var English = map[string]string{
"modlogResetPassword": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> reset a user's password`,
"modlogResetPasswordAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> reset <b> {{ .Data.RecipientUsername}}</b>'s password`,
+ "modlogRemoveUser": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> removed a user's account`,
"modlogMakeAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> made <b> {{ .Data.RecipientUsername}}</b> an admin`,
"modlogAddUser": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> manually registered an account for <b> {{ .Data.RecipientUsername }}</b>`,
"modlogDemoteAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> demoted <b>
{{ if eq .Data.ActingUsername .Data.RecipientUsername }} themselves
{{ else }} {{ .Data.RecipientUsername}} {{ end }}</b> from admin back to normal user`,
- // "modlogProposeAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> made <b> {{ .Data.RecipientUsername}} an admin`,
- // "modlogVetoAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> vetoed making {{ .Data.RecipientUsername }} an admin`,
- // "modlogConfirmAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> confirmed making {{ .Data.RecipientUsername }} a new admin`,
- // "modlogProposeDelete": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> proposed deleting a user's account`,
- // "modlogVetoDelete": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> vetoed deleting a user's account`,
- // "modlogConfirmDelete": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> confirmed deleting a user's account`,
- "modlogRemoveUser": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> removed a user's account`,
+ "modlogXProposedY": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> proposed: {{ .Data.Action }}`,
+ "modlogProposalMakeAdmin": `Make <b> {{ .Data.RecipientUsername}}</b> admin`,
+ "modlogProposalDemoteAdmin": `Demote <b> {{ .Data.RecipientUsername}}</b> from role admin`,
+ "modlogProposalRemoveUser": `Remove user <b> {{ .Data.RecipientUsername }} </b>`,
+ "modlogConfirm": "{{ .Data.Action }} <i>confirmed by {{ .Data.ActingUsername }}</i>",
+ "modlogVeto": "<s>{{ .Data.Action }}</s> <i>vetoed by {{ .Data.ActingUsername }}</i>",
"LoginNoAccount": "Don't have an account yet? <a href='/register'>Register</a> one.",
"LoginFailure": "<b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.",
diff --git a/server/moderation.go b/server/moderation.go
@@ -0,0 +1,456 @@
+package server
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+ "net/http"
+ "time"
+
+ "cerca/database"
+ "cerca/crypto"
+ "cerca/constants"
+ "cerca/i18n"
+ "cerca/util"
+)
+
+type AdminData struct {
+ Admins []database.User
+ Users []database.User
+ Proposals []PendingProposal
+ IsAdmin bool
+}
+
+type ModerationData struct {
+ Log []string
+}
+
+type PendingProposal struct {
+ // ID is the id of the proposal
+ ID, ProposerID int
+ Action string
+ Time time.Time // the time self-confirmations become possible for proposers
+ TimePassed bool // self-confirmations valid or not
+}
+
+func (h RequestHandler) displayErr(res http.ResponseWriter, req *http.Request, err error, title string) {
+ errMsg := util.Eout(err, fmt.Sprintf("%s failed", title))
+ fmt.Println(errMsg)
+ data := GenericMessageData{
+ Title: title,
+ Message: errMsg.Error(),
+ }
+ h.renderGenericMessage(res, req, data)
+}
+
+func (h RequestHandler) displaySuccess(res http.ResponseWriter, req *http.Request, title, message, backRoute string) {
+ data := GenericMessageData{
+ Title: title,
+ Message: message,
+ LinkText: h.translator.Translate("GoBack"),
+ Link: backRoute,
+ }
+ h.renderGenericMessage(res, req, data)
+}
+
+// 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
+}
+
+// there is a 2-quorum (requires 2 admins to take effect) imposed for the following actions, which are regarded as
+// consequential:
+// * make admin
+// * remove account
+// * demote admin
+
+// note: there is only a 2-quorum constraint imposed if there are actually 2 admins. an admin may also confirm their own
+// proposal if constants.PROPOSAL_SELF_CONFIRMATION_WAIT seconds have passed (1 week)
+func performQuorumCheck (ed util.ErrorDescriber, db *database.DB, adminUserId, targetUserId, proposedAction int) error {
+ // checks if a quorum is necessary for the proposed action: if a quorum constarin is in effect, a proposal is created
+ // otherwise (if no quorum threshold has been achieved) the action is taken directly
+ quorumActivated := db.QuorumActivated()
+
+ var err error
+ var modlogErr error
+ if quorumActivated {
+ err = db.ProposeModerationAction(adminUserId, targetUserId, proposedAction)
+ } else {
+ switch proposedAction {
+ case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
+ err = db.RemoveUser(targetUserId)
+ modlogErr = db.AddModerationLog(adminUserId, -1, constants.MODLOG_REMOVE_USER)
+ case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
+ err = db.AddAdmin(targetUserId)
+ modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_MAKE)
+ case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
+ err = db.DemoteAdmin(targetUserId)
+ modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_DEMOTE)
+ }
+ }
+ if modlogErr != nil {
+ fmt.Println(ed.Eout(err, "error adding moderation log"))
+ }
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request, targetUserId int) {
+ ed := util.Describe("Admin remove user")
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, adminUserId := h.IsAdmin(req)
+
+ if req.Method == "GET" || !loggedIn || !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER)
+
+ if err != nil {
+ h.displayErr(res, req, err, "User removal")
+ return
+ }
+
+ // success! redirect back to /admin
+ http.Redirect(res, req, "/admin", http.StatusFound)
+}
+
+func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserId int) {
+ ed := util.Describe("make user admin")
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, adminUserId := h.IsAdmin(req)
+ if req.Method == "GET" || !loggedIn || !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ title := "Make admin"
+
+ err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN)
+
+ if err != nil {
+ h.displayErr(res, req, err, title)
+ return
+ }
+
+ if !h.db.QuorumActivated() {
+ username, _ := h.db.GetUsername(targetUserId)
+ message := fmt.Sprintf("User %s is now a fellow admin user!", username)
+ h.displaySuccess(res, req, title, message, "/admin")
+ } else {
+ // redirect to admin view, which should have a proposal now
+ http.Redirect(res, req, "/admin", http.StatusFound)
+ }
+}
+
+func (h *RequestHandler) AdminDemoteAdmin(res http.ResponseWriter, req *http.Request) {
+ ed := util.Describe("demote admin route")
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, adminUserId := h.IsAdmin(req)
+
+ if req.Method == "GET" || !loggedIn || !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ title := "Demote admin"
+
+ useridString := req.PostFormValue("userid")
+ targetUserId, err := strconv.Atoi(useridString)
+ util.Check(err, "convert user id string to a plain userid")
+
+ err = performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN)
+
+ if err != nil {
+ h.displayErr(res, req, err, title)
+ return
+ }
+
+ if !h.db.QuorumActivated() {
+ username, _ := h.db.GetUsername(targetUserId)
+ message := fmt.Sprintf("User %s is now a regular user", username)
+ // output copy-pastable credentials page for admin to send to the user
+ h.displaySuccess(res, req, title, message, "/admin")
+ } else {
+ http.Redirect(res, req, "/admin", http.StatusFound)
+ }
+}
+
+func (h *RequestHandler) AdminManualAddUserRoute(res http.ResponseWriter, req *http.Request) {
+ ed := util.Describe("admin manually add user")
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, adminUserId := h.IsAdmin(req)
+
+ if !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ type AddUser struct {
+ ErrorMessage string
+ }
+
+ var data AddUser
+ view := TemplateData{Title: "Add a new user", Data: &data, HasRSS: false, IsAdmin: isAdmin, LoggedIn: loggedIn}
+
+ if req.Method == "GET" {
+ h.renderView(res, "admin-add-user", view)
+ return
+ }
+
+ if req.Method == "POST" && isAdmin {
+ username := req.PostFormValue("username")
+
+ // do a lil quick checky check to see if we already have that username registered,
+ // and if we do re-render the page with an error
+ existed, err := h.db.CheckUsernameExists(username)
+ ed.Check(err, "check username exists")
+
+ if existed {
+ data.ErrorMessage = fmt.Sprintf("Username (%s) is already registered", username)
+ h.renderView(res, "admin-add-user", view)
+ return
+ }
+
+ // set up basic credentials
+ newPassword := crypto.GeneratePassword()
+ passwordHash, err := crypto.HashPassword(newPassword)
+ ed.Check(err, "hash password")
+ targetUserId, err := h.db.CreateUser(username, passwordHash)
+ ed.Check(err, "create new user %s", username)
+
+ err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_ADD_USER)
+ if err != nil {
+ fmt.Println(ed.Eout(err, "error adding moderation log"))
+ }
+
+ title := "User successfully added"
+ message := fmt.Sprintf("Instructions: %s's password was set to: %s. After logging in, please change your password by going to /reset", username, newPassword)
+ h.displaySuccess(res, req, title, message, "/add-user")
+ }
+}
+
+func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserId int) {
+ ed := util.Describe("admin reset password")
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, adminUserId := h.IsAdmin(req)
+ if req.Method == "GET" || !loggedIn || !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ title := "Admin reset password"
+ newPassword, err := h.db.ResetPassword(targetUserId)
+
+ if err != nil {
+ h.displayErr(res, req, err, title)
+ return
+ }
+
+ err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_RESETPW)
+ if err != nil {
+ fmt.Println(ed.Eout(err, "error adding moderation log"))
+ }
+
+ username, _ := h.db.GetUsername(targetUserId)
+
+ message := fmt.Sprintf("Instructions: User %s's password was reset to: %s. After logging in, please change your password by going to /reset", username, newPassword)
+ h.displaySuccess(res, req, title, message, "/admin")
+}
+
+func (h *RequestHandler) ConfirmProposal(res http.ResponseWriter, req *http.Request) {
+ h.HandleProposal(res, req, constants.PROPOSAL_CONFIRM)
+}
+
+func (h *RequestHandler) VetoProposal(res http.ResponseWriter, req *http.Request) {
+ h.HandleProposal(res, req, constants.PROPOSAL_VETO)
+}
+
+func (h *RequestHandler) HandleProposal(res http.ResponseWriter, req *http.Request, decision bool) {
+ ed := util.Describe("handle proposal proposal")
+ isAdmin, adminUserId := h.IsAdmin(req)
+
+ if !isAdmin {
+ IndexRedirect(res, req)
+ return
+ }
+
+ if req.Method == "POST" {
+ proposalidString := req.PostFormValue("proposalid")
+ proposalid, err := strconv.Atoi(proposalidString)
+ ed.Check(err, "convert proposalid")
+ err = h.db.FinalizeProposedAction(proposalid, adminUserId, decision)
+ if err != nil {
+ ed.Eout(err, "finalizing the proposed action returned early with an error")
+ }
+ http.Redirect(res, req, "/admin", http.StatusFound)
+ return
+ }
+ IndexRedirect(res, req)
+}
+
+// Note: this route by definition contains user generated content, so we escape all usernames with
+// html.EscapeString(username)
+func (h *RequestHandler) ModerationLogRoute(res http.ResponseWriter, req *http.Request) {
+ loggedIn, _ := h.IsLoggedIn(req)
+ isAdmin, _ := h.IsAdmin(req)
+ // logs are sorted by time descending, from latest entry to oldest
+ logs := h.db.GetModerationLogs()
+ viewData := ModerationData{Log: make([]string, 0)}
+
+ type translationData struct {
+ Time, ActingUsername, RecipientUsername string
+ Action template.HTML
+ }
+
+ for _, entry := range logs {
+ var tdata translationData
+ var translationString string
+ tdata.Time = entry.Time.Format("2006-01-02 15:04:05")
+ tdata.ActingUsername = template.HTMLEscapeString(entry.ActingUsername)
+ tdata.RecipientUsername = template.HTMLEscapeString(entry.RecipientUsername)
+ switch entry.Action {
+ case constants.MODLOG_RESETPW:
+ translationString = "modlogResetPassword"
+ if isAdmin {
+ translationString += "Admin"
+ }
+ case constants.MODLOG_ADMIN_MAKE:
+ translationString = "modlogMakeAdmin"
+ case constants.MODLOG_REMOVE_USER:
+ translationString = "modlogRemoveUser"
+ case constants.MODLOG_ADMIN_ADD_USER:
+ translationString = "modlogAddUser"
+ case constants.MODLOG_ADMIN_DEMOTE:
+ translationString = "modlogDemoteAdmin"
+ case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
+ translationString = "modlogProposalDemoteAdmin"
+ case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
+ translationString = "modlogProposalMakeAdmin"
+ case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
+ translationString = "modlogProposalRemoveUser"
+ }
+
+ actionString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: tdata})
+
+ /* rendering of decision (confirm/veto) taken on a pending proposal */
+ if entry.QuorumUsername != "" {
+ // use the translated actionString to embed in the translated proposal decision (confirmation/veto)
+ propdata := translationData{ActingUsername: template.HTMLEscapeString(entry.QuorumUsername), Action: template.HTML(actionString)}
+ // if quorumDecision is true -> proposal was confirmed
+ translationString = "modlogConfirm"
+ if !entry.QuorumDecision {
+ translationString = "modlogVeto"
+ }
+ proposalString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: propdata})
+ viewData.Log = append(viewData.Log, proposalString)
+ /* rendering of "X proposed: <Y>" */
+ } else if entry.Action == constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN ||
+ entry.Action == constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN ||
+ entry.Action == constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER {
+ propXforY := translationData{Time: tdata.Time, ActingUsername: tdata.ActingUsername, Action: template.HTML(actionString)}
+ proposalString := h.translator.TranslateWithData("modlogXProposedY", i18n.TranslationData{Data: propXforY})
+ viewData.Log = append(viewData.Log, proposalString)
+ } else {
+ viewData.Log = append(viewData.Log, actionString)
+ }
+ }
+ view := TemplateData{Title: "Moderation log", IsAdmin: isAdmin, LoggedIn: loggedIn, Data: viewData}
+ h.renderView(res, "moderation-log", view)
+}
+
+// used for rendering /admin's pending proposals
+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)
+ case "remove-account":
+ h.AdminRemoveUser(res, req, targetUserId)
+ }
+ return
+ }
+
+ if req.Method == "GET" {
+ if !loggedIn || !isAdmin {
+ // non-admin users get a different view
+ h.ListAdmins(res, req)
+ return
+ }
+ admins := h.db.GetAdmins()
+ normalUsers := h.db.GetUsers(false) // do not include admins
+ proposedActions := h.db.GetProposedActions()
+ // massage pending proposals into something we can use in the rendered view
+ pendingProposals := make([]PendingProposal, len(proposedActions))
+ now := time.Now()
+ for i, prop := range proposedActions {
+ // escape all ugc
+ prop.ActingUsername = template.HTMLEscapeString(prop.ActingUsername)
+ prop.RecipientUsername = template.HTMLEscapeString(prop.RecipientUsername)
+ // one week from when the proposal was made
+ t := prop.Time.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT)
+ var str string
+ switch prop.Action {
+ case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
+ str = "modlogProposalDemoteAdmin"
+ case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
+ str = "modlogProposalMakeAdmin"
+ case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
+ str = "modlogProposalRemoveUser"
+ }
+
+ proposalString := h.translator.TranslateWithData(str, i18n.TranslationData{Data: prop})
+ pendingProposals[i] = PendingProposal{ID: prop.ProposalID, ProposerID: prop.ActingID, Action: proposalString, Time: t, TimePassed: now.After(t)}
+ }
+ data := AdminData{Admins: admins, Users: normalUsers, Proposals: pendingProposals}
+ view := TemplateData{Title: "Forum Administration", Data: &data, HasRSS: false, LoggedIn: loggedIn, LoggedInID: userid}
+ h.renderView(res, "admin", view)
+ }
+}
+
+// view of /admin for non-admin users (contains less information)
+func (h *RequestHandler) ListAdmins(res http.ResponseWriter, req *http.Request) {
+ loggedIn, _ := h.IsLoggedIn(req)
+ admins := h.db.GetAdmins()
+ data := AdminData{Admins: admins}
+ view := TemplateData{Title: "Forum Administrators", Data: &data, HasRSS: false, LoggedIn: loggedIn}
+ h.renderView(res, "admins-list", view)
+ return
+}
diff --git a/server/server.go b/server/server.go
@@ -6,7 +6,6 @@ import (
"fmt"
"html/template"
"log"
- "strconv"
"net"
"net/http"
"net/url"
@@ -18,7 +17,6 @@ import (
"time"
"cerca/crypto"
- "cerca/constants"
"cerca/database"
"cerca/defaults"
cercaHTML "cerca/html"
@@ -85,12 +83,6 @@ 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
@@ -170,35 +162,6 @@ 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
@@ -322,328 +285,6 @@ func (h RequestHandler) renderGenericMessage(res http.ResponseWriter, req *http.
return
}
-func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request, targetUserid int) {
- ed := util.Describe("Admin remove user")
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, adminUserid := h.IsAdmin(req)
- if req.Method == "GET" || !loggedIn || !isAdmin {
- IndexRedirect(res, req)
- return
- }
-
- err := h.db.RemoveUser(targetUserid)
-
- if err != nil {
- // TODO (2023-12-09): bubble up error to visible page as feedback for admin
- errMsg := ed.Eout(err, "remove user failed")
- fmt.Println(errMsg)
- data := GenericMessageData{
- Title: "User removal",
- Message: errMsg.Error(),
- }
- h.renderGenericMessage(res, req, data)
- return
- }
-
- err = h.db.AddModerationLog(adminUserid, -1, constants.MODLOG_REMOVE_USER)
- if err != nil {
- fmt.Println(ed.Eout(err, "error adding moderation log"))
- }
- // success! redirect back to /admin
- http.Redirect(res, req, "/admin", http.StatusSeeOther)
-}
-
-func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserid int) {
- ed := util.Describe("make user admin")
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, adminUserid := h.IsAdmin(req)
- if req.Method == "GET" || !loggedIn || !isAdmin {
- 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 := ed.Eout(err, "make admin failed")
- fmt.Println(errMsg)
- data := GenericMessageData{
- Title: "Make admin",
- Message: errMsg.Error(),
- }
- h.renderGenericMessage(res, req, data)
- return
- }
-
- username, _ := h.db.GetUsername(targetUserid)
- err = h.db.AddModerationLog(adminUserid, targetUserid, constants.MODLOG_ADMIN_MAKE)
- if err != nil {
- fmt.Println(ed.Eout(err, "error adding moderation log"))
- }
-
- // 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)
-}
-
-func (h *RequestHandler) AdminDemoteAdmin(res http.ResponseWriter, req *http.Request) {
- ed := util.Describe("demote admin route")
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, adminUserid := h.IsAdmin(req)
- if req.Method == "GET" || !loggedIn || !isAdmin {
- IndexRedirect(res, req)
- return
- }
- useridString := req.PostFormValue("userid")
- targetUserid, err := strconv.Atoi(useridString)
- util.Check(err, "convert user id string to a plain userid")
-
- // TODO (2023-12-10): introduce 2-quorom
- err = h.db.DemoteAdmin(targetUserid)
-
- if err != nil {
- errMsg := ed.Eout(err, "demote admin failed")
- fmt.Println(errMsg)
- data := GenericMessageData{
- Title: "Demote admin",
- Message: errMsg.Error(),
- }
- h.renderGenericMessage(res, req, data)
- return
- }
-
- username, _ := h.db.GetUsername(targetUserid)
- err = h.db.AddModerationLog(adminUserid, targetUserid, constants.MODLOG_ADMIN_DEMOTE)
- if err != nil {
- fmt.Println(ed.Eout(err, "error adding moderation log"))
- }
-
- // output copy-pastable credentials page for admin to send to the user
- data := GenericMessageData{
- Title: "Demote admin success",
- Message: fmt.Sprintf("User %s is now a regular user", username),
- LinkMessage: "Go back to the",
- LinkText: "admin view",
- Link: "/admin",
- }
- h.renderGenericMessage(res, req, data)
-}
-
-func (h *RequestHandler) AdminManualAddUserRoute(res http.ResponseWriter, req *http.Request) {
- ed := util.Describe("admin manually add user")
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, adminUserid := h.IsAdmin(req)
-
- if !isAdmin {
- IndexRedirect(res, req)
- return
- }
-
- type AddUser struct {
- ErrorMessage string
- }
-
- var data AddUser
- view := TemplateData{Title: "Add a new user", Data: &data, HasRSS: false, IsAdmin: isAdmin, LoggedIn: loggedIn}
-
- if req.Method == "GET" {
- h.renderView(res, "admin-add-user", view)
- return
- }
-
- if req.Method == "POST" && isAdmin {
- username := req.PostFormValue("username")
-
- // do a lil quick checky check to see if we already have that username registered,
- // and if we do re-render the page with an error
- existed, err := h.db.CheckUsernameExists(username)
- ed.Check(err, "check username exists")
-
- if existed {
- data.ErrorMessage = fmt.Sprintf("Username (%s) is already registered", username)
- h.renderView(res, "admin-add-user", view)
- return
- }
-
- // set up basic credentials
- newPassword := crypto.GeneratePassword()
- passwordHash, err := crypto.HashPassword(newPassword)
- ed.Check(err, "hash password")
- targetUserid, err := h.db.CreateUser(username, passwordHash)
- ed.Check(err, "create new user %s", username)
-
- // if err != nil {
- // // TODO (2023-12-09): bubble up error to visible page as feedback for admin
- // errMsg := ed.Eout(err, "reset password failed")
- // fmt.Println(errMsg)
- // data := GenericMessageData{
- // Title: "Admin reset password",
- // Message: errMsg.Error(),
- // }
- // h.renderGenericMessage(res, req, data)
- // return
- // }
-
- err = h.db.AddModerationLog(adminUserid, targetUserid, constants.MODLOG_ADMIN_ADD_USER)
- if err != nil {
- fmt.Println(ed.Eout(err, "error adding moderation log"))
- }
-
- // output copy-pastable credentials page for admin to send to the user
- data := GenericMessageData{
- Title: "User successfully added",
- Message: fmt.Sprintf("Instructions: %s's password was set to: %s. After logging in, please change your password by going to /reset", username, newPassword),
- LinkMessage: "Go back to the",
- LinkText: "add user view",
- Link: "/add-user",
- }
- h.renderGenericMessage(res, req, data)
- }
-}
-
-func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserid int) {
- ed := util.Describe("admin reset password")
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, adminUserid := h.IsAdmin(req)
- if req.Method == "GET" || !loggedIn || !isAdmin {
- 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 := ed.Eout(err, "reset password failed")
- fmt.Println(errMsg)
- data := GenericMessageData{
- Title: "Admin reset password",
- Message: errMsg.Error(),
- }
- h.renderGenericMessage(res, req, data)
- return
- }
-
- err = h.db.AddModerationLog(adminUserid, targetUserid, constants.MODLOG_RESETPW)
- if err != nil {
- fmt.Println(ed.Eout(err, "error adding moderation log"))
- }
-
- 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)
-}
-
-type ModerationData struct {
- Log []string
-}
-
-// Note: this will by definition contain ugc, so we need to escape all usernames with html.EscapeString(username) before
-// populating ModerationLogEntry
-/* sorted by time descending, from latest entry to oldest */
-
-func (h *RequestHandler) ModerationLogRoute(res http.ResponseWriter, req *http.Request) {
- loggedIn, _ := h.IsLoggedIn(req)
- isAdmin, _ := h.IsAdmin(req)
- logs := h.db.GetModerationLogs()
- fmt.Println("logs", logs)
- viewData := ModerationData{Log: make([]string, 0)}
- type translationData struct {
- Time, ActingUsername, RecipientUsername string
- }
- for _, entry := range logs {
- var tdata translationData
- var translationString string
- tdata.Time = entry.Time.Format("2006-01-02 15:04:05")
- tdata.ActingUsername = template.HTMLEscapeString(entry.ActingUsername)
- tdata.RecipientUsername = template.HTMLEscapeString(entry.RecipientUsername)
- switch entry.Action {
- case constants.MODLOG_RESETPW:
- translationString = "modlogResetPassword"
- if isAdmin {
- translationString += "Admin"
- }
- case constants.MODLOG_ADMIN_MAKE:
- translationString = "modlogMakeAdmin"
- case constants.MODLOG_REMOVE_USER:
- translationString = "modlogRemoveUser"
- case constants.MODLOG_ADMIN_ADD_USER:
- translationString = "modlogAddUser"
- case constants.MODLOG_ADMIN_DEMOTE:
- translationString = "modlogDemoteAdmin"
- }
- str := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: tdata})
- viewData.Log = append(viewData.Log, str)
- }
- view := TemplateData{Title: "Moderation log", IsAdmin: isAdmin, LoggedIn: loggedIn, Data: viewData}
- h.renderView(res, "moderation-log", view)
-}
-// 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!")
- h.AdminRemoveUser(res, req, targetUserid)
- }
- return
- }
- if req.Method == "GET" {
- if !loggedIn || !isAdmin {
- // non-admin users get a different view
- h.ListAdmins(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) ListAdmins(res http.ResponseWriter, req *http.Request) {
- loggedIn, _ := h.IsLoggedIn(req)
- admins := h.db.GetAdmins()
- data := AdminsData{Admins: admins}
- view := TemplateData{Title: "Forum Administrators", Data: &data, HasRSS: false, LoggedIn: loggedIn}
- h.renderView(res, "admins-list", view)
- return
-}
-
func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) {
threadid, ok := util.GetURLPortion(req, 2)
loggedIn, userid := h.IsLoggedIn(req)
@@ -1235,11 +876,16 @@ 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
+
+ // moderation and admin related routes, for contents see file server/moderation.go
s.ServeMux.HandleFunc("/reset/", handler.ResetPasswordRoute)
s.ServeMux.HandleFunc("/admin", handler.AdminRoute)
s.ServeMux.HandleFunc("/demote-admin", handler.AdminDemoteAdmin)
s.ServeMux.HandleFunc("/add-user", handler.AdminManualAddUserRoute)
s.ServeMux.HandleFunc("/moderations", handler.ModerationLogRoute)
+ s.ServeMux.HandleFunc("/proposal-veto", handler.VetoProposal)
+ s.ServeMux.HandleFunc("/proposal-confirm", handler.ConfirmProposal)
+ // regular ol forum routes
s.ServeMux.HandleFunc("/about", handler.AboutRoute)
s.ServeMux.HandleFunc("/logout", handler.LogoutRoute)
s.ServeMux.HandleFunc("/login", handler.LoginRoute)