cerca

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

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:
Mconstants/constants.go | 24+++++++++++++++---------
Mdatabase/database.go | 294++++++++++++-------------------------------------------------------------------
Adatabase/moderation.go | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/admin-add-user.html | 22++++++++++++++++++++++
Mhtml/admin.html | 53++++++++++++++++++++++++++++++++++++++++++++---------
Mi18n/i18n.go | 14+++++++-------
Aserver/moderation.go | 456+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/server.go | 364++-----------------------------------------------------------------------------
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)