moderation.go (15737B)
1 package server 2 3 import ( 4 "fmt" 5 "html/template" 6 "net/http" 7 "strconv" 8 "time" 9 10 "cerca/constants" 11 "cerca/crypto" 12 "cerca/database" 13 "cerca/i18n" 14 "cerca/util" 15 ) 16 17 type AdminData struct { 18 Admins []database.User 19 Users []database.User 20 Proposals []PendingProposal 21 IsAdmin bool 22 } 23 24 type ModerationData struct { 25 Log []string 26 } 27 28 type PendingProposal struct { 29 // ID is the id of the proposal 30 ID, ProposerID int 31 Action string 32 Time time.Time // the time self-confirmations become possible for proposers 33 TimePassed bool // self-confirmations valid or not 34 } 35 36 func (h RequestHandler) displayErr(res http.ResponseWriter, req *http.Request, err error, title string) { 37 errMsg := util.Eout(err, fmt.Sprintf("%s failed", title)) 38 fmt.Println(errMsg) 39 data := GenericMessageData{ 40 Title: title, 41 Message: errMsg.Error(), 42 } 43 h.renderGenericMessage(res, req, data) 44 } 45 46 func (h RequestHandler) displaySuccess(res http.ResponseWriter, req *http.Request, title, message, backRoute string) { 47 data := GenericMessageData{ 48 Title: title, 49 Message: message, 50 LinkText: h.translator.Translate("GoBack"), 51 Link: backRoute, 52 } 53 h.renderGenericMessage(res, req, data) 54 } 55 56 // TODO (2023-12-10): any vulns with this approach? could a user forge a session cookie with the user id of an admin? 57 func (h RequestHandler) IsAdmin(req *http.Request) (bool, int) { 58 ed := util.Describe("IsAdmin") 59 userid, err := h.session.Get(req) 60 err = ed.Eout(err, "getting userid from session cookie") 61 if err != nil { 62 dump(err) 63 return false, -1 64 } 65 66 // make sure the user from the cookie actually exists 67 userExists, err := h.db.CheckUserExists(userid) 68 if err != nil { 69 dump(ed.Eout(err, "check userid in db")) 70 return false, -1 71 } else if !userExists { 72 return false, -1 73 } 74 // make sure the user id is actually an admin 75 userIsAdmin, err := h.db.IsUserAdmin(userid) 76 if err != nil { 77 dump(ed.Eout(err, "IsUserAdmin in db")) 78 return false, -1 79 } else if !userIsAdmin { 80 return false, -1 81 } 82 return true, userid 83 } 84 85 // there is a 2-quorum (requires 2 admins to take effect) imposed for the following actions, which are regarded as 86 // consequential: 87 // * make admin 88 // * remove account 89 // * demote admin 90 91 // note: there is only a 2-quorum constraint imposed if there are actually 2 admins. an admin may also confirm their own 92 // proposal if constants.PROPOSAL_SELF_CONFIRMATION_WAIT seconds have passed (1 week) 93 func performQuorumCheck(ed util.ErrorDescriber, db *database.DB, adminUserId, targetUserId, proposedAction int) error { 94 // checks if a quorum is necessary for the proposed action: if a quorum constarin is in effect, a proposal is created 95 // otherwise (if no quorum threshold has been achieved) the action is taken directly 96 quorumActivated := db.QuorumActivated() 97 98 var err error 99 var modlogErr error 100 if quorumActivated { 101 err = db.ProposeModerationAction(adminUserId, targetUserId, proposedAction) 102 } else { 103 switch proposedAction { 104 case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 105 err = db.RemoveUser(targetUserId) 106 modlogErr = db.AddModerationLog(adminUserId, -1, constants.MODLOG_REMOVE_USER) 107 case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 108 err = db.AddAdmin(targetUserId) 109 modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_MAKE) 110 case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 111 err = db.DemoteAdmin(targetUserId) 112 modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_DEMOTE) 113 } 114 } 115 if modlogErr != nil { 116 fmt.Println(ed.Eout(err, "error adding moderation log")) 117 } 118 if err != nil { 119 return err 120 } 121 return nil 122 } 123 124 func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request, targetUserId int) { 125 ed := util.Describe("Admin remove user") 126 loggedIn, _ := h.IsLoggedIn(req) 127 isAdmin, adminUserId := h.IsAdmin(req) 128 129 if req.Method == "GET" || !loggedIn || !isAdmin { 130 IndexRedirect(res, req) 131 return 132 } 133 134 err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER) 135 136 if err != nil { 137 h.displayErr(res, req, err, "User removal") 138 return 139 } 140 141 // success! redirect back to /admin 142 http.Redirect(res, req, "/admin", http.StatusFound) 143 } 144 145 func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserId int) { 146 ed := util.Describe("make user admin") 147 loggedIn, _ := h.IsLoggedIn(req) 148 isAdmin, adminUserId := h.IsAdmin(req) 149 if req.Method == "GET" || !loggedIn || !isAdmin { 150 IndexRedirect(res, req) 151 return 152 } 153 154 title := h.translator.Translate("AdminMakeAdmin") 155 156 err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN) 157 158 if err != nil { 159 h.displayErr(res, req, err, title) 160 return 161 } 162 163 if !h.db.QuorumActivated() { 164 username, _ := h.db.GetUsername(targetUserId) 165 message := fmt.Sprintf("User %s is now a fellow admin user!", username) 166 h.displaySuccess(res, req, title, message, "/admin") 167 } else { 168 // redirect to admin view, which should have a proposal now 169 http.Redirect(res, req, "/admin", http.StatusFound) 170 } 171 } 172 173 func (h *RequestHandler) AdminDemoteAdmin(res http.ResponseWriter, req *http.Request) { 174 ed := util.Describe("demote admin route") 175 loggedIn, _ := h.IsLoggedIn(req) 176 isAdmin, adminUserId := h.IsAdmin(req) 177 178 if req.Method == "GET" || !loggedIn || !isAdmin { 179 IndexRedirect(res, req) 180 return 181 } 182 183 title := h.translator.Translate("AdminDemote") 184 185 useridString := req.PostFormValue("userid") 186 targetUserId, err := strconv.Atoi(useridString) 187 util.Check(err, "convert user id string to a plain userid") 188 189 err = performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN) 190 191 if err != nil { 192 h.displayErr(res, req, err, title) 193 return 194 } 195 196 if !h.db.QuorumActivated() { 197 username, _ := h.db.GetUsername(targetUserId) 198 message := fmt.Sprintf("User %s is now a regular user", username) 199 // output copy-pastable credentials page for admin to send to the user 200 h.displaySuccess(res, req, title, message, "/admin") 201 } else { 202 http.Redirect(res, req, "/admin", http.StatusFound) 203 } 204 } 205 206 func (h *RequestHandler) AdminManualAddUserRoute(res http.ResponseWriter, req *http.Request) { 207 ed := util.Describe("admin manually add user") 208 loggedIn, _ := h.IsLoggedIn(req) 209 isAdmin, adminUserId := h.IsAdmin(req) 210 211 if !isAdmin { 212 IndexRedirect(res, req) 213 return 214 } 215 216 type AddUser struct { 217 ErrorMessage string 218 } 219 220 var data AddUser 221 view := TemplateData{Title: h.translator.Translate("AdminAddNewUser"), Data: &data, HasRSS: false, IsAdmin: isAdmin, LoggedIn: loggedIn} 222 223 if req.Method == "GET" { 224 h.renderView(res, "admin-add-user", view) 225 return 226 } 227 228 if req.Method == "POST" && isAdmin { 229 username := req.PostFormValue("username") 230 231 // do a lil quick checky check to see if we already have that username registered, 232 // and if we do re-render the page with an error 233 existed, err := h.db.CheckUsernameExists(username) 234 ed.Check(err, "check username exists") 235 236 if existed { 237 data.ErrorMessage = fmt.Sprintf("Username (%s) is already registered", username) 238 h.renderView(res, "admin-add-user", view) 239 return 240 } 241 242 // set up basic credentials 243 newPassword := crypto.GeneratePassword() 244 passwordHash, err := crypto.HashPassword(newPassword) 245 ed.Check(err, "hash password") 246 targetUserId, err := h.db.CreateUser(username, passwordHash) 247 ed.Check(err, "create new user %s", username) 248 249 err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_ADD_USER) 250 if err != nil { 251 fmt.Println(ed.Eout(err, "error adding moderation log")) 252 } 253 254 title := h.translator.Translate("AdminAddNewUser") 255 message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) 256 h.displaySuccess(res, req, title, message, "/add-user") 257 } 258 } 259 260 func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserId int) { 261 ed := util.Describe("admin reset password") 262 loggedIn, _ := h.IsLoggedIn(req) 263 isAdmin, adminUserId := h.IsAdmin(req) 264 if req.Method == "GET" || !loggedIn || !isAdmin { 265 IndexRedirect(res, req) 266 return 267 } 268 269 title := util.Capitalize(h.translator.Translate("PasswordReset")) 270 newPassword, err := h.db.ResetPassword(targetUserId) 271 272 if err != nil { 273 h.displayErr(res, req, err, title) 274 return 275 } 276 277 err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_RESETPW) 278 if err != nil { 279 fmt.Println(ed.Eout(err, "error adding moderation log")) 280 } 281 282 username, _ := h.db.GetUsername(targetUserId) 283 284 message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) 285 h.displaySuccess(res, req, title, message, "/admin") 286 } 287 288 func (h *RequestHandler) ConfirmProposal(res http.ResponseWriter, req *http.Request) { 289 h.HandleProposal(res, req, constants.PROPOSAL_CONFIRM) 290 } 291 292 func (h *RequestHandler) VetoProposal(res http.ResponseWriter, req *http.Request) { 293 h.HandleProposal(res, req, constants.PROPOSAL_VETO) 294 } 295 296 func (h *RequestHandler) HandleProposal(res http.ResponseWriter, req *http.Request, decision bool) { 297 ed := util.Describe("handle proposal proposal") 298 isAdmin, adminUserId := h.IsAdmin(req) 299 300 if !isAdmin { 301 IndexRedirect(res, req) 302 return 303 } 304 305 if req.Method == "POST" { 306 proposalidString := req.PostFormValue("proposalid") 307 proposalid, err := strconv.Atoi(proposalidString) 308 ed.Check(err, "convert proposalid") 309 err = h.db.FinalizeProposedAction(proposalid, adminUserId, decision) 310 if err != nil { 311 ed.Eout(err, "finalizing the proposed action returned early with an error") 312 } 313 http.Redirect(res, req, "/admin", http.StatusFound) 314 return 315 } 316 IndexRedirect(res, req) 317 } 318 319 // Note: this route by definition contains user generated content, so we escape all usernames with 320 // html.EscapeString(username) 321 func (h *RequestHandler) ModerationLogRoute(res http.ResponseWriter, req *http.Request) { 322 loggedIn, _ := h.IsLoggedIn(req) 323 isAdmin, _ := h.IsAdmin(req) 324 // logs are sorted by time descending, from latest entry to oldest 325 logs := h.db.GetModerationLogs() 326 viewData := ModerationData{Log: make([]string, 0)} 327 328 type translationData struct { 329 Time, ActingUsername, RecipientUsername string 330 Action template.HTML 331 } 332 333 for _, entry := range logs { 334 var tdata translationData 335 var translationString string 336 tdata.Time = entry.Time.Format("2006-01-02 15:04:05") 337 tdata.ActingUsername = template.HTMLEscapeString(entry.ActingUsername) 338 tdata.RecipientUsername = template.HTMLEscapeString(entry.RecipientUsername) 339 switch entry.Action { 340 case constants.MODLOG_RESETPW: 341 translationString = "modlogResetPassword" 342 if isAdmin { 343 translationString += "Admin" 344 } 345 case constants.MODLOG_ADMIN_MAKE: 346 translationString = "modlogMakeAdmin" 347 case constants.MODLOG_REMOVE_USER: 348 translationString = "modlogRemoveUser" 349 case constants.MODLOG_ADMIN_ADD_USER: 350 translationString = "modlogAddUser" 351 if isAdmin { 352 translationString += "Admin" 353 } 354 case constants.MODLOG_ADMIN_DEMOTE: 355 translationString = "modlogDemoteAdmin" 356 case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 357 translationString = "modlogProposalDemoteAdmin" 358 case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 359 translationString = "modlogProposalMakeAdmin" 360 case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 361 translationString = "modlogProposalRemoveUser" 362 } 363 364 actionString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: tdata}) 365 366 /* rendering of decision (confirm/veto) taken on a pending proposal */ 367 if entry.QuorumUsername != "" { 368 // use the translated actionString to embed in the translated proposal decision (confirmation/veto) 369 propdata := translationData{ActingUsername: template.HTMLEscapeString(entry.QuorumUsername), Action: template.HTML(actionString)} 370 // if quorumDecision is true -> proposal was confirmed 371 translationString = "modlogConfirm" 372 if !entry.QuorumDecision { 373 translationString = "modlogVeto" 374 } 375 proposalString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: propdata}) 376 viewData.Log = append(viewData.Log, proposalString) 377 /* rendering of "X proposed: <Y>" */ 378 } else if entry.Action == constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN || 379 entry.Action == constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN || 380 entry.Action == constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER { 381 propXforY := translationData{Time: tdata.Time, ActingUsername: tdata.ActingUsername, Action: template.HTML(actionString)} 382 proposalString := h.translator.TranslateWithData("modlogXProposedY", i18n.TranslationData{Data: propXforY}) 383 viewData.Log = append(viewData.Log, proposalString) 384 } else { 385 viewData.Log = append(viewData.Log, actionString) 386 } 387 } 388 view := TemplateData{Title: h.translator.Translate("ModerationLog"), IsAdmin: isAdmin, LoggedIn: loggedIn, Data: viewData} 389 h.renderView(res, "moderation-log", view) 390 } 391 392 // used for rendering /admin's pending proposals 393 func (h *RequestHandler) AdminRoute(res http.ResponseWriter, req *http.Request) { 394 loggedIn, userid := h.IsLoggedIn(req) 395 isAdmin, _ := h.IsAdmin(req) 396 397 if req.Method == "POST" && loggedIn && isAdmin { 398 action := req.PostFormValue("admin-action") 399 useridString := req.PostFormValue("userid") 400 targetUserId, err := strconv.Atoi(useridString) 401 util.Check(err, "convert user id string to a plain userid") 402 403 switch action { 404 case "reset-password": 405 h.AdminResetUserPassword(res, req, targetUserId) 406 case "make-admin": 407 h.AdminMakeUserAdmin(res, req, targetUserId) 408 case "remove-account": 409 h.AdminRemoveUser(res, req, targetUserId) 410 } 411 return 412 } 413 414 if req.Method == "GET" { 415 if !loggedIn || !isAdmin { 416 // non-admin users get a different view 417 h.ListAdmins(res, req) 418 return 419 } 420 admins := h.db.GetAdmins() 421 normalUsers := h.db.GetUsers(false) // do not include admins 422 proposedActions := h.db.GetProposedActions() 423 // massage pending proposals into something we can use in the rendered view 424 pendingProposals := make([]PendingProposal, len(proposedActions)) 425 now := time.Now() 426 for i, prop := range proposedActions { 427 // escape all ugc 428 prop.ActingUsername = template.HTMLEscapeString(prop.ActingUsername) 429 prop.RecipientUsername = template.HTMLEscapeString(prop.RecipientUsername) 430 // one week from when the proposal was made 431 t := prop.Time.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT) 432 var str string 433 switch prop.Action { 434 case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 435 str = "modlogProposalDemoteAdmin" 436 case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 437 str = "modlogProposalMakeAdmin" 438 case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 439 str = "modlogProposalRemoveUser" 440 } 441 442 proposalString := h.translator.TranslateWithData(str, i18n.TranslationData{Data: prop}) 443 pendingProposals[i] = PendingProposal{ID: prop.ProposalID, ProposerID: prop.ActingID, Action: proposalString, Time: t, TimePassed: now.After(t)} 444 } 445 data := AdminData{Admins: admins, Users: normalUsers, Proposals: pendingProposals} 446 view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: false, LoggedIn: loggedIn, LoggedInID: userid} 447 h.renderView(res, "admin", view) 448 } 449 } 450 451 // view of /admin for non-admin users (contains less information) 452 func (h *RequestHandler) ListAdmins(res http.ResponseWriter, req *http.Request) { 453 loggedIn, _ := h.IsLoggedIn(req) 454 admins := h.db.GetAdmins() 455 data := AdminData{Admins: admins} 456 view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: false, LoggedIn: loggedIn} 457 h.renderView(res, "admins-list", view) 458 return 459 }