From 1913a0429f715223b45886aa2b9a545495bb1b70 Mon Sep 17 00:00:00 2001 From: Vicente Ferrari Smith Date: Tue, 12 May 2026 16:03:00 +0200 Subject: [PATCH] much better errors --- cmd/party/api/api.go | 8 +- cmd/party/api/device_tokens.go | 12 +-- cmd/party/api/errors.go | 139 ++++-------------------------- cmd/party/api/issues.go | 54 ++++-------- cmd/party/api/middleware.go | 20 +++-- cmd/party/api/parlvotedetail.go | 7 +- cmd/party/api/tokens.go | 10 +-- cmd/party/api/users.go | 51 +++-------- cmd/party/api/votes.go | 15 +--- cmd/party/common/application.go | 8 -- cmd/party/common/errors.go | 25 ------ cmd/party/common/helpers.go | 9 +- cmd/party/common/issues.go | 10 ++- cmd/party/common/users.go | 4 +- cmd/party/web/errors.go | 139 ++++-------------------------- cmd/party/web/issues.go | 7 +- cmd/party/web/middleware.go | 2 +- cmd/party/web/users.go | 18 ++-- internal/data/errors.go | 100 +++++++++++++++++++++ internal/data/helpers.go | 3 +- internal/data/models.go | 6 -- internal/data/users.go | 6 -- internal/data/votes.go | 9 -- web/html/base.layout.tmpl | 4 - web/html/home.page.tmpl | 4 + web/html/home_anonymous.page.tmpl | 4 + 26 files changed, 230 insertions(+), 444 deletions(-) delete mode 100644 cmd/party/common/errors.go create mode 100644 internal/data/errors.go diff --git a/cmd/party/api/api.go b/cmd/party/api/api.go index c64da75..2b66e67 100644 --- a/cmd/party/api/api.go +++ b/cmd/party/api/api.go @@ -27,14 +27,14 @@ func (api *Api) Authenticate(next http.Handler) http.Handler { headerParts := strings.Split(authorizationHeader, " ") if len(headerParts) != 2 || headerParts[0] != "Bearer" { - api.InvalidAuthenticationTokenResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidAuthToken) return } token := headerParts[1] v := validator.New() if data.ValidateTokenPlaintext(v, token); !v.Valid() { - api.InvalidAuthenticationTokenResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidAuthToken) return } @@ -42,7 +42,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler { if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): - api.InvalidAuthenticationTokenResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidAuthToken) default: api.ServerErrorResponse(w, r, err) } @@ -53,7 +53,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler { if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): - api.InvalidCredentialsResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidCredentials) default: api.ServerErrorResponse(w, r, err) } diff --git a/cmd/party/api/device_tokens.go b/cmd/party/api/device_tokens.go index 5ec821f..b553f94 100644 --- a/cmd/party/api/device_tokens.go +++ b/cmd/party/api/device_tokens.go @@ -1,11 +1,11 @@ package api import ( - "errors" "fmt" "net/http" "party.at/party/cmd/party/common" + "party.at/party/internal/data" ) func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { @@ -23,14 +23,14 @@ func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { "user_id": fmt.Sprint(user.ID), "error": err.Error(), }) - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } if input.Token == "" { api.App.Logger.PrintInfo("register device token: empty token", map[string]string{ "user_id": fmt.Sprint(user.ID), }) - api.BadRequestResponse(w, r, errors.New("token must be provided")) + api.errorResponse(w, r, data.ErrNoToken) return } @@ -55,11 +55,11 @@ func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) { Token string `json:"token"` } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } if input.Token == "" { - api.BadRequestResponse(w, r, errors.New("token must be provided")) + api.errorResponse(w, r, data.ErrNoToken) return } @@ -76,7 +76,7 @@ func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) { } } if !owns { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } diff --git a/cmd/party/api/errors.go b/cmd/party/api/errors.go index b7f2a4d..adbac8f 100644 --- a/cmd/party/api/errors.go +++ b/cmd/party/api/errors.go @@ -1,20 +1,13 @@ package api import( - "fmt" "net/http" + "errors" "party.at/party/cmd/party/common" + "party.at/party/internal/data" ) -// ── 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, @@ -22,124 +15,26 @@ func (api *Api) LogError(r *http.Request, err error) { }) } -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 { +func (api *Api) errorResponse(w http.ResponseWriter, r *http.Request, err error) { + apiErr := &data.Error{ + HttpCode: http.StatusInternalServerError, + Message: "the server encountered a problem", + } + + // Try to "unbox" the error to see if it's our rich *Error type + var customErr *data.Error + if errors.As(err, &customErr) { + apiErr = customErr + } + + if err := common.WriteJSON(w, apiErr.HttpCode, common.Envelope{"error": apiErr}, 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", - }) -} + api.App.LogError(r, err) -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", - }) + api.errorResponse(w, r, err) } diff --git a/cmd/party/api/issues.go b/cmd/party/api/issues.go index 991cc34..c6b9022 100644 --- a/cmd/party/api/issues.go +++ b/cmd/party/api/issues.go @@ -27,7 +27,7 @@ func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) { input.Filters.SortSafelist = []string{"id", "-id", "title", "-title", "description", "-description"} if data.ValidateFilters(v, input.Filters); !v.Valid() { - api.FailedValidationResponse(w, r, v.Errors) + api.errorResponse(w, r, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors)) return } @@ -52,18 +52,13 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) { } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } issue, options, err := api.App.CreateIssue(input.Title, input.Description, input.StartTime, input.EndTime, input.Options) if err != nil { - var ve *common.ValidationError - if errors.As(err, &ve) { - api.FailedValidationResponse(w, r, ve.Errors) - } else { - api.ServerErrorResponse(w, r, err) - } + api.errorResponse(w, r, err) return } @@ -77,14 +72,14 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) { func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } result, err := api.App.GetIssue(id, common.GetUser(r)) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) } else { api.ServerErrorResponse(w, r, err) } @@ -99,7 +94,7 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) { func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } @@ -111,23 +106,13 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) { } if err = common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } issue, err := api.App.UpdateIssue(id, input.Title, input.Description, input.StartTime, input.EndTime) if err != nil { - var ve *common.ValidationError - switch { - case errors.As(err, &ve): - api.FailedValidationResponse(w, r, ve.Errors) - case errors.Is(err, data.ErrRecordNotFound): - api.NotFoundResponse(w, r) - case errors.Is(err, data.ErrEditConflict): - api.EditConflictResponse(w, r) - default: - api.ServerErrorResponse(w, r, err) - } + api.errorResponse(w, r, err) return } @@ -139,13 +124,13 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) { func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } if err = api.App.DeleteIssue(id); err != nil { if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) } else { api.ServerErrorResponse(w, r, err) } @@ -160,14 +145,14 @@ func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) { func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } pubKey, err := api.App.GetIssuePublicKey(id) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) } else { api.ServerErrorResponse(w, r, err) } @@ -182,7 +167,7 @@ func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) { func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } @@ -190,22 +175,13 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { BlindedVote []byte `json:"blinded_vote"` } if err = common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } signed, err := api.App.BlindSign(id, input.BlindedVote, common.GetUser(r)) if err != nil { - switch { - case errors.Is(err, data.ErrRecordNotFound): - api.NotFoundResponse(w, r) - case errors.Is(err, data.ErrDuplicateBlindSign): - api.AlreadyBlindSignedResponse(w, r) - case errors.Is(err, data.ErrInvalidBlindedVote): - api.BlindedVoteOutOfRangeResponse(w, r) - default: - api.ServerErrorResponse(w, r, err) - } + api.errorResponse(w, r, err) return } diff --git a/cmd/party/api/middleware.go b/cmd/party/api/middleware.go index 7b961af..4263d8f 100644 --- a/cmd/party/api/middleware.go +++ b/cmd/party/api/middleware.go @@ -1,13 +1,15 @@ package api import ( - "net/http" "fmt" - "party.at/party/cmd/party/common" - "time" - "sync" - "golang.org/x/time/rate" "net" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" + "party.at/party/cmd/party/common" + "party.at/party/internal/data" ) func (api *Api) RecoverPanic(next http.Handler) http.Handler { @@ -25,7 +27,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 common.GetUser(r).IsAnonymous() { - api.AuthenticationRequiredResponse(w, r) + api.errorResponse(w, r, data.ErrAuthRequired) return } next.ServeHTTP(w, r) @@ -35,7 +37,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 !common.GetUser(r).Activated { - api.InactiveAccountResponse(w, r) + api.errorResponse(w, r, data.ErrInactiveAccount) return } next.ServeHTTP(w, r) @@ -51,7 +53,7 @@ func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.Handl return } if !permissions.Include(code) { - api.NotPermittedResponse(w, r) + api.errorResponse(w, r, data.ErrNotPermitted) return } next.ServeHTTP(w, r) @@ -96,7 +98,7 @@ func (api *Api) RateLimit(next http.Handler) http.Handler { clients[ip].lastSeen = time.Now() if !clients[ip].limiter.Allow() { mu.Unlock() - api.RateLimitExceededResponse(w, r) + api.errorResponse(w, r, data.ErrRateLimitExceeded) return } mu.Unlock() diff --git a/cmd/party/api/parlvotedetail.go b/cmd/party/api/parlvotedetail.go index 5c9683b..06c1a9a 100644 --- a/cmd/party/api/parlvotedetail.go +++ b/cmd/party/api/parlvotedetail.go @@ -1,18 +1,19 @@ package api import ( - "errors" "net/http" "strings" "party.at/party/cmd/party/common" + "party.at/party/internal/data" ) func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) { routePath := r.PathValue("path") routePath = strings.TrimPrefix(routePath, "/") if routePath == "" { - api.BadRequestResponse(w, r, errors.New("path is required")) + api.errorResponse(w, r, data.ErrBadRequest) + return } @@ -24,7 +25,7 @@ func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) { return } if detail == nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } diff --git a/cmd/party/api/tokens.go b/cmd/party/api/tokens.go index e4e6ea8..ad45871 100644 --- a/cmd/party/api/tokens.go +++ b/cmd/party/api/tokens.go @@ -17,7 +17,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } @@ -25,7 +25,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request v.Check(input.Email != "", "email", "must be provided") v.Check(input.Password != "", "password", "must be provided") if !v.Valid() { - api.FailedValidationResponse(w, r, v.Errors) + api.errorResponse(w, r, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors)) return } @@ -33,7 +33,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request if err != nil { switch { case errors.Is(err, data.ErrInvalidCredentials): - api.InvalidCredentialsResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidCredentials) default: api.ServerErrorResponse(w, r, err) } @@ -49,14 +49,14 @@ func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request authHeader := r.Header.Get("Authorization") parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { - api.InvalidAuthenticationTokenResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidAuthToken) return } token := parts[1] v := validator.New() if data.ValidateTokenPlaintext(v, token); !v.Valid() { - api.InvalidAuthenticationTokenResponse(w, r) + api.errorResponse(w, r, data.ErrInvalidAuthToken) return } diff --git a/cmd/party/api/users.go b/cmd/party/api/users.go index 3283901..5ea9437 100644 --- a/cmd/party/api/users.go +++ b/cmd/party/api/users.go @@ -25,7 +25,7 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) { input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"} if data.ValidateFilters(v, input.Filters); !v.Valid() { - api.FailedValidationResponse(w, r, v.Errors) + api.errorResponse(w, r, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors)) return } @@ -55,10 +55,12 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) { } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } + + user, authToken, err := api.App.RegisterUser(common.RegisterUserInput{ ProviderID: input.ProviderId, Username: input.Username, @@ -72,21 +74,7 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) { Address: input.Address, }) if err != nil { - var ve *common.ValidationError - switch { - case errors.As(err, &ve): - api.FailedValidationResponse(w, r, ve.Errors) - case errors.Is(err, data.ErrDuplicateEmail): - v := validator.New() - v.AddError("email", "a user with this email address already exists") - api.FailedValidationResponse(w, r, v.Errors) - case errors.Is(err, data.ErrDuplicateUser): - v := validator.New() - v.AddError("username", "a user with this username already exists") - api.FailedValidationResponse(w, r, v.Errors) - default: - api.ServerErrorResponse(w, r, err) - } + api.errorResponse(w, r, err) return } @@ -105,23 +93,20 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { var err error id, err = strconv.ParseInt(param, 10, 64) if err != nil || id < 1 { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } } 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", - }) + api.errorResponse(w, r, data.ErrNotPermitted) return } user, err := api.App.GetUser(id) if err != nil { if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) } else { api.ServerErrorResponse(w, r, err) } @@ -137,7 +122,7 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { // user := common.GetUser(r).ID // // if err != nil { // // if errors.Is(err, data.ErrRecordNotFound) { -// // api.NotFoundResponse(w, r) +// // api.errorResponse(w, r, data.ErrRecordNotFound) // // } else { // // api.ServerErrorResponse(w, r, err) // // } @@ -152,13 +137,13 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) { func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) return } if err = api.App.DeleteUser(id); err != nil { if errors.Is(err, data.ErrRecordNotFound) { - api.NotFoundResponse(w, r) + api.errorResponse(w, r, data.ErrRecordNotFound) } else { api.ServerErrorResponse(w, r, err) } @@ -176,27 +161,19 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) { } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } v := validator.New() if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() { - api.FailedValidationResponse(w, r, v.Errors) + api.errorResponse(w, r, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors)) return } user, err := api.App.ActivateUser(input.TokenPlaintext) if err != nil { - switch { - case errors.Is(err, data.ErrRecordNotFound): - v.AddError("token", "invalid or expired activation token") - api.FailedValidationResponse(w, r, v.Errors) - case errors.Is(err, data.ErrEditConflict): - api.EditConflictResponse(w, r) - default: - api.ServerErrorResponse(w, r, err) - } + api.errorResponse(w, r, err) return } diff --git a/cmd/party/api/votes.go b/cmd/party/api/votes.go index 97d85ff..5c87896 100644 --- a/cmd/party/api/votes.go +++ b/cmd/party/api/votes.go @@ -1,7 +1,6 @@ package api import ( - "errors" "net/http" "party.at/party/internal/data" @@ -17,21 +16,13 @@ func (api *Api) Vote(w http.ResponseWriter, r *http.Request) { } if err := common.ReadJSON(w, r, &input); err != nil { - api.BadRequestResponse(w, r, err) + api.errorResponse(w, r, data.ErrBadRequest) return } if err := api.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil { - switch { - case errors.Is(err, data.ErrRecordNotFound): - api.NotFoundResponse(w, r) - case errors.Is(err, data.ErrInvalidSignature): - api.InvalidSignatureResponse(w, r) - case errors.Is(err, data.ErrDuplicateVote): - api.AlreadyVotedResponse(w, r) - default: - api.ServerErrorResponse(w, r, err) - } + api.ServerErrorResponse(w, r, err) + return } diff --git a/cmd/party/common/application.go b/cmd/party/common/application.go index 8ea742a..89cc7a5 100644 --- a/cmd/party/common/application.go +++ b/cmd/party/common/application.go @@ -64,14 +64,6 @@ type Application struct { CORSTrustedOrigins []string } -type ValidationError struct { - Errors map[string]string -} - -func (e *ValidationError) Error() string { - return "validation failed" -} - func (app *Application) background(fn func()) { go func() { defer func() { diff --git a/cmd/party/common/errors.go b/cmd/party/common/errors.go deleted file mode 100644 index 7282713..0000000 --- a/cmd/party/common/errors.go +++ /dev/null @@ -1,25 +0,0 @@ -package common - -type ErrorCode int - -const ( - // 401 variants - ErrCodeInvalidCredentials ErrorCode = 4011 - ErrCodeInvalidAuthToken ErrorCode = 4012 - ErrCodeAuthRequired ErrorCode = 4013 - - // 403 variants - ErrCodeInactiveAccount ErrorCode = 4031 - ErrCodeNotPermitted ErrorCode = 4032 - - // 409 variants - ErrCodeEditConflict ErrorCode = 4091 - ErrCodeAlreadyVoted ErrorCode = 4092 - ErrCodeAlreadyBlindSigned ErrorCode = 4093 - ErrCodeVoteAlreadyCast ErrorCode = 4094 - - // 422 variants - ErrCodeValidationFailed ErrorCode = 4221 - ErrCodeBlindedVoteRange ErrorCode = 4222 - ErrCodeInvalidSignature ErrorCode = 4223 -) diff --git a/cmd/party/common/helpers.go b/cmd/party/common/helpers.go index 04caaad..0da0d20 100644 --- a/cmd/party/common/helpers.go +++ b/cmd/party/common/helpers.go @@ -15,6 +15,7 @@ import ( "strings" "party.at/party/internal/validator" + "party.at/party/internal/data" ) type Envelope map[string]interface{} @@ -49,14 +50,14 @@ func ReadJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { 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") + return data.ErrBadlyFormedJSON 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") + return data.ErrBodyEmpty 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) @@ -70,7 +71,7 @@ func ReadJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { } err = dec.Decode(&struct{}{}) if err != io.EOF { - return errors.New("body must only contain a single JSON value") + return data.ErrSingleValue } return nil } @@ -91,7 +92,7 @@ func GenerateIssueKey() ([]byte, int, []byte, error) { 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 0, data.ErrInvalidID } return id, nil } diff --git a/cmd/party/common/issues.go b/cmd/party/common/issues.go index 4c077ab..7e5f482 100644 --- a/cmd/party/common/issues.go +++ b/cmd/party/common/issues.go @@ -77,7 +77,7 @@ func (app *Application) CreateIssue(title, description string, startTime, endTim data.ValidateOption(v, option) } if data.ValidateIssue(v, issue); !v.Valid() { - return nil, nil, &ValidationError{Errors: v.Errors} + return nil, nil, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors) } n, e, privatePEM, err := generateIssueKey() @@ -145,7 +145,7 @@ func (app *Application) UpdateIssue(id int64, title, description *string, startT v := validator.New() if data.ValidateIssue(v, issue); !v.Valid() { - return nil, &ValidationError{Errors: v.Errors} + return nil, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors) } if err = app.Models.Issues.Update(issue); err != nil { @@ -167,12 +167,16 @@ func (app *Application) GetIssuePublicKey(id int64) (*PublicKey, error) { return &PublicKey{N: hex.EncodeToString(issue.N), E: issue.E}, nil } -func (app *Application) BlindSign(issueID int64, blindedVote []byte, user *data.User) ([]byte, error) { +func (app *Application) BlindSign(issueID int64, blindedVote []byte, user *data.User) ([]byte, error) { issue, err := app.Models.Issues.Get(issueID) if err != nil { return nil, err } + if issue.StartTime.After(time.Now()) { + return nil, data.ErrHasNotStarted + } + blindSign := &data.BlindSign{UserID: user.ID, IssueID: issue.ID} if err = app.Models.BlindSigns.Insert(blindSign); err != nil { return nil, err diff --git a/cmd/party/common/users.go b/cmd/party/common/users.go index d503e68..143e0c3 100644 --- a/cmd/party/common/users.go +++ b/cmd/party/common/users.go @@ -45,7 +45,7 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data data.ValidateUser(v, user) data.ValidateUserIdentity(v, userIdentity) if !v.Valid() { - return nil, nil, &ValidationError{Errors: v.Errors} + return nil, nil, data.ErrValidationFailed.(*data.Error).WithDetails(v.Errors) } if err := app.Models.Users.ExecuteRegistrationTx(user, userIdentity); err != nil { @@ -54,7 +54,7 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data role := "viewer" if app.Config.Env == "development" { - role = "" + role = "admin" } if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil { diff --git a/cmd/party/web/errors.go b/cmd/party/web/errors.go index 204896e..c2a0c9a 100644 --- a/cmd/party/web/errors.go +++ b/cmd/party/web/errors.go @@ -1,8 +1,8 @@ package web import( - "fmt" "net/http" + "errors" "party.at/party/internal/data" "party.at/party/cmd/party/common" @@ -10,12 +10,6 @@ import( // ── 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, @@ -23,131 +17,32 @@ func (web *Web) LogError(r *http.Request, err error) { }) } -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) - // } +func (web *Web) errorResponse(w http.ResponseWriter, r *http.Request, err error) { + displayError := data.Error{ + HttpCode: http.StatusInternalServerError, + Message: "the server encountered a problem and could not process your request", + } + + var customErr *data.Error + if errors.As(err, &customErr) { + displayError = *customErr + } user := common.GetUser(r) - - web.render(w, r, http.StatusOK, "error", struct { + + web.render(w, r, displayError.HttpCode, "error", struct { AuthenticatedUser *data.User - FormErrors []string IsDevelopment bool - Status int - We webError + Error data.Error }{ AuthenticatedUser: user, - IsDevelopment: web.App.Config.Env == "development", - Status: status, - We: we, + IsDevelopment: web.App.Config.Env == "development", + Error: displayError, }) } 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", - }) + web.errorResponse(w, r, err) } diff --git a/cmd/party/web/issues.go b/cmd/party/web/issues.go index 8b9480e..ec2db60 100644 --- a/cmd/party/web/issues.go +++ b/cmd/party/web/issues.go @@ -93,6 +93,7 @@ func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) { id, err := common.ReadIDParam(r) if err != nil { http.NotFound(w, r) + web.errorResponse(w, r, data.ErrRecordNotFound) return } @@ -239,9 +240,11 @@ func (web *Web) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { case errors.Is(err, data.ErrRecordNotFound): http.NotFound(w, r) case errors.Is(err, data.ErrDuplicateBlindSign): - common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": "bereits eine Blindsignatur für diese Abstimmung angefordert"}}, nil) + common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil) case errors.Is(err, data.ErrInvalidBlindedVote): common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil) + case errors.Is(err, data.ErrHasNotStarted): + common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil) default: web.App.LogError(r, err) common.WriteJSON(w, http.StatusInternalServerError, common.Envelope{"error": map[string]string{"message": "internal server error"}}, nil) @@ -250,6 +253,6 @@ func (web *Web) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { } if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"signed": signed}, nil); err != nil { - web.App.LogError(r, err) + web.ServerErrorResponse(w, r, err) } } diff --git a/cmd/party/web/middleware.go b/cmd/party/web/middleware.go index a2827be..4be2c25 100644 --- a/cmd/party/web/middleware.go +++ b/cmd/party/web/middleware.go @@ -91,7 +91,7 @@ func (web *Web) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc { 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) + web.errorResponse(w, r, data.ErrNotPermitted) return } next.ServeHTTP(w, r) diff --git a/cmd/party/web/users.go b/cmd/party/web/users.go index 370781f..a23fa96 100644 --- a/cmd/party/web/users.go +++ b/cmd/party/web/users.go @@ -48,20 +48,12 @@ func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) { }) if err != nil { var formErrors []string - var ve *common.ValidationError - switch { - case errors.As(err, &ve): - for _, msg := range ve.Errors { - formErrors = append(formErrors, msg) - } - case errors.Is(err, data.ErrDuplicateEmail): - formErrors = []string{"Diese E-Mail-Adresse wird bereits verwendet."} - case errors.Is(err, data.ErrDuplicateUser): - formErrors = []string{"Dieser Benutzername wird bereits verwendet."} - default: - web.App.LogError(r, err) - formErrors = []string{"Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."} + var customErr *data.Error + + if errors.As(err, &customErr) && len(customErr.Details) > 0 { + formErrors = customErr.DetailMessages() } + web.render(w, r, http.StatusUnprocessableEntity, "register", struct { AuthenticatedUser *data.User FormErrors []string diff --git a/internal/data/errors.go b/internal/data/errors.go new file mode 100644 index 0000000..99527cb --- /dev/null +++ b/internal/data/errors.go @@ -0,0 +1,100 @@ +package data + +import ( + "fmt" +) + +type ErrorCode int + +const ( + // 401 variants + ErrCodeInvalidCredentials ErrorCode = 4011 + ErrCodeInvalidAuthToken ErrorCode = 4012 + ErrCodeAuthRequired ErrorCode = 4013 + + // 403 variants + ErrCodeInactiveAccount ErrorCode = 4031 + ErrCodeNotPermitted ErrorCode = 4032 + + // 409 variants + ErrCodeEditConflict ErrorCode = 4091 + ErrCodeAlreadyVoted ErrorCode = 4092 + ErrCodeAlreadyBlindSigned ErrorCode = 4093 + ErrCodeVoteAlreadyCast ErrorCode = 4094 + + // 422 variants + ErrCodeValidationFailed ErrorCode = 4221 + ErrCodeBlindedVoteRange ErrorCode = 4222 + ErrCodeInvalidSignature ErrorCode = 4223 + ErrCodeHasNotStarted ErrorCode = 4224 +) + +type Error struct { + HttpCode int `json:"http_code,omitempty"` + Code ErrorCode `json:"code,omitempty"` + Message string `json:"message"` + Details map[string]string `json:"details,omitempty"` +} + +func (e *Error) Error() string { + return e.Message +} + +func (e *Error) WithDetails(details map[string]string) error { + e.Details = details + return e +} + + +func (e *Error) DetailMessages() []string { + msgs := make([]string, 0, len(e.Details)) + for key, msg := range e.Details { + msgs = append(msgs, fmt.Sprintf("%s: %s", key, msg)) + } + return msgs +} + +func New(httpCode int, code ErrorCode, text string) error { + return &Error{HttpCode: httpCode, Code: code, Message: text} +} + +var ( + // 400 Bad Request + ErrFailedPEM = New(400, 0, "failed to decode PEM block") + ErrBadlyFormedJSON = New(400, 0, "body contains badly-formed JSON") + ErrBodyEmpty = New(400, 0, "body must not be empty") + ErrSingleValue = New(400, 0, "body must only contain a single JSON value") + ErrInvalidID = New(400, 0, "invalid id parameter") + ErrBadRequest = New(400, 0, "the server cannot process the request due to a client error") + + // 401 Unauthorized + ErrInvalidCredentials = New(401, 4011, "invalid credentials") + ErrInvalidAuthToken = New(401, 4012, "invalid or missing authentication token") + ErrNoToken = New(401, 4012, "token must be provided") + ErrAuthRequired = New(401, 4013, "you must be authenticated to access this resource") + ErrHasNotStarted = New(401, 4224, "the vote has not yet started") + + // 403 Forbidden + ErrInactiveAccount = New(403, 4031, "your user account must be activated to access this resource") + ErrNotPermitted = New(403, 4032, "your user account doesn't have the necessary permissions to access this resource") + + // 404 Not Found + ErrRecordNotFound = New(404, 0, "record not found") + ErrNoPath = New(404, 0, "path is required") + + // 409 Conflict + ErrEditConflict = New(409, 4091, "edit conflict") + ErrDuplicateVote = New(409, 4092, "this signature has already been used to cast a vote") + ErrDuplicateBlindSign = New(409, 4093, "user has already requested a blind signature for this issue") + ErrDuplicateSignature = New(409, 4094, "this signature has already been used to cast a vote") + ErrDuplicateEmail = New(409, 0, "duplicate email") + ErrDuplicateUser = New(409, 0, "duplicate username") + + // 422 Unprocessable Entity + ErrValidationFailed = New(422, 4221, "validation failed") + ErrInvalidBlindedVote = New(422, 4222, "blinded_vote is out of valid range [1, n-1]") + ErrInvalidSignature = New(422, 4223, "signature verification failed") + + // 429 Too Many Requests + ErrRateLimitExceeded = New(429, 0, "rate limit exceeded") +) diff --git a/internal/data/helpers.go b/internal/data/helpers.go index c3245d1..dd8d2f9 100644 --- a/internal/data/helpers.go +++ b/internal/data/helpers.go @@ -3,14 +3,13 @@ package data import( "crypto/rsa" "encoding/pem" - "errors" "crypto/x509" ) func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { - return nil, errors.New("failed to decode PEM block") + return nil, ErrFailedPEM } return x509.ParsePKCS1PrivateKey(block.Bytes) } diff --git a/internal/data/models.go b/internal/data/models.go index 1babb04..47ee955 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -2,12 +2,6 @@ package data import ( "database/sql" - "errors" -) - -var ( - ErrRecordNotFound = errors.New("record not found") - ErrEditConflict = errors.New("edit conflict") ) type Models struct { diff --git a/internal/data/users.go b/internal/data/users.go index 017bd47..ab88a43 100644 --- a/internal/data/users.go +++ b/internal/data/users.go @@ -12,12 +12,6 @@ import ( "party.at/party/internal/validator" ) -var ( - ErrDuplicateEmail = errors.New("duplicate email") - ErrDuplicateUser = errors.New("duplicate username") - ErrInvalidCredentials = errors.New("invalid credentials") -) - var AnonymousUser = &User{} type User struct { diff --git a/internal/data/votes.go b/internal/data/votes.go index df9ce33..8d9aac3 100644 --- a/internal/data/votes.go +++ b/internal/data/votes.go @@ -1,7 +1,6 @@ package data import ( - "errors" "time" "database/sql" "math/big" @@ -12,14 +11,6 @@ import ( "github.com/lib/pq" ) -var ( - ErrDuplicateBlindSign = errors.New("user has already requested a blind signature for this issue") - ErrInvalidBlindedVote = errors.New("blinded_vote is out of valid range [1, n-1]") - ErrInvalidSignature = errors.New("signature verification failed") - ErrDuplicateVote = errors.New("this signature has already been used to cast a vote") -) - - type Vote struct { ID int64 `json:"id"` OptionID int64 `json:"option_id"` diff --git a/web/html/base.layout.tmpl b/web/html/base.layout.tmpl index d2adf19..4f7bbdf 100644 --- a/web/html/base.layout.tmpl +++ b/web/html/base.layout.tmpl @@ -27,10 +27,6 @@ {{end}} -
- DPÖ Logo -
-
{{template "body" .}} diff --git a/web/html/home.page.tmpl b/web/html/home.page.tmpl index f8ff5e8..3440bdf 100644 --- a/web/html/home.page.tmpl +++ b/web/html/home.page.tmpl @@ -3,6 +3,10 @@ {{define "title"}}Übersicht{{end}} {{define "body"}} +
+ DPÖ Logo +
+