cerca

lean forum software (pmc local branch)
git clone http://git.permacomputing.net/repos/cerca.git # read-only access
Log | Files | Refs | README | LICENSE

moderation.go (16209B)


      1 package database
      2 
      3 import (
      4 	"context"
      5 	"database/sql"
      6 	"errors"
      7 	"fmt"
      8 	"log"
      9 	"time"
     10 
     11 	"cerca/constants"
     12 	"cerca/util"
     13 
     14 	_ "github.com/mattn/go-sqlite3"
     15 )
     16 
     17 // there are a bunch of places that reference a user's id, so i don't want to break all of those
     18 //
     19 // i also want to avoid big invisible holes in a conversation's history
     20 //
     21 // remove user performs the following operation:
     22 // 1. checks to see if the DELETED USER exists; otherwise create it and remember its id
     23 //
     24 // 2. if it exists, we swap out the userid for the DELETED_USER in tables:
     25 // - table threads authorid
     26 // - table posts authorid
     27 // - table moderation_log actingid or recipientid
     28 //
     29 // the entry in registrations correlating to userid is removed
     30 // if allowing deletion of post contents as well when removing account,
     31 // userid should be used to get all posts from table posts and change the contents
     32 // to say _deleted_
     33 func (d DB) RemoveUser(userid int) (finalErr error) {
     34 	ed := util.Describe("remove user")
     35 	// there is a single user we call the "deleted user", and we make sure this deleted user exists on startup
     36 	// they will take the place of the old user when they remove their account.
     37 	deletedUserID, err := d.GetUserID(DELETED_USER_NAME)
     38 	if err != nil {
     39 		log.Fatalln(ed.Eout(err, "get deleted user id"))
     40 	}
     41 	// create a transaction spanning all our removal-related ops
     42 	tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
     43 	rollbackOnErr := func(incomingErr error) bool {
     44 		if incomingErr != nil {
     45 			_ = tx.Rollback()
     46 			log.Println(incomingErr, "rolling back")
     47 			finalErr = incomingErr
     48 			return true
     49 		}
     50 		return false
     51 	}
     52 	if rollbackOnErr(ed.Eout(err, "start transaction")) {
     53 		return
     54 	}
     55 
     56 	// create prepared statements performing the required removal operations for tables that reference a userid as a
     57 	// foreign key: threads, posts, moderation_log, and registrations
     58 	threadsStmt, err := tx.Prepare("UPDATE threads SET authorid = ? WHERE authorid = ?")
     59 	defer threadsStmt.Close()
     60 	if rollbackOnErr(ed.Eout(err, "prepare threads stmt")) {
     61 		return
     62 	}
     63 
     64 	postsStmt, err := tx.Prepare(`UPDATE posts SET content = "_deleted_", authorid = ? WHERE authorid = ?`)
     65 	defer postsStmt.Close()
     66 	if rollbackOnErr(ed.Eout(err, "prepare posts stmt")) {
     67 		return
     68 	}
     69 
     70 	modlogStmt1, err := tx.Prepare("UPDATE moderation_log SET recipientid = ? WHERE recipientid = ?")
     71 	defer modlogStmt1.Close()
     72 	if rollbackOnErr(ed.Eout(err, "prepare modlog stmt #1")) {
     73 		return
     74 	}
     75 
     76 	modlogStmt2, err := tx.Prepare("UPDATE moderation_log SET actingid = ? WHERE actingid = ?")
     77 	defer modlogStmt2.Close()
     78 	if rollbackOnErr(ed.Eout(err, "prepare modlog stmt #2")) {
     79 		return
     80 	}
     81 
     82 	stmtReg, err := tx.Prepare("DELETE FROM registrations where userid = ?")
     83 	defer stmtReg.Close()
     84 	if rollbackOnErr(ed.Eout(err, "prepare registrations stmt")) {
     85 		return
     86 	}
     87 
     88 	// and finally: removing the entry from the user's table itself
     89 	stmtUsers, err := tx.Prepare("DELETE FROM users where id = ?")
     90 	defer stmtUsers.Close()
     91 	if rollbackOnErr(ed.Eout(err, "prepare users stmt")) {
     92 		return
     93 	}
     94 
     95 	_, err = threadsStmt.Exec(deletedUserID, userid)
     96 	if rollbackOnErr(ed.Eout(err, "exec threads stmt")) {
     97 		return
     98 	}
     99 	_, err = postsStmt.Exec(deletedUserID, userid)
    100 	if rollbackOnErr(ed.Eout(err, "exec posts stmt")) {
    101 		return
    102 	}
    103 	_, err = modlogStmt1.Exec(deletedUserID, userid)
    104 	if rollbackOnErr(ed.Eout(err, "exec modlog #1 stmt")) {
    105 		return
    106 	}
    107 	_, err = modlogStmt2.Exec(deletedUserID, userid)
    108 	if rollbackOnErr(ed.Eout(err, "exec modlog #2 stmt")) {
    109 		return
    110 	}
    111 	_, err = stmtReg.Exec(userid)
    112 	if rollbackOnErr(ed.Eout(err, "exec registration stmt")) {
    113 		return
    114 	}
    115 	_, err = stmtUsers.Exec(userid)
    116 	if rollbackOnErr(ed.Eout(err, "exec users stmt")) {
    117 		return
    118 	}
    119 
    120 	err = tx.Commit()
    121 	ed.Check(err, "commit transaction")
    122 	finalErr = nil
    123 	return
    124 }
    125 
    126 func (d DB) AddModerationLog(actingid, recipientid, action int) error {
    127 	ed := util.Describe("add moderation log")
    128 	t := time.Now()
    129 	// we have a recipient
    130 	var err error
    131 	if recipientid > 0 {
    132 		insert := `INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`
    133 		_, err = d.Exec(insert, actingid, recipientid, action, t)
    134 	} else {
    135 		// we are not listing a recipient
    136 		insert := `INSERT INTO moderation_log (actingid, action, time) VALUES (?, ?, ?)`
    137 		_, err = d.Exec(insert, actingid, action, t)
    138 	}
    139 	if err = ed.Eout(err, "exec prepared statement"); err != nil {
    140 		return err
    141 	}
    142 	return nil
    143 }
    144 
    145 type ModerationEntry struct {
    146 	ActingUsername, RecipientUsername, QuorumUsername string
    147 	QuorumDecision                                    bool
    148 	Action                                            int
    149 	Time                                              time.Time
    150 }
    151 
    152 func (d DB) GetModerationLogs() []ModerationEntry {
    153 	ed := util.Describe("moderation log")
    154 	query := `SELECT uact.name, urecp.name, uquorum.name, q.decision, m.action, m.time 
    155 	FROM moderation_LOG m 
    156 
    157 	LEFT JOIN users uact ON uact.id = m.actingid
    158 	LEFT JOIN users urecp ON urecp.id = m.recipientid
    159 
    160 	LEFT JOIN quorum_decisions q ON q.modlogid = m.id
    161 	LEFT JOIN users uquorum ON uquorum.id = q.userid
    162 
    163 	ORDER BY time DESC`
    164 
    165 	stmt, err := d.db.Prepare(query)
    166 	defer stmt.Close()
    167 	ed.Check(err, "prep stmt")
    168 
    169 	rows, err := stmt.Query()
    170 	defer rows.Close()
    171 	util.Check(err, "run query")
    172 
    173 	var logs []ModerationEntry
    174 	for rows.Next() {
    175 		var entry ModerationEntry
    176 		var actingUsername, recipientUsername, quorumUsername sql.NullString
    177 		var quorumDecision sql.NullBool
    178 		if err := rows.Scan(&actingUsername, &recipientUsername, &quorumUsername, &quorumDecision, &entry.Action, &entry.Time); err != nil {
    179 			ed.Check(err, "scanning loop")
    180 		}
    181 		if actingUsername.Valid {
    182 			entry.ActingUsername = actingUsername.String
    183 		}
    184 		if recipientUsername.Valid {
    185 			entry.RecipientUsername = recipientUsername.String
    186 		}
    187 		if quorumUsername.Valid {
    188 			entry.QuorumUsername = quorumUsername.String
    189 		}
    190 		if quorumDecision.Valid {
    191 			entry.QuorumDecision = quorumDecision.Bool
    192 		}
    193 		logs = append(logs, entry)
    194 	}
    195 	return logs
    196 }
    197 
    198 func (d DB) ProposeModerationAction(proposerid, recipientid, action int) (finalErr error) {
    199 	ed := util.Describe("propose mod action")
    200 
    201 	t := time.Now()
    202 	tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
    203 	ed.Check(err, "open transaction")
    204 
    205 	rollbackOnErr := func(incomingErr error) bool {
    206 		if incomingErr != nil {
    207 			_ = tx.Rollback()
    208 			log.Println(incomingErr, "rolling back")
    209 			finalErr = incomingErr
    210 			return true
    211 		}
    212 		return false
    213 	}
    214 
    215 	// start tx
    216 	propRecipientId := -1
    217 	// there should only be one pending proposal of each type for any given recipient
    218 	// so let's check to make sure that's true!
    219 	stmt, err := tx.Prepare("SELECT recipientid FROM moderation_proposals WHERE action = ?")
    220 	defer stmt.Close()
    221 	err = stmt.QueryRow(action).Scan(&propRecipientId)
    222 	if err == nil && propRecipientId != -1 {
    223 		finalErr = tx.Commit()
    224 		return
    225 	}
    226 	// there was no pending proposal of the proposed action for recipient - onwards!
    227 
    228 	// add the proposal
    229 	stmt, err = tx.Prepare("INSERT INTO moderation_proposals (proposerid, recipientid, time, action) VALUES (?, ?, ?, ?)")
    230 	defer stmt.Close()
    231 	if rollbackOnErr(ed.Eout(err, "prepare proposal stmt")) {
    232 		return
    233 	}
    234 	_, err = stmt.Exec(proposerid, recipientid, t, action)
    235 	if rollbackOnErr(ed.Eout(err, "insert into proposals table")) {
    236 		return
    237 	}
    238 
    239 	// TODO (2023-12-18): hmm how do we do this properly now? only have one constant per action
    240 	// {demote, make admin, remove user} but vary translations for these three depending on if there is also a decision or not?
    241 
    242 	// add moderation log that user x proposed action y for recipient z
    243 	stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
    244 	defer stmt.Close()
    245 	if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) {
    246 		return
    247 	}
    248 	_, err = stmt.Exec(proposerid, recipientid, action, t)
    249 	if rollbackOnErr(ed.Eout(err, "insert into modlog")) {
    250 		return
    251 	}
    252 
    253 	err = tx.Commit()
    254 	ed.Check(err, "commit transaction")
    255 	return
    256 }
    257 
    258 type ModProposal struct {
    259 	ActingUsername, RecipientUsername string
    260 	ActingID, RecipientID             int
    261 	ProposalID, Action                int
    262 	Time                              time.Time
    263 }
    264 
    265 func (d DB) GetProposedActions() []ModProposal {
    266 	ed := util.Describe("get moderation proposals")
    267 	stmt, err := d.db.Prepare(`SELECT mp.id, proposerid, up.name, recipientid, ur.name, action, mp.time 
    268 	FROM moderation_proposals mp
    269 	INNER JOIN users up on mp.proposerid = up.id 
    270 	INNER JOIN users ur on mp.recipientid = ur.id 
    271 	ORDER BY time DESC
    272 	;`)
    273 	defer stmt.Close()
    274 	ed.Check(err, "prepare stmt")
    275 	rows, err := stmt.Query()
    276 	ed.Check(err, "perform query")
    277 	defer rows.Close()
    278 	var proposals []ModProposal
    279 	for rows.Next() {
    280 		var prop ModProposal
    281 		if err = rows.Scan(&prop.ProposalID, &prop.ActingID, &prop.ActingUsername, &prop.RecipientID, &prop.RecipientUsername, &prop.Action, &prop.Time); err != nil {
    282 			ed.Check(err, "error scanning in row data")
    283 		}
    284 		proposals = append(proposals, prop)
    285 	}
    286 	return proposals
    287 }
    288 
    289 // finalize a proposal by either confirming or vetoing it, logging the requisite information and then finally executing
    290 // the proposed action itself
    291 func (d DB) FinalizeProposedAction(proposalid, adminid int, decision bool) (finalErr error) {
    292 	ed := util.Describe("finalize proposed mod action")
    293 
    294 	t := time.Now()
    295 	tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
    296 	ed.Check(err, "open transaction")
    297 
    298 	rollbackOnErr := func(incomingErr error) bool {
    299 		if incomingErr != nil {
    300 			_ = tx.Rollback()
    301 			log.Println(incomingErr, "rolling back")
    302 			finalErr = incomingErr
    303 			return true
    304 		}
    305 		return false
    306 	}
    307 
    308 	/* start tx */
    309 	// make sure the proposal is still there (i.e. nobody has beat us to acting on it yet)
    310 	stmt, err := tx.Prepare("SELECT 1 FROM moderation_proposals WHERE id = ?")
    311 	defer stmt.Close()
    312 	if rollbackOnErr(ed.Eout(err, "prepare proposal existence stmt")) {
    313 		return
    314 	}
    315 	existence := -1
    316 	err = stmt.QueryRow(proposalid).Scan(&existence)
    317 	// proposal id did not exist (it was probably already acted on!)
    318 	if err != nil {
    319 		_ = tx.Commit()
    320 		return
    321 	}
    322 	// retrieve the proposal & populate with our dramatis personae
    323 	var proposerid, recipientid, proposalAction int
    324 	var proposalDate time.Time
    325 	stmt, err = tx.Prepare(`SELECT proposerid, recipientid, action, time from moderation_proposals WHERE id = ?`)
    326 	defer stmt.Close()
    327 	err = stmt.QueryRow(proposalid).Scan(&proposerid, &recipientid, &proposalAction, &proposalDate)
    328 	if rollbackOnErr(ed.Eout(err, "retrieve proposal vals")) {
    329 		return
    330 	}
    331 
    332 	isSelfConfirm := proposerid == adminid
    333 	timeSelfConfirmOK := proposalDate.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT)
    334 	// TODO (2024-01-07): render err message in admin view?
    335 	// self confirms are not allowed at this point in time, exit early without performing any changes
    336 	if isSelfConfirm && (decision == constants.PROPOSAL_CONFIRM && !time.Now().After(timeSelfConfirmOK)) {
    337 		err = tx.Commit()
    338 		ed.Check(err, "commit transaction")
    339 		finalErr = nil
    340 		return
    341 	}
    342 
    343 	// convert proposed action (semantically different for the sake of logs) from the finalized action
    344 	var action int
    345 	switch proposalAction {
    346 	case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
    347 		action = constants.MODLOG_ADMIN_DEMOTE
    348 	case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
    349 		action = constants.MODLOG_REMOVE_USER
    350 	case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
    351 		action = constants.MODLOG_ADMIN_MAKE
    352 	default:
    353 		ed.Check(errors.New("unknown proposal action"), "convertin proposalAction into action")
    354 	}
    355 
    356 	// remove proposal from proposal table as it has been executed as desired
    357 	stmt, err = tx.Prepare("DELETE FROM moderation_proposals WHERE id = ?")
    358 	defer stmt.Close()
    359 	if rollbackOnErr(ed.Eout(err, "prepare proposal removal stmt")) {
    360 		return
    361 	}
    362 	_, err = stmt.Exec(proposalid)
    363 	if rollbackOnErr(ed.Eout(err, "remove proposal from table")) {
    364 		return
    365 	}
    366 
    367 	// add moderation log
    368 	stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
    369 	defer stmt.Close()
    370 	if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) {
    371 		return
    372 	}
    373 	// the admin who proposed the action will be logged as the one performing it
    374 	// get the modlog so we can reference it in the quorum_decisions table. this will be used to augment the moderation
    375 	// log view with quorum info
    376 	result, err := stmt.Exec(proposerid, recipientid, action, t)
    377 	if rollbackOnErr(ed.Eout(err, "insert into modlog")) {
    378 		return
    379 	}
    380 	modlogid, err := result.LastInsertId()
    381 	if rollbackOnErr(ed.Eout(err, "get last insert id")) {
    382 		return
    383 	}
    384 
    385 	// update the quorum decisions table so that we can use its info to augment the moderation log view
    386 	stmt, err = tx.Prepare(`INSERT INTO quorum_decisions (userid, decision, modlogid) VALUES (?, ?, ?)`)
    387 	defer stmt.Close()
    388 	if rollbackOnErr(ed.Eout(err, "prepare quorum insertion stmt")) {
    389 		return
    390 	}
    391 	// decision = confirm or veto => values true or false
    392 	_, err = stmt.Exec(adminid, decision, modlogid)
    393 	if rollbackOnErr(ed.Eout(err, "execute quorum insertion")) {
    394 		return
    395 	}
    396 
    397 	err = tx.Commit()
    398 	ed.Check(err, "commit transaction")
    399 
    400 	// the decision was to veto the proposal: there's nothing more to do! except return outta this function ofc ofc
    401 	if decision == constants.PROPOSAL_VETO {
    402 		return
    403 	}
    404 	// perform the actual action; would be preferable to do this in the transaction somehow
    405 	// but hell no am i copying in those bits here X)
    406 	switch proposalAction {
    407 	case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
    408 		err = d.DemoteAdmin(recipientid)
    409 		ed.Check(err, "remove user", recipientid)
    410 	case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
    411 		err = d.RemoveUser(recipientid)
    412 		ed.Check(err, "remove user", recipientid)
    413 	case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
    414 		d.AddAdmin(recipientid)
    415 		ed.Check(err, "add admin", recipientid)
    416 	}
    417 	return
    418 }
    419 
    420 type User struct {
    421 	Name string
    422 	ID   int
    423 }
    424 
    425 func (d DB) AddAdmin(userid int) error {
    426 	ed := util.Describe("add admin")
    427 	// make sure the id exists
    428 	exists, err := d.CheckUserExists(userid)
    429 	if !exists {
    430 		return errors.New(fmt.Sprintf("add admin: userid %d did not exist", userid))
    431 	}
    432 	if err != nil {
    433 		return ed.Eout(err, "CheckUserExists had an error")
    434 	}
    435 	isAdminAlready, err := d.IsUserAdmin(userid)
    436 	if isAdminAlready {
    437 		return errors.New(fmt.Sprintf("userid %d was already an admin", userid))
    438 	}
    439 	if err != nil {
    440 		// some kind of error, let's bubble it up
    441 		return ed.Eout(err, "IsUserAdmin")
    442 	}
    443 	// insert into table, we gots ourselves a new sheriff in town [|:D
    444 	stmt := `INSERT INTO admins (id) VALUES (?)`
    445 	_, err = d.db.Exec(stmt, userid)
    446 	if err != nil {
    447 		return ed.Eout(err, "inserting new admin")
    448 	}
    449 	return nil
    450 }
    451 
    452 func (d DB) DemoteAdmin(userid int) error {
    453 	ed := util.Describe("demote admin")
    454 	// make sure the id exists
    455 	exists, err := d.CheckUserExists(userid)
    456 	if !exists {
    457 		return errors.New(fmt.Sprintf("demote admin: userid %d did not exist", userid))
    458 	}
    459 	if err != nil {
    460 		return ed.Eout(err, "CheckUserExists had an error")
    461 	}
    462 	isAdmin, err := d.IsUserAdmin(userid)
    463 	if !isAdmin {
    464 		return errors.New(fmt.Sprintf("demote admin: userid %d was not an admin", userid))
    465 	}
    466 	if err != nil {
    467 		// some kind of error, let's bubble it up
    468 		return ed.Eout(err, "IsUserAdmin")
    469 	}
    470 	// all checks are done: perform the removal
    471 	stmt := `DELETE FROM admins WHERE id = ?`
    472 	_, err = d.db.Exec(stmt, userid)
    473 	if err != nil {
    474 		return ed.Eout(err, "inserting new admin")
    475 	}
    476 	return nil
    477 }
    478 
    479 func (d DB) IsUserAdmin(userid int) (bool, error) {
    480 	stmt := `SELECT 1 FROM admins WHERE id = ?`
    481 	return d.existsQuery(stmt, userid)
    482 }
    483 
    484 func (d DB) QuorumActivated() bool {
    485 	admins := d.GetAdmins()
    486 	return len(admins) >= 2
    487 }
    488 
    489 func (d DB) GetAdmins() []User {
    490 	ed := util.Describe("get admins")
    491 	query := `SELECT u.name, a.id 
    492   FROM users u 
    493   INNER JOIN admins a ON u.id = a.id 
    494   ORDER BY u.name
    495   `
    496 	stmt, err := d.db.Prepare(query)
    497 	defer stmt.Close()
    498 	ed.Check(err, "prep stmt")
    499 
    500 	rows, err := stmt.Query()
    501 	defer rows.Close()
    502 	util.Check(err, "run query")
    503 
    504 	var user User
    505 	var admins []User
    506 	for rows.Next() {
    507 		if err := rows.Scan(&user.Name, &user.ID); err != nil {
    508 			ed.Check(err, "scanning loop")
    509 		}
    510 		admins = append(admins, user)
    511 	}
    512 	return admins
    513 }