From 4e72beb4339aeb2af7eaca97464b2260c8c19bbc Mon Sep 17 00:00:00 2001 From: Vicente Ferrari Smith Date: Tue, 12 May 2026 12:34:49 +0200 Subject: [PATCH] better code --- .vscode/launch.json | 2 +- cmd/party/api/api.go | 378 +------------------------------- cmd/party/api/device_tokens.go | 10 +- cmd/party/api/errors.go | 145 ++++++++++++ cmd/party/api/healthcheck.go | 6 +- cmd/party/api/issues.go | 28 +-- cmd/party/api/middleware.go | 22 +- cmd/party/api/mps.go | 4 +- cmd/party/api/parlvotedetail.go | 4 +- cmd/party/api/parlvotes.go | 3 +- cmd/party/api/tokens.go | 7 +- cmd/party/api/users.go | 54 ++--- cmd/party/api/votes.go | 5 +- cmd/party/common/application.go | 30 +++ cmd/party/common/handler.go | 103 --------- cmd/party/common/helpers.go | 85 +++---- cmd/party/common/users.go | 3 +- cmd/party/handlers.go | 87 -------- cmd/party/routes.go | 100 ++++----- cmd/party/web/errors.go | 153 +++++++++++++ cmd/party/web/home.go | 2 +- cmd/party/web/issues.go | 24 +- cmd/party/web/middleware.go | 56 +++-- cmd/party/web/mps.go | 3 +- cmd/party/web/parlvotedetail.go | 8 +- cmd/party/web/parlvotes.go | 3 +- cmd/party/web/templates.go | 6 +- cmd/party/web/users.go | 16 +- cmd/party/web/votes.go | 2 +- cmd/party/web/web.go | 49 ++--- cmd/party/web_socket.go | 56 +++++ web/html/error.page.tmpl | 11 + web/html/forbidden.page.tmpl | 10 + web/html/issue.page.tmpl | 2 +- web/html/issues.page.tmpl | 10 +- 35 files changed, 675 insertions(+), 812 deletions(-) create mode 100644 cmd/party/api/errors.go delete mode 100644 cmd/party/common/handler.go delete mode 100644 cmd/party/handlers.go create mode 100644 cmd/party/web/errors.go create mode 100644 cmd/party/web_socket.go create mode 100644 web/html/error.page.tmpl create mode 100644 web/html/forbidden.page.tmpl diff --git a/.vscode/launch.json b/.vscode/launch.json index 266cb0f..ef49b52 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "mode": "auto", "program": "${workspaceFolder}/cmd/party", "cwd": "${workspaceFolder}", - "envFile": "${workspaceFolder}/.env", + "envFile": "${workspaceFolder}/.envrc", "args": ["--env=development"] } ] diff --git a/cmd/party/api/api.go b/cmd/party/api/api.go index 8762317..c64da75 100644 --- a/cmd/party/api/api.go +++ b/cmd/party/api/api.go @@ -1,7 +1,6 @@ package api import( - "context" "errors" "net/http" "strings" @@ -9,42 +8,19 @@ import( "party.at/party/cmd/party/common" "party.at/party/internal/data" "party.at/party/internal/validator" - - - "encoding/json" - "fmt" - "io" - "net/url" - "strconv" ) type Api struct { App *common.Application } -type contextKey string - -const userKey contextKey = "user" - -func SetUser(r *http.Request, user *data.User) *http.Request { - return r.WithContext(context.WithValue(r.Context(), userKey, user)) -} - -func GetUser(r *http.Request) *data.User { - user, ok := r.Context().Value(userKey).(*data.User) - if !ok { - panic("missing user value in request context") - } - return user -} - func (api *Api) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Vary", "Authorization") authorizationHeader := r.Header.Get("Authorization") if authorizationHeader == "" { - r = SetUser(r, data.AnonymousUser) + r = common.SetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } @@ -84,357 +60,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler { return } - r = SetUser(r, user) + r = common.SetUser(r, user) next.ServeHTTP(w, r) }) } - -type envelope map[string]interface{} - -type apiError struct { - Code common.ErrorCode `json:"code,omitempty"` - Message string `json:"message"` - Details map[string]string `json:"details,omitempty"` -} - - - -// ── JSON helpers ───────────────────────────────────────────────────────────── - -func (api *Api) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { - js, err := json.MarshalIndent(data, "", "\t") - if err != nil { - return err - } - js = append(js, '\n') - for key, value := range headers { - w.Header()[key] = value - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - w.Write(js) - return nil -} - -func (api *Api) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { - r.Body = http.MaxBytesReader(w, r.Body, 1048576) - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - - err := dec.Decode(dst) - if err != nil { - var syntaxError *json.SyntaxError - var unmarshalTypeError *json.UnmarshalTypeError - var invalidUnmarshalError *json.InvalidUnmarshalError - - switch { - case errors.As(err, &syntaxError): - return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) - case errors.Is(err, io.ErrUnexpectedEOF): - return errors.New("body contains badly-formed JSON") - case errors.As(err, &unmarshalTypeError): - if unmarshalTypeError.Field != "" { - return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) - } - return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) - case errors.Is(err, io.EOF): - return errors.New("body must not be empty") - case strings.HasPrefix(err.Error(), "json: unknown field "): - fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") - return fmt.Errorf("body contains unknown key %s", fieldName) - case err.Error() == "http: request body too large": - return fmt.Errorf("body must not be larger than 1048576 bytes") - case errors.As(err, &invalidUnmarshalError): - panic(err) - default: - return err - } - } - - err = dec.Decode(&struct{}{}) - if err != io.EOF { - return errors.New("body must only contain a single JSON value") - } - return nil -} - -func (api *Api) readIDParam(r *http.Request) (int64, error) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil || id < 1 { - return 0, errors.New("invalid id parameter") - } - return id, nil -} - -func (api *Api) readString(qs url.Values, key, defaultValue string) string { - if s := qs.Get(key); s != "" { - return s - } - return defaultValue -} - -func (api *Api) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { - s := qs.Get(key) - if s == "" { - return defaultValue - } - i, err := strconv.Atoi(s) - if err != nil { - v.AddError(key, "must be an integer value") - return defaultValue - } - return i -} - -// ── Error responses ────────────────────────────────────────────────────────── - -func (api *Api) LogError(r *http.Request, err error) { - api.App.Logger.PrintError(err, map[string]string{ - "request_method": r.Method, - "request_url": r.URL.String(), - }) -} - -func (api *Api) errorResponse(w http.ResponseWriter, r *http.Request, status int, ae apiError) { - if err := common.WriteJSON(w, status, common.Envelope{"error": ae}, nil); err != nil { - api.App.LogError(r, err) - w.WriteHeader(500) - } -} - -func (api *Api) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) { - api.App.LogError(r, err) - api.errorResponse(w, r, http.StatusInternalServerError, apiError{ - Message: "the server encountered a problem and could not process your request", - }) -} - -func (api *Api) NotFoundResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusNotFound, apiError{ - Message: "the requested resource could not be found", - }) -} - -func (api *Api) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusMethodNotAllowed, apiError{ - Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method), - }) -} - -func (api *Api) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) { - api.errorResponse(w, r, http.StatusBadRequest, apiError{Message: err.Error()}) -} - -func (api *Api) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { - api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ - Code: common.ErrCodeValidationFailed, - Message: "validation failed", - Details: errors, - }) -} - -func (api *Api) EditConflictResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusConflict, apiError{ - Code: common.ErrCodeEditConflict, - Message: "unable to update the record due to an edit conflict, please try again", - }) -} - -func (api *Api) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusUnauthorized, apiError{ - Code: common.ErrCodeInvalidCredentials, - Message: "invalid authentication credentials", - }) -} - -func (api *Api) InvalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { - w.Header().Set("WWW-Authenticate", "Bearer") - api.errorResponse(w, r, http.StatusUnauthorized, apiError{ - Code: common.ErrCodeInvalidAuthToken, - Message: "invalid or missing authentication token", - }) -} - -func (api *Api) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusTooManyRequests, apiError{ - Message: "rate limit exceeded", - }) -} - -func (api *Api) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusUnauthorized, apiError{ - Code: common.ErrCodeAuthRequired, - Message: "you must be authenticated to access this resource", - }) -} - -func (api *Api) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusForbidden, apiError{ - Code: common.ErrCodeInactiveAccount, - Message: "your user account must be activated to access this resource", - }) -} - -func (api *Api) NotPermittedResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusForbidden, apiError{ - Code: common.ErrCodeNotPermitted, - Message: "your user account doesn't have the necessary permissions to access this resource", - }) -} - -func (api *Api) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusConflict, apiError{ - Code: common.ErrCodeAlreadyVoted, - Message: "your user account already voted for this issue", - }) -} - -func (api *Api) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusConflict, apiError{ - Code: common.ErrCodeAlreadyBlindSigned, - Message: "already requested a blind signature for this issue", - }) -} - -func (api *Api) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ - Code: common.ErrCodeBlindedVoteRange, - Message: "blinded_vote is out of valid range", - }) -} - -func (api *Api) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ - Code: common.ErrCodeInvalidSignature, - Message: "invalid signature", - }) -} - -func (api *Api) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) { - api.errorResponse(w, r, http.StatusConflict, apiError{ - Code: common.ErrCodeVoteAlreadyCast, - Message: "this vote has already been cast", - }) -} - - - - - - - - -// func (app *Application) errorResponse(w http.ResponseWriter, r *http.Request, status int, ae apiError) { -// if err := WriteJSON(w, status, Envelope{"error": ae}, nil); err != nil { -// app.LogError(r, err) -// w.WriteHeader(500) -// } -// } - -// func (app *Application) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) { -// app.LogError(r, err) -// app.errorResponse(w, r, http.StatusInternalServerError, apiError{ -// Message: "the server encountered a problem and could not process your request", -// }) -// } - -// func (app *Application) NotFoundResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusNotFound, apiError{ -// Message: "the requested resource could not be found", -// }) -// } - -// func (app *Application) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusMethodNotAllowed, apiError{ -// Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method), -// }) -// } - -// func (app *Application) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) { -// app.errorResponse(w, r, http.StatusBadRequest, apiError{Message: err.Error()}) -// } - -// func (app *Application) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { -// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ -// Code: ErrCodeValidationFailed, -// Message: "validation failed", -// Details: errors, -// }) -// } - -// func (app *Application) EditConflictResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusConflict, apiError{ -// Code: ErrCodeEditConflict, -// Message: "unable to update the record due to an edit conflict, please try again", -// }) -// } - -// func (app *Application) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusUnauthorized, apiError{ -// Code: ErrCodeInvalidCredentials, -// Message: "invalid authentication credentials", -// }) -// } - -// func (app *Application) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusTooManyRequests, apiError{ -// Message: "rate limit exceeded", -// }) -// } - -// func (app *Application) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusUnauthorized, apiError{ -// Code: ErrCodeAuthRequired, -// Message: "you must be authenticated to access this resource", -// }) -// } - -// func (app *Application) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusForbidden, apiError{ -// Code: ErrCodeInactiveAccount, -// Message: "your user account must be activated to access this resource", -// }) -// } - -// func (app *Application) NotPermittedResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusForbidden, apiError{ -// Code: ErrCodeNotPermitted, -// Message: "your user account doesn't have the necessary permissions to access this resource", -// }) -// } - -// func (app *Application) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusConflict, apiError{ -// Code: ErrCodeAlreadyVoted, -// Message: "your user account already voted for this issue", -// }) -// } - -// func (app *Application) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusConflict, apiError{ -// Code: ErrCodeAlreadyBlindSigned, -// Message: "already requested a blind signature for this issue", -// }) -// } - -// func (app *Application) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ -// Code: ErrCodeBlindedVoteRange, -// Message: "blinded_vote is out of valid range", -// }) -// } - -// func (app *Application) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ -// Code: ErrCodeInvalidSignature, -// Message: "invalid signature", -// }) -// } - -// func (app *Application) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) { -// app.errorResponse(w, r, http.StatusConflict, apiError{ -// Code: ErrCodeVoteAlreadyCast, -// Message: "this vote has already been cast", -// }) -// } - diff --git a/cmd/party/api/device_tokens.go b/cmd/party/api/device_tokens.go index 0eabf1d..5ec821f 100644 --- a/cmd/party/api/device_tokens.go +++ b/cmd/party/api/device_tokens.go @@ -4,10 +4,12 @@ import ( "errors" "fmt" "net/http" + + "party.at/party/cmd/party/common" ) func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { - user := GetUser(r) + user := common.GetUser(r) api.App.Logger.PrintInfo("register device token: request", map[string]string{ "user_id": fmt.Sprint(user.ID), @@ -16,7 +18,7 @@ func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { var input struct { Token string `json:"token"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.App.Logger.PrintInfo("register device token: bad request", map[string]string{ "user_id": fmt.Sprint(user.ID), "error": err.Error(), @@ -47,12 +49,12 @@ func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { } func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) { - user := GetUser(r) + user := common.GetUser(r) var input struct { Token string `json:"token"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } diff --git a/cmd/party/api/errors.go b/cmd/party/api/errors.go new file mode 100644 index 0000000..b7f2a4d --- /dev/null +++ b/cmd/party/api/errors.go @@ -0,0 +1,145 @@ +package api + +import( + "fmt" + "net/http" + + "party.at/party/cmd/party/common" +) + +// ── Error responses ────────────────────────────────────────────────────────── + +type apiError struct { + Code common.ErrorCode `json:"code,omitempty"` + Message string `json:"message"` + Details map[string]string `json:"details,omitempty"` +} + +func (api *Api) LogError(r *http.Request, err error) { + api.App.Logger.PrintError(err, map[string]string{ + "request_method": r.Method, + "request_url": r.URL.String(), + }) +} + +func (api *Api) errorResponse(w http.ResponseWriter, r *http.Request, status int, ae apiError) { + if err := common.WriteJSON(w, status, common.Envelope{"error": ae}, nil); err != nil { + api.App.LogError(r, err) + w.WriteHeader(500) + } +} + +func (api *Api) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + api.App.LogError(r, err) + api.errorResponse(w, r, http.StatusInternalServerError, apiError{ + Message: "the server encountered a problem and could not process your request", + }) +} + +func (api *Api) NotFoundResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusNotFound, apiError{ + Message: "the requested resource could not be found", + }) +} + +func (api *Api) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusMethodNotAllowed, apiError{ + Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method), + }) +} + +func (api *Api) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + api.errorResponse(w, r, http.StatusBadRequest, apiError{Message: err.Error()}) +} + +func (api *Api) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ + Code: common.ErrCodeValidationFailed, + Message: "validation failed", + Details: errors, + }) +} + +func (api *Api) EditConflictResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusConflict, apiError{ + Code: common.ErrCodeEditConflict, + Message: "unable to update the record due to an edit conflict, please try again", + }) +} + +func (api *Api) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusUnauthorized, apiError{ + Code: common.ErrCodeInvalidCredentials, + Message: "invalid authentication credentials", + }) +} + +func (api *Api) InvalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + api.errorResponse(w, r, http.StatusUnauthorized, apiError{ + Code: common.ErrCodeInvalidAuthToken, + Message: "invalid or missing authentication token", + }) +} + +func (api *Api) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusTooManyRequests, apiError{ + Message: "rate limit exceeded", + }) +} + +func (api *Api) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusUnauthorized, apiError{ + Code: common.ErrCodeAuthRequired, + Message: "you must be authenticated to access this resource", + }) +} + +func (api *Api) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusForbidden, apiError{ + Code: common.ErrCodeInactiveAccount, + Message: "your user account must be activated to access this resource", + }) +} + +func (api *Api) NotPermittedResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusForbidden, apiError{ + Code: common.ErrCodeNotPermitted, + Message: "your user account doesn't have the necessary permissions to access this resource", + }) +} + +func (api *Api) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusConflict, apiError{ + Code: common.ErrCodeAlreadyVoted, + Message: "your user account already voted for this issue", + }) +} + +func (api *Api) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusConflict, apiError{ + Code: common.ErrCodeAlreadyBlindSigned, + Message: "already requested a blind signature for this issue", + }) +} + +func (api *Api) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ + Code: common.ErrCodeBlindedVoteRange, + Message: "blinded_vote is out of valid range", + }) +} + +func (api *Api) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{ + Code: common.ErrCodeInvalidSignature, + Message: "invalid signature", + }) +} + +func (api *Api) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) { + api.errorResponse(w, r, http.StatusConflict, apiError{ + Code: common.ErrCodeVoteAlreadyCast, + Message: "this vote has already been cast", + }) +} diff --git a/cmd/party/api/healthcheck.go b/cmd/party/api/healthcheck.go index 3902635..1b92614 100644 --- a/cmd/party/api/healthcheck.go +++ b/cmd/party/api/healthcheck.go @@ -1,7 +1,9 @@ package api -import "net/http" -import "party.at/party/cmd/party/common" +import ( + "net/http" + "party.at/party/cmd/party/common" +) func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) { env := common.Envelope{ diff --git a/cmd/party/api/issues.go b/cmd/party/api/issues.go index ddc5c6c..991cc34 100644 --- a/cmd/party/api/issues.go +++ b/cmd/party/api/issues.go @@ -31,13 +31,13 @@ func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) { return } - issues, metadata, err := api.App.FetchIssues(input.Title, input.Filters, GetUser(r)) + issues, metadata, err := api.App.FetchIssues(input.Title, input.Filters, common.GetUser(r)) if err != nil { api.ServerErrorResponse(w, r, err) return } - if err = api.writeJSON(w, http.StatusOK, envelope{"issues": issues, "metadata": metadata}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issues": issues, "metadata": metadata}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -51,7 +51,7 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) { Options []string `json:"options"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -69,7 +69,7 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) { headers := make(http.Header) headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID)) - if err = api.writeJSON(w, http.StatusCreated, envelope{"issue": issue, "options": options}, headers); err != nil { + if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"issue": issue, "options": options}, headers); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -81,7 +81,7 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) { return } - result, err := api.App.GetIssue(id, GetUser(r)) + result, err := api.App.GetIssue(id, common.GetUser(r)) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { api.NotFoundResponse(w, r) @@ -91,7 +91,7 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"issue": result.IssueDetail, "options": result.Options}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issue": result.IssueDetail, "options": result.Options}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -110,7 +110,7 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) { EndTime *time.Time `json:"end_time"` } - if err = api.readJSON(w, r, &input); err != nil { + if err = common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -131,7 +131,7 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issue": issue}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -152,7 +152,7 @@ func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"message": "issue successfully deleted"}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "issue successfully deleted"}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -174,13 +174,13 @@ func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"public_key": pubKey}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"public_key": pubKey}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { - id, err := api.readIDParam(r) + id, err := common.ReadIDParam(r) if err != nil { api.NotFoundResponse(w, r) return @@ -189,12 +189,12 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { var input struct { BlindedVote []byte `json:"blinded_vote"` } - if err = api.readJSON(w, r, &input); err != nil { + if err = common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } - signed, err := api.App.BlindSign(id, input.BlindedVote, GetUser(r)) + signed, err := api.App.BlindSign(id, input.BlindedVote, common.GetUser(r)) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): @@ -209,7 +209,7 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"signed": signed}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"signed": signed}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/api/middleware.go b/cmd/party/api/middleware.go index acd889b..7b961af 100644 --- a/cmd/party/api/middleware.go +++ b/cmd/party/api/middleware.go @@ -1,12 +1,14 @@ package api -import "net/http" -import "fmt" -// import "party.at/party/cmd/party/common" -import "time" -import "sync" -import "golang.org/x/time/rate" -import "net" +import ( + "net/http" + "fmt" + "party.at/party/cmd/party/common" + "time" + "sync" + "golang.org/x/time/rate" + "net" +) func (api *Api) RecoverPanic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -22,7 +24,7 @@ func (api *Api) RecoverPanic(next http.Handler) http.Handler { func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if GetUser(r).IsAnonymous() { + if common.GetUser(r).IsAnonymous() { api.AuthenticationRequiredResponse(w, r) return } @@ -32,7 +34,7 @@ func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc { fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !GetUser(r).Activated { + if !common.GetUser(r).Activated { api.InactiveAccountResponse(w, r) return } @@ -43,7 +45,7 @@ func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc { func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc { fn := func(w http.ResponseWriter, r *http.Request) { - permissions, err := api.App.Models.Permissions.GetAllForUser(GetUser(r).ID) + permissions, err := api.App.Models.Permissions.GetAllForUser(common.GetUser(r).ID) if err != nil { api.ServerErrorResponse(w, r, err) return diff --git a/cmd/party/api/mps.go b/cmd/party/api/mps.go index e4c707d..ef43d1a 100644 --- a/cmd/party/api/mps.go +++ b/cmd/party/api/mps.go @@ -3,6 +3,8 @@ package api import ( "net/http" "sort" + + "party.at/party/cmd/party/common" ) func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) { @@ -19,7 +21,7 @@ func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) { return members[i].LastName < members[j].LastName }) - if err := api.writeJSON(w, http.StatusOK, envelope{ + if err := common.WriteJSON(w, http.StatusOK, common.Envelope{ "members": members, "total": len(members), }, nil); err != nil { diff --git a/cmd/party/api/parlvotedetail.go b/cmd/party/api/parlvotedetail.go index 79e40a4..5c9683b 100644 --- a/cmd/party/api/parlvotedetail.go +++ b/cmd/party/api/parlvotedetail.go @@ -4,6 +4,8 @@ import ( "errors" "net/http" "strings" + + "party.at/party/cmd/party/common" ) func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) { @@ -26,7 +28,7 @@ func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) { return } - if err := api.writeJSON(w, http.StatusOK, envelope{"vote": detail}, nil); err != nil { + if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"vote": detail}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/api/parlvotes.go b/cmd/party/api/parlvotes.go index 3d67d4f..b8c0559 100644 --- a/cmd/party/api/parlvotes.go +++ b/cmd/party/api/parlvotes.go @@ -7,6 +7,7 @@ import ( "time" "party.at/party/cmd/party/parlament" + "party.at/party/cmd/party/common" ) type apiPartyStat struct { @@ -95,7 +96,7 @@ func (api *Api) ListParlVotes(w http.ResponseWriter, r *http.Request) { return partyStats[i].Code < partyStats[j].Code }) - if err := api.writeJSON(w, http.StatusOK, envelope{ + if err := common.WriteJSON(w, http.StatusOK, common.Envelope{ "documents": docs, "total": len(docs), "total_with_votes": totalWithVotes, diff --git a/cmd/party/api/tokens.go b/cmd/party/api/tokens.go index d8c55cd..e4e6ea8 100644 --- a/cmd/party/api/tokens.go +++ b/cmd/party/api/tokens.go @@ -7,6 +7,7 @@ import ( "party.at/party/internal/data" "party.at/party/internal/validator" + "party.at/party/cmd/party/common" ) func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request) { @@ -15,7 +16,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request Password string `json:"password"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -39,7 +40,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request return } - if err = api.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"authentication_token": token}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -64,7 +65,7 @@ func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request return } - if err := api.writeJSON(w, http.StatusOK, envelope{"message": "authentication token successfully deleted"}, nil); err != nil { + if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "authentication token successfully deleted"}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/api/users.go b/cmd/party/api/users.go index 7424b09..3283901 100644 --- a/cmd/party/api/users.go +++ b/cmd/party/api/users.go @@ -19,9 +19,9 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) { v := validator.New() qs := r.URL.Query() - input.Filters.Page = api.readInt(qs, "page", 1, v) - input.Filters.PageSize = api.readInt(qs, "page_size", 20, v) - input.Filters.Sort = api.readString(qs, "sort", "id") + input.Filters.Page = common.ReadInt(qs, "page", 1, v) + input.Filters.PageSize = common.ReadInt(qs, "page_size", 20, v) + input.Filters.Sort = common.ReadString(qs, "sort", "id") input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"} if data.ValidateFilters(v, input.Filters); !v.Valid() { @@ -35,7 +35,7 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"users": users, "metadata": metadata}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"users": users, "metadata": metadata}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -54,7 +54,7 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) { Address string `json:"address"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -90,7 +90,7 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusCreated, envelope{"user": user, "authentication_token": authToken}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"user": user, "authentication_token": authToken}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -100,7 +100,7 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { param := r.PathValue("id") if param == "me" { - id = GetUser(r).ID + id = common.GetUser(r).ID } else { var err error id, err = strconv.ParseInt(param, 10, 64) @@ -110,7 +110,7 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { } } - if GetUser(r).ID != id { + if common.GetUser(r).ID != id { api.errorResponse(w, r, http.StatusForbidden, apiError{ Code: common.ErrCodeNotPermitted, Message: "your user account doesn't have the necessary permissions to access this resource", @@ -128,29 +128,29 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } -func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) { - user, err := api.App.GetUser(GetUser(r).ID) - if err != nil { - if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) - } else { - api.ServerErrorResponse(w, r, err) - } - return - } +// func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) { +// user := common.GetUser(r).ID +// // if err != nil { +// // if errors.Is(err, data.ErrRecordNotFound) { +// // api.NotFoundResponse(w, r) +// // } else { +// // api.ServerErrorResponse(w, r, err) +// // } +// // return +// // } - if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { - api.ServerErrorResponse(w, r, err) - } -} +// if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil { +// api.ServerErrorResponse(w, r, err) +// } +// } func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) { - id, err := api.readIDParam(r) + id, err := common.ReadIDParam(r) if err != nil { api.NotFoundResponse(w, r) return @@ -165,7 +165,7 @@ func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"message": "user successfully deleted"}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "user successfully deleted"}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } @@ -175,7 +175,7 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) { TokenPlaintext string `json:"token"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -200,7 +200,7 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) { return } - if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/api/votes.go b/cmd/party/api/votes.go index 17924dc..97d85ff 100644 --- a/cmd/party/api/votes.go +++ b/cmd/party/api/votes.go @@ -5,6 +5,7 @@ import ( "net/http" "party.at/party/internal/data" + "party.at/party/cmd/party/common" ) func (api *Api) Vote(w http.ResponseWriter, r *http.Request) { @@ -15,7 +16,7 @@ func (api *Api) Vote(w http.ResponseWriter, r *http.Request) { Signature []byte `json:"signature"` } - if err := api.readJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { api.BadRequestResponse(w, r, err) return } @@ -34,7 +35,7 @@ func (api *Api) Vote(w http.ResponseWriter, r *http.Request) { return } - if err := api.writeJSON(w, http.StatusCreated, envelope{"message": "vote successfully cast"}, nil); err != nil { + if err := common.WriteJSON(w, http.StatusCreated, common.Envelope{"message": "vote successfully cast"}, nil); err != nil { api.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/common/application.go b/cmd/party/common/application.go index 2bc2ad1..8ea742a 100644 --- a/cmd/party/common/application.go +++ b/cmd/party/common/application.go @@ -2,6 +2,8 @@ package common import ( "fmt" + "context" + "net/http" apns "github.com/sideshow/apns2" "party.at/party/cmd/party/parlament" @@ -81,3 +83,31 @@ func (app *Application) background(fn func()) { }() } +type contextKey string + +const userKey contextKey = "web_user" +const permissionsKey contextKey = "web_permissions" + +func SetUser(r *http.Request, user *data.User) *http.Request { + return r.WithContext(context.WithValue(r.Context(), userKey, user)) +} + +func GetUser(r *http.Request) *data.User { + user, ok := r.Context().Value(userKey).(*data.User) + if !ok { + return data.AnonymousUser + } + return user +} + +func SetPermissions(r *http.Request, permissions data.Permissions) *http.Request { + return r.WithContext(context.WithValue(r.Context(), permissionsKey, permissions)) +} + +func GetPermissions(r *http.Request) data.Permissions { + permissions, ok := r.Context().Value(permissionsKey).(data.Permissions) + if !ok { + return data.Permissions{} + } + return permissions +} diff --git a/cmd/party/common/handler.go b/cmd/party/common/handler.go deleted file mode 100644 index 24a2c5f..0000000 --- a/cmd/party/common/handler.go +++ /dev/null @@ -1,103 +0,0 @@ -package common - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - - "party.at/party/internal/validator" -) - -type envelope map[string]interface{} - -type apiError struct { - Code ErrorCode `json:"code,omitempty"` - Message string `json:"message"` - Details map[string]string `json:"details,omitempty"` -} - -// ── JSON helpers ───────────────────────────────────────────────────────────── - -func (app *Application) ReadJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { - r.Body = http.MaxBytesReader(w, r.Body, 1048576) - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - - err := dec.Decode(dst) - if err != nil { - var syntaxError *json.SyntaxError - var unmarshalTypeError *json.UnmarshalTypeError - var invalidUnmarshalError *json.InvalidUnmarshalError - - switch { - case errors.As(err, &syntaxError): - return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) - case errors.Is(err, io.ErrUnexpectedEOF): - return errors.New("body contains badly-formed JSON") - case errors.As(err, &unmarshalTypeError): - if unmarshalTypeError.Field != "" { - return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) - } - return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) - case errors.Is(err, io.EOF): - return errors.New("body must not be empty") - case strings.HasPrefix(err.Error(), "json: unknown field "): - fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") - return fmt.Errorf("body contains unknown key %s", fieldName) - case err.Error() == "http: request body too large": - return fmt.Errorf("body must not be larger than 1048576 bytes") - case errors.As(err, &invalidUnmarshalError): - panic(err) - default: - return err - } - } - - err = dec.Decode(&struct{}{}) - if err != io.EOF { - return errors.New("body must only contain a single JSON value") - } - return nil -} - -func ReadIDParam(r *http.Request) (int64, error) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil || id < 1 { - return 0, errors.New("invalid id parameter") - } - return id, nil -} - -func ReadString(qs url.Values, key, defaultValue string) string { - if s := qs.Get(key); s != "" { - return s - } - return defaultValue -} - -func ReadInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { - s := qs.Get(key) - if s == "" { - return defaultValue - } - i, err := strconv.Atoi(s) - if err != nil { - v.AddError(key, "must be an integer value") - return defaultValue - } - return i -} - -// ── Error responses ────────────────────────────────────────────────────────── - -func (app *Application) LogError(r *http.Request, err error) { - app.Logger.PrintError(err, map[string]string{ - "request_method": r.Method, - "request_url": r.URL.String(), - }) -} diff --git a/cmd/party/common/helpers.go b/cmd/party/common/helpers.go index 20a3fb6..04caaad 100644 --- a/cmd/party/common/helpers.go +++ b/cmd/party/common/helpers.go @@ -19,44 +19,6 @@ import ( type Envelope map[string]interface{} -func readString(qs url.Values, key string, defaultValue string) string { - s := qs.Get(key) - if s == "" { - return defaultValue - } - return s -} - -func readCSV(qs url.Values, key string, defaultValue []string) []string { - csv := qs.Get(key) - if csv == "" { - return defaultValue - } - return strings.Split(csv, ",") -} - -func readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { - s := qs.Get(key) - if s == "" { - return defaultValue - } - i, err := strconv.Atoi(s) - if err != nil { - v.AddError(key, "must be an integer value") - return defaultValue - } - return i -} - -// func readIDParam(r *http.Request) (int64, error) { -// params := httprouter.ParamsFromContext(r.Context()) -// id, err := strconv.ParseInt(params.ByName("id"), 10, 64) -// if err != nil || id < 1 { -// return 0, errors.New("invalid id parameter") -// } -// return id, nil -// } - func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.Header) error { js, err := json.MarshalIndent(data, "", "\t") if err != nil { @@ -72,7 +34,7 @@ func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.He return nil } -func readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { +func ReadJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { maxBytes := 1048576 r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) dec := json.NewDecoder(r.Body) @@ -125,3 +87,48 @@ func GenerateIssueKey() ([]byte, int, []byte, error) { }) return key.N.Bytes(), key.E, privPEM, err } + +func ReadIDParam(r *http.Request) (int64, error) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + return 0, errors.New("invalid id parameter") + } + return id, nil +} + +func ReadString(qs url.Values, key, defaultValue string) string { + if s := qs.Get(key); s != "" { + return s + } + return defaultValue +} + +func ReadInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { + s := qs.Get(key) + if s == "" { + return defaultValue + } + i, err := strconv.Atoi(s) + if err != nil { + v.AddError(key, "must be an integer value") + return defaultValue + } + return i +} + +func ReadCSV(qs url.Values, key string, defaultValue []string) []string { + csv := qs.Get(key) + if csv == "" { + return defaultValue + } + return strings.Split(csv, ",") +} + +// ── Error responses ────────────────────────────────────────────────────────── + +func (app *Application) LogError(r *http.Request, err error) { + app.Logger.PrintError(err, map[string]string{ + "request_method": r.Method, + "request_url": r.URL.String(), + }) +} diff --git a/cmd/party/common/users.go b/cmd/party/common/users.go index 17dc693..d503e68 100644 --- a/cmd/party/common/users.go +++ b/cmd/party/common/users.go @@ -54,8 +54,9 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data role := "viewer" if app.Config.Env == "development" { - role = "admin" + role = "" } + if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil { return nil, nil, err } diff --git a/cmd/party/handlers.go b/cmd/party/handlers.go deleted file mode 100644 index 9240e0e..0000000 --- a/cmd/party/handlers.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "time" -) - -func ws(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - fmt.Println("Upgrade error:", err) - return - } - defer conn.Close() - - done := make(chan struct{}) - - go func() { - ticker := time.NewTicker(1 * time.Second) - - for { - select { - case <-done: - return - case t := <-ticker.C: - msg := map[string]interface{}{ - "type": "server_tick", - "timestamp": t.Format(time.RFC3339), - } - - if err := conn.WriteJSON(msg); err != nil { - fmt.Println("Write error:", err) - return - } - } - } - }() - - for { - var msg Message - err := conn.ReadJSON(&msg) - if err != nil { - fmt.Println("Read error:", err) - break - } - fmt.Println(msg) - - // Send a response - // response := fmt.Sprintf("Server time: %s", time.Now()) - // if err := conn.WriteMessage(websocket.TextMessage, []byte(response)); err != nil { - // fmt.Println("Write error:", err) - // break - // } - } -} - -// func handleMobileLogin(w http.ResponseWriter, r *http.Request) { -// // 1. Get the token from the request header -// rawIDToken := r.Header.Get("Authorization") - -// // 2. Initialize the verifier (pointing to ID Austria's keys) -// verifier := provider.Verifier(&oidc.Config{ClientID: "YOUR_APP_ID"}) - -// // 3. Verify the signature and expiration -// idToken, err := verifier.Verify(ctx, rawIDToken) -// if err != nil { -// http.Error(w, "Invalid Token", http.StatusUnauthorized) -// return -// } - -// // 4. Extract User Data -// var claims struct { -// Subject string `json:"sub"` // This is the unique ID -// Name string `json:"name"` -// } -// if err := idToken.Claims(&claims); err != nil { -// // Handle error -// } - -// // 5. Create your own application session for the mobile app -// issueLocalSession(w, claims.Subject) -// } - -func redirectToHTTPS(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://localhost:8443"+r.URL.RequestURI(), http.StatusMovedPermanently) -} diff --git a/cmd/party/routes.go b/cmd/party/routes.go index 15dcffa..e1f83ee 100644 --- a/cmd/party/routes.go +++ b/cmd/party/routes.go @@ -16,39 +16,29 @@ func routes(app *common.Application) http.Handler { } apiMux := http.NewServeMux() - // Note: Standard library doesn't have a simple .NotFound property. - // You handle that by wrapping the final mux in middleware or a custom handler. - apiMux.HandleFunc("GET /v1/healthcheck", api.Healthcheck) - // Path parameters use {id} instead of :id - apiMux.HandleFunc("GET /v1/issues", api.RequirePermission("issues:read", api.ListIssues)) - apiMux.HandleFunc("POST /v1/issues", api.RequirePermission("issues:write", api.CreateIssue)) - apiMux.HandleFunc("GET /v1/issues/{id}", api.RequirePermission("issues:read", api.ReadIssue)) - apiMux.HandleFunc("PATCH /v1/issues/{id}", api.RequirePermission("issues:write", api.UpdateIssue)) - apiMux.HandleFunc("DELETE /v1/issues/{id}", api.RequirePermission("issues:write", api.DeleteIssue)) - apiMux.HandleFunc("GET /v1/issues/{id}/pubkey", api.RequirePermission("issues:read", api.ReadIssuePubKey)) - apiMux.HandleFunc("POST /v1/issues/{id}/blind-sign", api.RequirePermission("issues:read", api.BlindSignIssueVote)) - - apiMux.HandleFunc("POST /v1/votes", api.Vote) - apiMux.HandleFunc("POST /v1/users", api.CreateUser) - apiMux.HandleFunc("GET /v1/users", api.RequirePermission("users:read", api.ListUsers)) - apiMux.HandleFunc("GET /v1/users/{id}", api.RequireAuthenticatedUser(api.ReadUser)) - apiMux.HandleFunc("DELETE /v1/users/{id}", api.DeleteUser) - apiMux.HandleFunc("PUT /v1/users/activated", api.ActivateUser) - - apiMux.HandleFunc("POST /v1/tokens/authentication", api.CreateAuthenticationToken) - apiMux.HandleFunc("DELETE /v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken)) - - apiMux.HandleFunc("POST /v1/device-tokens", api.RequireAuthenticatedUser(api.RegisterDeviceToken)) - apiMux.HandleFunc("DELETE /v1/device-tokens", api.RequireAuthenticatedUser(api.DeleteDeviceToken)) - - apiMux.HandleFunc("GET /v1/mps", api.ListMPs) - apiMux.HandleFunc("GET /v1/parlament/votes", api.ListParlVotes) - - // Wildcards use {name...} + apiMux.HandleFunc("GET /v1/issues", api.RequirePermission("issues:read", api.ListIssues)) + apiMux.HandleFunc("POST /v1/issues", api.RequirePermission("issues:write", api.CreateIssue)) + apiMux.HandleFunc("GET /v1/issues/{id}", api.RequirePermission("issues:read", api.ReadIssue)) + apiMux.HandleFunc("PATCH /v1/issues/{id}", api.RequirePermission("issues:write", api.UpdateIssue)) + apiMux.HandleFunc("DELETE /v1/issues/{id}", api.RequirePermission("issues:write", api.DeleteIssue)) + apiMux.HandleFunc("GET /v1/issues/{id}/pubkey", api.RequirePermission("issues:read", api.ReadIssuePubKey)) + apiMux.HandleFunc("POST /v1/issues/{id}/blind-sign", api.RequirePermission("issues:read", api.BlindSignIssueVote)) + apiMux.HandleFunc("POST /v1/votes", api.Vote) + apiMux.HandleFunc("POST /v1/users", api.CreateUser) + apiMux.HandleFunc("GET /v1/users", api.RequirePermission("users:read", api.ListUsers)) + apiMux.HandleFunc("GET /v1/users/{id}", api.RequireAuthenticatedUser(api.ReadUser)) + apiMux.HandleFunc("DELETE /v1/users/{id}", api.RequirePermission("users:write", api.DeleteUser)) + apiMux.HandleFunc("PUT /v1/users/activated", api.ActivateUser) + apiMux.HandleFunc("POST /v1/tokens/authentication", api.CreateAuthenticationToken) + apiMux.HandleFunc("DELETE /v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken)) + apiMux.HandleFunc("POST /v1/device-tokens", api.RequireAuthenticatedUser(api.RegisterDeviceToken)) + apiMux.HandleFunc("DELETE /v1/device-tokens", api.RequireAuthenticatedUser(api.DeleteDeviceToken)) + apiMux.HandleFunc("GET /v1/mps", api.ListMPs) + apiMux.HandleFunc("GET /v1/parlament/votes", api.ListParlVotes) apiMux.HandleFunc("GET /v1/parlament/votes/{path...}", api.GetParlVoteDetail) - apiMux.Handle("GET /debug/vars", expvar.Handler()) + apiMux.Handle("GET /debug/vars", expvar.Handler()) apiChain := app.Metrics(api.RecoverPanic(app.EnableCORS(api.RateLimit(api.Authenticate(apiMux))))) @@ -58,31 +48,33 @@ func routes(app *common.Application) http.Handler { } webMux := http.NewServeMux() - webMux.HandleFunc("GET /", web.Home) - webMux.HandleFunc("GET /register", web.Register) - webMux.HandleFunc("POST /register", web.RegisterUserPage) - webMux.HandleFunc("GET /issues", web.IssuesPage) - webMux.HandleFunc("POST /issues", web.CreateIssueAction) - webMux.HandleFunc("GET /issues/{id}", web.IssuePage) - webMux.HandleFunc("PATCH /issues/{id}", web.UpdateIssueAction) - webMux.HandleFunc("DELETE /issues/{id}", web.DeleteIssueAction) - webMux.HandleFunc("GET /issues/{id}/pubkey", web.GetIssuePubKey) - webMux.HandleFunc("POST /issues/{id}/blind-sign", web.BlindSignIssue) - webMux.HandleFunc("POST /votes", web.VoteAction) - webMux.HandleFunc("GET /users", web.UsersPage) - webMux.HandleFunc("GET /users/me", web.ProfilePage) - webMux.HandleFunc("GET /users/activated", web.ActivatePage) - webMux.HandleFunc("POST /users/activated", web.ActivateUserAction) - webMux.HandleFunc("DELETE /users/{id}", web.DeleteUserAction) - webMux.HandleFunc("GET /mps", web.MembersOfParliamentPage) - webMux.HandleFunc("GET /parlament", web.ParlVotesPage) - webMux.HandleFunc("GET /parlament/{path...}", web.ParlamentDispatch) - webMux.HandleFunc("POST /login", web.Login) - webMux.HandleFunc("POST /dev-login", web.DevLogin) - webMux.HandleFunc("POST /logout", web.Logout) - webMux.HandleFunc("GET /ws", ws) + webMux.HandleFunc("GET /", web.Home) + webMux.HandleFunc("GET /register", web.Register) + webMux.HandleFunc("POST /register", web.RegisterUserPage) + webMux.HandleFunc("GET /issues", web.RequirePermission("issues:read", web.IssuesPage)) + webMux.HandleFunc("POST /issues", web.RequirePermission("issues:write", web.CreateIssueAction)) + webMux.HandleFunc("GET /issues/{id}", web.RequirePermission("issues:read", web.IssuePage)) + webMux.HandleFunc("PATCH /issues/{id}", web.RequirePermission("issues:write", web.UpdateIssueAction)) + webMux.HandleFunc("DELETE /issues/{id}", web.RequirePermission("issues:write", web.DeleteIssueAction)) + webMux.HandleFunc("GET /issues/{id}/pubkey", web.RequirePermission("issues:read", web.GetIssuePubKey)) + webMux.HandleFunc("POST /issues/{id}/blind-sign", web.RequirePermission("issues:read", web.BlindSignIssueVote)) + webMux.HandleFunc("POST /votes", web.VoteAction) + + webMux.HandleFunc("GET /users", web.RequirePermission("users:read", web.UsersPage)) + webMux.HandleFunc("GET /users/me", web.RequireAuthenticatedUser(web.ProfilePage)) + webMux.HandleFunc("GET /users/activated", web.ActivatePage) + webMux.HandleFunc("POST /users/activated", web.ActivateUserAction) + webMux.HandleFunc("DELETE /users/{id}", web.RequirePermission("users:write", web.DeleteUserAction)) + + webMux.HandleFunc("GET /mps", web.MembersOfParliamentPage) + webMux.HandleFunc("GET /parlament", web.ParlVotesPage) + webMux.HandleFunc("GET /parlament/{path...}", web.ParlamentDispatch) + + webMux.HandleFunc("POST /login", web.Login) + webMux.HandleFunc("POST /dev-login", web.DevLogin) + webMux.HandleFunc("POST /logout", web.Logout) + webMux.HandleFunc("GET /ws", ws) - // Static files in standard library fileServer := http.FileServer(http.Dir("web/static")) webMux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) diff --git a/cmd/party/web/errors.go b/cmd/party/web/errors.go new file mode 100644 index 0000000..204896e --- /dev/null +++ b/cmd/party/web/errors.go @@ -0,0 +1,153 @@ +package web + +import( + "fmt" + "net/http" + + "party.at/party/internal/data" + "party.at/party/cmd/party/common" +) + +// ── Error responses ────────────────────────────────────────────────────────── + +type webError struct { + Code common.ErrorCode `json:"code,omitempty"` + Message string `json:"message"` + Details map[string]string `json:"details,omitempty"` +} + +func (web *Web) LogError(r *http.Request, err error) { + web.App.Logger.PrintError(err, map[string]string{ + "request_method": r.Method, + "request_url": r.URL.String(), + }) +} + +func (web *Web) errorResponse(w http.ResponseWriter, r *http.Request, status int, we webError) { + // if err := common.WriteJSON(w, status, common.Envelope{"error": we}, nil); err != nil { + // web.LogError(r, err) + // w.WriteHeader(500) + // } + + user := common.GetUser(r) + + web.render(w, r, http.StatusOK, "error", struct { + AuthenticatedUser *data.User + FormErrors []string + IsDevelopment bool + Status int + We webError + }{ + AuthenticatedUser: user, + IsDevelopment: web.App.Config.Env == "development", + Status: status, + We: we, + }) +} + +func (web *Web) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + web.LogError(r, err) + web.errorResponse(w, r, http.StatusInternalServerError, webError{ + Message: "the server encountered a problem and could not process your request", + }) +} + +func (web *Web) NotFoundResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusNotFound, webError{ + Message: "the requested resource could not be found", + }) +} + +func (web *Web) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusMethodNotAllowed, webError{ + Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method), + }) +} + +func (web *Web) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + web.errorResponse(w, r, http.StatusBadRequest, webError{Message: err.Error()}) +} + +func (web *Web) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + web.errorResponse(w, r, http.StatusUnprocessableEntity, webError{ + Code: common.ErrCodeValidationFailed, + Message: "validation failed", + Details: errors, + }) +} + +func (web *Web) EditConflictResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusConflict, webError{ + Code: common.ErrCodeEditConflict, + Message: "unable to update the record due to an edit conflict, please try again", + }) +} + +func (web *Web) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusUnauthorized, webError{ + Code: common.ErrCodeInvalidCredentials, + Message: "invalid authentication credentials", + }) +} + +func (web *Web) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusTooManyRequests, webError{ + Message: "rate limit exceeded", + }) +} + +func (web *Web) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusUnauthorized, webError{ + Code: common.ErrCodeAuthRequired, + Message: "you must be authenticated to access this resource", + }) +} + +func (web *Web) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusForbidden, webError{ + Code: common.ErrCodeInactiveAccount, + Message: "your user account must be activated to access this resource", + }) +} + +func (web *Web) NotPermittedResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusForbidden, webError{ + Code: common.ErrCodeNotPermitted, + Message: "your user account doesn't have the necessary permissions to access this resource", + }) +} + +func (web *Web) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusConflict, webError{ + Code: common.ErrCodeAlreadyVoted, + Message: "your user account already voted for this issue", + }) +} + +func (web *Web) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusConflict, webError{ + Code: common.ErrCodeAlreadyBlindSigned, + Message: "already requested a blind signature for this issue", + }) +} + +func (web *Web) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusUnprocessableEntity, webError{ + Code: common.ErrCodeBlindedVoteRange, + Message: "blinded_vote is out of valid range", + }) +} + +func (web *Web) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusUnprocessableEntity, webError{ + Code: common.ErrCodeInvalidSignature, + Message: "invalid signature", + }) +} + +func (web *Web) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) { + web.errorResponse(w, r, http.StatusConflict, webError{ + Code: common.ErrCodeVoteAlreadyCast, + Message: "this vote has already been cast", + }) +} diff --git a/cmd/party/web/home.go b/cmd/party/web/home.go index c97f28f..0679187 100644 --- a/cmd/party/web/home.go +++ b/cmd/party/web/home.go @@ -13,7 +13,7 @@ import ( ) func (web *Web) Home(w http.ResponseWriter, r *http.Request) { - if !getUser(r).IsAnonymous() { + if !common.GetUser(r).IsAnonymous() { http.Redirect(w, r, "/issues", http.StatusSeeOther) return } diff --git a/cmd/party/web/issues.go b/cmd/party/web/issues.go index 91b8d73..8b9480e 100644 --- a/cmd/party/web/issues.go +++ b/cmd/party/web/issues.go @@ -12,7 +12,7 @@ import ( ) func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) { - user := getUser(r) + user := common.GetUser(r) if user.IsAnonymous() { http.Redirect(w, r, "/", http.StatusSeeOther) return @@ -30,21 +30,17 @@ func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) { return } - permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID) - web.render(w, r, http.StatusOK, "issues", struct { AuthenticatedUser *data.User Issues []common.IssueDetail - CanWriteIssues bool }{ AuthenticatedUser: user, Issues: issues, - CanWriteIssues: permissions.Include("issues:write"), }) } func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) { - user := getUser(r) + user := common.GetUser(r) if user.IsAnonymous() { http.Redirect(w, r, "/", http.StatusSeeOther) return @@ -88,7 +84,7 @@ func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) { } func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) { - user := getUser(r) + user := common.GetUser(r) if user.IsAnonymous() { http.Redirect(w, r, "/", http.StatusSeeOther) return @@ -111,21 +107,17 @@ func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) { return } - permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID) - web.render(w, r, http.StatusOK, "issue", struct { AuthenticatedUser *data.User Issue *common.IssueWithOptions - CanWriteIssues bool }{ AuthenticatedUser: user, Issue: result, - CanWriteIssues: permissions.Include("issues:write"), }) } func (web *Web) UpdateIssueAction(w http.ResponseWriter, r *http.Request) { - if getUser(r).IsAnonymous() { + if common.GetUser(r).IsAnonymous() { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -172,7 +164,7 @@ func (web *Web) UpdateIssueAction(w http.ResponseWriter, r *http.Request) { } func (web *Web) DeleteIssueAction(w http.ResponseWriter, r *http.Request) { - if getUser(r).IsAnonymous() { + if common.GetUser(r).IsAnonymous() { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -220,8 +212,8 @@ func (web *Web) GetIssuePubKey(w http.ResponseWriter, r *http.Request) { } } -func (web *Web) BlindSignIssue(w http.ResponseWriter, r *http.Request) { - user := getUser(r) +func (web *Web) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { + user := common.GetUser(r) if user.IsAnonymous() { common.WriteJSON(w, http.StatusUnauthorized, common.Envelope{"error": map[string]string{"message": "authentication required"}}, nil) return @@ -236,7 +228,7 @@ func (web *Web) BlindSignIssue(w http.ResponseWriter, r *http.Request) { var input struct { BlindedVote []byte `json:"blinded_vote"` } - if err = web.App.ReadJSON(w, r, &input); err != nil { + if err = common.ReadJSON(w, r, &input); err != nil { common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil) return } diff --git a/cmd/party/web/middleware.go b/cmd/party/web/middleware.go index ce030a2..a2827be 100644 --- a/cmd/party/web/middleware.go +++ b/cmd/party/web/middleware.go @@ -11,6 +11,7 @@ import ( "golang.org/x/time/rate" "party.at/party/internal/data" "party.at/party/internal/validator" + "party.at/party/cmd/party/common" ) const cookieName = "session" @@ -19,7 +20,7 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(cookieName) if err != nil { - r = setUser(r, data.AnonymousUser) + r = common.SetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } @@ -27,7 +28,7 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler { v := validator.New() if data.ValidateTokenPlaintext(v, cookie.Value); !v.Valid() { clearCookie(w) - r = setUser(r, data.AnonymousUser) + r = common.SetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } @@ -40,7 +41,7 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler { return } clearCookie(w) - r = setUser(r, data.AnonymousUser) + r = common.SetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } @@ -53,38 +54,47 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler { return } clearCookie(w) - r = setUser(r, data.AnonymousUser) + r = common.SetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } - r = setUser(r, user) + r = common.SetUser(r, user) permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID) - r = setPermissions(r, permissions) + r = common.SetPermissions(r, permissions) next.ServeHTTP(w, r) }) } -func setCookie(w http.ResponseWriter, token string, expiry time.Time) { - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: token, - Expires: expiry, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, +func (web *Web) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if common.GetUser(r).IsAnonymous() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + } +} + +func (web *Web) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc { + return web.RequireAuthenticatedUser(func(w http.ResponseWriter, r *http.Request) { + user := common.GetUser(r) + + if !user.Activated { + web.render(w, r, http.StatusOK, "forbidden", nil) + return + } + next.ServeHTTP(w, r) }) } -func clearCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: "", - Expires: time.Unix(0, 0), - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - MaxAge: -1, +func (web *Web) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc { + return web.RequireActivatedUser(func(w http.ResponseWriter, r *http.Request) { + if !common.GetPermissions(r).Include(code) { + web.NotPermittedResponse(w, r) + return + } + next.ServeHTTP(w, r) }) } diff --git a/cmd/party/web/mps.go b/cmd/party/web/mps.go index e734d2f..22c0861 100644 --- a/cmd/party/web/mps.go +++ b/cmd/party/web/mps.go @@ -5,6 +5,7 @@ import ( "sort" "party.at/party/cmd/party/parlament" + "party.at/party/cmd/party/common" "party.at/party/internal/data" ) @@ -53,7 +54,7 @@ func (web *Web) MembersOfParliamentPage(w http.ResponseWriter, r *http.Request) Groups []factionGroup Total int }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), Groups: groups, Total: len(members), }) diff --git a/cmd/party/web/parlvotedetail.go b/cmd/party/web/parlvotedetail.go index 61b87a7..92c3a9f 100644 --- a/cmd/party/web/parlvotedetail.go +++ b/cmd/party/web/parlvotedetail.go @@ -4,11 +4,13 @@ import ( "net/http" "party.at/party/cmd/party/parlament" + "party.at/party/cmd/party/common" "party.at/party/internal/data" ) func (web *Web) ParlamentDispatch(w http.ResponseWriter, r *http.Request) { - if r.PathValue("path") == "/rows" { + p := r.PathValue("path") + if p == "rows" { web.ParlVotesRowsFragment(w, r) return } @@ -21,7 +23,7 @@ func (web *Web) ParlVoteDetailPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad Request", http.StatusBadRequest) return } - path := "/gegenstand" + routePath + path := "/gegenstand/" + routePath detail, err := web.App.Parlament.GetDocumentVote(path) if err != nil { @@ -39,7 +41,7 @@ func (web *Web) ParlVoteDetailPage(w http.ResponseWriter, r *http.Request) { Detail *parlament.DocumentVoteDetail Path string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), Detail: detail, Path: path, }) diff --git a/cmd/party/web/parlvotes.go b/cmd/party/web/parlvotes.go index 7186c32..6d88f8b 100644 --- a/cmd/party/web/parlvotes.go +++ b/cmd/party/web/parlvotes.go @@ -7,6 +7,7 @@ import ( "time" "party.at/party/cmd/party/parlament" + "party.at/party/cmd/party/common" "party.at/party/internal/data" ) @@ -121,7 +122,7 @@ func (web *Web) ParlVotesPage(w http.ResponseWriter, r *http.Request) { AuthenticatedUser *data.User Period string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), Period: parlament.CurrentPeriod, }) } diff --git a/cmd/party/web/templates.go b/cmd/party/web/templates.go index 24f99e5..f2e3fc3 100644 --- a/cmd/party/web/templates.go +++ b/cmd/party/web/templates.go @@ -4,12 +4,14 @@ import ( "fmt" "html/template" "net/http" + + "party.at/party/cmd/party/common" ) func (web *Web) renderFragment(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) { funcs := template.FuncMap{ "hasPermission": func(code string) bool { - return getPermissions(r).Include(code) + return common.GetPermissions(r).Include(code) }, "partyColor": func(code string) string { switch code { @@ -54,7 +56,7 @@ func (web *Web) render(w http.ResponseWriter, r *http.Request, status int, page return m, nil }, "hasPermission": func(code string) bool { - return getPermissions(r).Include(code) + return common.GetPermissions(r).Include(code) }, } ts, err := template.New("base").Funcs(funcs).ParseFiles( diff --git a/cmd/party/web/users.go b/cmd/party/web/users.go index 9d25c52..370781f 100644 --- a/cmd/party/web/users.go +++ b/cmd/party/web/users.go @@ -14,7 +14,7 @@ func (web *Web) Register(w http.ResponseWriter, r *http.Request) { AuthenticatedUser *data.User FormErrors []string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), }) } @@ -66,7 +66,7 @@ func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) { AuthenticatedUser *data.User FormErrors []string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), FormErrors: formErrors, }) return @@ -77,7 +77,7 @@ func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) { } func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) { - user := getUser(r) + user := common.GetUser(r) if user.IsAnonymous() { http.Redirect(w, r, "/", http.StatusSeeOther) return @@ -104,7 +104,7 @@ func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) { } func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) { - user := getUser(r) + user := common.GetUser(r) if user.IsAnonymous() { http.Redirect(w, r, "/", http.StatusSeeOther) return @@ -122,7 +122,7 @@ func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) { return } - permissions := getPermissions(r) + permissions := common.GetPermissions(r) if !permissions.Include("users:read") { http.Error(w, "Forbidden", http.StatusForbidden) return @@ -145,7 +145,7 @@ func (web *Web) ActivatePage(w http.ResponseWriter, r *http.Request) { FormErrors []string Token string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), Token: r.URL.Query().Get("token"), }) } @@ -171,7 +171,7 @@ func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) { FormErrors []string Token string }{ - AuthenticatedUser: getUser(r), + AuthenticatedUser: common.GetUser(r), FormErrors: []string{msg}, Token: token, }) @@ -182,7 +182,7 @@ func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) { } func (web *Web) DeleteUserAction(w http.ResponseWriter, r *http.Request) { - currentUser := getUser(r) + currentUser := common.GetUser(r) if currentUser.IsAnonymous() { http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/cmd/party/web/votes.go b/cmd/party/web/votes.go index 7e0c112..1bc7208 100644 --- a/cmd/party/web/votes.go +++ b/cmd/party/web/votes.go @@ -16,7 +16,7 @@ func (web *Web) VoteAction(w http.ResponseWriter, r *http.Request) { Signature []byte `json:"signature"` } - if err := web.App.ReadJSON(w, r, &input); err != nil { + if err := common.ReadJSON(w, r, &input); err != nil { common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil) return } diff --git a/cmd/party/web/web.go b/cmd/party/web/web.go index 58dea01..5935d44 100644 --- a/cmd/party/web/web.go +++ b/cmd/party/web/web.go @@ -1,42 +1,37 @@ package web import ( - "context" + // "context" "net/http" + "time" "party.at/party/cmd/party/common" - "party.at/party/internal/data" + // "party.at/party/internal/data" ) type Web struct { App *common.Application } -type contextKey string - -const userKey contextKey = "web_user" -const permissionsKey contextKey = "web_permissions" - -func setUser(r *http.Request, user *data.User) *http.Request { - return r.WithContext(context.WithValue(r.Context(), userKey, user)) +func setCookie(w http.ResponseWriter, token string, expiry time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Expires: expiry, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) } -func getUser(r *http.Request) *data.User { - user, ok := r.Context().Value(userKey).(*data.User) - if !ok { - return data.AnonymousUser - } - return user -} - -func setPermissions(r *http.Request, permissions data.Permissions) *http.Request { - return r.WithContext(context.WithValue(r.Context(), permissionsKey, permissions)) -} - -func getPermissions(r *http.Request) data.Permissions { - permissions, ok := r.Context().Value(permissionsKey).(data.Permissions) - if !ok { - return data.Permissions{} - } - return permissions +func clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Expires: time.Unix(0, 0), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) } diff --git a/cmd/party/web_socket.go b/cmd/party/web_socket.go new file mode 100644 index 0000000..257ee1a --- /dev/null +++ b/cmd/party/web_socket.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "net/http" + "time" +) + +func ws(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + fmt.Println("Upgrade error:", err) + return + } + defer conn.Close() + + done := make(chan struct{}) + + go func() { + ticker := time.NewTicker(1 * time.Second) + + for { + select { + case <-done: + return + case t := <-ticker.C: + msg := map[string]interface{}{ + "type": "server_tick", + "timestamp": t.Format(time.RFC3339), + } + + if err := conn.WriteJSON(msg); err != nil { + fmt.Println("Write error:", err) + return + } + } + } + }() + + for { + var msg Message + err := conn.ReadJSON(&msg) + if err != nil { + fmt.Println("Read error:", err) + break + } + fmt.Println(msg) + + // Send a response + // response := fmt.Sprintf("Server time: %s", time.Now()) + // if err := conn.WriteMessage(websocket.TextMessage, []byte(response)); err != nil { + // fmt.Println("Write error:", err) + // break + // } + } +} diff --git a/web/html/error.page.tmpl b/web/html/error.page.tmpl new file mode 100644 index 0000000..7babc7e --- /dev/null +++ b/web/html/error.page.tmpl @@ -0,0 +1,11 @@ +{{template "base" .}} + +{{define "title"}}Error{{end}} + +{{define "body"}} + + +{{end}} diff --git a/web/html/forbidden.page.tmpl b/web/html/forbidden.page.tmpl new file mode 100644 index 0000000..ca0aad7 --- /dev/null +++ b/web/html/forbidden.page.tmpl @@ -0,0 +1,10 @@ +{{template "base" .}} + +{{define "title"}}Forbidden{{end}} + +{{define "body"}} + + +{{end}} diff --git a/web/html/issue.page.tmpl b/web/html/issue.page.tmpl index 2065327..9dbeaac 100644 --- a/web/html/issue.page.tmpl +++ b/web/html/issue.page.tmpl @@ -13,7 +13,7 @@
{{.Issue.StartTime.Format "02.01.2006"}} – {{.Issue.EndTime.Format "02.01.2006"}}
- {{if .CanWriteIssues}} + {{if hasPermission "issues:write"}}
Details - {{if $.CanWriteIssues}} + {{if hasPermission "issues:write"}} @@ -32,7 +34,11 @@

Keine Abstimmungen vorhanden.

{{end}} -{{if .CanWriteIssues}} +{{else}} +

Sie haben keine Berechtigung, die Abstimmungen einzusehen.

+{{end}} + +{{if hasPermission "issues:write"}}
+ Neue Abstimmung