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 (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 }