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 }