Compare commits

...
7 Commits
Author SHA1 Message Date
vfs 074792be8f . 2026-05-25 10:32:36 +02:00
vfs f77bcb3772 . 2026-05-20 07:54:07 +02:00
vfs 2900582eb4 logging 2026-05-20 07:50:56 +02:00
vfs 65326b0ff5 better logging 2026-05-20 07:22:08 +02:00
vfs 4855255373 . 2026-05-15 20:19:32 +02:00
vfs 529bb6336e web mobile changes 2026-05-13 21:03:49 +02:00
vfs 6f6eea5529 . 2026-05-12 20:38:47 +02:00
35 changed files with 387 additions and 213 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -44,7 +44,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler {
case errors.Is(err, data.ErrRecordNotFound):
api.errorResponse(w, r, data.ErrInvalidAuthToken)
default:
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
@@ -55,7 +55,7 @@ func (api *Api) Authenticate(next http.Handler) http.Handler {
case errors.Is(err, data.ErrRecordNotFound):
api.errorResponse(w, r, data.ErrInvalidCredentials)
default:
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
+3 -3
View File
@@ -38,7 +38,7 @@ func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) {
api.App.Logger.PrintError(err, map[string]string{
"user_id": fmt.Sprint(user.ID),
})
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
@@ -65,7 +65,7 @@ func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) {
tokens, err := api.App.Models.DeviceTokens.GetForUser(user.ID)
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
owns := false
@@ -81,7 +81,7 @@ func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) {
}
if err := api.App.Models.DeviceTokens.Delete(input.Token); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
+10 -15
View File
@@ -16,25 +16,20 @@ func (api *Api) LogError(r *http.Request, err error) {
}
func (api *Api) errorResponse(w http.ResponseWriter, r *http.Request, err error) {
apiErr := &data.Error{
HttpCode: http.StatusInternalServerError,
Message: "the server encountered a problem",
}
api.App.LogError(r, err)
// 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
}
apiErr := &data.Error{
HttpCode: http.StatusInternalServerError,
Message: "the server encountered a problem",
}
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, err)
}
+1 -1
View File
@@ -15,6 +15,6 @@ func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) {
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"health_check": env}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+11 -11
View File
@@ -33,12 +33,12 @@ func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) {
issues, metadata, err := api.App.FetchIssues(input.Title, input.Filters, common.GetUser(r))
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issues": issues, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -65,7 +65,7 @@ func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) {
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID))
if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"issue": issue, "options": options}, headers); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -81,13 +81,13 @@ func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, data.ErrRecordNotFound) {
api.errorResponse(w, r, data.ErrRecordNotFound)
} else {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issue": result.IssueDetail, "options": result.Options}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -117,7 +117,7 @@ func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"issue": issue}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -132,13 +132,13 @@ func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, data.ErrRecordNotFound) {
api.errorResponse(w, r, data.ErrRecordNotFound)
} else {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "issue successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -154,13 +154,13 @@ func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, data.ErrRecordNotFound) {
api.errorResponse(w, r, data.ErrRecordNotFound)
} else {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"public_key": pubKey}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -186,6 +186,6 @@ func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"signed": signed}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ func (api *Api) RecoverPanic(next http.Handler) http.Handler {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
api.ServerErrorResponse(w, r, fmt.Errorf("%s", err))
api.errorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
@@ -49,7 +49,7 @@ func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.Handl
fn := func(w http.ResponseWriter, r *http.Request) {
permissions, err := api.App.Models.Permissions.GetAllForUser(common.GetUser(r).ID)
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if !permissions.Include(code) {
@@ -87,7 +87,7 @@ func (api *Api) RateLimit(next http.Handler) http.Handler {
if api.App.LimiterEnabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) {
members, err := api.App.Parlament.ListNRMembers("XXVIII")
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
@@ -25,6 +25,6 @@ func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) {
"members": members,
"total": len(members),
}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) {
detail, err := api.App.Parlament.GetDocumentVote(path)
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if detail == nil {
@@ -30,6 +30,6 @@ func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) {
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"vote": detail}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+2 -2
View File
@@ -33,7 +33,7 @@ func (api *Api) ListParlVotes(w http.ResponseWriter, r *http.Request) {
Period: []string{parlament.CurrentPeriod},
})
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
@@ -102,7 +102,7 @@ func (api *Api) ListParlVotes(w http.ResponseWriter, r *http.Request) {
"total_with_votes": totalWithVotes,
"party_stats": partyStats,
}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+4 -4
View File
@@ -35,13 +35,13 @@ func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request
case errors.Is(err, data.ErrInvalidCredentials):
api.errorResponse(w, r, data.ErrInvalidCredentials)
default:
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"authentication_token": token}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -61,11 +61,11 @@ func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request
}
if err := api.App.DeleteToken(token); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "authentication token successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+22 -45
View File
@@ -4,7 +4,6 @@ import (
"errors"
"net/http"
"strconv"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
@@ -31,62 +30,45 @@ func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) {
users, metadata, err := api.App.ListUsers(input.Filters)
if err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"users": users, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
ProviderId int64 `json:"provider_id"`
Username string `json:"username"`
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
AltName *string `json:"alt_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Address string `json:"address"`
}
var input common.RegisterUserInput
if err := common.ReadJSON(w, r, &input); err != nil {
api.errorResponse(w, r, data.ErrBadRequest)
return
}
user, authToken, err := api.App.RegisterUser(common.RegisterUserInput{
ProviderID: input.ProviderId,
Username: input.Username,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Email: input.Email,
Password: input.Password,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
})
user, authToken, err := api.App.RegisterUser(input)
if err != nil {
api.errorResponse(w, r, err)
return
}
api.App.Logger.PrintInfo("user registered", map[string]string{
"user_id": strconv.FormatInt(user.ID, 10),
"email": user.Email,
})
if err = common.WriteJSON(w, http.StatusCreated, common.Envelope{"user": user, "authentication_token": authToken}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
var id int64
param := r.PathValue("id")
user := common.GetUser(r)
param := r.PathValue("id")
var id int64
if param == "me" {
id = common.GetUser(r).ID
} else {
@@ -98,23 +80,18 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
}
}
if common.GetUser(r).ID != id {
api.errorResponse(w, r, data.ErrNotPermitted)
return
}
user, err := api.App.GetUser(id)
target, err := api.App.GetUser(user, id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.errorResponse(w, r, data.ErrRecordNotFound)
} else {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"user": target}, nil); err != nil {
api.errorResponse(w, r, err)
}
}
@@ -122,7 +99,7 @@ func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
// user := common.GetUser(r).ID
// if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil {
// api.ServerErrorResponse(w, r, err)
// api.errorResponse(w, r, err)
// }
// }
@@ -137,13 +114,13 @@ func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, data.ErrRecordNotFound) {
api.errorResponse(w, r, data.ErrRecordNotFound)
} else {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "user successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
@@ -170,6 +147,6 @@ func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) {
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+7 -2
View File
@@ -8,6 +8,11 @@ import (
)
func (api *Api) Vote(w http.ResponseWriter, r *http.Request) {
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
api.errorResponse(w, r, data.ErrAuthenticatedVoteRejected)
return
}
var input struct {
IssueID int64 `json:"issue_id"`
OptionID int64 `json:"option_id"`
@@ -21,12 +26,12 @@ func (api *Api) Vote(w http.ResponseWriter, r *http.Request) {
}
if err := api.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
return
}
if err := common.WriteJSON(w, http.StatusCreated, common.Envelope{"message": "vote successfully cast"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
api.errorResponse(w, r, err)
}
}
+1
View File
@@ -79,6 +79,7 @@ type contextKey string
const userKey contextKey = "web_user"
const permissionsKey contextKey = "web_permissions"
const RequestBodyKey contextKey = "request_body"
func SetUser(r *http.Request, user *data.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userKey, user))
+13 -2
View File
@@ -1,6 +1,7 @@
package common
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -128,8 +129,18 @@ func ReadCSV(qs url.Values, key string, defaultValue []string) []string {
// ── Error responses ──────────────────────────────────────────────────────────
func (app *Application) LogError(r *http.Request, err error) {
app.Logger.PrintError(err, map[string]string{
props := map[string]string{
"request_method": r.Method,
"request_url": r.URL.String(),
})
}
if buf, ok := r.Context().Value(RequestBodyKey).(*bytes.Buffer); ok && buf.Len() > 0 {
props["request_body"] = buf.String()
}
var apiErr *data.Error
if errors.As(err, &apiErr) {
for k, v := range apiErr.Details {
props[k] = v
}
}
app.Logger.PrintError(err, props)
}
+12
View File
@@ -1,7 +1,10 @@
package common
import (
"bytes"
"context"
"expvar"
"io"
"net/http"
"strconv"
"sync"
@@ -9,6 +12,15 @@ import (
"github.com/felixge/httpsnoop"
)
func (app *Application) CaptureBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := &bytes.Buffer{}
r.Body = io.NopCloser(io.TeeReader(r.Body, buf))
ctx := context.WithValue(r.Context(), RequestBodyKey, buf)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
var (
metricsOnce sync.Once
metricTotalRequests *expvar.Int
+61 -14
View File
@@ -8,16 +8,17 @@ import (
)
type RegisterUserInput struct {
ProviderID int64
Username string
PhoneNumber string
Country string
Email string
Password string
Name string
AltName *string
DateOfBirth time.Time
Address string
ProviderID int64 `json:"provider_id"`
Username string `json:"username"`
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
AltName *string `json:"alt_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Address string `json:"address"`
Role string `json:"role"`
}
func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data.Token, error) {
@@ -53,8 +54,8 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data
}
role := "viewer"
if app.Config.Env == "development" {
role = "admin"
if app.Config.Env == "development" && input.Role != "" {
role = input.Role
}
if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil {
@@ -85,8 +86,54 @@ func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data
return user, authToken, nil
}
func (app *Application) GetUser(id int64) (*data.User, error) {
return app.Models.Users.Get(id)
func (app *Application) GetUser(user *data.User, id int64) (*data.User, error) {
var ret data.User
roles, err := app.Models.Roles.GetAllForUser(user.ID)
if err != nil {
return &ret, err
}
target, err := app.Models.Users.Get(id)
if err != nil {
return &ret, err
}
if user.ID == target.ID {
return target, nil
}
if roles[0].Code == "viewer" {
return nil, data.ErrNotPermitted
}
if roles[0].Code == "contributor" {
return nil, data.ErrInvalidCredentials
}
if roles[0].Code == "member_of_parliament" {
ret.Email = target.Email
ret.PhoneNumber = target.PhoneNumber
ret.Country = target.Country
ret.Name = target.Name
ret.AltName = target.AltName
ret.DateOfBirth = target.DateOfBirth
ret.Address = target.Address
}
if roles[0].Code == "party_leadership" {
}
if roles[0].Code == "admin" {
ret.ID = target.ID
ret.LastLogin = target.LastLogin
ret.Activated = target.Activated
ret.Created = target.Created
ret.Version = target.Version
}
return &ret, nil
}
func (app *Application) ListUsers(filters data.Filters) ([]*data.User, data.Metadata, error) {
+2 -2
View File
@@ -40,7 +40,7 @@ func routes(app *common.Application) http.Handler {
apiMux.HandleFunc("GET /v1/parlament/votes/{path...}", api.GetParlVoteDetail)
apiMux.Handle("GET /debug/vars", expvar.Handler())
apiChain := app.Metrics(api.RecoverPanic(app.EnableCORS(api.RateLimit(api.Authenticate(apiMux)))))
apiChain := app.Metrics(api.RecoverPanic(app.CaptureBody(app.EnableCORS(api.RateLimit(api.Authenticate(apiMux))))))
// ── Web router ──────────────────────────────────────────────────────────
web := web.Web{
@@ -63,7 +63,7 @@ func routes(app *common.Application) http.Handler {
webMux.HandleFunc("GET /users", web.RequirePermission("users:read", web.UsersPage))
webMux.HandleFunc("GET /users/me", web.RequireAuthenticatedUser(web.ProfilePage))
webMux.HandleFunc("GET /users/activated", web.ActivatePage)
webMux.HandleFunc("POST /users/activated", web.ActivateUserAction)
webMux.HandleFunc("PUT /users/activated", web.ActivateUserAction)
webMux.HandleFunc("DELETE /users/{id}", web.RequirePermission("users:write", web.DeleteUserAction))
webMux.HandleFunc("GET /mps", web.MembersOfParliamentPage)
+8 -1
View File
@@ -92,8 +92,14 @@ func (ts *testServer) get(t *testing.T, path string) (int, http.Header, []byte)
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) registerAndLogin(t *testing.T, email, password string) string {
func (ts *testServer) registerAndLogin(t *testing.T, email, password string, roles ...string) string {
t.Helper()
var role string
if len(role) > 0 {
role = roles[0]
}
registerBody := map[string]any{
"email": email,
"password": password,
@@ -101,6 +107,7 @@ func (ts *testServer) registerAndLogin(t *testing.T, email, password string) str
"name": "Test User",
"alt_name": "",
"provider_id": 1,
"role": role,
}
code, _, body := ts.postJSON(t, "/v1/users", registerBody)
if code != http.StatusCreated {
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"bytes"
// "net/http"
// "strconv"
"testing"
// "time"
)
func TestReadUserHandler(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
token := ts.registerAndLogin(t, uniqueEmail(), "pa$$word123", "viewer")
code, _, body := ts.getWithToken(t, "/v1/users/1", token)
if code != 200 {
t.Errorf("want %d; got %d", 403, code)
}
if !bytes.Contains(body, []byte("An old silent pond...")) {
t.Errorf("want body to contain %q; got %q", "", string(body))
}
}
+11 -10
View File
@@ -65,7 +65,11 @@ func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) {
}
setCookie(w, authToken.Plaintext, authToken.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
if web.App.Config.Env == "production" {
http.Redirect(w, r, "/users/activated", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
}
func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) {
@@ -75,7 +79,7 @@ func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) {
return
}
fullUser, err := web.App.GetUser(user.ID)
fullUser, err := web.App.GetUser(user, user.ID)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
@@ -158,19 +162,16 @@ func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) {
web.App.LogError(r, err)
msg = "Aktivierung fehlgeschlagen. Bitte versuchen Sie es erneut."
}
web.render(w, r, http.StatusUnprocessableEntity, "activated", struct {
AuthenticatedUser *data.User
FormErrors []string
Token string
web.renderFragment(w, r, http.StatusUnprocessableEntity, "activated", struct {
FormErrors []string
}{
AuthenticatedUser: common.GetUser(r),
FormErrors: []string{msg},
Token: token,
FormErrors: []string{msg},
})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
}
func (web *Web) DeleteUserAction(w http.ResponseWriter, r *http.Request) {
+41 -38
View File
@@ -7,26 +7,28 @@ import (
type ErrorCode int
const (
// 400 variants
// 401 variants
ErrCodeInvalidCredentials ErrorCode = 4011
ErrCodeInvalidAuthToken ErrorCode = 4012
ErrCodeAuthRequired ErrorCode = 4013
errCodeInvalidCredentials ErrorCode = 4011
errCodeInvalidAuthToken ErrorCode = 4012
errCodeAuthRequired ErrorCode = 4013
// 403 variants
ErrCodeInactiveAccount ErrorCode = 4031
ErrCodeNotPermitted ErrorCode = 4032
errCodeInactiveAccount ErrorCode = 4031
errCodeNotPermitted ErrorCode = 4032
// 409 variants
ErrCodeEditConflict ErrorCode = 4091
ErrCodeAlreadyVoted ErrorCode = 4092
ErrCodeAlreadyBlindSigned ErrorCode = 4093
ErrCodeVoteAlreadyCast ErrorCode = 4094
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
errCodeValidationFailed ErrorCode = 4221
errCodeBlindedVoteRange ErrorCode = 4222
errCodeInvalidSignature ErrorCode = 4223
errCodeHasNotStarted ErrorCode = 4224
)
type Error struct {
@@ -60,41 +62,42 @@ func New(httpCode int, code ErrorCode, text string) error {
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")
ErrFailedPEM = New(400, 400, "failed to decode PEM block")
ErrBadlyFormedJSON = New(400, 400, "body contains badly-formed JSON")
ErrBodyEmpty = New(400, 400, "body must not be empty")
ErrSingleValue = New(400, 400, "body must only contain a single JSON value")
ErrInvalidID = New(400, 400, "invalid id parameter")
ErrBadRequest = New(400, 400, "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")
ErrInvalidCredentials = New(401, errCodeInvalidCredentials, "invalid credentials")
ErrInvalidAuthToken = New(401, errCodeInvalidAuthToken, "invalid or missing authentication token")
ErrNoToken = New(401, errCodeInvalidAuthToken, "token must be provided")
ErrAuthRequired = New(401, errCodeAuthRequired, "you must be authenticated to access this resource")
// 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")
ErrInactiveAccount = New(403, errCodeInactiveAccount, "your user account must be activated to access this resource")
ErrNotPermitted = New(403, errCodeNotPermitted, "your user account doesn't have the necessary permissions to access this resource")
ErrAuthenticatedVoteRejected = New(403, errCodeNotPermitted, "authenticated vote rejected")
// 404 Not Found
ErrRecordNotFound = New(404, 0, "record not found")
ErrNoPath = New(404, 0, "path is required")
ErrRecordNotFound = New(404, 404, "record not found")
ErrNoPath = New(404, 404, "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")
ErrEditConflict = New(409, errCodeEditConflict, "edit conflict")
ErrDuplicateVote = New(409, errCodeAlreadyVoted, "this signature has already been used to cast a vote")
ErrDuplicateBlindSign = New(409, errCodeAlreadyBlindSigned, "user has already requested a blind signature for this issue")
ErrDuplicateSignature = New(409, errCodeVoteAlreadyCast, "this signature has already been used to cast a vote")
ErrDuplicateEmail = New(409, 409, "duplicate email")
ErrDuplicateUser = New(409, 409, "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")
ErrValidationFailed = New(422, errCodeValidationFailed, "validation failed")
ErrInvalidBlindedVote = New(422, errCodeBlindedVoteRange, "blinded_vote is out of valid range [1, n-1]")
ErrInvalidSignature = New(422, errCodeInvalidSignature, "signature verification failed")
ErrHasNotStarted = New(422, errCodeHasNotStarted, "the vote has not yet started")
// 429 Too Many Requests
ErrRateLimitExceeded = New(429, 0, "rate limit exceeded")
ErrRateLimitExceeded = New(429, 429, "rate limit exceeded")
)
+34 -15
View File
@@ -15,17 +15,17 @@ import (
var AnonymousUser = &User{}
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Name string `json:"name"`
AltName *string `json:"alt_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Address string `json:"address"`
Created time.Time `json:"created"`
LastLogin time.Time `json:"last_login"`
Activated bool `json:"activated"`
ID int64 `json:"id,omitempty"`
Email string `json:"email,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
Country string `json:"country,omitempty"`
Name string `json:"name,omitempty"`
AltName *string `json:"alt_name,omitempty"`
DateOfBirth time.Time `json:"date_of_birth,omitempty"`
Address string `json:"address,omitempty"`
Created time.Time `json:"created,omitempty"`
LastLogin time.Time `json:"last_login,omitempty"`
Activated bool `json:"activated,omitempty"`
Version int32 `json:"-"`
}
@@ -306,10 +306,29 @@ func (m UserModel) Update(user *User) error {
func (m UserModel) GetAll(filters Filters) ([]*User, Metadata, error) {
query := fmt.Sprintf(`
SELECT COUNT(*) OVER(), id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version
FROM users
ORDER BY %s %s, id ASC
LIMIT $1 OFFSET $2`,
SELECT
COUNT(*) OVER(),
id,
email,
phone_number,
country,
name,
alt_name,
date_of_birth,
address,
created,
last_login,
activated,
version
FROM
users
ORDER BY
%s %s,
id ASC
LIMIT
$1
OFFSET
$2`,
filters.sortColumn(),
filters.sortDirection(),
)
+3 -9
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"io"
"os"
"runtime/debug"
"sync"
"time"
)
@@ -67,18 +66,13 @@ func (l *Logger) print(level Level, message string, properties map[string]string
Time string `json:"time"`
Message string `json:"message"`
Properties map[string]string `json:"properties,omitempty"`
Trace string `json:"trace,omitempty"`
}{
Level: level.String(),
Time: time.Now().UTC().Format(time.RFC3339),
Message: message,
Level: level.String(),
Time: time.Now().UTC().Format(time.RFC3339),
Message: message,
Properties: properties,
}
if level >= LevelError {
aux.Trace = string(debug.Stack())
}
var line []byte
line, err := json.Marshal(aux)
+5
View File
@@ -0,0 +1,5 @@
{{define "activated"}}
<div class="alert alert--error">
{{range .FormErrors}}<p>{{.}}</p>{{end}}
</div>
{{end}}
+2 -6
View File
@@ -8,13 +8,9 @@
<h2 class="auth-title">Konto aktivieren</h2>
<p class="auth-subtitle">Geben Sie Ihren Aktivierungstoken ein, um Ihr Konto freizuschalten.</p>
{{if .FormErrors}}
<div class="alert alert--error">
{{range .FormErrors}}<p>{{.}}</p>{{end}}
</div>
{{end}}
<div id="activation-result"></div>
<form method="POST" action="/users/activated">
<form hx-put="/users/activated" hx-target="#activation-result" hx-swap="innerHTML">
<div class="form-group">
<label class="form-label" for="token">Aktivierungstoken</label>
<input class="form-input" id="token" name="token" type="text"
+1 -1
View File
@@ -13,7 +13,7 @@
<body>
{{if .AuthenticatedUser}}
<nav style="border-bottom:1px solid var(--border); background:var(--bg-card);">
<div class="container" style="display:flex; align-items:center; gap:20px; padding:10px 0;">
<div class="container" style="display:flex; align-items:center; gap:10px 20px; padding-top:10px; padding-bottom:10px; flex-wrap:wrap;">
<a href="/" class="link">Start</a>
<a href="/issues" class="link">Abstimmungen</a>
<a href="/mps" class="link">Nationalrat</a>
+1 -1
View File
@@ -4,7 +4,7 @@
{{define "body"}}
<div style="text-align:center; padding: 40px 0 24px;">
<img src="/static/logo.svg" alt="DPÖ Logo" style="height: 600px; width: auto;">
<img src="/static/logo.svg" alt="DPÖ Logo" style="height: auto; max-height: 600px; width: 100%; max-width: 600px;">
</div>
<div class="page-header">
+1 -1
View File
@@ -4,7 +4,7 @@
{{define "body"}}
<div style="text-align:center; padding: 40px 0 24px;">
<img src="/static/logo.svg" alt="DPÖ Logo" style="height: 600px; width: auto;">
<img src="/static/logo.svg" alt="DPÖ Logo" style="height: auto; max-height: 600px; width: 100%; max-width: 600px;">
</div>
<div class="hero">
+1 -1
View File
@@ -14,7 +14,7 @@
{{.Name}}
<span style="font-size: 13px; font-weight: 500; color: var(--text-muted); font-family: 'Source Sans 3', sans-serif;">{{len .Members}} Mandate</span>
</h2>
<div style="background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow);">
<div style="background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow);" class="table-scroll">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: var(--bg); border-bottom: 1px solid var(--border);">
+1 -1
View File
@@ -50,7 +50,7 @@
</div>
{{/* ── Two-column faction breakdown ─────────────────────────────────────── */}}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px;">
<div class="faction-grid">
{{/* Yes column */}}
<div>
+1 -1
View File
@@ -34,7 +34,7 @@
<span>{{.Label}}</span>
<span style="font-size: 12px; font-weight: 400; color: var(--text-muted);">{{len .Docs}}</span>
</summary>
<div style="border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius) var(--radius); overflow: hidden; background: var(--bg-card);">
<div style="border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius) var(--radius); background: var(--bg-card);" class="table-scroll">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: var(--bg); border-bottom: 1px solid var(--border);">
+50 -16
View File
@@ -3,25 +3,59 @@
{{define "title"}}Mein Profil{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Mein Profil</h1>
</div>
<div style="display:flex; flex-direction:column; align-items:center; padding-top:32px;">
<div class="card" style="max-width:520px;">
<div class="card-title">{{.User.Name}}{{if .User.AltName}} / {{.User.AltName}}{{end}}</div>
<div style="margin-top:12px; display:flex; flex-direction:column; gap:8px; font-size:15px;">
<div><span style="color:var(--text-muted);">E-Mail</span> &nbsp; {{.User.Email}}</div>
<div><span style="color:var(--text-muted);">Telefon</span> &nbsp; {{.User.PhoneNumber}}</div>
<div><span style="color:var(--text-muted);">Land</span> &nbsp; {{.User.Country}}</div>
<div><span style="color:var(--text-muted);">Adresse</span> &nbsp; {{.User.Address}}</div>
<div><span style="color:var(--text-muted);">Geburtsdatum</span> &nbsp; {{.User.DateOfBirth.Format "02.01.2006"}}</div>
<div><span style="color:var(--text-muted);">Mitglied seit</span> &nbsp; {{.User.Created.Format "02.01.2006"}}</div>
<div style="width:88px; height:88px; border-radius:50%; background:var(--accent); display:flex; align-items:center; justify-content:center; font-family:'Montserrat',sans-serif; font-size:34px; font-weight:700; color:#1a1008; box-shadow:0 4px 18px rgba(200,150,26,0.35); margin-bottom:20px; flex-shrink:0;">
{{slice .User.Name 0 1}}
</div>
<div style="margin-top:20px; border-top:1px solid var(--border); padding-top:16px;">
<button class="btn btn--danger"
hx-delete="/users/{{.User.ID}}"
hx-confirm="Konto wirklich unwiderruflich löschen?">Konto löschen</button>
<h1 style="font-family:'Montserrat',sans-serif; font-size:28px; font-weight:700; color:var(--dark); text-align:center; margin-bottom:4px;">
{{.User.Name}}{{if .User.AltName}} <span style="color:var(--text-muted); font-weight:600;">/ {{.User.AltName}}</span>{{end}}
</h1>
<p style="font-size:13px; font-weight:600; letter-spacing:0.09em; text-transform:uppercase; color:var(--accent); margin-bottom:36px;">Mitglied</p>
<div style="background:var(--bg-card); border:1px solid var(--border); border-radius:12px; box-shadow:0 6px 32px rgba(0,0,0,0.10); width:100%; max-width:620px; overflow:hidden;">
<div style="background:var(--dark); padding:16px 32px; border-bottom:3px solid var(--accent);">
<span style="font-family:'Montserrat',sans-serif; font-size:12px; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; color:rgba(255,255,255,0.5);">Profildaten</span>
</div>
<div style="padding:0 32px;">
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">E-Mail</span>
<span style="font-size:16px; color:var(--text);">{{.User.Email}}</span>
</div>
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">Telefon</span>
<span style="font-size:16px; color:var(--text);">{{.User.PhoneNumber}}</span>
</div>
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">Land</span>
<span style="font-size:16px; color:var(--text);">{{.User.Country}}</span>
</div>
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">Adresse</span>
<span style="font-size:16px; color:var(--text);">{{.User.Address}}</span>
</div>
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">Geburtsdatum</span>
<span style="font-size:16px; color:var(--text);">{{.User.DateOfBirth.Format "02.01.2006"}}</span>
</div>
<div style="display:flex; align-items:baseline; gap:16px; padding:18px 0;">
<span style="font-size:13px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; width:140px; flex-shrink:0;">Mitglied seit</span>
<span style="font-size:16px; color:var(--accent); font-weight:600;">{{.User.Created.Format "02.01.2006"}}</span>
</div>
</div>
<div style="border-top:1px solid var(--border); background:#fef2f2; padding:20px 32px; display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="font-size:13px; font-weight:700; color:#991b1b; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:2px;">Gefahrenzone</div>
<div style="font-size:13px; color:#b91c1c;">Diese Aktion kann nicht rückgängig gemacht werden.</div>
</div>
<button class="btn btn--danger"
hx-delete="/users/{{.User.ID}}"
hx-confirm="Konto wirklich unwiderruflich löschen?">Konto löschen</button>
</div>
</div>
</div>
{{end}}
+2 -2
View File
@@ -18,7 +18,7 @@
{{end}}
<form method="POST" action="/register">
<div style="display:grid; grid-template-columns:1fr 1fr; gap: 0 16px;">
<div class="form-row-2">
<div class="form-group">
<label class="form-label" for="name">Vollständiger Name</label>
<input class="form-input" type="text" id="name" name="name"
@@ -49,7 +49,7 @@
placeholder="••••••••" required autocomplete="new-password">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap: 0 16px;">
<div class="form-row-2">
<div class="form-group">
<label class="form-label" for="phone_number">Telefonnummer</label>
<input class="form-input" type="tel" id="phone_number" name="phone_number"
+40
View File
@@ -373,6 +373,17 @@ a:hover {
color: var(--text);
}
.btn--danger {
background: #dc2626;
color: #fff;
}
.btn--danger:hover {
background: #b91c1c;
color: #fff;
box-shadow: 0 2px 8px rgba(220,38,38,0.3);
}
.btn--full {
width: 100%;
margin-top: 4px;
@@ -430,11 +441,40 @@ a:hover {
color: rgba(255,255,255,0.2);
}
/* ── Table scroll wrapper ── */
.table-scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
/* ── 2-col form row ── */
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
/* ── Faction breakdown grid ── */
.faction-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.brand-name { display: none; }
.hero-title { font-size: 36px; }
.hero { padding: 40px 0 32px; margin-bottom: 32px; }
.auth-card { padding: 24px; }
.card-grid { grid-template-columns: 1fr; }
.form-row-2 { grid-template-columns: 1fr; }
.faction-grid { grid-template-columns: 1fr; }
.site-main { padding: 28px 0 40px; }
}