/* 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.sessionManager.Put(r.Context(), "flash", 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.sessionManager.Put( r.Context(), "flash", 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.sessionManager.Put( r.Context(), "flash", fmt.Sprintf("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.sessionManager.Put( r.Context(), "flash", 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.sessionManager.Put( r.Context(), "flash", 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) submitUserRoles(_ http.ResponseWriter, _ *http.Request) { // TODO: implement submitUserRoles panic("not implemented") } 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 { return } var form forms.EditUserForm err := app.decodePostForm(r, &form) if 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.sessionManager.Put( r.Context(), "flash", fmt.Sprintf("User %s has been modified.", userToEdit.Name), ) http.Redirect(w, r, "/users/", http.StatusSeeOther) } func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) { // TODO: implement userAddEmailForm panic("not implemented") } func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Request) { // TODO: implement userAddEmailSubmit panic("not implemented") } 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 userDeleteEmailSubmit 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.sessionManager.Put( r.Context(), "flash", 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) }