/* Copyright 2017-2022 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "encoding/json" "fmt" "net/http" "strings" "time" "github.com/julienschmidt/httprouter" "git.cacert.org/cacert-boardvoting/internal/forms" "git.cacert.org/cacert-boardvoting/internal/models" ) func checkRole(v *models.User, roles ...models.RoleName) (bool, error) { hasRole, err := v.HasRole(roles...) if err != nil { return false, fmt.Errorf("could not determine user roles: %w", err) } return hasRole, nil } type topLevelNavItem string type subLevelNavItem string const ( topLevelNavMotions topLevelNavItem = "motions" topLevelNavUsers topLevelNavItem = "users" subLevelNavMotionsAll subLevelNavItem = "all-motions" subLevelNavMotionsUnvoted subLevelNavItem = "unvoted-motions" subLevelNavUsers subLevelNavItem = "users" ) func (m *templateData) motionPaginationOptions(limit int, first, last *time.Time) error { motions := m.Motions if len(motions) == limit && first.Before(motions[len(motions)-1].Proposed) { marshalled, err := motions[len(motions)-1].Proposed.MarshalText() if err != nil { return fmt.Errorf("could not serialize timestamp: %w", err) } m.NextPage = string(marshalled) } if len(motions) > 0 && last.After(motions[0].Proposed) { marshalled, err := motions[0].Proposed.MarshalText() if err != nil { return fmt.Errorf("could not serialize timestamp: %w", err) } m.PrevPage = string(marshalled) } return nil } func (app *application) motionList(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/motions/" { app.notFound(w) return } var ( listOptions *models.MotionListOptions err error ) listOptions, err = app.calculateMotionListOptions(r) if err != nil { app.clientError(w, http.StatusBadRequest) return } ctx := r.Context() motions, err := app.motions.List(ctx, listOptions) if err != nil { app.serverError(w, err) return } first, last, err := app.motions.TimestampRange(ctx, listOptions) if err != nil { app.serverError(w, err) return } templateData := app.newTemplateData(r, "motions", "all-motions") if listOptions.UnvotedOnly { templateData.ActiveSubNav = subLevelNavMotionsUnvoted } templateData.Motions = motions err = templateData.motionPaginationOptions(listOptions.Limit, first, last) if err != nil { app.serverError(w, err) return } app.render(w, http.StatusOK, "motions.html", templateData) } func (app *application) calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { const ( queryParamBefore = "before" queryParamAfter = "after" queryParamUnvoted = "unvoted" motionsPerPage = 10 ) listOptions := &models.MotionListOptions{Limit: motionsPerPage} if r.URL.Query().Has(queryParamAfter) { var after time.Time err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter))) if err != nil { return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) } listOptions.After = &after } else if r.URL.Query().Has(queryParamBefore) { var before time.Time err := before.UnmarshalText([]byte(r.URL.Query().Get(queryParamBefore))) if err != nil { return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) } listOptions.Before = &before } if r.URL.Query().Has(queryParamUnvoted) { listOptions.UnvotedOnly = true voter, err := app.GetUser(r) if err != nil { return nil, fmt.Errorf("could not get voter: %w", err) } if voter != nil { listOptions.VoterID = voter.ID } } return listOptions, nil } func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion app.render(w, http.StatusOK, "motion.html", data) } func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Form = &forms.EditMotionForm{ Type: models.VoteTypeMotion, } app.render(w, http.StatusOK, "create_motion.html", data) } const hoursInDay = 24 func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { var form forms.EditMotionForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.Validate() if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Form = form app.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) return } user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) return } now := time.Now().UTC() dueDuration := time.Duration(form.Due) * hoursInDay * time.Hour decisionID, err := app.motions.Create( r.Context(), user, form.Type, form.Title, form.Content, now, now.Add(dueDuration), ) if err != nil { app.serverError(w, err) return } decision, err := app.motions.ByID(r.Context(), decisionID) if err != nil { app.serverError(w, err) return } app.mailNotifier.Notify(&NewDecisionNotification{ Decision: decision, Proposer: user, }) app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.addFlash(r, &FlashMessage{ Variant: flashSuccess, Title: "New motion started", Message: fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title), }) http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion data.Form = &forms.EditMotionForm{ Title: motion.Title, Content: motion.Content, Type: motion.Type, } app.render(w, http.StatusOK, "edit_motion.html", data) } func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } var form forms.EditMotionForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.Validate() if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Form = form app.render(w, http.StatusUnprocessableEntity, "edit_motion.html", data) return } form.Normalize() user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) return } now := time.Now().UTC() dueDuration := time.Duration(form.Due) * hoursInDay * time.Hour err = app.motions.Update( r.Context(), motion.ID, func(m *models.Motion) { m.Type = form.Type m.Title = strings.TrimSpace(form.Title) m.Content = strings.TrimSpace(form.Content) m.Due = now.Add(dueDuration) }, ) if err != nil { app.serverError(w, err) return } decision, err := app.motions.ByID(r.Context(), motion.ID) if err != nil { app.serverError(w, err) return } app.mailNotifier.Notify(&UpdateDecisionNotification{ Decision: decision, User: user, }) app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.addFlash(r, &FlashMessage{ Variant: flashInfo, Title: "Motion modified", Message: fmt.Sprintf("The motion %s has been modified!", decision.Tag), }) http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) withdrawMotionForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { app.notFound(w) return } data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion app.render(w, http.StatusOK, "withdraw_motion.html", data) } func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { app.notFound(w) return } user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) return } err = app.motions.Withdraw(r.Context(), motion.ID) if err != nil { app.serverError(w, err) return } app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user}) app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.addFlash(r, &FlashMessage{ Variant: flashWarning, Title: "Motion withdrawn", Message: fmt.Sprintf("The motion %s has been withdrawn!", motion.Tag), }) http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) voteForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { app.notFound(w) return } choice := app.choiceFromRequestParam(w, params) if choice == nil { app.notFound(w) return } data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion data.Form = &forms.DirectVoteForm{ Choice: choice, } app.render(w, http.StatusOK, "direct_vote.html", data) } func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } choice := app.choiceFromRequestParam(w, params) if choice == nil { return } user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) return } clientCert, err := getPEMClientCert(r) if err != nil { app.serverError(w, err) return } if err := app.motions.UpdateVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { v.Vote = choice v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) }); err != nil { app.serverError(w, err) return } app.mailNotifier.Notify(&DirectVoteNotification{ Decision: motion, User: user, Choice: choice, }) app.addFlash(r, &FlashMessage{ Variant: flashSuccess, Title: "Vote registered", Message: fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag), }) http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion potentialVoters, err := app.users.Voters(r.Context()) if err != nil { app.serverError(w, err) return } data.Form = &forms.ProxyVoteForm{ Voters: potentialVoters, } app.render(w, http.StatusOK, "proxy_vote.html", data) } func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) motion := app.motionFromRequestParam(w, r, params) if motion == nil { return } var form forms.ProxyVoteForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.Validate() if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion potentialVoters, err := app.users.Voters(r.Context()) if err != nil { app.serverError(w, err) return } form.Voters = potentialVoters data.Form = form app.render(w, http.StatusUnprocessableEntity, "proxy_vote.html", data) return } form.Normalize() user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) return } clientCert, err := getPEMClientCert(r) if err != nil { app.serverError(w, err) return } voter := app.getVoter(w, r, form.Voter.ID) if voter == nil { return } if err := app.motions.UpdateVote(r.Context(), form.Voter.ID, motion.ID, func(v *models.Vote) { v.Vote = form.Choice v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert) }); err != nil { app.serverError(w, err) return } app.mailNotifier.Notify(&ProxyVoteNotification{ Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification, }) app.addFlash(r, &FlashMessage{ Variant: flashSuccess, Title: "Proxy vote registered", Message: fmt.Sprintf( "Your proxy vote for %s for motion %s has been registered.", voter.Name, motion.Tag, ), }) http.Redirect(w, r, "/motions/", http.StatusSeeOther) } func (app *application) userList(w http.ResponseWriter, r *http.Request) { data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) users, err := app.users.List( r.Context(), app.users.WithRoles(), app.users.CanDelete(), ) if err != nil { app.serverError(w, err) return } data.Users = users app.render(w, http.StatusOK, "users.html", data) } func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses()) if userToEdit == nil { return } roles, err := userToEdit.Roles() if err != nil { app.serverError(w, err) return } emailAddresses, err := userToEdit.EmailAddresses() if err != nil { app.serverError(w, err) return } roleNames := make([]string, len(roles)) for i := range roles { roleNames[i] = roles[i].Name } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) data.Form = &forms.EditUserForm{ User: userToEdit, Name: userToEdit.Name, MailAddresses: emailAddresses, AllRoles: models.AllRoles, ReminderMail: userToEdit.Reminder.String, Roles: roleNames, } app.render(w, http.StatusOK, "edit_user.html", data) } func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses()) if userToEdit == nil { app.notFound(w) return } var form forms.EditUserForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.User = userToEdit form.AllRoles = models.AllRoles if err := form.Validate(); err != nil { app.serverError(w, err) return } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) if !form.Valid() { roles, err := userToEdit.Roles() if err != nil { app.serverError(w, err) return } emailAddresses, err := userToEdit.EmailAddresses() if err != nil { app.serverError(w, err) return } roleNames := make([]string, len(roles)) for i := range roles { roleNames[i] = roles[i].Name } form.MailAddresses = emailAddresses form.Roles = roleNames data.Form = &form app.render(w, http.StatusUnprocessableEntity, "edit_user.html", data) return } form.Normalize() form.UpdateUser(userToEdit) if err := app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { app.serverError(w, err) return } app.addFlash(r, &FlashMessage{ Variant: flashInfo, Title: "User modified", Message: fmt.Sprintf("User %s has been modified.", userToEdit.Name), }) http.Redirect(w, r, "/users/", http.StatusSeeOther) } func (app *application) userAddEmailForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) userToEdit := app.userFromRequestParam(w, r, params, app.users.WithEmailAddresses()) if userToEdit == nil { app.notFound(w) return } emailAddresses, err := userToEdit.EmailAddresses() if err != nil { app.serverError(w, err) return } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) data.Form = &forms.AddEmailForm{ User: userToEdit, EmailAddresses: emailAddresses, } app.render(w, http.StatusOK, "add_email.html", data) } func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) userToEdit := app.userFromRequestParam(w, r, params, app.users.WithEmailAddresses()) if userToEdit == nil { app.notFound(w) return } var form forms.AddEmailForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.User = userToEdit form.Validate() data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) if form.Valid() { emailExists, err := app.users.EmailExists(r.Context(), form.EmailAddress) if err != nil { app.serverError(w, err) return } if emailExists { form.FieldErrors = map[string]string{ "email_address": "Email address must be unique", } } } if !form.Valid() { emailAddresses, err := userToEdit.EmailAddresses() if err != nil { app.serverError(w, err) return } form.EmailAddresses = emailAddresses data.Form = &form app.render(w, http.StatusUnprocessableEntity, "add_email.html", data) return } form.Normalize() if err := app.users.AddEmail(r.Context(), userToEdit, data.User, form.EmailAddress, form.Reasoning); err != nil { app.serverError(w, err) return } app.addFlash(r, &FlashMessage{ Variant: flashSuccess, Title: "Email address added", Message: fmt.Sprintf( "Added email address %s for user %s", form.EmailAddress, userToEdit.Name, ), }) http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) } func (app *application) userDeleteEmailForm(_ http.ResponseWriter, _ *http.Request) { // TODO: implement userDeleteEmailForm panic("not implemented") } func (app *application) userDeleteEmailSubmit(_ http.ResponseWriter, _ *http.Request) { // TODO: implement userDeleteEmailSubmit panic("not implemented") } func (app *application) newUserForm(_ http.ResponseWriter, _ *http.Request) { // TODO: implement newUserForm panic("not implemented") } func (app *application) newUserSubmit(_ http.ResponseWriter, _ *http.Request) { // TODO: implement newUserSubmit panic("not implemented") } func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) { userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete()) if userToDelete == nil { return } if !userToDelete.CanDelete() { app.notFound(w) return } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) data.Form = &forms.DeleteUserForm{ User: userToDelete, } app.render(w, http.StatusOK, "delete_user.html", data) } func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) { userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete()) if userToDelete == nil { return } if !userToDelete.CanDelete() { app.notFound(w) return } var form forms.DeleteUserForm err := app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } form.User = userToDelete form.Validate() data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) if !form.Valid() { data.Form = &form app.render(w, http.StatusUnprocessableEntity, "delete_user.html", data) return } form.Normalize() if err = app.users.DeleteUser(r.Context(), userToDelete, data.User, form.Reasoning); err != nil { app.serverError(w, err) return } app.addFlash(r, &FlashMessage{ Variant: flashWarning, Title: "User deleted", Message: fmt.Sprintf("User %s has been deleted.", userToDelete.Name), }) http.Redirect(w, r, "/users/", http.StatusSeeOther) } func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { const ( ok = "OK" failed = "FAILED" ) response := struct { DB string `json:"database"` Mail string `json:"mail"` }{DB: ok, Mail: ok} enc := json.NewEncoder(w) w.Header().Set("Content-Type", "application/json") w.Header().Set("Refresh", "10") w.Header().Add("Cache-Control", "no-store") var ( err error hasErrors = false ) if err = app.mailNotifier.Ping(); err != nil { hasErrors = true response.Mail = failed } if err = app.motions.DB.Ping(); err != nil { hasErrors = true response.DB = failed } if hasErrors { w.WriteHeader(http.StatusInternalServerError) } else { w.WriteHeader(http.StatusOK) } _ = enc.Encode(response) }