better code

This commit is contained in:
Vicente Ferrari Smith 2026-05-12 12:34:49 +02:00
parent 40c3406dcb
commit 4e72beb433
35 changed files with 675 additions and 812 deletions

2
.vscode/launch.json vendored
View File

@ -11,7 +11,7 @@
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/cmd/party", "program": "${workspaceFolder}/cmd/party",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.envrc",
"args": ["--env=development"] "args": ["--env=development"]
} }
] ]

View File

@ -1,7 +1,6 @@
package api package api
import( import(
"context"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@ -9,42 +8,19 @@ import(
"party.at/party/cmd/party/common" "party.at/party/cmd/party/common"
"party.at/party/internal/data" "party.at/party/internal/data"
"party.at/party/internal/validator" "party.at/party/internal/validator"
"encoding/json"
"fmt"
"io"
"net/url"
"strconv"
) )
type Api struct { type Api struct {
App *common.Application 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 { func (api *Api) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization") w.Header().Add("Vary", "Authorization")
authorizationHeader := r.Header.Get("Authorization") authorizationHeader := r.Header.Get("Authorization")
if authorizationHeader == "" { if authorizationHeader == "" {
r = SetUser(r, data.AnonymousUser) r = common.SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -84,357 +60,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler {
return return
} }
r = SetUser(r, user) r = common.SetUser(r, user)
next.ServeHTTP(w, r) 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",
// })
// }

View File

@ -4,10 +4,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"party.at/party/cmd/party/common"
) )
func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) { 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{ api.App.Logger.PrintInfo("register device token: request", map[string]string{
"user_id": fmt.Sprint(user.ID), "user_id": fmt.Sprint(user.ID),
@ -16,7 +18,7 @@ func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) {
var input struct { var input struct {
Token string `json:"token"` 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{ api.App.Logger.PrintInfo("register device token: bad request", map[string]string{
"user_id": fmt.Sprint(user.ID), "user_id": fmt.Sprint(user.ID),
"error": err.Error(), "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) { func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) {
user := GetUser(r) user := common.GetUser(r)
var input struct { var input struct {
Token string `json:"token"` 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) api.BadRequestResponse(w, r, err)
return return
} }

145
cmd/party/api/errors.go Normal file
View File

@ -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",
})
}

View File

@ -1,7 +1,9 @@
package api package api
import "net/http" import (
import "party.at/party/cmd/party/common" "net/http"
"party.at/party/cmd/party/common"
)
func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) { func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) {
env := common.Envelope{ env := common.Envelope{

View File

@ -31,13 +31,13 @@ func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
api.ServerErrorResponse(w, r, err) api.ServerErrorResponse(w, r, err)
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -51,7 +51,7 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) {
Options []string `json:"options"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -69,7 +69,7 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) {
headers := make(http.Header) headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID)) 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -81,7 +81,7 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := api.App.GetIssue(id, GetUser(r)) result, err := api.App.GetIssue(id, common.GetUser(r))
if err != nil { if err != nil {
if errors.Is(err, data.ErrRecordNotFound) { if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r) api.NotFoundResponse(w, r)
@ -91,7 +91,7 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) {
return 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) 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"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -131,7 +131,7 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -152,7 +152,7 @@ func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -174,13 +174,13 @@ func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) { func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r) id, err := common.ReadIDParam(r)
if err != nil { if err != nil {
api.NotFoundResponse(w, r) api.NotFoundResponse(w, r)
return return
@ -189,12 +189,12 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
var input struct { var input struct {
BlindedVote []byte `json:"blinded_vote"` 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) api.BadRequestResponse(w, r, err)
return 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 { if err != nil {
switch { switch {
case errors.Is(err, data.ErrRecordNotFound): case errors.Is(err, data.ErrRecordNotFound):
@ -209,7 +209,7 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }

View File

@ -1,12 +1,14 @@
package api package api
import "net/http" import (
import "fmt" "net/http"
// import "party.at/party/cmd/party/common" "fmt"
import "time" "party.at/party/cmd/party/common"
import "sync" "time"
import "golang.org/x/time/rate" "sync"
import "net" "golang.org/x/time/rate"
"net"
)
func (api *Api) RecoverPanic(next http.Handler) http.Handler { func (api *Api) RecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetUser(r).IsAnonymous() { if common.GetUser(r).IsAnonymous() {
api.AuthenticationRequiredResponse(w, r) api.AuthenticationRequiredResponse(w, r)
return return
} }
@ -32,7 +34,7 @@ func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc
func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc { func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !GetUser(r).Activated { if !common.GetUser(r).Activated {
api.InactiveAccountResponse(w, r) api.InactiveAccountResponse(w, r)
return 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 { func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
api.ServerErrorResponse(w, r, err) api.ServerErrorResponse(w, r, err)
return return

View File

@ -3,6 +3,8 @@ package api
import ( import (
"net/http" "net/http"
"sort" "sort"
"party.at/party/cmd/party/common"
) )
func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) { 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 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, "members": members,
"total": len(members), "total": len(members),
}, nil); err != nil { }, nil); err != nil {

View File

@ -4,6 +4,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"party.at/party/cmd/party/common"
) )
func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) { 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 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) api.ServerErrorResponse(w, r, err)
} }
} }

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"party.at/party/cmd/party/parlament" "party.at/party/cmd/party/parlament"
"party.at/party/cmd/party/common"
) )
type apiPartyStat struct { type apiPartyStat struct {
@ -95,7 +96,7 @@ func (api *Api) ListParlVotes(w http.ResponseWriter, r *http.Request) {
return partyStats[i].Code < partyStats[j].Code 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, "documents": docs,
"total": len(docs), "total": len(docs),
"total_with_votes": totalWithVotes, "total_with_votes": totalWithVotes,

View File

@ -7,6 +7,7 @@ import (
"party.at/party/internal/data" "party.at/party/internal/data"
"party.at/party/internal/validator" "party.at/party/internal/validator"
"party.at/party/cmd/party/common"
) )
func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request) { 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"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -39,7 +40,7 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -64,7 +65,7 @@ func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request
return 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) api.ServerErrorResponse(w, r, err)
} }
} }

View File

@ -19,9 +19,9 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) {
v := validator.New() v := validator.New()
qs := r.URL.Query() qs := r.URL.Query()
input.Filters.Page = api.readInt(qs, "page", 1, v) input.Filters.Page = common.ReadInt(qs, "page", 1, v)
input.Filters.PageSize = api.readInt(qs, "page_size", 20, v) input.Filters.PageSize = common.ReadInt(qs, "page_size", 20, v)
input.Filters.Sort = api.readString(qs, "sort", "id") input.Filters.Sort = common.ReadString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"} input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"}
if data.ValidateFilters(v, input.Filters); !v.Valid() { if data.ValidateFilters(v, input.Filters); !v.Valid() {
@ -35,7 +35,7 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -54,7 +54,7 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) {
Address string `json:"address"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -90,7 +90,7 @@ func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -100,7 +100,7 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
param := r.PathValue("id") param := r.PathValue("id")
if param == "me" { if param == "me" {
id = GetUser(r).ID id = common.GetUser(r).ID
} else { } else {
var err error var err error
id, err = strconv.ParseInt(param, 10, 64) 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{ api.errorResponse(w, r, http.StatusForbidden, apiError{
Code: common.ErrCodeNotPermitted, Code: common.ErrCodeNotPermitted,
Message: "your user account doesn't have the necessary permissions to access this resource", 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 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) api.ServerErrorResponse(w, r, err)
} }
} }
func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) { // func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) {
user, err := api.App.GetUser(GetUser(r).ID) // user := common.GetUser(r).ID
if err != nil { // // if err != nil {
if errors.Is(err, data.ErrRecordNotFound) { // // if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r) // // api.NotFoundResponse(w, r)
} else { // // } else {
api.ServerErrorResponse(w, r, err) // // api.ServerErrorResponse(w, r, err)
} // // }
return // // 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) // api.ServerErrorResponse(w, r, err)
} // }
} // }
func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) { func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r) id, err := common.ReadIDParam(r)
if err != nil { if err != nil {
api.NotFoundResponse(w, r) api.NotFoundResponse(w, r)
return return
@ -165,7 +165,7 @@ func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }
@ -175,7 +175,7 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) {
TokenPlaintext string `json:"token"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -200,7 +200,7 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"party.at/party/internal/data" "party.at/party/internal/data"
"party.at/party/cmd/party/common"
) )
func (api *Api) Vote(w http.ResponseWriter, r *http.Request) { 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"` 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) api.BadRequestResponse(w, r, err)
return return
} }
@ -34,7 +35,7 @@ func (api *Api) Vote(w http.ResponseWriter, r *http.Request) {
return 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) api.ServerErrorResponse(w, r, err)
} }
} }

View File

@ -2,6 +2,8 @@ package common
import ( import (
"fmt" "fmt"
"context"
"net/http"
apns "github.com/sideshow/apns2" apns "github.com/sideshow/apns2"
"party.at/party/cmd/party/parlament" "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
}

View File

@ -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(),
})
}

View File

@ -19,44 +19,6 @@ import (
type Envelope map[string]interface{} 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 { func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t") js, err := json.MarshalIndent(data, "", "\t")
if err != nil { if err != nil {
@ -72,7 +34,7 @@ func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.He
return nil 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 maxBytes := 1048576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@ -125,3 +87,48 @@ func GenerateIssueKey() ([]byte, int, []byte, error) {
}) })
return key.N.Bytes(), key.E, privPEM, err 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(),
})
}

View File

@ -54,8 +54,9 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data
role := "viewer" role := "viewer"
if app.Config.Env == "development" { if app.Config.Env == "development" {
role = "admin" role = ""
} }
if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil { if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -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)
}

View File

@ -16,12 +16,8 @@ func routes(app *common.Application) http.Handler {
} }
apiMux := http.NewServeMux() 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) 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("GET /v1/issues", api.RequirePermission("issues:read", api.ListIssues))
apiMux.HandleFunc("POST /v1/issues", api.RequirePermission("issues:write", api.CreateIssue)) 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("GET /v1/issues/{id}", api.RequirePermission("issues:read", api.ReadIssue))
@ -29,24 +25,18 @@ func routes(app *common.Application) http.Handler {
apiMux.HandleFunc("DELETE /v1/issues/{id}", api.RequirePermission("issues:write", api.DeleteIssue)) 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("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/issues/{id}/blind-sign", api.RequirePermission("issues:read", api.BlindSignIssueVote))
apiMux.HandleFunc("POST /v1/votes", api.Vote) apiMux.HandleFunc("POST /v1/votes", api.Vote)
apiMux.HandleFunc("POST /v1/users", api.CreateUser) apiMux.HandleFunc("POST /v1/users", api.CreateUser)
apiMux.HandleFunc("GET /v1/users", api.RequirePermission("users:read", api.ListUsers)) apiMux.HandleFunc("GET /v1/users", api.RequirePermission("users:read", api.ListUsers))
apiMux.HandleFunc("GET /v1/users/{id}", api.RequireAuthenticatedUser(api.ReadUser)) apiMux.HandleFunc("GET /v1/users/{id}", api.RequireAuthenticatedUser(api.ReadUser))
apiMux.HandleFunc("DELETE /v1/users/{id}", api.DeleteUser) apiMux.HandleFunc("DELETE /v1/users/{id}", api.RequirePermission("users:write", api.DeleteUser))
apiMux.HandleFunc("PUT /v1/users/activated", api.ActivateUser) apiMux.HandleFunc("PUT /v1/users/activated", api.ActivateUser)
apiMux.HandleFunc("POST /v1/tokens/authentication", api.CreateAuthenticationToken) apiMux.HandleFunc("POST /v1/tokens/authentication", api.CreateAuthenticationToken)
apiMux.HandleFunc("DELETE /v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken)) apiMux.HandleFunc("DELETE /v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken))
apiMux.HandleFunc("POST /v1/device-tokens", api.RequireAuthenticatedUser(api.RegisterDeviceToken)) apiMux.HandleFunc("POST /v1/device-tokens", api.RequireAuthenticatedUser(api.RegisterDeviceToken))
apiMux.HandleFunc("DELETE /v1/device-tokens", api.RequireAuthenticatedUser(api.DeleteDeviceToken)) apiMux.HandleFunc("DELETE /v1/device-tokens", api.RequireAuthenticatedUser(api.DeleteDeviceToken))
apiMux.HandleFunc("GET /v1/mps", api.ListMPs) apiMux.HandleFunc("GET /v1/mps", api.ListMPs)
apiMux.HandleFunc("GET /v1/parlament/votes", api.ListParlVotes) apiMux.HandleFunc("GET /v1/parlament/votes", api.ListParlVotes)
// Wildcards use {name...}
apiMux.HandleFunc("GET /v1/parlament/votes/{path...}", api.GetParlVoteDetail) apiMux.HandleFunc("GET /v1/parlament/votes/{path...}", api.GetParlVoteDetail)
apiMux.Handle("GET /debug/vars", expvar.Handler()) apiMux.Handle("GET /debug/vars", expvar.Handler())
@ -61,28 +51,30 @@ func routes(app *common.Application) http.Handler {
webMux.HandleFunc("GET /", web.Home) webMux.HandleFunc("GET /", web.Home)
webMux.HandleFunc("GET /register", web.Register) webMux.HandleFunc("GET /register", web.Register)
webMux.HandleFunc("POST /register", web.RegisterUserPage) webMux.HandleFunc("POST /register", web.RegisterUserPage)
webMux.HandleFunc("GET /issues", web.IssuesPage) webMux.HandleFunc("GET /issues", web.RequirePermission("issues:read", web.IssuesPage))
webMux.HandleFunc("POST /issues", web.CreateIssueAction) webMux.HandleFunc("POST /issues", web.RequirePermission("issues:write", web.CreateIssueAction))
webMux.HandleFunc("GET /issues/{id}", web.IssuePage) webMux.HandleFunc("GET /issues/{id}", web.RequirePermission("issues:read", web.IssuePage))
webMux.HandleFunc("PATCH /issues/{id}", web.UpdateIssueAction) webMux.HandleFunc("PATCH /issues/{id}", web.RequirePermission("issues:write", web.UpdateIssueAction))
webMux.HandleFunc("DELETE /issues/{id}", web.DeleteIssueAction) webMux.HandleFunc("DELETE /issues/{id}", web.RequirePermission("issues:write", web.DeleteIssueAction))
webMux.HandleFunc("GET /issues/{id}/pubkey", web.GetIssuePubKey) webMux.HandleFunc("GET /issues/{id}/pubkey", web.RequirePermission("issues:read", web.GetIssuePubKey))
webMux.HandleFunc("POST /issues/{id}/blind-sign", web.BlindSignIssue) webMux.HandleFunc("POST /issues/{id}/blind-sign", web.RequirePermission("issues:read", web.BlindSignIssueVote))
webMux.HandleFunc("POST /votes", web.VoteAction) webMux.HandleFunc("POST /votes", web.VoteAction)
webMux.HandleFunc("GET /users", web.UsersPage)
webMux.HandleFunc("GET /users/me", web.ProfilePage) 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("GET /users/activated", web.ActivatePage)
webMux.HandleFunc("POST /users/activated", web.ActivateUserAction) webMux.HandleFunc("POST /users/activated", web.ActivateUserAction)
webMux.HandleFunc("DELETE /users/{id}", web.DeleteUserAction) webMux.HandleFunc("DELETE /users/{id}", web.RequirePermission("users:write", web.DeleteUserAction))
webMux.HandleFunc("GET /mps", web.MembersOfParliamentPage) webMux.HandleFunc("GET /mps", web.MembersOfParliamentPage)
webMux.HandleFunc("GET /parlament", web.ParlVotesPage) webMux.HandleFunc("GET /parlament", web.ParlVotesPage)
webMux.HandleFunc("GET /parlament/{path...}", web.ParlamentDispatch) webMux.HandleFunc("GET /parlament/{path...}", web.ParlamentDispatch)
webMux.HandleFunc("POST /login", web.Login) webMux.HandleFunc("POST /login", web.Login)
webMux.HandleFunc("POST /dev-login", web.DevLogin) webMux.HandleFunc("POST /dev-login", web.DevLogin)
webMux.HandleFunc("POST /logout", web.Logout) webMux.HandleFunc("POST /logout", web.Logout)
webMux.HandleFunc("GET /ws", ws) webMux.HandleFunc("GET /ws", ws)
// Static files in standard library
fileServer := http.FileServer(http.Dir("web/static")) fileServer := http.FileServer(http.Dir("web/static"))
webMux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) webMux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))

153
cmd/party/web/errors.go Normal file
View File

@ -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",
})
}

View File

@ -13,7 +13,7 @@ import (
) )
func (web *Web) Home(w http.ResponseWriter, r *http.Request) { 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) http.Redirect(w, r, "/issues", http.StatusSeeOther)
return return
} }

View File

@ -12,7 +12,7 @@ import (
) )
func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) { func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
@ -30,21 +30,17 @@ func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) {
return return
} }
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issues", struct { web.render(w, r, http.StatusOK, "issues", struct {
AuthenticatedUser *data.User AuthenticatedUser *data.User
Issues []common.IssueDetail Issues []common.IssueDetail
CanWriteIssues bool
}{ }{
AuthenticatedUser: user, AuthenticatedUser: user,
Issues: issues, Issues: issues,
CanWriteIssues: permissions.Include("issues:write"),
}) })
} }
func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) { func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return 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) { func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
@ -111,21 +107,17 @@ func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) {
return return
} }
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issue", struct { web.render(w, r, http.StatusOK, "issue", struct {
AuthenticatedUser *data.User AuthenticatedUser *data.User
Issue *common.IssueWithOptions Issue *common.IssueWithOptions
CanWriteIssues bool
}{ }{
AuthenticatedUser: user, AuthenticatedUser: user,
Issue: result, Issue: result,
CanWriteIssues: permissions.Include("issues:write"),
}) })
} }
func (web *Web) UpdateIssueAction(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return 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) { 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) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return 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) { func (web *Web) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
common.WriteJSON(w, http.StatusUnauthorized, common.Envelope{"error": map[string]string{"message": "authentication required"}}, nil) common.WriteJSON(w, http.StatusUnauthorized, common.Envelope{"error": map[string]string{"message": "authentication required"}}, nil)
return return
@ -236,7 +228,7 @@ func (web *Web) BlindSignIssue(w http.ResponseWriter, r *http.Request) {
var input struct { var input struct {
BlindedVote []byte `json:"blinded_vote"` 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) common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return return
} }

View File

@ -11,6 +11,7 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
"party.at/party/internal/data" "party.at/party/internal/data"
"party.at/party/internal/validator" "party.at/party/internal/validator"
"party.at/party/cmd/party/common"
) )
const cookieName = "session" 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName) cookie, err := r.Cookie(cookieName)
if err != nil { if err != nil {
r = setUser(r, data.AnonymousUser) r = common.SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -27,7 +28,7 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler {
v := validator.New() v := validator.New()
if data.ValidateTokenPlaintext(v, cookie.Value); !v.Valid() { if data.ValidateTokenPlaintext(v, cookie.Value); !v.Valid() {
clearCookie(w) clearCookie(w)
r = setUser(r, data.AnonymousUser) r = common.SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -40,7 +41,7 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler {
return return
} }
clearCookie(w) clearCookie(w)
r = setUser(r, data.AnonymousUser) r = common.SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -53,38 +54,47 @@ func (web *Web) AuthenticateCookie(next http.Handler) http.Handler {
return return
} }
clearCookie(w) clearCookie(w)
r = setUser(r, data.AnonymousUser) r = common.SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
r = setUser(r, user) r = common.SetUser(r, user)
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID) permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
r = setPermissions(r, permissions) r = common.SetPermissions(r, permissions)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
func setCookie(w http.ResponseWriter, token string, expiry time.Time) { func (web *Web) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
http.SetCookie(w, &http.Cookie{ return func(w http.ResponseWriter, r *http.Request) {
Name: cookieName, if common.GetUser(r).IsAnonymous() {
Value: token, http.Redirect(w, r, "/", http.StatusSeeOther)
Expires: expiry, return
Path: "/", }
HttpOnly: true, next.ServeHTTP(w, r)
SameSite: http.SameSiteLaxMode, }
}
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) { func (web *Web) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
http.SetCookie(w, &http.Cookie{ return web.RequireActivatedUser(func(w http.ResponseWriter, r *http.Request) {
Name: cookieName, if !common.GetPermissions(r).Include(code) {
Value: "", web.NotPermittedResponse(w, r)
Expires: time.Unix(0, 0), return
Path: "/", }
HttpOnly: true, next.ServeHTTP(w, r)
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}) })
} }

View File

@ -5,6 +5,7 @@ import (
"sort" "sort"
"party.at/party/cmd/party/parlament" "party.at/party/cmd/party/parlament"
"party.at/party/cmd/party/common"
"party.at/party/internal/data" "party.at/party/internal/data"
) )
@ -53,7 +54,7 @@ func (web *Web) MembersOfParliamentPage(w http.ResponseWriter, r *http.Request)
Groups []factionGroup Groups []factionGroup
Total int Total int
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
Groups: groups, Groups: groups,
Total: len(members), Total: len(members),
}) })

View File

@ -4,11 +4,13 @@ import (
"net/http" "net/http"
"party.at/party/cmd/party/parlament" "party.at/party/cmd/party/parlament"
"party.at/party/cmd/party/common"
"party.at/party/internal/data" "party.at/party/internal/data"
) )
func (web *Web) ParlamentDispatch(w http.ResponseWriter, r *http.Request) { 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) web.ParlVotesRowsFragment(w, r)
return return
} }
@ -21,7 +23,7 @@ func (web *Web) ParlVoteDetailPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad Request", http.StatusBadRequest) http.Error(w, "Bad Request", http.StatusBadRequest)
return return
} }
path := "/gegenstand" + routePath path := "/gegenstand/" + routePath
detail, err := web.App.Parlament.GetDocumentVote(path) detail, err := web.App.Parlament.GetDocumentVote(path)
if err != nil { if err != nil {
@ -39,7 +41,7 @@ func (web *Web) ParlVoteDetailPage(w http.ResponseWriter, r *http.Request) {
Detail *parlament.DocumentVoteDetail Detail *parlament.DocumentVoteDetail
Path string Path string
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
Detail: detail, Detail: detail,
Path: path, Path: path,
}) })

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"party.at/party/cmd/party/parlament" "party.at/party/cmd/party/parlament"
"party.at/party/cmd/party/common"
"party.at/party/internal/data" "party.at/party/internal/data"
) )
@ -121,7 +122,7 @@ func (web *Web) ParlVotesPage(w http.ResponseWriter, r *http.Request) {
AuthenticatedUser *data.User AuthenticatedUser *data.User
Period string Period string
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
Period: parlament.CurrentPeriod, Period: parlament.CurrentPeriod,
}) })
} }

View File

@ -4,12 +4,14 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"party.at/party/cmd/party/common"
) )
func (web *Web) renderFragment(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) { func (web *Web) renderFragment(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) {
funcs := template.FuncMap{ funcs := template.FuncMap{
"hasPermission": func(code string) bool { "hasPermission": func(code string) bool {
return getPermissions(r).Include(code) return common.GetPermissions(r).Include(code)
}, },
"partyColor": func(code string) string { "partyColor": func(code string) string {
switch code { switch code {
@ -54,7 +56,7 @@ func (web *Web) render(w http.ResponseWriter, r *http.Request, status int, page
return m, nil return m, nil
}, },
"hasPermission": func(code string) bool { "hasPermission": func(code string) bool {
return getPermissions(r).Include(code) return common.GetPermissions(r).Include(code)
}, },
} }
ts, err := template.New("base").Funcs(funcs).ParseFiles( ts, err := template.New("base").Funcs(funcs).ParseFiles(

View File

@ -14,7 +14,7 @@ func (web *Web) Register(w http.ResponseWriter, r *http.Request) {
AuthenticatedUser *data.User AuthenticatedUser *data.User
FormErrors []string 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 AuthenticatedUser *data.User
FormErrors []string FormErrors []string
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
FormErrors: formErrors, FormErrors: formErrors,
}) })
return 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) { func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return 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) { func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := common.GetUser(r)
if user.IsAnonymous() { if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
@ -122,7 +122,7 @@ func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) {
return return
} }
permissions := getPermissions(r) permissions := common.GetPermissions(r)
if !permissions.Include("users:read") { if !permissions.Include("users:read") {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
@ -145,7 +145,7 @@ func (web *Web) ActivatePage(w http.ResponseWriter, r *http.Request) {
FormErrors []string FormErrors []string
Token string Token string
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
Token: r.URL.Query().Get("token"), Token: r.URL.Query().Get("token"),
}) })
} }
@ -171,7 +171,7 @@ func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) {
FormErrors []string FormErrors []string
Token string Token string
}{ }{
AuthenticatedUser: getUser(r), AuthenticatedUser: common.GetUser(r),
FormErrors: []string{msg}, FormErrors: []string{msg},
Token: token, 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) { func (web *Web) DeleteUserAction(w http.ResponseWriter, r *http.Request) {
currentUser := getUser(r) currentUser := common.GetUser(r)
if currentUser.IsAnonymous() { if currentUser.IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return

View File

@ -16,7 +16,7 @@ func (web *Web) VoteAction(w http.ResponseWriter, r *http.Request) {
Signature []byte `json:"signature"` 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) common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return return
} }

View File

@ -1,42 +1,37 @@
package web package web
import ( import (
"context" // "context"
"net/http" "net/http"
"time"
"party.at/party/cmd/party/common" "party.at/party/cmd/party/common"
"party.at/party/internal/data" // "party.at/party/internal/data"
) )
type Web struct { type Web struct {
App *common.Application App *common.Application
} }
type contextKey string func setCookie(w http.ResponseWriter, token string, expiry time.Time) {
http.SetCookie(w, &http.Cookie{
const userKey contextKey = "web_user" Name: cookieName,
const permissionsKey contextKey = "web_permissions" Value: token,
Expires: expiry,
func setUser(r *http.Request, user *data.User) *http.Request { Path: "/",
return r.WithContext(context.WithValue(r.Context(), userKey, user)) HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
} }
func getUser(r *http.Request) *data.User { func clearCookie(w http.ResponseWriter) {
user, ok := r.Context().Value(userKey).(*data.User) http.SetCookie(w, &http.Cookie{
if !ok { Name: cookieName,
return data.AnonymousUser Value: "",
} Expires: time.Unix(0, 0),
return user Path: "/",
} HttpOnly: true,
SameSite: http.SameSiteLaxMode,
func setPermissions(r *http.Request, permissions data.Permissions) *http.Request { MaxAge: -1,
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
} }

56
cmd/party/web_socket.go Normal file
View File

@ -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
// }
}
}

11
web/html/error.page.tmpl Normal file
View File

@ -0,0 +1,11 @@
{{template "base" .}}
{{define "title"}}Error{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Error</h1>
<h2 class="page-title">{{ .We.Message }}</h2>
</div>
{{end}}

View File

@ -0,0 +1,10 @@
{{template "base" .}}
{{define "title"}}Forbidden{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Forbidden</h1>
</div>
{{end}}

View File

@ -13,7 +13,7 @@
<div style="font-size:13px; color:var(--text-muted); margin-top:8px;"> <div style="font-size:13px; color:var(--text-muted); margin-top:8px;">
{{.Issue.StartTime.Format "02.01.2006"}} {{.Issue.EndTime.Format "02.01.2006"}} {{.Issue.StartTime.Format "02.01.2006"}} {{.Issue.EndTime.Format "02.01.2006"}}
</div> </div>
{{if .CanWriteIssues}} {{if hasPermission "issues:write"}}
<div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;"> <div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;">
<button class="btn btn--danger" <button class="btn btn--danger"
hx-delete="/issues/{{.Issue.ID}}" hx-delete="/issues/{{.Issue.ID}}"

View File

@ -3,6 +3,8 @@
{{define "title"}}Abstimmungen{{end}} {{define "title"}}Abstimmungen{{end}}
{{define "body"}} {{define "body"}}
{{if hasPermission "issues:read"}}
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Abstimmungen</h1> <h1 class="page-title">Abstimmungen</h1>
<p class="page-subtitle">Aktuelle Abstimmungen der Digitalen Partei Österreich</p> <p class="page-subtitle">Aktuelle Abstimmungen der Digitalen Partei Österreich</p>
@ -19,7 +21,7 @@
</div> </div>
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;"> <div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
<a href="/issues/{{.ID}}" class="btn btn--primary">Details</a> <a href="/issues/{{.ID}}" class="btn btn--primary">Details</a>
{{if $.CanWriteIssues}} {{if hasPermission "issues:write"}}
<button class="btn btn--danger" <button class="btn btn--danger"
hx-delete="/issues/{{.ID}}" hx-delete="/issues/{{.ID}}"
hx-confirm="Abstimmung wirklich löschen?">Löschen</button> hx-confirm="Abstimmung wirklich löschen?">Löschen</button>
@ -32,7 +34,11 @@
<p style="color: var(--text-muted);">Keine Abstimmungen vorhanden.</p> <p style="color: var(--text-muted);">Keine Abstimmungen vorhanden.</p>
{{end}} {{end}}
{{if .CanWriteIssues}} {{else}}
<p style="color: var(--text-muted);">Sie haben keine Berechtigung, die Abstimmungen einzusehen.</p>
{{end}}
{{if hasPermission "issues:write"}}
<details style="margin-top:32px;"> <details style="margin-top:32px;">
<summary class="btn btn--primary" style="display:inline-block; cursor:pointer; list-style:none;">+ Neue Abstimmung</summary> <summary class="btn btn--primary" style="display:inline-block; cursor:pointer; list-style:none;">+ Neue Abstimmung</summary>
<div class="auth-card" style="max-width:520px; margin-top:16px;"> <div class="auth-card" style="max-width:520px; margin-top:16px;">