moved a lot

This commit is contained in:
Vicente Ferrari Smith 2026-05-09 14:40:14 +02:00
parent 5772a7d855
commit 136a1c34b3
98 changed files with 6950 additions and 2183 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.vscode/launch.json vendored
View File

@ -9,7 +9,8 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/cmd/api", "program": "${workspaceFolder}/cmd/party",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.env",
"args": ["--env=development"] "args": ["--env=development"]
} }

View File

@ -1,26 +0,0 @@
package main
import (
"context"
"net/http"
"party.at/party/internal/data"
)
type contextKey string
const userContextKey = "user"
func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
return r.WithContext(ctx)
}
func (app *application) contextGetUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userContextKey).(*data.User)
if !ok {
panic("missing user value in request context")
}
return user
}

View File

@ -1,141 +0,0 @@
package main
import (
// "context"
"database/sql"
"log"
)
func database_init_dont_use(conn *sql.DB) {
sql := `DROP TABLE IF EXISTS vote;
DROP TABLE IF EXISTS vote_token;
DROP TABLE IF EXISTS option;
DROP TABLE IF EXISTS issues;
DROP TABLE IF EXISTS account;`
_, err := conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE account (
id BIGINT primary key generated always as identity,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
first_name TEXT not null,
last_name TEXT not null,
created TIMESTAMPTZ DEFAULT now() NOT NULL,
last_login TIMESTAMPTZ
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('vik', '$argon2id$v=19$m=65536,t=1,p=32$+dQ9uB7kKL7t7G3bI+TOMw$Wvic27W6SYH6Fx2Pp84irhVJ/blVh5qINlkv58bpgEc', 'Vicente', 'Ferrari Smith', 'vikhenzo@gmail.com', '2024-05-24 13:21:48.179');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('Al Orjales', '$argon2id$v=19$m=65536,t=1,p=1$rQODKJ0+mUZ6v6ChUAcr4Q$x0cDjym/QB9lFFq/77FPv7R90Ao5gldb9cuNprBpGAs', '', '', '', '2024-09-26 17:43:37.879');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('mkBflwkpe', '$argon2id$v=19$m=65536,t=1,p=1$BWcWdp8bhgS84LWqUCb2IA$DGF/FzQbSNHnfZraE9F2qvfdBGf5XB81+w00QgY/jG0', 'zWkxKTNolTgJwO', 'OahedOBLSo', 'bellrebekaou@gmail.com', '2024-11-25 03:00:18.211');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('TzBeIMeRjxrfsM', '$argon2id$v=19$m=65536,t=1,p=1$ZIpNaO6RPeGncWe9cw8Iog$di0qjf8G0HlcZE8Hl+krNDlBeMrtfuGMwFWAlAnEMNs', 'ZBbLOWXqQlr', 'pIomimIQ', 'denielkgb21@gmail.com', '2024-11-19 13:20:33.177');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('TeAOvwIdnfqpxy', '$argon2id$v=19$m=65536,t=1,p=1$GRL555iybL1S8GjPq8jlOg$YnuwBxHAT8/I+cU548CYuhwaJdcEXe+R2PQbYpV7UKQ', 'RzqsaECTYrz', 'zreWiEtZGOeRNI', 'aolelonnum@yahoo.com', '2024-11-26 01:19:37.780');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('XIdQMFvxmT', '$argon2id$v=19$m=65536,t=1,p=1$PIcX7GObR0wlgRTmcMRUug$Kpc7SAv5K1PRxBtqioV4uoCZlkvGebkBmYyXCwoTgmM', 'ayZKALohBYmBx', 'smILWvtOvb', 'heilwoodsf@gmail.com', '2024-11-23 00:41:59.471');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('yIQvINFS', '$argon2id$v=19$m=65536,t=1,p=1$SJxMxkotEr+lfGeaCs7vlA$fffslijaMuPy+XPBEdv9iPqLbt66H/qJbGUHGrbpdz0', 'nKXBOwRbtXmMedo', 'KvMechDKtPPMgM', 'cortezzavira@gmail.com', '2024-11-24 06:29:58.378');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('HEjcEByjlPjtaGE', '$argon2id$v=19$m=65536,t=1,p=1$Hu2qnPxi0nz0OHx97h5j5Q$xh7HwKBIi9mp+WWU7rS9MfnDohtJqrv0EUrF2mVpnto', 'yJrQikhSHRYNgdu', 'GttOfPxSPoVWWl', 'jacobsheizitn4963@gmail.com', '2024-11-27 00:07:53.617');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('DggewDRjXu', '$argon2id$v=19$m=65536,t=1,p=1$MzFSqUr5J5IRC930empHrw$y7kogPP715NOtazsXqjR56LQGF2Eaes9CyAoPjRm3xk', 'uMezBkaQcfBbD', 'YoaQNBvZgHvTaFf', 'kinharrispa@gmail.com', '2024-11-27 21:55:53.037');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('yyZGFAsmPEBoSf', '$argon2id$v=19$m=65536,t=1,p=1$vLVm5ol6GQkXPU0BPut92g$Kyvp7/dl3lGUszXRiXfgsgB/IY0EKulZVpVttXQaDDU', 'svVjyPUaAkN', 'mdngeHlf', 'giyahyd4141@gmail.com', '2024-11-28 19:02:32.806');`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE issues (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE option (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE vote_token (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE vote (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token UUID NOT NULL UNIQUE REFERENCES vote_token(token) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES option(id) ON DELETE CASCADE,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE INDEX idx_votes_option_id ON vote(option_id);
CREATE INDEX idx_vote_tokens_issue_id ON vote_token(issue_id);
CREATE INDEX idx_options_issue_id ON option(issue_id);`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
}

View File

@ -1,104 +0,0 @@
package main
import (
"fmt"
"net/http"
)
// The logError() method is a generic helper for logging an error message. Later in the
// book we'll upgrade this to use structured logging, and record additional information
// about the request including the HTTP method and URL.
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(),
})
}
// The errorResponse() method is a generic helper for sending JSON-formatted error
// messages to the client with a given status code. Note that we're using an interface{}
// type for the message parameter, rather than just a string type, as this gives us
// more flexibility over the values that we can include in the response.
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) {
env := envelope{"error": message}
// Write the response using the writeJSON() helper. If this happens to return an
// error then log it, and fall back to sending the client an empty response with a
// 500 Internal Server Error status code.
err := app.writeJSON(w, status, env, nil)
if err != nil {
app.logError(r, err)
w.WriteHeader(500)
}
}
// The serverErrorResponse() method will be used when our application encounters an
// unexpected problem at runtime. It logs the detailed error message, then uses the
// errorResponse() helper to send a 500 Internal Server Error status code and JSON
// response (containing a generic error message) to the client.
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
app.logError(r, err)
message :=
"the server encountered a problem and could not process your request"
app.errorResponse(w, r, http.StatusInternalServerError, message)
}
// The notFoundResponse() method will be used to send a 404 Not Found status code and
// JSON response to the client.
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
message := "the requested resource could not be found"
app.errorResponse(w, r, http.StatusNotFound, message)
}
// The methodNotAllowedResponse() method will be used to send a 405 Method Not Allowed
// status code and JSON response to the client.
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
}
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
}
func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
message := "unable to update the record due to an edit conflict, please try again"
app.errorResponse(w, r, http.StatusConflict, message)
}
func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
message := "rate limit exceeded"
app.errorResponse(w, r, http.StatusTooManyRequests, message)
}
func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
message := "invalid authentication credentials"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Bearer")
message := "invalid or missing authentication token"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
message :="you must be authenticated to access this resource"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
message := "your user account must be activated to access this resource"
app.errorResponse(w, r, http.StatusForbidden, message)
}
func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) {
message := "your user account doesn't have the necessary permissions to access this resource"
app.errorResponse(w, r, http.StatusForbidden, message)
}

View File

@ -1,22 +0,0 @@
package main
import (
// "encoding/json"
//"fmt"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
env := envelope{
"status": "available",
"system_info": map[string]string{
"environment": app.config.env,
"version": version,
},
}
err := app.writeJSON(w, http.StatusOK, envelope{"health_check": env}, nil)
if err != nil {
app.serverErrorResponse(w,r, err)
}
}

View File

@ -1,212 +0,0 @@
package main
import (
"net/http"
"encoding/json"
"strings"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"party.at/party/internal/validator"
"github.com/julienschmidt/httprouter"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"crypto/rand"
)
type envelope map[string]interface{}
func (app *application) readString(qs url.Values, key string, defaultValue string) string {
// Extract the value for a given key from the query string. If no key exists this
// will return the empty string "".
s := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if s == "" {
return defaultValue
}
// Otherwise return the string.
return s
}
func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string {
// Extract the value from the query string.
csv := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if csv == "" {
return defaultValue
}
// Otherwise parse the value into a []string slice and return it.
return strings.Split(csv, ",")
}
func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
// Extract the value from the query string.
s := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if s == "" {
return defaultValue
}
// Try to convert the value to an int. If this fails, add an error message to the
// validator instance and return the default value.
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
// Otherwise, return the converted integer value.
return i
}
func (app *application) 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 (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
// Encode the data to JSON, returning the error if there was one.
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
// Append a newline to make it easier to view in terminal applications.
js = append(js, '\n')
// At this point, we know that we won't encounter any more errors before writing the
// response, so it's safe to add any headers that we want to include. We loop
// through the header map and add each header to the http.ResponseWriter header map.
// Note that it's OK if the provided header map is nil. Go doesn't throw an error
// if you try to range over (or generally, read from) a nil map.
for key, value := range headers {
w.Header()[key] = value
}
// Add the "Content-Type: application/json" header, then write the status code and
// JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
maxBytes := 1048576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
// Initialize the json.Decoder, and call the DisallowUnknownFields() method on it
// before decoding. This means that if the JSON from the client now includes any
// field which cannot be mapped to the target destination, the decoder will return
// an error instead of just ignoring the field.
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
// Decode the request body to the destination.
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")
// If the JSON contains a field which cannot be mapped to the target destination
// then Decode() will now return an error message in the format "json: unknown
// field "<name>"". We check for this, extract the field name from the error,
// and interpolate it into our custom error message. Note that there's an open
// issue at https://github.com/golang/go/issues/29035 regarding turning this
// into a distinct error type in the future.
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)
// If the request body exceeds 1MB in size the decode will now fail with the
// error "http: request body too large". There is an open issue about turning
// this into a distinct error type at https://github.com/golang/go/issues/30715.
case err.Error() == "http: request body too large":
return fmt.Errorf("body must not be larger than %d bytes", maxBytes)
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
// Call Decode() again, using a pointer to an empty anonymous struct as the
// destination. If the request body only contained a single JSON value this will
// return an io.EOF error. So if we get anything else, we know that there is
// additional data in the request body and we return our own custom error message.
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func (app *application) background(fn func()) {
// Launch a background goroutine.
go func() {
// Recover any panic.
defer func() {
if err := recover(); err != nil {
app.logger.PrintError(fmt.Errorf("%s", err), nil)
}
}()
// Execute the arbitrary function that we passed as the parameter.
fn()
}()
}
func GenerateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
// Encode private key as PEM
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privDER,
})
return key.N.Bytes(), key.E, privPEM, err
}

View File

@ -1,295 +0,0 @@
package main
import (
// "encoding/json"
"fmt"
// "html/template"
// "log"
"net/http"
"time"
// "github.com/julienschmidt/httprouter"
// "strconv"
"errors"
"party.at/party/internal/data"
"party.at/party/internal/validator"
"encoding/hex"
)
func (app *application) listIssuesHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Title = app.readString(qs, "title", "")
// input.Genres = app.readCSV(qs, "genres", []string{})
input.Filters.Page = app.readInt(qs, "page", 1, v)
input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)
input.Filters.Sort = app.readString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "title", "-title", "description", "-description"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
issues, metadata, err := app.models.Issues.GetAll(input.Title, input.Filters)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"issues": issues, "metadata": metadata}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) createIssueHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
}
err := app.readJSON(w, r, &input)
if err != nil {
// Use the new badRequestResponse() helper.
app.badRequestResponse(w, r, err)
return
}
n, e, private_pem, err := GenerateIssueKey()
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
issue := &data.Issue{
Title: input.Title,
Description: input.Description,
StartTime: input.StartTime,
EndTime: input.EndTime,
N: n,
E: e,
PrivatePem: private_pem,
}
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Issues.Insert(issue)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID))
err = app.writeJSON(w, http.StatusCreated, envelope{"issue": issue}, headers)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) readIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Encode the struct to JSON and send it as the HTTP response.
err = app.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
var input struct {
Title *string `json:"title"`
Description *string `json:"description"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
err = app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
if input.Title != nil { issue.Title = *input.Title }
if input.Description != nil { issue.Description = *input.Description }
if input.StartTime != nil { issue.StartTime = *input.StartTime }
if input.StartTime != nil { issue.EndTime = *input.EndTime }
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Issues.Update(issue)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Write the updated issue record in a JSON response.
err = app.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
// Delete the issue from the database, sending a 404 Not Found response to the
// client if there isn't a matching record.
err = app.models.Issues.Delete(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Return a 200 OK status code along with a success message.
err = app.writeJSON(w, http.StatusOK, envelope{"message": "issue successfully deleted"}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) readIssuePubKeyHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
type response struct {
N string `json:"n"`
E int `json:"e"`
}
res := response{
N: hex.EncodeToString(issue.N),
E: issue.E,
}
err = app.writeJSON(w, http.StatusOK, envelope{"public_key": res}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) blindSignIssueVoteHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
type response struct {
N string `json:"n"`
E int `json:"e"`
}
res := response{
N: hex.EncodeToString(issue.N),
E: issue.E,
}
err = app.writeJSON(w, http.StatusOK, envelope{"public_key": res}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -1,65 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
)
func TestReadIssueHandler(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, app.routes())
defer ts.Close()
token := ts.registerAndLogin(t, uniqueEmail(), "pa$$word123")
req := map[string]any{
"title": "An old silent pond...",
"description": "A frog jumps into the pond",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", token, req)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
var resp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
}
json.Unmarshal(res, &resp)
issueID := resp.Issue.ID
tests := []struct {
name string
urlPath string
wantCode int
wantBody []byte
}{
{"Valid ID", "/v1/issues/" + strconv.Itoa(int(issueID)), http.StatusOK, []byte("An old silent pond...")},
{"Non-existent ID", "/v1/issues/" + strconv.Itoa(int(issueID + 1)), http.StatusNotFound, nil},
{"Negative ID", "/v1/issues/-1", http.StatusNotFound, nil},
{"Decimal ID", "/v1/issues/1.23", http.StatusNotFound, nil},
{"String ID", "/v1/issues/foo", http.StatusNotFound, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.getWithToken(t, tt.urlPath, token)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body to contain %q; got %q", tt.wantBody, string(body))
}
})
}
}

View File

@ -1,252 +0,0 @@
package main
import (
"context"
"log"
"strings"
"expvar"
"runtime"
//"html/template"
"flag"
"net/http"
"fmt"
"github.com/gorilla/websocket"
"os"
"time"
"database/sql"
_ "github.com/lib/pq"
"party.at/party/internal/data"
"party.at/party/internal/mailer"
"party.at/party/internal/jsonlog"
"crypto/rand"
"crypto/aes"
"crypto/cipher"
)
var version string
var buildTime string
type config struct {
port int
env string
db struct {
dsn string
maxOpenConns int
maxIdleConns int
maxIdleTime string
}
limiter struct {
rps float64
burst int
enabled bool
}
smtp struct {
host string
port int
username string
password string
sender string
}
cors struct {
trustedOrigins []string
}
}
type application struct {
config config
logger *jsonlog.Logger
models data.Models
mailer mailer.Mailer
}
type Message struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Random float64 `json:"random"`
}
//var page_template = template.Must(template.ParseFiles("../../ui/html/index.html"))
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // allow all origins
}
func main() {
// 32 bytes = AES-256
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
panic(err)
}
message := []byte("hello secure world")
nonce, ciphertext, err := encrypt(key, message)
if err != nil {
panic(err)
}
plaintext, err := decrypt(key, nonce, ciphertext)
if err != nil {
panic(err)
}
fmt.Printf("Original: %s\n", message)
fmt.Printf("Decrypted: %s\n", plaintext)
var cfg config
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.StringVar(&cfg.env, "env", "production", "Environment (development|staging|production)")
flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("PARTY_DB_DSN"), "PostgreSQL DSN")
//addr := flag.String("addr", ":8443", "HTTP network address")
flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time")
flag.StringVar(&cfg.smtp.host, "smtp-host", "smtp.mailtrap.io", "SMTP host")
flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
flag.StringVar(&cfg.smtp.username, "smtp-username", "98cf60028d7fcb", "SMTP username")
flag.StringVar(&cfg.smtp.password, "smtp-password", "b9d4a35372e971", "SMTP password")
flag.StringVar(&cfg.smtp.sender, "smtp-sender", "DPÖ <no-reply@party.at>", "SMTP sender")
flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
cfg.cors.trustedOrigins = strings.Fields(val)
return nil
})
displayVersion := flag.Bool("version", false, "Display version and exit")
flag.Parse()
if *displayVersion {
fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Build time:\t%s\n", buildTime)
os.Exit(0)
}
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
log.Printf("%s\n", cfg.db.dsn)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
expvar.NewString("version").Set(version)
expvar.Publish("goroutines", expvar.Func(func() interface{} {
return runtime.NumGoroutine()
}))
expvar.Publish("database", expvar.Func(func() interface{} {
return db.Stats()
}))
expvar.Publish("timestamp", expvar.Func(func() interface{} {
return time.Now().Unix()
}))
app := &application{
config: cfg,
logger: logger,
models: data.NewModels(db),
mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender),
}
log.Println("Hello, Sailor!")
err = app.serve()
if err != nil {
logger.PrintFatal(err, nil)
}
}
func openDB(cfg config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.db.dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.db.maxOpenConns)
db.SetMaxIdleConns(cfg.db.maxIdleConns)
duration, err := time.ParseDuration(cfg.db.maxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use PingContext() to establish a new connection to the database, passing in the
// context we created above as a parameter. If the connection couldn't be
// established successfully within the 5 second deadline, then this will return an
// error.
err = db.PingContext(ctx)
if err != nil {
return nil, err
}
return db, nil
}
func encrypt(key [32]byte, plaintext []byte) (nonce, ciphertext []byte, err error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
panic(err)
}
ciphertext = aead.Seal(nil, nonce, plaintext, nil)
return nonce, ciphertext, nil
}
func decrypt(key [32]byte, nonce, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}

View File

@ -1,280 +0,0 @@
package main
import (
"fmt"
"net"
"net/http"
"sync"
"time"
"strings"
"errors"
"expvar"
"strconv"
"github.com/felixge/httpsnoop"
"golang.org/x/time/rate"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create a deferred function (which will always be run in the event of a panic
// as Go unwinds the stack).
defer func() {
// Use the builtin recover function to check if there has been a panic or
// not.
if err := recover(); err != nil {
// If there was a panic, set a "Connection: close" header on the
// response. This acts as a trigger to make Go's HTTP server
// automatically close the current connection after a response has been
// sent.
w.Header().Set("Connection", "close")
// The value returned by recover() has the type interface{}, so we use
// fmt.Errorf() to normalize it into an error and call our
// serverErrorResponse() helper. In turn, this will log the error using
// our custom Logger type at the ERROR level and send the client a 500
// Internal Server Error response.
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (app *application) rateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
var clients = make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
// Lock the mutex to prevent any rate limiter checks from happening while
// the cleanup is taking place.
mu.Lock()
// Loop through all clients. If they haven't been seen within the last three
// minutes, delete the corresponding entry from the map.
for ip, client := range clients {
if time.Since(client.lastSeen) > 3 * time.Minute {
delete(clients, ip)
}
}
// Importantly, unlock the mutex when the cleanup is complete.
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.config.limiter.enabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(app.config.limiter.rps), app.config.limiter.burst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
app.rateLimitExceededResponse(w, r)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
authorizationHeader := r.Header.Get("Authorization")
if authorizationHeader == "" {
r = app.contextSetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
app.invalidAuthenticationTokenResponse(w, r)
return
}
token := headerParts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
app.invalidAuthenticationTokenResponse(w, r)
return
}
userIdentity, err := app.models.UserIdentities.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidAuthenticationTokenResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
user, err := app.models.Users.Get(userIdentity.UserID)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Call the contextSetUser() helper to add the user information to the request
// context.
r = app.contextSetUser(r, user)
// Call the next handler in the chain.
next.ServeHTTP(w, r)
})
}
func (app *application) requireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := app.contextGetUser(r)
if user.IsAnonymous() {
app.authenticationRequiredResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
// Rather than returning this http.HandlerFunc we assign it to the variable fn.
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := app.contextGetUser(r)
// Check that a user is activated.
if !user.Activated {
app.inactiveAccountResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
// Wrap fn with the requireAuthenticatedUser() middleware before returning it.
return app.requireAuthenticatedUser(fn)
}
func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
// Retrieve the user from the request context.
user := app.contextGetUser(r)
// Get the slice of permissions for the user.
permissions, err := app.models.Permissions.GetAllForUser(user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Check if the slice includes the required permission. If it doesn't, then
// return a 403 Forbidden response.
if !permissions.Include(code) {
app.notPermittedResponse(w, r)
return
}
// Otherwise they have the required permission so we call the next handler in
// the chain.
next.ServeHTTP(w, r)
}
// Wrap this with the requireActivatedUser() middleware before returning it.
return app.requireActivatedUser(fn)
}
func (app *application) enableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin")
w.Header().Add("Vary", "Access-Control-Request-Method")
origin := r.Header.Get("Origin")
if origin != "" && len(app.config.cors.trustedOrigins) != 0 {
// Loop through the list of trusted origins, checking to see if the request
// origin exactly matches one of them.
for i := range app.config.cors.trustedOrigins {
if origin == app.config.cors.trustedOrigins[i] {
// If there is a match, then set a "Access-Control-Allow-Origin"
// response header with the request origin as the value.
w.Header().Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
// Set the necessary preflight response headers, as discussed
// previously.
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers","Authorization, Content-Type")
// Write the headers along with a 200 OK status and return from
// the middleware with no further action.
w.WriteHeader(http.StatusOK)
return
}
}
}
}
next.ServeHTTP(w, r)
})
}
func (app *application) metrics(next http.Handler) http.Handler {
// Initialize the new expvar variables when the middleware chain is first built.
totalRequestsReceived := expvar.NewInt("total_requests_received")
totalResponsesSent := expvar.NewInt("total_responses_sent")
totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_μs")
totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
totalRequestsReceived.Add(1)
// Call the next handler in the chain.
metrics := httpsnoop.CaptureMetrics(next, w, r)
// On the way back up the middleware chain, increment the number of responses
// sent by 1.
totalResponsesSent.Add(1)
totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds())
totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1)
})
}

View File

@ -1,44 +0,0 @@
package main
import (
"net/http"
"expvar"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
fileServer := http.FileServer(http.Dir("ui/static"))
router.HandlerFunc(http.MethodGet, "/", home)
router.HandlerFunc(http.MethodGet, "/ws", ws)
router.Handler (http.MethodGet, "/static/", http.StripPrefix("/static", fileServer))
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodGet, "/v1/issues", app.requirePermission("issues:read", app.listIssuesHandler))
router.HandlerFunc(http.MethodPost, "/v1/issues", app.requirePermission("issues:write", app.createIssueHandler))
router.HandlerFunc(http.MethodGet, "/v1/issues/:id", app.requirePermission("issues:read", app.readIssueHandler))
router.HandlerFunc(http.MethodPatch, "/v1/issues/:id", app.requirePermission("issues:write", app.updateIssueHandler))
router.HandlerFunc(http.MethodDelete, "/v1/issues/:id", app.requirePermission("issues:write", app.deleteIssueHandler))
router.HandlerFunc(http.MethodGet, "/v1/issues/:id/pubkey", app.requirePermission("issues:read", app.readIssuePubKeyHandler))
router.HandlerFunc(http.MethodPost, "/v1/issues/:id/blind-sign", app.requirePermission("issues:read", app.blindSignIssueVoteHandler))
router.HandlerFunc(http.MethodPost, "/v1/users", app.createUserHandler)
// router.HandlerFunc(http.MethodGet, "/v1/users/:id", app.readUserHandler)
// router.HandlerFunc(http.MethodPatch, "/v1/users/:id", app.updateUserHandler)
router.HandlerFunc(http.MethodDelete, "/v1/users/:id", app.deleteUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
router.Handler (http.MethodGet, "/debug/vars", expvar.Handler())
return app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))))
}

View File

@ -1,202 +0,0 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"os"
"encoding/json"
"bytes"
"fmt"
"time"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
)
func newTestApplication(t *testing.T) *application {
cfg := config{}
cfg.db.dsn = "postgres://party:password@localhost:5432/party?sslmode=disable"
cfg.db.maxOpenConns = 25
cfg.db.maxIdleConns = 25
cfg.db.maxIdleTime = "15m"
cfg.limiter.enabled = false
cfg.env = "development"
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
t.Cleanup(func() {db.Close()})
return &application{
logger: logger,
models: data.NewModels(db),
config: cfg,
}
}
type testServer struct {
*httptest.Server
app *application
}
func newTestServer(t *testing.T, app *application, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts, app}
}
func (ts *testServer) postJSON(t *testing.T, path string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header, respBody
}
func (ts *testServer) get(t *testing.T, path string) (int, http.Header, []byte) {
t.Helper()
rs, err := ts.Client().Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
// registers a user, activates them, logs in, returns the bearer token
func (ts *testServer) registerAndLogin(t *testing.T, email, password string) string {
t.Helper()
// 1. Register
registerBody := map[string]any{
"email": email,
"password": password,
"username": email,
"name": "Test User",
"alt_name" : "",
"provider_id": 1,
}
code, _, body := ts.postJSON(t, "/v1/users", registerBody)
if code != http.StatusCreated {
t.Fatalf("register: want 201 got %d: %s", code, body)
}
// 2. Activate — if your flow requires it, either hit the endpoint
// or directly flip the activated flag in the test DB
// ts.activateUser(t, email)
// 3. Login
loginBody := map[string]string{"email": email, "password": password}
code, _, body = ts.postJSON(t, "/v1/tokens/authentication", loginBody)
if code != http.StatusCreated {
t.Fatalf("login: want 201 got %d: %s", code, body)
}
// 4. Parse token out of response
var resp struct {
AuthenticationToken struct {
Token string `json:"token"`
} `json:"authentication_token"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("parse token: %v", err)
}
return resp.AuthenticationToken.Token
}
// activateUser directly updates the DB — avoids needing a real email flow
// func (ts *testServer) activateUser(t *testing.T, email string) {
// t.Helper()
// _, err := ts.app.db.Exec("UPDATE users SET activated = true WHERE email = $1", email)
// if err != nil {
// t.Fatalf("activate user: %v", err)
// }
// }
// like ts.get but adds Authorization header
func (ts *testServer) getWithToken(t *testing.T, path, token string) (int, http.Header, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) postJSONWithToken(t *testing.T, path, token string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
respBody, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, respBody
}
func uniqueEmail() string {
return fmt.Sprintf("test_%d@example.com", time.Now().UnixNano())
}

View File

@ -1,77 +0,0 @@
package main
import (
"errors"
"net/http"
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the email and password provided by the client.
v := validator.New()
data.ValidateEmail(v, input.Email)
data.ValidatePasswordPlaintext(v, input.Password)
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
identities, err := app.models.UserIdentities.GetByUser(user.ID)
var authenticatedIdentity *data.UserIdentity
for _, user_identity := range identities {
match, err := user_identity.Password.Matches(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if match {
authenticatedIdentity = user_identity
break
}
}
if authenticatedIdentity == nil {
app.invalidCredentialsResponse(w, r)
return
}
token, err := app.models.Tokens.New(user.ID, authenticatedIdentity.ID, 24 * time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -1,224 +0,0 @@
package main
import (
"errors"
"net/http"
"party.at/party/internal/data"
"party.at/party/internal/validator"
"time"
)
func (app *application) createUserHandler(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"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
user := &data.User{
Email: input.Email,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
Activated: false,
}
if app.config.env == "development" {
user.Activated = true
}
userIdentity := &data.UserIdentity{
ProviderID: input.ProviderId,
ProviderUserID: input.Username,
}
err = userIdentity.Password.Set(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateUser(v, user); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
if data.ValidateUserIdentity(v, userIdentity); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Users.ExecuteRegistrationTx(user, userIdentity)
if err != nil {
switch {
case errors.Is(err, data.ErrDuplicateEmail):
v.AddError("email", "a user with this email address already exists")
app.failedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrDuplicateUser):
v.AddError("username", "a user with this username already exists")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.models.Permissions.AddForUser(user.ID, "issues:read")
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if app.config.env == "development" {
err = app.models.Permissions.AddForUser(user.ID, "issues:write")
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
}
if app.config.env == "production" {
token, err := app.models.Tokens.New(user.ID, userIdentity.ID, 3 * 24 * time.Hour, data.ScopeActivation)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
app.background(func() {
data := map[string]interface{}{
"token": token.Plaintext,
"userID": user.ID,
}
err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
if err != nil {
app.logger.PrintError(err, nil)
}
})
}
authentication_token, err := app.models.Tokens.New(user.ID, userIdentity.ID, 24 * time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Write a JSON response containing the user data along with a 201 Created status
// code.
err = app.writeJSON(w, http.StatusCreated, envelope{"user": user, "authentication_token": authentication_token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteUserHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
// Delete the issue from the database, sending a 404 Not Found response to the
// client if there isn't a matching record.
err = app.models.Users.Delete(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Return a 200 OK status code along with a success message.
err = app.writeJSON(w, http.StatusOK, envelope{"message": "user successfully deleted"}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
// Parse the plaintext activation token from the request body.
var input struct {
TokenPlaintext string `json:"token"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the plaintext token provided by the client.
v := validator.New()
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
// Retrieve the details of the user associated with the token using the
// GetForToken() method (which we will create in a minute). If no matching record
// is found, then we let the client know that the token they provided is not valid.
user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("token", "invalid or expired activation token")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Update the user's activation status.
user.Activated = true
// Save the updated user record in our database, checking for any edit conflicts in
// the same way that we did for our movie records.
err = app.models.Users.Update(user)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// If everything went successfully, then we delete all activation tokens for the
// user.
err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Send the updated user details to the client in a JSON response.
err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

83
cmd/party/api/api.go Normal file
View File

@ -0,0 +1,83 @@
package api
import(
"context"
"errors"
"net/http"
"strings"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type Api struct {
App *common.Application
}
type contextKey string
const userKey contextKey = "user"
func SetUser(r *http.Request, user *data.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userKey, user))
}
func GetUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userKey).(*data.User)
if !ok {
panic("missing user value in request context")
}
return user
}
func (api *Api) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
authorizationHeader := r.Header.Get("Authorization")
if authorizationHeader == "" {
r = SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
token := headerParts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
userIdentity, err := api.App.Models.UserIdentities.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.InvalidAuthenticationTokenResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
user, err := api.App.Models.Users.Get(userIdentity.UserID)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.InvalidCredentialsResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
r = SetUser(r, user)
next.ServeHTTP(w, r)
})
}

366
cmd/party/api/handler.go Normal file
View File

@ -0,0 +1,366 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/internal/validator"
)
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) {
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 (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

@ -0,0 +1,18 @@
package api
import "net/http"
import "party.at/party/cmd/party/common"
func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) {
env := common.Envelope{
"status": "available",
"system_info": map[string]string{
"environment": api.App.Config.Env,
"version": "1",
},
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"health_check": env}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}

215
cmd/party/api/issues.go Normal file
View File

@ -0,0 +1,215 @@
package api
import (
"errors"
"fmt"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Title = common.ReadString(qs, "title", "")
input.Filters.Page = common.ReadInt(qs, "page", 1, v)
input.Filters.PageSize = common.ReadInt(qs, "page_size", 20, v)
input.Filters.Sort = common.ReadString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "title", "-title", "description", "-description"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
issues, metadata, err := api.App.FetchIssues(input.Title, input.Filters, GetUser(r))
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issues": issues, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Options []string `json:"options"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
issue, options, err := api.App.CreateIssue(input.Title, input.Description, input.StartTime, input.EndTime, input.Options)
if err != nil {
var ve *common.ValidationError
if errors.As(err, &ve) {
api.FailedValidationResponse(w, r, ve.Errors)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID))
if err = api.writeJSON(w, http.StatusCreated, envelope{"issue": issue, "options": options}, headers); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
result, err := api.App.GetIssue(id, GetUser(r))
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issue": result.IssueDetail, "options": result.Options}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
var input struct {
Title *string `json:"title"`
Description *string `json:"description"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
if err = api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
issue, err := api.App.UpdateIssue(id, input.Title, input.Description, input.StartTime, input.EndTime)
if err != nil {
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
api.FailedValidationResponse(w, r, ve.Errors)
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrEditConflict):
api.EditConflictResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
if err = api.App.DeleteIssue(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"message": "issue successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
pubKey, err := api.App.GetIssuePublicKey(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"public_key": pubKey}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
var input struct {
BlindedVote []byte `json:"blinded_vote"`
}
if err = api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
signed, err := api.App.BlindSign(id, input.BlindedVote, GetUser(r))
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrDuplicateBlindSign):
api.AlreadyBlindSignedResponse(w, r)
case errors.Is(err, data.ErrInvalidBlindedVote):
api.BlindedVoteOutOfRangeResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"signed": signed}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}

104
cmd/party/api/middleware.go Normal file
View File

@ -0,0 +1,104 @@
package api
import "net/http"
import "fmt"
// import "party.at/party/cmd/party/common"
import "time"
import "sync"
import "golang.org/x/time/rate"
import "net"
func (api *Api) RecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
api.ServerErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetUser(r).IsAnonymous() {
api.AuthenticationRequiredResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !GetUser(r).Activated {
api.InactiveAccountResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
return api.RequireAuthenticatedUser(fn)
}
func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
permissions, err := api.App.Models.Permissions.GetAllForUser(GetUser(r).ID)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if !permissions.Include(code) {
api.NotPermittedResponse(w, r)
return
}
next.ServeHTTP(w, r)
}
return api.RequireActivatedUser(fn)
}
func (api *Api) RateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
var clients = make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, c := range clients {
if time.Since(c.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.App.LimiterEnabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(api.App.LimiterRPS), api.App.LimiterBurst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
api.RateLimitExceededResponse(w, r)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}

70
cmd/party/api/tokens.go Normal file
View File

@ -0,0 +1,70 @@
package api
import (
"errors"
"net/http"
"strings"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
v := validator.New()
v.Check(input.Email != "", "email", "must be provided")
v.Check(input.Password != "", "password", "must be provided")
if !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
token, err := api.App.LoginUser(input.Email, input.Password)
if err != nil {
switch {
case errors.Is(err, data.ErrInvalidCredentials):
api.InvalidCredentialsResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
token := parts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
if err := api.App.DeleteToken(token); err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err := api.writeJSON(w, http.StatusOK, envelope{"message": "authentication token successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}

205
cmd/party/api/users.go Normal file
View File

@ -0,0 +1,205 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) {
var input struct {
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Filters.Page = api.readInt(qs, "page", 1, v)
input.Filters.PageSize = api.readInt(qs, "page_size", 20, v)
input.Filters.Sort = api.readString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
users, metadata, err := api.App.ListUsers(input.Filters)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"users": users, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(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"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
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,
})
if err != nil {
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
api.FailedValidationResponse(w, r, ve.Errors)
case errors.Is(err, data.ErrDuplicateEmail):
v := validator.New()
v.AddError("email", "a user with this email address already exists")
api.FailedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrDuplicateUser):
v := validator.New()
v.AddError("username", "a user with this username already exists")
api.FailedValidationResponse(w, r, v.Errors)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusCreated, envelope{"user": user, "authentication_token": authToken}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
var id int64
if param := httprouter.ParamsFromContext(r.Context()).ByName("id"); param == "me" {
id = GetUser(r).ID
} else {
var err error
id, err = strconv.ParseInt(param, 10, 64)
if err != nil || id < 1 {
api.NotFoundResponse(w, r)
return
}
}
if GetUser(r).ID != id {
api.errorResponse(w, r, http.StatusForbidden, apiError{
Code: common.ErrCodeNotPermitted,
Message: "your user account doesn't have the necessary permissions to access this resource",
})
return
}
user, err := api.App.GetUser(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) {
user, err := api.App.GetUser(GetUser(r).ID)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
if err = api.App.DeleteUser(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"message": "user successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
TokenPlaintext string `json:"token"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
user, err := api.App.ActivateUser(input.TokenPlaintext)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("token", "invalid or expired activation token")
api.FailedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrEditConflict):
api.EditConflictResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}

40
cmd/party/api/votes.go Normal file
View File

@ -0,0 +1,40 @@
package api
import (
"errors"
"net/http"
"party.at/party/internal/data"
)
func (api *Api) Vote(w http.ResponseWriter, r *http.Request) {
var input struct {
IssueID int64 `json:"issue_id"`
OptionID int64 `json:"option_id"`
Nonce []byte `json:"nonce"`
Signature []byte `json:"signature"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
if err := api.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrInvalidSignature):
api.InvalidSignatureResponse(w, r)
case errors.Is(err, data.ErrDuplicateVote):
api.AlreadyVotedResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err := api.writeJSON(w, http.StatusCreated, envelope{"message": "vote successfully cast"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}

View File

@ -0,0 +1,73 @@
package common
import (
"fmt"
"party.at/party/cmd/party/parlament"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
"party.at/party/internal/mailer"
)
type Config struct {
Port int
Env string
DB struct {
DSN string
MaxOpenConns int
MaxIdleConns int
MaxIdleTime string
}
Limiter struct {
RPS float64
Burst int
Enabled bool
}
SMTP struct {
Host string
Port int
Username string
Password string
Sender string
}
CORS struct {
TrustedOrigins []string
}
}
type Application struct {
Config Config
Logger *jsonlog.Logger
Models data.Models
Mailer mailer.Mailer
Parlament *parlament.Client
Env string
LimiterEnabled bool
LimiterRPS float64
LimiterBurst int
CORSTrustedOrigins []string
}
type ValidationError struct {
Errors map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
func (app *Application) background(fn func()) {
go func() {
defer func() {
if err := recover(); err != nil {
app.Logger.PrintError(fmt.Errorf("%s", err), nil)
}
}()
fn()
}()
}

View File

@ -0,0 +1,25 @@
package common
type ErrorCode int
const (
// 401 variants
ErrCodeInvalidCredentials ErrorCode = 4011
ErrCodeInvalidAuthToken ErrorCode = 4012
ErrCodeAuthRequired ErrorCode = 4013
// 403 variants
ErrCodeInactiveAccount ErrorCode = 4031
ErrCodeNotPermitted ErrorCode = 4032
// 409 variants
ErrCodeEditConflict ErrorCode = 4091
ErrCodeAlreadyVoted ErrorCode = 4092
ErrCodeAlreadyBlindSigned ErrorCode = 4093
ErrCodeVoteAlreadyCast ErrorCode = 4094
// 422 variants
ErrCodeValidationFailed ErrorCode = 4221
ErrCodeBlindedVoteRange ErrorCode = 4222
ErrCodeInvalidSignature ErrorCode = 4223
)

105
cmd/party/common/handler.go Normal file
View File

@ -0,0 +1,105 @@
package common
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"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) {
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 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(),
})
}

128
cmd/party/common/helpers.go Normal file
View File

@ -0,0 +1,128 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"party.at/party/internal/validator"
)
type Envelope map[string]interface{}
func readString(qs url.Values, key string, defaultValue string) string {
s := qs.Get(key)
if s == "" {
return defaultValue
}
return s
}
func readCSV(qs url.Values, key string, defaultValue []string) []string {
csv := qs.Get(key)
if csv == "" {
return defaultValue
}
return strings.Split(csv, ",")
}
func readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
s := qs.Get(key)
if s == "" {
return defaultValue
}
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
return i
}
func readIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
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 readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
maxBytes := 1048576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body)
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 %d bytes", maxBytes)
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 GenerateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privDER,
})
return key.N.Bytes(), key.E, privPEM, err
}

192
cmd/party/common/issues.go Normal file
View File

@ -0,0 +1,192 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type IssueDetail struct {
data.Issue
CanVote bool `json:"can_vote"`
}
type OptionResult struct {
data.Option
VoteCount int64 `json:"vote_count"`
}
type IssueWithOptions struct {
IssueDetail
Options []OptionResult
}
type PublicKey struct {
N string `json:"n"`
E int `json:"e"`
}
func (app *Application) FetchIssues(title string, filters data.Filters, user *data.User) ([]IssueDetail, data.Metadata, error) {
issues, metadata, err := app.Models.Issues.GetAll(title, filters)
if err != nil {
return nil, data.Metadata{}, err
}
details := make([]IssueDetail, len(issues))
for i, issue := range issues {
canVote := false
if !user.IsAnonymous() {
_, err = app.Models.BlindSigns.Get(user.ID, issue.ID)
switch {
case errors.Is(err, data.ErrRecordNotFound):
canVote = true
case err != nil:
return nil, data.Metadata{}, err
}
}
details[i] = IssueDetail{Issue: *issue, CanVote: canVote}
}
return details, metadata, nil
}
func (app *Application) CreateIssue(title, description string, startTime, endTime time.Time, optionLabels []string) (*data.Issue, []*data.Option, error) {
issue := &data.Issue{
Title: title,
Description: description,
StartTime: startTime,
EndTime: endTime,
}
options := make([]*data.Option, len(optionLabels))
for i, label := range optionLabels {
options[i] = &data.Option{Label: label}
}
v := validator.New()
v.Check(len(optionLabels) >= 2, "options", "must provide at least two options")
v.Check(len(optionLabels) <= 10, "options", "must not provide more than ten options")
for _, option := range options {
data.ValidateOption(v, option)
}
if data.ValidateIssue(v, issue); !v.Valid() {
return nil, nil, &ValidationError{Errors: v.Errors}
}
n, e, privatePEM, err := generateIssueKey()
if err != nil {
return nil, nil, err
}
issue.N = n
issue.E = e
issue.PrivatePem = privatePEM
if err = app.Models.Issues.InsertWithOptions(issue, options); err != nil {
return nil, nil, err
}
return issue, options, nil
}
func (app *Application) GetIssue(id int64, user *data.User) (*IssueWithOptions, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
canVote := false
if !user.IsAnonymous() {
_, err = app.Models.BlindSigns.Get(user.ID, issue.ID)
switch {
case errors.Is(err, data.ErrRecordNotFound):
canVote = true
case err != nil:
return nil, err
}
}
options, err := app.Models.Options.GetAllForIssue(id)
if err != nil {
return nil, err
}
results := make([]OptionResult, len(options))
for i, o := range options {
voteCount, err := app.Models.Votes.CountForOption(o.ID)
if err != nil {
return nil, err
}
results[i] = OptionResult{Option: *o, VoteCount: voteCount}
}
return &IssueWithOptions{
IssueDetail: IssueDetail{Issue: *issue, CanVote: canVote},
Options: results,
}, nil
}
func (app *Application) UpdateIssue(id int64, title, description *string, startTime, endTime *time.Time) (*data.Issue, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
if title != nil { issue.Title = *title }
if description != nil { issue.Description = *description }
if startTime != nil { issue.StartTime = *startTime }
if endTime != nil { issue.EndTime = *endTime }
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
return nil, &ValidationError{Errors: v.Errors}
}
if err = app.Models.Issues.Update(issue); err != nil {
return nil, err
}
return issue, nil
}
func (app *Application) DeleteIssue(id int64) error {
return app.Models.Issues.Delete(id)
}
func (app *Application) GetIssuePublicKey(id int64) (*PublicKey, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
return &PublicKey{N: hex.EncodeToString(issue.N), E: issue.E}, nil
}
func (app *Application) BlindSign(issueID int64, blindedVote []byte, user *data.User) ([]byte, error) {
issue, err := app.Models.Issues.Get(issueID)
if err != nil {
return nil, err
}
blindSign := &data.BlindSign{UserID: user.ID, IssueID: issue.ID}
if err = app.Models.BlindSigns.Insert(blindSign); err != nil {
return nil, err
}
return app.Models.BlindSigns.BlindSign(issue.ID, blindedVote)
}
func generateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
return key.N.Bytes(), key.E, privPEM, nil
}

View File

@ -0,0 +1,58 @@
package common
import (
"expvar"
"net/http"
"strconv"
"sync"
"github.com/felixge/httpsnoop"
)
var (
metricsOnce sync.Once
metricTotalRequests *expvar.Int
metricTotalResponses *expvar.Int
metricTotalProcessingTime *expvar.Int
metricResponsesByStatus *expvar.Map
)
func (app *Application) Metrics(next http.Handler) http.Handler {
metricsOnce.Do(func() {
metricTotalRequests = expvar.NewInt("total_requests_received")
metricTotalResponses = expvar.NewInt("total_responses_sent")
metricTotalProcessingTime = expvar.NewInt("total_processing_time_μs")
metricResponsesByStatus = expvar.NewMap("total_responses_sent_by_status")
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metricTotalRequests.Add(1)
m := httpsnoop.CaptureMetrics(next, w, r)
metricTotalResponses.Add(1)
metricTotalProcessingTime.Add(m.Duration.Microseconds())
metricResponsesByStatus.Add(strconv.Itoa(m.Code), 1)
})
}
func (app *Application) EnableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin")
w.Header().Add("Vary", "Access-Control-Request-Method")
origin := r.Header.Get("Origin")
if origin != "" && len(app.CORSTrustedOrigins) != 0 {
for i := range app.CORSTrustedOrigins {
if origin == app.CORSTrustedOrigins[i] {
w.Header().Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.WriteHeader(http.StatusOK)
return
}
}
}
}
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,55 @@
package common
import (
"crypto/sha256"
"errors"
"time"
"party.at/party/internal/data"
)
func (app *Application) LoginUser(email, password string) (*data.Token, error) {
user, err := app.Models.Users.GetByEmail(email)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
return nil, data.ErrInvalidCredentials
}
return nil, err
}
identities, err := app.Models.UserIdentities.GetByUser(user.ID)
if err != nil {
return nil, err
}
var authenticatedIdentity *data.UserIdentity
for _, identity := range identities {
match, err := identity.Password.Matches(password)
if err != nil {
return nil, err
}
if match {
authenticatedIdentity = identity
break
}
}
if authenticatedIdentity == nil {
return nil, data.ErrInvalidCredentials
}
return app.Models.Tokens.New(user.ID, authenticatedIdentity.ID, 24*time.Hour, data.ScopeAuthentication)
}
func (app *Application) GetUserFromToken(plaintext string) (*data.User, error) {
userIdentity, err := app.Models.UserIdentities.GetForToken(data.ScopeAuthentication, plaintext)
if err != nil {
return nil, err
}
return app.Models.Users.Get(userIdentity.UserID)
}
func (app *Application) DeleteToken(plaintext string) error {
hash := sha256.Sum256([]byte(plaintext))
return app.Models.Tokens.Delete(hash[:])
}

115
cmd/party/common/users.go Normal file
View File

@ -0,0 +1,115 @@
package common
import (
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type RegisterUserInput struct {
ProviderID int64
Username string
PhoneNumber string
Country string
Email string
Password string
Name string
AltName *string
DateOfBirth time.Time
Address string
}
func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data.Token, error) {
user := &data.User{
Email: input.Email,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
Activated: app.Config.Env == "development",
}
userIdentity := &data.UserIdentity{
ProviderID: input.ProviderID,
ProviderUserID: input.Username,
}
if err := userIdentity.Password.Set(input.Password); err != nil {
return nil, nil, err
}
v := validator.New()
data.ValidateUser(v, user)
data.ValidateUserIdentity(v, userIdentity)
if !v.Valid() {
return nil, nil, &ValidationError{Errors: v.Errors}
}
if err := app.Models.Users.ExecuteRegistrationTx(user, userIdentity); err != nil {
return nil, nil, err
}
role := "viewer"
if app.Config.Env == "development" {
role = "admin"
}
if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil {
return nil, nil, err
}
if app.Config.Env == "production" {
token, err := app.Models.Tokens.New(user.ID, userIdentity.ID, 3*24*time.Hour, data.ScopeActivation)
if err != nil {
return nil, nil, err
}
app.background(func() {
templateData := map[string]interface{}{
"token": token.Plaintext,
"userID": user.ID,
}
if err := app.Mailer.Send(user.Email, "user_welcome.tmpl", templateData); err != nil {
app.Logger.PrintError(err, nil)
}
})
}
authToken, err := app.Models.Tokens.New(user.ID, userIdentity.ID, 24*time.Hour, data.ScopeAuthentication)
if err != nil {
return nil, nil, err
}
return user, authToken, nil
}
func (app *Application) GetUser(id int64) (*data.User, error) {
return app.Models.Users.Get(id)
}
func (app *Application) ListUsers(filters data.Filters) ([]*data.User, data.Metadata, error) {
return app.Models.Users.GetAll(filters)
}
func (app *Application) DeleteUser(id int64) error {
return app.Models.Users.Delete(id)
}
func (app *Application) ActivateUser(tokenPlaintext string) (*data.User, error) {
user, err := app.Models.Users.GetForToken(data.ScopeActivation, tokenPlaintext)
if err != nil {
return nil, err
}
user.Activated = true
if err = app.Models.Users.Update(user); err != nil {
return nil, err
}
if err = app.Models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID); err != nil {
return nil, err
}
return user, nil
}

18
cmd/party/common/votes.go Normal file
View File

@ -0,0 +1,18 @@
package common
import "party.at/party/internal/data"
func (app *Application) CastVote(issueID, optionID int64, nonce, signature []byte) error {
issue, err := app.Models.Issues.Get(issueID)
if err != nil {
return err
}
vote := &data.Vote{
OptionID: optionID,
Nonce: nonce,
Signature: signature,
}
return app.Models.Votes.Insert(vote, issue)
}

View File

@ -3,8 +3,6 @@ package main
import ( import (
// "encoding/json" // "encoding/json"
"fmt" "fmt"
"html/template"
"log"
"net/http" "net/http"
"time" "time"
// "github.com/julienschmidt/httprouter" // "github.com/julienschmidt/httprouter"
@ -14,35 +12,6 @@ import (
// "party.at/party/internal/validator" // "party.at/party/internal/validator"
) )
func home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Println(r.URL.Path)
ts, err := template.ParseFiles("ui/html/home.page.tmpl", "ui/html/base.layout.tmpl")
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
return
}
err = ts.Execute(w, nil)
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
}
// data := struct {
// Name string
// }{
// Name: "Vicente",
// }
// page_template.Execute(w, data)
}
func ws(w http.ResponseWriter, r *http.Request) { func ws(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {

View File

@ -8,11 +8,11 @@ import (
"testing" "testing"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
) )
func TestReadIDParam(t *testing.T) { func TestReadIDParam(t *testing.T) {
app := newTestApplication(t)
const test_id int64 = 3 const test_id int64 = 3
r := httptest.NewRequest(http.MethodGet, "/v1/issues/" + strconv.FormatInt(test_id, 10), nil) r := httptest.NewRequest(http.MethodGet, "/v1/issues/" + strconv.FormatInt(test_id, 10), nil)
@ -21,7 +21,7 @@ func TestReadIDParam(t *testing.T) {
ctx := context.WithValue(r.Context(), httprouter.ParamsKey, params) ctx := context.WithValue(r.Context(), httprouter.ParamsKey, params)
r = r.WithContext(ctx) r = r.WithContext(ctx)
id, err := app.readIDParam(r) id, err := common.ReadIDParam(r)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

100
cmd/party/issues_test.go Normal file
View File

@ -0,0 +1,100 @@
package main
import (
"bytes"
"net/http"
"strconv"
"testing"
"time"
)
func TestReadIssueHandler(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
token := ts.registerAndLogin(t, uniqueEmail(), "pa$$word123")
req := map[string]any{
"title": "An old silent pond...",
"description": "A frog jumps into the pond",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Option A", "Option B", "Option C"},
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", token, req)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
var createResp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
}
mustUnmarshal(t, res, &createResp)
issueID := createResp.Issue.ID
tests := []struct {
name string
urlPath string
wantCode int
wantBody []byte
}{
{"Valid ID", "/v1/issues/" + strconv.Itoa(int(issueID)), http.StatusOK, []byte("An old silent pond...")},
{"Non-existent ID", "/v1/issues/" + strconv.Itoa(int(issueID+1)), http.StatusNotFound, nil},
{"Negative ID", "/v1/issues/-1", http.StatusNotFound, nil},
{"Decimal ID", "/v1/issues/1.23", http.StatusNotFound, nil},
{"String ID", "/v1/issues/foo", http.StatusNotFound, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.getWithToken(t, tt.urlPath, token)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body to contain %q; got %q", tt.wantBody, string(body))
}
})
}
// verify options are present on a successful read
code, _, body := ts.getWithToken(t, "/v1/issues/" + i64str(issueID), token)
if code != http.StatusOK {
t.Fatalf("read issue: want 200 got %d: %s", code, body)
}
var readResp struct {
Options []struct {
ID int64 `json:"id"`
Label string `json:"label"`
VoteCount int64 `json:"vote_count"`
} `json:"options"`
}
mustUnmarshal(t, body, &readResp)
if len(readResp.Options) != 3 {
t.Fatalf("want 3 options, got %d: %s", len(readResp.Options), body)
}
for _, o := range readResp.Options {
if o.VoteCount != 0 {
t.Errorf("option %q: want vote_count 0, got %d", o.Label, o.VoteCount)
}
}
labels := map[string]bool{}
for _, o := range readResp.Options {
labels[o.Label] = true
}
for _, want := range []string{"Option A", "Option B", "Option C"} {
if !labels[want] {
t.Errorf("missing option %q in response", want)
}
}
}

148
cmd/party/main.go Normal file
View File

@ -0,0 +1,148 @@
package main
import (
"context"
"expvar"
"flag"
"fmt"
"log"
"os"
"runtime"
"strings"
"time"
"database/sql"
"net/http"
_ "github.com/lib/pq"
"github.com/gorilla/websocket"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
"party.at/party/internal/mailer"
"party.at/party/cmd/party/parlament"
)
var version string
var buildTime string
type Message struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Random float64 `json:"random"`
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func main() {
test()
var cfg common.Config
flag.IntVar(&cfg.Port, "port", 4000, "API server port")
flag.StringVar(&cfg.Env, "env", "production", "Environment (development|staging|production)")
flag.StringVar(&cfg.DB.DSN, "db-dsn", os.Getenv("PARTY_DB_DSN"), "PostgreSQL DSN")
flag.IntVar(&cfg.DB.MaxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
flag.IntVar(&cfg.DB.MaxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
flag.StringVar(&cfg.DB.MaxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time")
flag.StringVar(&cfg.SMTP.Host, "smtp-host", "smtp.mailtrap.io", "SMTP host")
flag.IntVar(&cfg.SMTP.Port, "smtp-port", 25, "SMTP port")
flag.StringVar(&cfg.SMTP.Username, "smtp-username", "98cf60028d7fcb", "SMTP username")
flag.StringVar(&cfg.SMTP.Password, "smtp-password", "b9d4a35372e971", "SMTP password")
flag.StringVar(&cfg.SMTP.Sender, "smtp-sender", "DPÖ <no-reply@party.at>", "SMTP sender")
flag.Float64Var(&cfg.Limiter.RPS, "limiter-rps", 2, "Rate limiter maximum requests per second")
flag.IntVar(&cfg.Limiter.Burst, "limiter-burst", 4, "Rate limiter maximum burst")
flag.BoolVar(&cfg.Limiter.Enabled, "limiter-enabled", true, "Enable rate limiter")
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
cfg.CORS.TrustedOrigins = strings.Fields(val)
return nil
})
displayVersion := flag.Bool("version", false, "Display version and exit")
flag.Parse()
if *displayVersion {
fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Build time:\t%s\n", buildTime)
os.Exit(0)
}
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
log.Printf("%s\n", cfg.DB.DSN)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
expvar.NewString("version").Set(version)
expvar.Publish("goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))
expvar.Publish("database", expvar.Func(func() any { return db.Stats() }))
expvar.Publish("timestamp", expvar.Func(func() any { return time.Now().Unix() }))
app := &common.Application{
Config: cfg,
Logger: logger,
Models: data.NewModels(db),
Mailer: mailer.New(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Sender),
Parlament: parlament.NewClient(),
}
log.Println("Hello, Sailor!")
if err = serve(app); err != nil {
logger.PrintFatal(err, nil)
}
}
func openDB(cfg common.Config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.DB.DSN)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.DB.MaxOpenConns)
db.SetMaxIdleConns(cfg.DB.MaxIdleConns)
duration, err := time.ParseDuration(cfg.DB.MaxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
func test() {
client := parlament.NewClient()
sessions, total, err := client.ListSessions(parlament.SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
panic(err)
}
fmt.Printf("Found %d sessions\n\n", total)
for i, s := range sessions {
if i >= 10 {
break
}
fmt.Printf("%s | %s\n", s.Date, s.Title)
}
}

View File

@ -0,0 +1,138 @@
package parlament
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const baseURL = "https://www.parlament.gv.at"
// Client is a thin HTTP client for the Austrian parliament open-data API.
// No authentication is required; all endpoints are public.
type Client struct {
http *http.Client
}
func NewClient() *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
}
}
// filterResponse is the raw shape returned by every list/filter endpoint.
type filterResponse struct {
Pages int `json:"pages"`
Count int `json:"count"`
Header []colMeta `json:"header"`
Rows [][]any `json:"rows"`
}
// colMeta only captures what we actually use; other fields vary by endpoint
// and have inconsistent types (e.g. sortable is "1"/"0" on newer endpoints).
type colMeta struct {
Label string `json:"label"`
Hidden bool `json:"hidden"`
}
// colIndex builds a label→position map so rows can be accessed by column name.
func colIndex(headers []colMeta) map[string]int {
m := make(map[string]int, len(headers))
for i, h := range headers {
m[h.Label] = i
}
return m
}
func strCol(row []any, idx map[string]int, label string) string {
i, ok := idx[label]
if !ok || i >= len(row) {
return ""
}
if s, ok := row[i].(string); ok {
return s
}
return fmt.Sprintf("%v", row[i])
}
// postFilter calls the legacy FBEZ-style filter endpoint.
func (c *Client) postFilter(fbez string, payload any) (*filterResponse, error) {
url := fmt.Sprintf("%s/Filter/api/json/post?jsMode=EVAL&FBEZ=%s&listeId=undefined&showAll=true", baseURL, fbez)
return c.post(url, payload)
}
// postDataset calls the newer numeric-dataset-ID filter endpoint.
func (c *Client) postDataset(datasetID int, payload any) (*filterResponse, error) {
url := fmt.Sprintf("%s/Filter/api/filter/data/%d?js=eval&showAll=true", baseURL, datasetID)
return c.post(url, payload)
}
func (c *Client) post(url string, payload any) (*filterResponse, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", baseURL+"/")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; research-bot)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result filterResponse
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("unexpected response (not JSON): %s", truncate(string(raw), 200))
}
return &result, nil
}
// GetDetail fetches a single record by appending ?json=TRUE to any detail page path.
// path example: "/gegenstand/NR/I/I_00458"
func (c *Client) GetDetail(path string) (map[string]any, error) {
url := baseURL + path + "?json=TRUE"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", baseURL+"/")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; research-bot)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@ -0,0 +1,65 @@
package parlament
// CommitteeMembership records a person's role on a parliamentary committee.
type CommitteeMembership struct {
Period string // legislative period, e.g. "XXVIII"
Role string // "Funktion" — e.g. "Mitglied", "Obmann"
Committee string // short committee name, e.g. "Hauptausschuss"
Chamber string // "nrbr" — "NR" or "BR"
Body string // "Gremium" — full body name, e.g. "Nationalrat"
PersonID string // "PAD_INTERN" — internal numeric person identifier
FromDate string // "fromDate" — ISO-style timestamp of membership start
// Path is the portal-relative URL for the committee, usable with Client.GetDetail.
Path string // "URL" column
}
type CommitteeMembershipFilter struct {
// Period filters by legislative period, e.g. "XXVIII".
Period []string
// PersonID filters by the internal person identifier ("PAD_INTERN").
PersonID []string
// Committee filters by committee name.
Committee []string
// Chamber filters by chamber: "NR" or "BR".
Chamber []string
}
// ListCommitteeMemberships returns committee membership records (dataset 250).
func (c *Client) ListCommitteeMemberships(f CommitteeMembershipFilter) ([]CommitteeMembership, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["GP"] = f.Period
}
if len(f.PersonID) > 0 {
payload["PAD_INTERN"] = f.PersonID
}
if len(f.Committee) > 0 {
payload["AUSSCHUSS"] = f.Committee
}
if len(f.Chamber) > 0 {
payload["NRBR"] = f.Chamber
}
resp, err := c.postDataset(250, payload)
if err != nil {
return nil, 0, err
}
// "Ausschuss" appears twice: index 3 has the name with date "(24.10.2024)",
// index 8 has the short name without date. colIndex picks the last (index 8).
idx := colIndex(resp.Header)
memberships := make([]CommitteeMembership, 0, len(resp.Rows))
for _, row := range resp.Rows {
memberships = append(memberships, CommitteeMembership{
Period: strCol(row, idx, "GP"),
Role: strCol(row, idx, "Funktion"),
Committee: strCol(row, idx, "Ausschuss"), // last occurrence = short name
Chamber: strCol(row, idx, "nrbr"),
Body: strCol(row, idx, "Gremium"),
PersonID: strCol(row, idx, "PAD_INTERN"),
FromDate: strCol(row, idx, "fromDate"),
Path: strCol(row, idx, "URL"),
})
}
return memberships, resp.Count, nil
}

View File

@ -0,0 +1,129 @@
package parlament
// DocumentType selects which legislative document category to query.
type DocumentType string
const (
DocResolution DocumentType = "BNR" // Beschlüsse (resolutions)
DocMotion DocumentType = "ANTR" // Anträge (motions)
DocGovBill DocumentType = "RV" // Regierungsvorlagen (government bills)
DocCitizenInitiative DocumentType = "BI" // Bürgerinitiativen (citizen initiatives)
DocPetition DocumentType = "PET" // Petitionen (petitions)
DocWrittenQuestion DocumentType = "ANFR" // Schriftliche Anfragen (written questions)
DocEUStatement DocumentType = "EU" // EU-Stellungnahmen (EU committee statements)
)
// Document is a legislative item (resolution, motion, or government bill).
type Document struct {
Date string
Title string // "Betreff" column
Number string // "Nummer" column, e.g. "69/BNR"
GPCode string
Type string // "Art" column
Topics string
VoteResult string // "Abstimmung 3. Lesung"
VotesFor string // "Dafür" — party names
VotesAgainst string // "Dagegen" — party names
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "HIS_URL" column
}
type DocumentFilter struct {
// Chamber is "NR" (Nationalrat) or "BR" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
// Type narrows to a specific document category (DocResolution, DocMotion, DocGovBill).
Type DocumentType
// Topics is a free-text topic filter.
Topics []string
// DateFrom in YYYY-MM-DD format.
DateFrom string
}
// ListDocuments returns legislative documents matching the given filter.
func (c *Client) ListDocuments(f DocumentFilter) ([]Document, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NRBR"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if f.Type != "" {
payload["VHG"] = []string{string(f.Type)}
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
if f.DateFrom != "" {
payload["DATUM_VON"] = []string{f.DateFrom}
}
resp, err := c.postDataset(101, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
docs := make([]Document, 0, len(resp.Rows))
for _, row := range resp.Rows {
docs = append(docs, Document{
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Betreff"),
Number: strCol(row, idx, "Nummer"),
GPCode: strCol(row, idx, "GP_CODE"),
Type: strCol(row, idx, "Art"),
Topics: strCol(row, idx, "THEMEN"),
VoteResult: strCol(row, idx, "Abstimmung 3. Lesung"),
VotesFor: strCol(row, idx, "Dafür"),
VotesAgainst: strCol(row, idx, "Dagegen"),
Path: strCol(row, idx, "HIS_URL"),
})
}
return docs, resp.Count, nil
}
// Protocol is a stenographic protocol of a plenary session.
type Protocol struct {
Date string
Session string
Chamber string // "NBVS" column, e.g. "NRSITZ"
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "uri" column
}
type ProtocolFilter struct {
// Chamber is "NRSITZ" (Nationalrat) or "BRSITZ" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
}
// ListProtocols returns stenographic protocols matching the given filter.
func (c *Client) ListProtocols(f ProtocolFilter) ([]Protocol, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NBVS"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
resp, err := c.postDataset(211, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
protocols := make([]Protocol, 0, len(resp.Rows))
for _, row := range resp.Rows {
protocols = append(protocols, Protocol{
Date: strCol(row, idx, "Datum"),
Session: strCol(row, idx, "Sitzung"),
Chamber: strCol(row, idx, "NBVS"),
Path: strCol(row, idx, "uri"),
})
}
return protocols, resp.Count, nil
}

View File

@ -0,0 +1,65 @@
package parlament
import "fmt"
// Event is a parliamentary event from the public schedule.
type Event struct {
// Date is the ISO-formatted start datetime (e.g. "2025-12-17T13:00:00").
Date string
DateTo string // end datetime; empty if single-day event
Title string
Type string // "Terminart" — e.g. "Plenarsitzung", "Ausschusssitzung"
Venue string // "Ort" — e.g. "Bundesratssaal"
Topics string // "Themen" — JSON-encoded array
Body string // "Gremium" — parliamentary body (e.g. "Bundesrat")
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string
}
type EventFilter struct {
// Body filters by parliamentary body name ("Gremium"), e.g. "Nationalrat".
Body []string
// EventType filters by event category, e.g. "Plenarsitzung".
EventType []string
// DateFrom and DateTo define an inclusive date range (YYYY-MM-DD).
// Both must be set together; setting only one is a no-op.
DateFrom string
DateTo string
}
// ListEvents returns parliamentary events matching the given filter (dataset 600).
func (c *Client) ListEvents(f EventFilter) ([]Event, int, error) {
payload := map[string]any{}
if len(f.Body) > 0 {
payload["GREMIUM"] = f.Body
}
if len(f.EventType) > 0 {
payload["TERMINART"] = f.EventType
}
if f.DateFrom != "" && f.DateTo != "" {
payload["DATERANGE"] = []string{fmt.Sprintf("%s,%s", f.DateFrom, f.DateTo)}
}
resp, err := c.postDataset(600, payload)
if err != nil {
return nil, 0, err
}
// Dataset 600 has duplicate column names. colIndex resolves each to its last
// occurrence: "Datum" → index 15 (ISO datetime), "Terminart" → index 6.
idx := colIndex(resp.Header)
events := make([]Event, 0, len(resp.Rows))
for _, row := range resp.Rows {
events = append(events, Event{
Date: strCol(row, idx, "Datum"), // last "Datum" = ISO datetime
DateTo: strCol(row, idx, "Datum Bis"),
Title: strCol(row, idx, "Bezeichnung"),
Type: strCol(row, idx, "Terminart"),
Venue: strCol(row, idx, "Ort"),
Topics: strCol(row, idx, "Themen"),
Body: strCol(row, idx, "Gremium"),
Path: strCol(row, idx, "Link"),
})
}
return events, resp.Count, nil
}

View File

@ -0,0 +1,65 @@
package parlament
// GovernmentMember is a federal minister or state secretary.
type GovernmentMember struct {
Name string
Role string // current role with date range, e.g. "Bundesminister für Inneres\n03.03.2025 -"
RoleShort string // clean role title without the date suffix ("funk_text")
Party string // "partei_code", e.g. "ÖVP"
PartyFull string // "partei", e.g. "Österreichische Volkspartei"
Gender string // "geschlecht" — "männlich" / "weiblich"
State string // "bundesland" — federal state if a state-level official
Active bool
ActiveFrom string // "funk_ab_date" ISO datetime
ActiveTo string // "funk_bis_date" ISO datetime; empty if still active
PhotoURL string // "portrait" — partial URL like "/dokument/bild/..."
BiographyPath string // "biografielink" — portal-relative path usable with GetDetail
}
type GovernmentMemberFilter struct {
// Active, when true, restricts results to currently serving members.
Active bool
// Party filters by party code, e.g. "ÖVP", "SPÖ".
Party []string
// State filters by Austrian federal state name.
State []string
}
// ListGovernmentMembers returns federal ministers and state secretaries (dataset 408).
func (c *Client) ListGovernmentMembers(f GovernmentMemberFilter) ([]GovernmentMember, int, error) {
payload := map[string]any{}
if f.Active {
payload["aktiv"] = []string{"aktiv"}
}
if len(f.Party) > 0 {
payload["partei_code"] = f.Party
}
if len(f.State) > 0 {
payload["bundesland"] = f.State
}
resp, err := c.postDataset(408, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
members := make([]GovernmentMember, 0, len(resp.Rows))
for _, row := range resp.Rows {
members = append(members, GovernmentMember{
Name: strCol(row, idx, "name"),
Role: strCol(row, idx, "funktion"),
RoleShort: strCol(row, idx, "funk_text"),
Party: strCol(row, idx, "partei_code"),
PartyFull: strCol(row, idx, "partei"),
Gender: strCol(row, idx, "geschlecht"),
State: strCol(row, idx, "bundesland"),
Active: strCol(row, idx, "aktiv") == "aktiv",
ActiveFrom: strCol(row, idx, "funk_ab_date"),
ActiveTo: strCol(row, idx, "funk_bis_date"),
PhotoURL: strCol(row, idx, "portrait"),
BiographyPath: strCol(row, idx, "biografielink"),
})
}
return members, resp.Count, nil
}

View File

@ -0,0 +1,654 @@
package parlament
import (
"testing"
)
// These tests hit the live parlament.gv.at API.
// Run with: go test ./cmd/party/parlament/ -v
// Skip with: go test -short
func newTestClient(t *testing.T) *Client {
t.Helper()
if testing.Short() {
t.Skip("skipping live API test in short mode")
}
return NewClient()
}
// assertNonEmpty fails if any session has a blank field.
func assertSessionsPopulated(t *testing.T, sessions []Session) {
t.Helper()
for i, s := range sessions {
if s.Date == "" {
t.Errorf("session[%d] missing Date", i)
}
if s.Title == "" {
t.Errorf("session[%d] missing Title", i)
}
if s.Path == "" {
t.Errorf("session[%d] missing Path", i)
}
}
}
func TestListSessions(t *testing.T) {
c := newTestClient(t)
sessions, total, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
if total == 0 {
t.Fatal("expected at least one session, got 0")
}
if len(sessions) == 0 {
t.Fatal("expected rows, got none")
}
assertSessionsPopulated(t, sessions)
t.Logf("total=%d rows=%d", total, len(sessions))
for i, s := range sessions {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", s.Date, s.Title, s.Path)
}
}
func TestListSessions_TotalMatchesRows(t *testing.T) {
c := newTestClient(t)
sessions, total, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
if total != len(sessions) {
t.Errorf("total=%d but got %d rows (showAll=true should return everything)", total, len(sessions))
}
}
func TestListSessions_BothChambers(t *testing.T) {
c := newTestClient(t)
nr, nrTotal, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("NR: %v", err)
}
br, brTotal, err := c.ListSessions(SessionFilter{Chamber: []string{"BR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("BR: %v", err)
}
assertSessionsPopulated(t, nr)
assertSessionsPopulated(t, br)
t.Logf("NR sessions: %d, BR sessions: %d", nrTotal, brTotal)
if nrTotal == 0 || brTotal == 0 {
t.Error("expected results for both chambers")
}
}
func TestListSessions_PeriodFilter(t *testing.T) {
c := newTestClient(t)
_, xxvii, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVII"}})
if err != nil {
t.Fatalf("XXVII: %v", err)
}
_, xxviii, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("XXVIII: %v", err)
}
// XXVII was a full 5-year term; XXVIII is still in progress — XXVII should have more
t.Logf("XXVII=%d XXVIII=%d", xxvii, xxviii)
if xxvii <= xxviii {
t.Errorf("expected XXVII (%d) to have more sessions than ongoing XXVIII (%d)", xxvii, xxviii)
}
}
func TestListParliamentarians(t *testing.T) {
c := newTestClient(t)
mps, total, err := c.ListParliamentarians(ParliamentarianFilter{
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListParliamentarians: %v", err)
}
if total == 0 {
t.Fatal("expected at least one MP, got 0")
}
t.Logf("total=%d rows=%d", total, len(mps))
for i, mp := range mps {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", mp.Name, mp.Faction, mp.Constituency)
}
for i, mp := range mps {
if mp.Name == "" {
t.Errorf("mp[%d] missing Name", i)
}
if mp.Path == "" {
t.Errorf("mp[%d] missing Path", i)
}
}
}
func TestGetDetail_Parliamentarian(t *testing.T) {
c := newTestClient(t)
mps, _, err := c.ListParliamentarians(ParliamentarianFilter{})
if err != nil || len(mps) == 0 {
t.Fatalf("ListParliamentarians: %v", err)
}
// Find the first MP with a valid path.
var path string
for _, mp := range mps {
if mp.Path != "" {
path = mp.Path
break
}
}
if path == "" {
t.Fatal("no MP has a Path")
}
detail, err := c.GetDetail(path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", path, err)
}
for _, key := range []string{"meta", "pagetype", "content"} {
if _, ok := detail[key]; !ok {
t.Errorf("detail missing key %q", key)
}
}
t.Logf("path: %s", path)
}
func TestListDocuments_Resolutions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocResolution,
})
if err != nil {
t.Fatalf("ListDocuments(resolutions): %v", err)
}
if total == 0 {
t.Fatal("expected at least one resolution")
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
for i, d := range docs {
if d.Date == "" {
t.Errorf("doc[%d] missing Date", i)
}
if d.Number == "" {
t.Errorf("doc[%d] missing Number", i)
}
if d.Path == "" {
t.Errorf("doc[%d] missing Path", i)
}
}
}
func TestListDocuments_Motions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocMotion,
})
if err != nil {
t.Fatalf("ListDocuments(motions): %v", err)
}
if total == 0 {
t.Fatal("expected at least one motion")
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListDocuments_GovBills(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocGovBill,
})
if err != nil {
t.Fatalf("ListDocuments(gov bills): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListProtocols(t *testing.T) {
c := newTestClient(t)
protocols, total, err := c.ListProtocols(ProtocolFilter{
Chamber: []string{"NRSITZ"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListProtocols: %v", err)
}
if total == 0 {
t.Fatal("expected at least one protocol")
}
t.Logf("total=%d rows=%d", total, len(protocols))
for i, p := range protocols {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", p.Date, p.Session, p.Path)
}
for i, p := range protocols {
if p.Date == "" {
t.Errorf("protocol[%d] missing Date", i)
}
if p.Session == "" {
t.Errorf("protocol[%d] missing Session", i)
}
if p.Path == "" {
t.Errorf("protocol[%d] missing Path", i)
}
}
}
func TestGetDetail_Document(t *testing.T) {
c := newTestClient(t)
docs, _, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocResolution,
})
if err != nil || len(docs) == 0 {
t.Fatalf("ListDocuments: %v", err)
}
first := docs[0]
if first.Path == "" {
t.Skip("document has no path, skipping detail fetch")
}
detail, err := c.GetDetail(first.Path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", first.Path, err)
}
for _, key := range []string{"meta", "pagetype", "content"} {
if _, ok := detail[key]; !ok {
t.Errorf("detail missing key %q", key)
}
}
t.Logf("path: %s", first.Path)
}
func TestListDocuments_CitizenInitiatives(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Type: DocCitizenInitiative,
})
if err != nil {
t.Fatalf("ListDocuments(citizen initiatives): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
if total == 0 {
t.Fatal("expected at least one citizen initiative")
}
for i, d := range docs {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListDocuments_WrittenQuestions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocWrittenQuestion,
})
if err != nil {
t.Fatalf("ListDocuments(written questions): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
}
func TestListEvents(t *testing.T) {
c := newTestClient(t)
events, total, err := c.ListEvents(EventFilter{
DateFrom: "2025-01-01",
DateTo: "2025-03-31",
})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if total == 0 {
t.Fatal("expected at least one event")
}
if len(events) == 0 {
t.Fatal("expected rows")
}
t.Logf("total=%d rows=%d", total, len(events))
for i, e := range events {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | %s", e.Date, e.Type, e.Body, e.Title)
}
for i, e := range events {
if e.Date == "" {
t.Errorf("event[%d] missing Date", i)
}
if e.Title == "" {
t.Errorf("event[%d] missing Title", i)
}
}
}
func TestListEvents_ByBody(t *testing.T) {
c := newTestClient(t)
events, total, err := c.ListEvents(EventFilter{
Body: []string{"Nationalrat"},
DateFrom: "2025-01-01",
DateTo: "2025-12-31",
})
if err != nil {
t.Fatalf("ListEvents(Nationalrat): %v", err)
}
t.Logf("Nationalrat events in 2025: %d", total)
if total == 0 {
t.Fatal("expected Nationalrat events")
}
for _, e := range events {
if e.Body != "Nationalrat" {
t.Errorf("expected Body=Nationalrat, got %q", e.Body)
break
}
}
}
func TestListGovernmentMembers(t *testing.T) {
c := newTestClient(t)
members, total, err := c.ListGovernmentMembers(GovernmentMemberFilter{})
if err != nil {
t.Fatalf("ListGovernmentMembers: %v", err)
}
if total == 0 {
t.Fatal("expected at least one government member")
}
t.Logf("total=%d rows=%d", total, len(members))
for i, m := range members {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", m.Name, m.RoleShort, m.Party)
}
for i, m := range members {
if m.Name == "" {
t.Errorf("member[%d] missing Name", i)
}
}
}
func TestListGovernmentMembers_Active(t *testing.T) {
c := newTestClient(t)
_, allTotal, err := c.ListGovernmentMembers(GovernmentMemberFilter{})
if err != nil {
t.Fatalf("all: %v", err)
}
active, activeTotal, err := c.ListGovernmentMembers(GovernmentMemberFilter{Active: true})
if err != nil {
t.Fatalf("active: %v", err)
}
t.Logf("all=%d active=%d", allTotal, activeTotal)
if activeTotal == 0 {
t.Fatal("expected active government members")
}
if activeTotal >= allTotal {
t.Errorf("active (%d) should be fewer than all (%d)", activeTotal, allTotal)
}
for i, m := range active {
if !m.Active {
t.Errorf("active[%d] %q has Active=false", i, m.Name)
}
}
}
func TestListPressReleases(t *testing.T) {
c := newTestClient(t)
releases, total, err := c.ListPressReleases(PressReleaseFilter{
Year: []string{"2025"},
})
if err != nil {
t.Fatalf("ListPressReleases: %v", err)
}
if total == 0 {
t.Fatal("expected at least one press release")
}
t.Logf("total=%d rows=%d", total, len(releases))
for i, r := range releases {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", r.Date, r.Number, r.Title)
}
for i, r := range releases {
if r.Date == "" {
t.Errorf("release[%d] missing Date", i)
}
if r.Title == "" {
t.Errorf("release[%d] missing Title", i)
}
if r.Path == "" {
t.Errorf("release[%d] missing Path", i)
}
}
}
func TestListCommitteeMemberships(t *testing.T) {
c := newTestClient(t)
memberships, total, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
Chamber: []string{"NR"},
})
if err != nil {
t.Fatalf("ListCommitteeMemberships: %v", err)
}
if total == 0 {
t.Fatal("expected at least one membership")
}
t.Logf("total=%d rows=%d", total, len(memberships))
for i, m := range memberships {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | %s", m.Period, m.Role, m.Committee, m.PersonID)
}
for i, m := range memberships {
if m.Committee == "" {
t.Errorf("membership[%d] missing Committee", i)
}
if m.PersonID == "" {
t.Errorf("membership[%d] missing PersonID", i)
}
}
}
func TestListCommitteeMemberships_ByPerson(t *testing.T) {
c := newTestClient(t)
// Get one MP's PAD_INTERN and check their committee memberships.
mps, _, err := c.ListParliamentarians(ParliamentarianFilter{Period: []string{"XXVIII"}})
if err != nil || len(mps) == 0 {
t.Fatalf("ListParliamentarians: %v", err)
}
memberships, _, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
})
if err != nil || len(memberships) == 0 {
t.Fatalf("ListCommitteeMemberships: %v", err)
}
// Look up the first known PersonID and filter to just that person.
personID := memberships[0].PersonID
byPerson, total, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
PersonID: []string{personID},
})
if err != nil {
t.Fatalf("ListCommitteeMemberships(person %s): %v", personID, err)
}
t.Logf("personID=%s committees=%d", personID, total)
for _, m := range byPerson {
if m.PersonID != personID {
t.Errorf("unexpected PersonID %q (want %q)", m.PersonID, personID)
}
}
}
func TestListParticipatoryItems(t *testing.T) {
c := newTestClient(t)
items, total, err := c.ListParticipatoryItems(ParticipatoryFilter{
Active: true,
})
if err != nil {
t.Fatalf("ListParticipatoryItems: %v", err)
}
if total == 0 {
t.Fatal("expected at least one active participatory item")
}
t.Logf("total=%d rows=%d", total, len(items))
for i, it := range items {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | supporters=%s", it.Date, it.Type, it.Number, it.Supporters)
}
for i, it := range items {
if !it.Active {
t.Errorf("item[%d] %q has Active=false despite AKTIV=J filter", i, it.Number)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title", i)
}
}
}
func TestListParticipatoryItems_ByPeriod(t *testing.T) {
c := newTestClient(t)
items, total, err := c.ListParticipatoryItems(ParticipatoryFilter{
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListParticipatoryItems(XXVIII): %v", err)
}
t.Logf("XXVIII participatory items: %d", total)
if total == 0 {
t.Fatal("expected participatory items for XXVIII")
}
for i, it := range items {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", it.Date, it.Category, it.Title)
}
}
func TestGetDetail(t *testing.T) {
c := newTestClient(t)
// Fetch the latest session from ListSessions and get its detail page.
sessions, _, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil || len(sessions) == 0 {
t.Fatalf("ListSessions: %v", err)
}
latest := sessions[0]
if latest.Path == "" {
t.Fatalf("session has no path: %+v", latest)
}
detail, err := c.GetDetail(latest.Path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", latest.Path, err)
}
if len(detail) == 0 {
t.Fatal("expected non-empty detail map")
}
t.Logf("path: %s", latest.Path)
ks := make([]string, 0, len(detail))
for k := range detail {
ks = append(ks, k)
}
t.Logf("top-level keys: %v", ks)
}

View File

@ -0,0 +1,118 @@
package parlament
// Parliamentarian is a member of the Austrian parliament (dataset 409, historical).
type Parliamentarian struct {
Name string
Faction string // "Klub/Fraktion" column
Period string // "Gesetzgebungsperiode" column
Constituency string // "Wahlkreis" column
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "Bio" column
}
type ParliamentarianFilter struct {
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
// Maps to the gp_text_full_short filter dimension.
Period []string
}
// ListParliamentarians returns MPs matching the given filter (dataset 409, all periods).
func (c *Client) ListParliamentarians(f ParliamentarianFilter) ([]Parliamentarian, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["gp_text_full_short"] = f.Period
}
resp, err := c.postDataset(409, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
mps := make([]Parliamentarian, 0, len(resp.Rows))
for _, row := range resp.Rows {
mps = append(mps, Parliamentarian{
Name: strCol(row, idx, "Name"),
Faction: strCol(row, idx, "Klub/Fraktion"),
Period: strCol(row, idx, "Gesetzgebungsperiode"),
Constituency: strCol(row, idx, "Wahlkreis"),
Path: strCol(row, idx, "Bio"),
})
}
return mps, resp.Count, nil
}
// Member is a richer parliamentarian record from dataset 401, which supports
// filtering by active status, legislative period, and parliamentary body.
type Member struct {
Name string // "Nachname Vorname" format
FirstName string
LastName string
Faction string // party abbreviation, e.g. "FPÖ"
Role string // current role, e.g. "Abgeordnete zum Nationalrat"
Constituency string // "Wahlkreis"
// PhotoURL is the partial image path (prepend baseURL to resolve).
PhotoURL string // e.g. "/dokument/bild/201448/20144872_180.jpg"
// Path is the portal-relative biography URL, usable with Client.GetDetail.
Path string
Active bool
}
// MemberType distinguishes the kind of parliamentary mandate.
const (
MemberTypeNR = "NR" // Nationalrat deputy
MemberTypeBR = "BR" // Bundesrat member
MemberTypePres = "MPO" // President / Vice-President
MemberTypeGovt = "BuREG" // Federal government member
)
type MemberFilter struct {
// Period is the legislative period, e.g. "XXVIII".
Period []string
// Active, when true, restricts to currently serving members.
Active bool
// Type filters by mandate kind (MemberTypeNR, MemberTypeBR, etc.).
Type []string
// Faction filters by party abbreviation, e.g. "FPÖ".
Faction []string
}
// ListMembers returns parliamentary members using dataset 401, which supports
// richer filtering than ListParliamentarians (active status, mandate type).
func (c *Client) ListMembers(f MemberFilter) ([]Member, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if f.Active {
payload["AKTIV"] = []string{"J"}
}
if len(f.Type) > 0 {
payload["persart"] = f.Type
}
if len(f.Faction) > 0 {
payload["frak"] = f.Faction
}
resp, err := c.postDataset(401, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
members := make([]Member, 0, len(resp.Rows))
for _, row := range resp.Rows {
members = append(members, Member{
Name: strCol(row, idx, "Name"),
FirstName: strCol(row, idx, "vorname"),
LastName: strCol(row, idx, "nachname"),
Faction: strCol(row, idx, "frak"),
Role: strCol(row, idx, "Funktion"),
Constituency: strCol(row, idx, "Wahlkreis"),
PhotoURL: strCol(row, idx, "Image"),
Path: strCol(row, idx, "link"),
Active: strCol(row, idx, "aktiv") == "J",
})
}
return members, resp.Count, nil
}

View File

@ -0,0 +1,83 @@
package parlament
// ParticipatoryType distinguishes citizen-participation item categories.
type ParticipatoryType string
const (
ParticipatoryMinisterialDraft ParticipatoryType = "MEG" // Ministerialentwurf (public consultation)
ParticipatoryPetition ParticipatoryType = "PET" // Petition
ParticipatoryInitiative ParticipatoryType = "BIG" // Bürgerinitiative (citizen initiative)
)
// ParticipatoryItem is a participatory democracy item: a public consultation,
// petition, or citizen initiative open for comment or signature collection.
type ParticipatoryItem struct {
Period string
Date string // "Datum" — display date, e.g. "04.05.2026"
Title string // "Betreff"
Number string // "Nr." — e.g. "101/ME"
Type string // "DOKTYP" — e.g. "MEG", "PET", "BIG"
Category string // "Gegenstand" — human-readable type, e.g. "Ministerialentwurf"
Supporters string // "Unterstützungen" — signature count (as string)
Statements string // "Stellungnahmen" — submitted opinions count
Topics string // "Themen" — JSON-encoded array
Active bool // "Aktiv?" == "J"
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "b" column
}
type ParticipatoryFilter struct {
// Active, when true, restricts to items currently open for participation.
Active bool
// Period filters by legislative period, e.g. "XXVIII".
Period []string
// Type filters by document category (e.g. ParticipatoryPetition).
Type []ParticipatoryType
// Topics filters by subject area.
Topics []string
}
// ListParticipatoryItems returns participatory democracy items (dataset 143).
func (c *Client) ListParticipatoryItems(f ParticipatoryFilter) ([]ParticipatoryItem, int, error) {
payload := map[string]any{}
if f.Active {
payload["AKTIV"] = []string{"J"}
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if len(f.Type) > 0 {
types := make([]string, len(f.Type))
for i, t := range f.Type {
types[i] = string(t)
}
payload["DOKTYP"] = types
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
resp, err := c.postDataset(143, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
items := make([]ParticipatoryItem, 0, len(resp.Rows))
for _, row := range resp.Rows {
items = append(items, ParticipatoryItem{
Period: strCol(row, idx, "GP_CODE"),
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Betreff"),
Number: strCol(row, idx, "Nr."),
Type: strCol(row, idx, "DOKTYP"),
Category: strCol(row, idx, "Gegenstand"),
Supporters: strCol(row, idx, "Unterstützungen"),
Statements: strCol(row, idx, "Stellungnahmen"),
Topics: strCol(row, idx, "Themen"),
Active: strCol(row, idx, "Aktiv?") == "J",
Path: strCol(row, idx, "b"),
})
}
return items, resp.Count, nil
}

View File

@ -0,0 +1,59 @@
package parlament
// PressRelease is a parliamentary correspondence item (Parlamentskorrespondenz).
type PressRelease struct {
// Date is the ISO-formatted publication date (e.g. "2025-12-30T00:00:00").
Date string
Number string // e.g. "PK1235"
Title string
Subtitle string
Topics string // "Abo-Themen" — JSON-encoded array, e.g. ["Bildung"]
Keywords string // "Stichworte" — JSON-encoded array of finer-grained tags
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string
}
type PressReleaseFilter struct {
// Year filters by publication year, e.g. "2025".
Year []string
// Topics filters by broad subject area.
Topics []string
// Keywords filters by finer-grained subject tags ("STW").
Keywords []string
}
// ListPressReleases returns parliamentary press releases matching the filter (dataset 110).
func (c *Client) ListPressReleases(f PressReleaseFilter) ([]PressRelease, int, error) {
payload := map[string]any{}
if len(f.Year) > 0 {
payload["JAHR"] = f.Year
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
if len(f.Keywords) > 0 {
payload["STW"] = f.Keywords
}
resp, err := c.postDataset(110, payload)
if err != nil {
return nil, 0, err
}
// Dataset 110 has duplicate column names. colIndex resolves each to its last
// occurrence: "Datum" → index 11 (ISO date), "Titel" → index 5 (clean plain text).
idx := colIndex(resp.Header)
releases := make([]PressRelease, 0, len(resp.Rows))
for _, row := range resp.Rows {
releases = append(releases, PressRelease{
Date: strCol(row, idx, "Datum"), // last "Datum" = ISO date
Number: strCol(row, idx, "Nr."),
Title: strCol(row, idx, "Titel"), // last "Titel" = plain text
Subtitle: strCol(row, idx, "Untertitel"),
Topics: strCol(row, idx, "Abo-Themen"),
Keywords: strCol(row, idx, "Stichworte"),
Path: strCol(row, idx, "Link"),
})
}
return releases, resp.Count, nil
}

View File

@ -0,0 +1,48 @@
package parlament
// Session is a plenary session (Nationalrat or Bundesrat).
type Session struct {
Date string
Title string
Agenda string
GPCode string
// Path is the portal-relative URL for this session, usable with Client.GetDetail.
Path string
}
type SessionFilter struct {
// Chamber is "NR" (Nationalrat) or "BR" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
}
// ListSessions returns plenary sessions matching the given filter.
// Leave a field nil/empty to omit that dimension.
func (c *Client) ListSessions(f SessionFilter) ([]Session, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NRBRBV"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP"] = f.Period
}
resp, err := c.postFilter("WFP_007", payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
sessions := make([]Session, 0, len(resp.Rows))
for _, row := range resp.Rows {
sessions = append(sessions, Session{
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Sitzung"),
Agenda: strCol(row, idx, "Tagesordnung"),
GPCode: strCol(row, idx, "gp_code"),
Path: strCol(row, idx, "pfad"),
})
}
return sessions, resp.Count, nil
}

101
cmd/party/roles_test.go Normal file
View File

@ -0,0 +1,101 @@
package main
import (
"net/http"
"testing"
"time"
)
func TestRoles(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
issueBody := map[string]any{
"title": "Role test issue",
"description": "Testing role-based access.",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Option A", "Option B"},
}
// Seed a contributor so we have an issue to read against.
adminEmail := uniqueEmail()
adminToken := ts.registerAndLogin(t, adminEmail, "pa$$word123")
code, _, res := ts.postJSONWithToken(t, "/v1/issues", adminToken, issueBody)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
// -------------------------------------------------------------------------
// No role — all protected routes must return 403.
// -------------------------------------------------------------------------
t.Run("no role is forbidden", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("look up user: %v", err)
}
if _, err = ts.app.Models.Roles.DB.Exec(`DELETE FROM users_roles WHERE user_id = $1`, user.ID); err != nil {
t.Fatalf("strip roles: %v", err)
}
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusForbidden {
t.Errorf("GET /v1/issues: want 403, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusForbidden {
t.Errorf("POST /v1/issues: want 403, got %d", code)
}
})
// -------------------------------------------------------------------------
// Viewer — can read, cannot write.
// -------------------------------------------------------------------------
t.Run("viewer can read but not write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "viewer")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusForbidden {
t.Errorf("POST /v1/issues: want 403, got %d", code)
}
})
// -------------------------------------------------------------------------
// Contributor — full read/write access.
// -------------------------------------------------------------------------
t.Run("contributor can read and write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "contributor")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusCreated {
t.Errorf("POST /v1/issues: want 201, got %d", code)
}
})
// -------------------------------------------------------------------------
// Admin — same permissions as contributor for now.
// -------------------------------------------------------------------------
t.Run("admin can read and write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "admin")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusCreated {
t.Errorf("POST /v1/issues: want 201, got %d", code)
}
})
}

86
cmd/party/routes.go Normal file
View File

@ -0,0 +1,86 @@
package main
import (
"expvar"
"net/http"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/cmd/party/api"
"party.at/party/cmd/party/web"
)
func routes(app *common.Application) http.Handler {
// ── API router (Bearer token auth) ──────────────────────────────────────
api := api.Api{
App: app,
}
apiRouter := httprouter.New()
apiRouter.NotFound = http.HandlerFunc(api.NotFoundResponse)
apiRouter.MethodNotAllowed = http.HandlerFunc(api.MethodNotAllowedResponse)
apiRouter.HandlerFunc(http.MethodGet, "/v1/healthcheck", api.Healthcheck)
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues", api.RequirePermission("issues:read", api.ListIssues))
apiRouter.HandlerFunc(http.MethodPost, "/v1/issues", api.RequirePermission("issues:write", api.CreateIssue))
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues/:id", api.RequirePermission("issues:read", api.ReadIssue))
apiRouter.HandlerFunc(http.MethodPatch, "/v1/issues/:id", api.RequirePermission("issues:write", api.UpdateIssue))
apiRouter.HandlerFunc(http.MethodDelete, "/v1/issues/:id", api.RequirePermission("issues:write", api.DeleteIssue))
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues/:id/pubkey", api.RequirePermission("issues:read", api.ReadIssuePubKey))
apiRouter.HandlerFunc(http.MethodPost, "/v1/issues/:id/blind-sign", api.RequirePermission("issues:read", api.BlindSignIssueVote))
apiRouter.HandlerFunc(http.MethodPost, "/v1/votes", api.Vote)
apiRouter.HandlerFunc(http.MethodPost, "/v1/users", api.CreateUser)
apiRouter.HandlerFunc(http.MethodGet, "/v1/users", api.RequirePermission("users:read", api.ListUsers))
apiRouter.HandlerFunc(http.MethodGet, "/v1/users/:id", api.RequireAuthenticatedUser(api.ReadUser))
apiRouter.HandlerFunc(http.MethodDelete, "/v1/users/:id", api.DeleteUser)
apiRouter.HandlerFunc(http.MethodPut, "/v1/users/activated", api.ActivateUser)
apiRouter.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", api.CreateAuthenticationToken)
apiRouter.HandlerFunc(http.MethodDelete, "/v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken))
apiRouter.Handler(http.MethodGet, "/debug/vars", expvar.Handler())
apiChain := app.Metrics(api.RecoverPanic(app.EnableCORS(api.RateLimit(api.Authenticate(apiRouter)))))
// ── Web router (cookie auth) ─────────────────────────────────────────────
web := web.Web{
App: app,
}
webRouter := httprouter.New()
webRouter.HandlerFunc(http.MethodGet, "/", web.Home)
webRouter.HandlerFunc(http.MethodGet, "/register", web.Register)
webRouter.HandlerFunc(http.MethodPost, "/register", web.RegisterUserPage)
webRouter.HandlerFunc(http.MethodGet, "/issues", web.IssuesPage)
webRouter.HandlerFunc(http.MethodPost, "/issues", web.CreateIssueAction)
webRouter.HandlerFunc(http.MethodGet, "/issues/:id", web.IssuePage)
webRouter.HandlerFunc(http.MethodPatch, "/issues/:id", web.UpdateIssueAction)
webRouter.HandlerFunc(http.MethodDelete, "/issues/:id", web.DeleteIssueAction)
webRouter.HandlerFunc(http.MethodGet, "/issues/:id/pubkey", web.GetIssuePubKey)
webRouter.HandlerFunc(http.MethodPost, "/issues/:id/blind-sign", web.BlindSignIssue)
webRouter.HandlerFunc(http.MethodPost, "/votes", web.VoteAction)
webRouter.HandlerFunc(http.MethodGet, "/users", web.UsersPage)
webRouter.HandlerFunc(http.MethodGet, "/users/me", web.ProfilePage)
webRouter.HandlerFunc(http.MethodGet, "/users/activated", web.ActivatePage)
webRouter.HandlerFunc(http.MethodPost, "/users/activated", web.ActivateUserAction)
webRouter.HandlerFunc(http.MethodDelete, "/users/:id", web.DeleteUserAction)
webRouter.HandlerFunc(http.MethodGet, "/mps", web.MembersOfParliamentPage)
webRouter.HandlerFunc(http.MethodPost, "/login", web.Login)
webRouter.HandlerFunc(http.MethodPost, "/dev-login", web.DevLogin)
webRouter.HandlerFunc(http.MethodPost, "/logout", web.Logout)
webRouter.HandlerFunc(http.MethodGet, "/ws", ws)
webRouter.ServeFiles("/static/*filepath", http.Dir("web/static"))
webChain := app.Metrics(web.WebRecoverPanic(web.WebRateLimit(web.AuthenticateCookie(webRouter))))
// ── Top-level mux ────────────────────────────────────────────────────────
mux := http.NewServeMux()
mux.Handle("/v1/", apiChain)
mux.Handle("/debug/", apiChain)
mux.Handle("/", webChain)
return mux
}

View File

@ -4,20 +4,21 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"time"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"party.at/party/cmd/party/common"
) )
func (app *application) serve() error { func serve(app *common.Application) error {
// Declare a HTTP server using the same settings as in our main() function.
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", app.config.port), Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: app.routes(), Handler: routes(app),
ErrorLog: log.New(app.logger, "", 0), ErrorLog: log.New(app.Logger, "", 0),
IdleTimeout: time.Minute, IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
@ -26,27 +27,23 @@ func (app *application) serve() error {
shutdownError := make(chan error) shutdownError := make(chan error)
go func() { go func() {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit s := <-quit
app.logger.PrintInfo("shutting down server", map[string]string{ app.Logger.PrintInfo("shutting down server", map[string]string{
"signal": s.String(), "signal": s.String(),
}) })
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
shutdownError <- srv.Shutdown(ctx) shutdownError <- srv.Shutdown(ctx)
}() }()
// Likewise log a "starting server" message. app.Logger.PrintInfo("starting server", map[string]string{
app.logger.PrintInfo("starting server", map[string]string{
"addr": srv.Addr, "addr": srv.Addr,
"env": app.config.env, "env": app.Config.Env,
}) })
err := srv.ListenAndServe() err := srv.ListenAndServe()
@ -59,7 +56,7 @@ func (app *application) serve() error {
return err return err
} }
app.logger.PrintInfo("stopped server", map[string]string{ app.Logger.PrintInfo("stopped server", map[string]string{
"addr": srv.Addr, "addr": srv.Addr,
}) })

233
cmd/party/testutils_test.go Normal file
View File

@ -0,0 +1,233 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
)
func newTestApplication(t *testing.T) *common.Application {
var cfg common.Config
cfg.DB.DSN = "postgres://party:password@localhost:5432/party?sslmode=disable"
cfg.DB.MaxOpenConns = 25
cfg.DB.MaxIdleConns = 25
cfg.DB.MaxIdleTime = "15m"
cfg.Limiter.Enabled = false
cfg.Env = "development"
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
t.Cleanup(func() { db.Close() })
app := &common.Application{
Logger: logger,
Models: data.NewModels(db),
Config: cfg,
}
return app
}
type testServer struct {
*httptest.Server
app *common.Application
}
func newTestServer(t *testing.T, app *common.Application, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts, app}
}
func (ts *testServer) postJSON(t *testing.T, path string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header, respBody
}
func (ts *testServer) get(t *testing.T, path string) (int, http.Header, []byte) {
t.Helper()
rs, err := ts.Client().Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) registerAndLogin(t *testing.T, email, password string) string {
t.Helper()
registerBody := map[string]any{
"email": email,
"password": password,
"username": email,
"name": "Test User",
"alt_name": "",
"provider_id": 1,
}
code, _, body := ts.postJSON(t, "/v1/users", registerBody)
if code != http.StatusCreated {
t.Fatalf("register: want 201 got %d: %s", code, body)
}
loginBody := map[string]string{"email": email, "password": password}
code, _, body = ts.postJSON(t, "/v1/tokens/authentication", loginBody)
if code != http.StatusCreated {
t.Fatalf("login: want 201 got %d: %s", code, body)
}
var resp struct {
AuthenticationToken struct {
Token string `json:"token"`
} `json:"authentication_token"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("parse token: %v", err)
}
return resp.AuthenticationToken.Token
}
func (ts *testServer) getWithToken(t *testing.T, path, token string) (int, http.Header, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) postJSONWithToken(t *testing.T, path, token string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
respBody, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, respBody
}
func uniqueEmail() string {
return fmt.Sprintf("test_%d@example.com", time.Now().UnixNano())
}
func (ts *testServer) assignRole(t *testing.T, email string, roleCode string) {
t.Helper()
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("assignRole: look up user %q: %v", email, err)
}
if err = ts.app.Models.Roles.AssignToUser(user.ID, roleCode); err != nil {
t.Fatalf("assignRole: assign %q to user %d: %v", roleCode, user.ID, err)
}
}
func (ts *testServer) setRole(t *testing.T, email, roleCode string) {
t.Helper()
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("setRole: look up user %q: %v", email, err)
}
if _, err = ts.app.Models.Roles.DB.Exec(`DELETE FROM users_roles WHERE user_id = $1`, user.ID); err != nil {
t.Fatalf("setRole: clear roles for user %d: %v", user.ID, err)
}
if err = ts.app.Models.Roles.AssignToUser(user.ID, roleCode); err != nil {
t.Fatalf("setRole: assign %q to user %d: %v", roleCode, user.ID, err)
}
}
func generateBlindingFactor(n *big.Int) (*big.Int, error) {
one := big.NewInt(1)
for {
r, err := randBigInt(n)
if err != nil {
return nil, err
}
if r.Cmp(one) <= 0 {
continue
}
gcd := new(big.Int).GCD(nil, nil, r, n)
if gcd.Cmp(one) == 0 {
return r, nil
}
}
}
func randBigInt(max *big.Int) (*big.Int, error) {
b := make([]byte, (max.BitLen()+7)/8)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
r := new(big.Int).SetBytes(b)
return r.Mod(r, max), nil
}
func mustUnmarshal(t *testing.T, body []byte, v any) {
t.Helper()
if err := json.Unmarshal(body, v); err != nil {
t.Fatalf("unmarshal: %v\nbody: %s", err, body)
}
}
func i64str(n int64) string {
return strconv.FormatInt(n, 10)
}

241
cmd/party/vote_test.go Normal file
View File

@ -0,0 +1,241 @@
package main
import (
"crypto/rand"
"math/big"
"net/http"
"testing"
"time"
"party.at/party/internal/crypto"
)
func getVotes(t *testing.T, ts *testServer, issueID int64, optionID int64, adminToken string) int64 {
// -----------------------------------------------------------------------
// 8. Tally
// -----------------------------------------------------------------------
code, _, res := ts.getWithToken(t, "/v1/issues/" + i64str(issueID), adminToken)
if code != http.StatusOK {
t.Fatalf("read issue: want 200 got %d: %s", code, res)
}
var resp struct {
Options []struct {
ID int64 `json:"id"`
VoteCount int64 `json:"vote_count"`
} `json:"options"`
}
mustUnmarshal(t, res, &resp)
for _, o := range resp.Options {
if o.ID == optionID {
return o.VoteCount
}
}
return 0
}
func vote(t *testing.T, ts *testServer, issueID int64, optionID int64) {
voterEmail := uniqueEmail()
voterToken := ts.registerAndLogin(t, voterEmail, "pa$$word123")
ts.assignRole(t, voterEmail, "contributor")
code, _, res := ts.getWithToken(t, "/v1/issues/" + i64str(issueID)+"/pubkey", voterToken)
if code != http.StatusOK {
t.Fatalf("get pubkey: want 200 got %d: %s", code, res)
}
var pubKeyResp struct {
PublicKey struct {
N string `json:"n"`
E int `json:"e"`
} `json:"public_key"`
}
mustUnmarshal(t, res, &pubKeyResp)
nInt, ok := new(big.Int).SetString(pubKeyResp.PublicKey.N, 16)
if !ok {
t.Fatal("failed to parse public key modulus")
}
eInt := big.NewInt(int64(pubKeyResp.PublicKey.E))
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
t.Fatalf("generate nonce: %v", err)
}
m := crypto.VoteMessage(issueID, optionID, nonce)
mInt := new(big.Int).SetBytes(m[:])
r, err := generateBlindingFactor(nInt)
if err != nil {
t.Fatalf("generate blinding factor: %v", err)
}
// After fetching pubkey:
t.Logf("n from pubkey (first 8 bytes): %x", nInt.Bytes()[:8])
// m' = m * r^e mod n
re := new(big.Int).Exp(r, eInt, nInt)
mBlind := new(big.Int).Mod(new(big.Int).Mul(mInt, re), nInt)
// -----------------------------------------------------------------------
// 4. Client sends blinded vote to server (authenticated)
// -----------------------------------------------------------------------
code, _, res = ts.postJSONWithToken(t, "/v1/issues/" + i64str(issueID)+"/blind-sign", voterToken, map[string]any{
"blinded_vote": mBlind.Bytes(),
})
if code != http.StatusOK {
t.Fatalf("blind-sign: want 200 got %d: %s", code, res)
}
var blindSigResp struct {
Signed []byte `json:"signed"`
}
mustUnmarshal(t, res, &blindSigResp)
// -----------------------------------------------------------------------
// 5. Client unblinds: s = s' * r^-1 mod n
// -----------------------------------------------------------------------
t.Logf("raw blind-sign response: %s", res)
sPrime := new(big.Int).SetBytes(blindSigResp.Signed)
// After getting blind sig back, before unblinding:
t.Logf("s' (first 8 bytes): %x", sPrime.Bytes()[:8])
rInv := new(big.Int).ModInverse(r, nInt)
if rInv == nil {
t.Fatal("could not compute modular inverse of r")
}
s := new(big.Int).Mod(new(big.Int).Mul(sPrime, rInv), nInt)
// Sanity check: verify s^e mod n == m before submitting
recovered := new(big.Int).Exp(s, eInt, nInt)
if recovered.Cmp(mInt) != 0 {
t.Fatal("unblinded signature verification failed: s^e mod n != m")
}
// After unblinding:
t.Logf("s (first 8 bytes): %x", s.Bytes()[:8])
t.Logf("m (expected): %x", mInt.Bytes())
t.Logf("s^e mod n: %x", recovered.Bytes())
// -----------------------------------------------------------------------
// 6. Client logs out -- anonymous submission has no auth header
// -----------------------------------------------------------------------
code, _, res = ts.postJSON(t, "/v1/votes", map[string]any{
"issue_id": issueID,
"option_id": optionID,
"nonce": nonce,
"signature": s.Bytes(),
})
if code != http.StatusCreated {
t.Fatalf("submit vote: want 201 got %d: %s", code, res)
}
// -----------------------------------------------------------------------
// 7. Replay attack -- same signature should be rejected
// -----------------------------------------------------------------------
code, _, _ = ts.postJSON(t, "/v1/votes", map[string]any{
"issue_id": issueID,
"option_id": optionID,
"nonce": nonce,
"signature": s.Bytes(),
})
if code != http.StatusConflict {
t.Errorf("replay attack: want 409 got %d", code)
}
}
func TestVote(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
// -----------------------------------------------------------------------
// 1. Admin registers, logs in, creates an issue with options
// -----------------------------------------------------------------------
adminEmail := uniqueEmail()
adminToken := ts.registerAndLogin(t, adminEmail, "pa$$word123")
issueBody := map[string]any{
"title": "Should we adopt a four-day work week?",
"description": "Vote yes or no.",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Yes", "No"},
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", adminToken, issueBody)
if code != http.StatusCreated {
t.Fatalf("create issue: want 201 got %d: %s", code, res)
}
var issueResp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
Options []struct {
ID int64 `json:"id"`
Label string `json:"label"`
} `json:"options"`
}
mustUnmarshal(t, res, &issueResp)
issueID := issueResp.Issue.ID
var yesOptionID, noOptionID int64
for _, opt := range issueResp.Options {
switch opt.Label {
case "Yes":
yesOptionID = opt.ID
case "No":
noOptionID = opt.ID
}
}
if yesOptionID == 0 || noOptionID == 0 {
t.Fatalf("expected Yes and No options in response, got: %v", issueResp.Options)
}
// -----------------------------------------------------------------------
// 2. Normal user registers, logs in, fetches the public key
// -----------------------------------------------------------------------
count := getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 0 {
t.Fatalf("expected 0 yes votes")
}
count = getVotes(t, ts, issueID, noOptionID, adminToken)
if count != 0 {
t.Fatalf("expected 0 no votes")
}
vote(t, ts, issueID, yesOptionID)
count = getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 1 {
t.Fatalf("expected 1 yes votes")
}
vote(t, ts, issueID, yesOptionID)
count = getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 2 {
t.Fatalf("expected 2 yes votes")
}
vote(t, ts, issueID, noOptionID)
count = getVotes(t, ts, issueID, noOptionID, adminToken)
if count != 1 {
t.Fatalf("expected 1 no votes")
}
}

100
cmd/party/web/home.go Normal file
View File

@ -0,0 +1,100 @@
package web
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) Home(w http.ResponseWriter, r *http.Request) {
if !getUser(r).IsAnonymous() {
http.Redirect(w, r, "/issues", http.StatusSeeOther)
return
}
web.render(w, r, http.StatusOK, "home_anonymous", struct {
AuthenticatedUser *data.User
FormErrors []string
IsDevelopment bool
}{
IsDevelopment: web.App.Config.Env == "development",
})
}
func (web *Web) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
token, err := web.App.LoginUser(r.PostFormValue("email"), r.PostFormValue("password"))
if err != nil {
var msg string
if errors.Is(err, data.ErrInvalidCredentials) {
msg = "Ungültige E-Mail-Adresse oder Passwort."
} else {
web.App.LogError(r, err)
msg = "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
}
web.render(w, r, http.StatusUnprocessableEntity, "home_anonymous", struct {
AuthenticatedUser *data.User
FormErrors []string
IsDevelopment bool
}{
FormErrors: []string{msg},
IsDevelopment: web.App.Config.Env == "development",
})
return
}
setCookie(w, token.Plaintext, token.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) DevLogin(w http.ResponseWriter, r *http.Request) {
if web.App.Config.Env != "development" {
http.NotFound(w, r)
return
}
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
suffix := hex.EncodeToString(b)
_, authToken, err := web.App.RegisterUser(common.RegisterUserInput{
ProviderID: 1,
Username: fmt.Sprintf("dev-%s", suffix),
Email: fmt.Sprintf("dev-%s@test.local", suffix),
Password: "devpassword123",
Name: fmt.Sprintf("Dev User %s", suffix),
DateOfBirth: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
Country: "AT",
PhoneNumber: "+43000000000",
Address: "Teststraße 1, 1010 Wien",
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
setCookie(w, authToken.Plaintext, authToken.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(cookieName); err == nil {
_ = web.App.DeleteToken(cookie.Value)
}
clearCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

263
cmd/party/web/issues.go Normal file
View File

@ -0,0 +1,263 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
issues, _, err := web.App.FetchIssues("", data.Filters{
Page: 1,
PageSize: 50,
Sort: "-id",
SortSafelist: []string{"id", "-id", "title", "-title"},
}, user)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issues", struct {
AuthenticatedUser *data.User
Issues []common.IssueDetail
CanWriteIssues bool
}{
AuthenticatedUser: user,
Issues: issues,
CanWriteIssues: permissions.Include("issues:write"),
})
}
func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
startTime, err := time.Parse("2006-01-02 15:04", r.PostFormValue("start_date")+" "+r.PostFormValue("start_clock"))
if err != nil {
startTime = time.Time{}
}
endTime, err := time.Parse("2006-01-02 15:04", r.PostFormValue("end_date")+" "+r.PostFormValue("end_clock"))
if err != nil {
endTime = time.Time{}
}
var options []string
for _, v := range r.PostForm["options"] {
if trimmed := strings.TrimSpace(v); trimmed != "" {
options = append(options, trimmed)
}
}
issue, _, err := web.App.CreateIssue(
r.PostFormValue("title"),
r.PostFormValue("description"),
startTime,
endTime,
options,
)
if err != nil {
web.App.LogError(r, err)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%d", issue.ID), http.StatusSeeOther)
}
func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
result, err := web.App.GetIssue(id, user)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issue", struct {
AuthenticatedUser *data.User
Issue *common.IssueWithOptions
CanWriteIssues bool
}{
AuthenticatedUser: user,
Issue: result,
CanWriteIssues: permissions.Include("issues:write"),
})
}
func (web *Web) UpdateIssueAction(w http.ResponseWriter, r *http.Request) {
if getUser(r).IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
var title, description *string
var startTime, endTime *time.Time
if v := r.FormValue("title"); v != "" {
title = &v
}
if v := r.FormValue("description"); v != "" {
description = &v
}
if d, c := r.FormValue("start_date"), r.FormValue("start_clock"); d != "" && c != "" {
if t, parseErr := time.Parse("2006-01-02 15:04", d+" "+c); parseErr == nil {
startTime = &t
}
}
if d, c := r.FormValue("end_date"), r.FormValue("end_clock"); d != "" && c != "" {
if t, parseErr := time.Parse("2006-01-02 15:04", d+" "+c); parseErr == nil {
endTime = &t
}
}
if _, err = web.App.UpdateIssue(id, title, description, startTime, endTime); err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%d", id))
w.WriteHeader(http.StatusOK)
}
func (web *Web) DeleteIssueAction(w http.ResponseWriter, r *http.Request) {
if getUser(r).IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = web.App.DeleteIssue(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
w.Header().Set("HX-Redirect", "/issues")
w.WriteHeader(http.StatusOK)
}
func (web *Web) GetIssuePubKey(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
pubKey, err := web.App.GetIssuePublicKey(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"public_key": pubKey}, nil); err != nil {
web.App.LogError(r, err)
}
}
func (web *Web) BlindSignIssue(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
common.WriteJSON(w, http.StatusUnauthorized, common.Envelope{"error": map[string]string{"message": "authentication required"}}, nil)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
var input struct {
BlindedVote []byte `json:"blinded_vote"`
}
if err = web.App.ReadJSON(w, r, &input); err != nil {
common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return
}
signed, err := web.App.BlindSign(id, input.BlindedVote, user)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
http.NotFound(w, r)
case errors.Is(err, data.ErrDuplicateBlindSign):
common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": "bereits eine Blindsignatur für diese Abstimmung angefordert"}}, nil)
case errors.Is(err, data.ErrInvalidBlindedVote):
common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
default:
web.App.LogError(r, err)
common.WriteJSON(w, http.StatusInternalServerError, common.Envelope{"error": map[string]string{"message": "internal server error"}}, nil)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"signed": signed}, nil); err != nil {
web.App.LogError(r, err)
}
}

148
cmd/party/web/middleware.go Normal file
View File

@ -0,0 +1,148 @@
package web
import (
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
const cookieName = "session"
func (web *Web) AuthenticateCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err != nil {
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
v := validator.New()
if data.ValidateTokenPlaintext(v, cookie.Value); !v.Valid() {
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
userIdentity, err := web.App.Models.UserIdentities.GetForToken(data.ScopeAuthentication, cookie.Value)
if err != nil {
if !errors.Is(err, data.ErrRecordNotFound) {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
user, err := web.App.Models.Users.Get(userIdentity.UserID)
if err != nil {
if !errors.Is(err, data.ErrRecordNotFound) {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
r = setUser(r, user)
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
r = setPermissions(r, permissions)
next.ServeHTTP(w, r)
})
}
func setCookie(w http.ResponseWriter, token string, expiry time.Time) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Expires: expiry,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Expires: time.Unix(0, 0),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (web *Web) WebRecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
web.App.LogError(r, fmt.Errorf("%s", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (web *Web) WebRateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
clients := make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, c := range clients {
if time.Since(c.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if web.App.LimiterEnabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(web.App.LimiterRPS), web.App.LimiterBurst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}

63
cmd/party/web/mps.go Normal file
View File

@ -0,0 +1,63 @@
package web
import (
"net/http"
"sort"
"party.at/party/cmd/party/parlament"
"party.at/party/internal/data"
)
type factionGroup struct {
Name string
Members []parlament.Member
}
func (web *Web) MembersOfParliamentPage(w http.ResponseWriter, r *http.Request) {
members, _, err := web.App.Parlament.ListMembers(parlament.MemberFilter{
Period: []string{"XXVIII"},
Active: true,
Type: []string{parlament.MemberTypeNR},
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Group by faction, preserving insertion order for stable sort later.
groupMap := make(map[string]*factionGroup)
var factionOrder []string
for _, m := range members {
if _, exists := groupMap[m.Faction]; !exists {
groupMap[m.Faction] = &factionGroup{Name: m.Faction}
factionOrder = append(factionOrder, m.Faction)
}
groupMap[m.Faction].Members = append(groupMap[m.Faction].Members, m)
}
// Sort factions by descending seat count.
sort.Slice(factionOrder, func(i, j int) bool {
return len(groupMap[factionOrder[i]].Members) > len(groupMap[factionOrder[j]].Members)
})
// Sort members within each faction by last name.
groups := make([]factionGroup, 0, len(factionOrder))
for _, name := range factionOrder {
g := groupMap[name]
sort.Slice(g.Members, func(i, j int) bool {
return g.Members[i].LastName < g.Members[j].LastName
})
groups = append(groups, *g)
}
web.render(w, r, http.StatusOK, "mps", struct {
AuthenticatedUser *data.User
Groups []factionGroup
Total int
}{
AuthenticatedUser: getUser(r),
Groups: groups,
Total: len(members),
})
}

View File

@ -0,0 +1,39 @@
package web
import (
"fmt"
"html/template"
"net/http"
)
func (web *Web) render(w http.ResponseWriter, r *http.Request, status int, page string, data interface{}) {
funcs := template.FuncMap{
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict requires an even number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
m[fmt.Sprint(pairs[i])] = pairs[i+1]
}
return m, nil
},
"hasPermission": func(code string) bool {
return getPermissions(r).Include(code)
},
}
ts, err := template.New("base").Funcs(funcs).ParseFiles(
"web/html/base.layout.tmpl",
"web/html/partials.tmpl",
"web/html/"+page+".page.tmpl",
)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
if err = ts.ExecuteTemplate(w, "base", data); err != nil {
web.App.LogError(r, err)
}
}

214
cmd/party/web/users.go Normal file
View File

@ -0,0 +1,214 @@
package web
import (
"errors"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) Register(w http.ResponseWriter, r *http.Request) {
web.render(w, r, http.StatusOK, "register", struct {
AuthenticatedUser *data.User
FormErrors []string
}{
AuthenticatedUser: getUser(r),
})
}
func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
var altName *string
if s := r.PostFormValue("alt_name"); s != "" {
altName = &s
}
dob, err := time.Parse("2006-01-02", r.PostFormValue("date_of_birth"))
if err != nil {
dob = time.Time{}
}
_, authToken, err := web.App.RegisterUser(common.RegisterUserInput{
ProviderID: 1,
Username: r.PostFormValue("username"),
Email: r.PostFormValue("email"),
Password: r.PostFormValue("password"),
Name: r.PostFormValue("name"),
AltName: altName,
DateOfBirth: dob,
Country: r.PostFormValue("country"),
PhoneNumber: r.PostFormValue("phone_number"),
Address: r.PostFormValue("address"),
})
if err != nil {
var formErrors []string
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
for _, msg := range ve.Errors {
formErrors = append(formErrors, msg)
}
case errors.Is(err, data.ErrDuplicateEmail):
formErrors = []string{"Diese E-Mail-Adresse wird bereits verwendet."}
case errors.Is(err, data.ErrDuplicateUser):
formErrors = []string{"Dieser Benutzername wird bereits verwendet."}
default:
web.App.LogError(r, err)
formErrors = []string{"Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."}
}
web.render(w, r, http.StatusUnprocessableEntity, "register", struct {
AuthenticatedUser *data.User
FormErrors []string
}{
AuthenticatedUser: getUser(r),
FormErrors: formErrors,
})
return
}
setCookie(w, authToken.Plaintext, authToken.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
fullUser, err := web.App.GetUser(user.ID)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
web.render(w, r, http.StatusOK, "profile", struct {
AuthenticatedUser *data.User
User *data.User
}{
AuthenticatedUser: user,
User: fullUser,
})
}
func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
users, _, err := web.App.ListUsers(data.Filters{
Page: 1,
PageSize: 100,
Sort: "id",
SortSafelist: []string{"id", "-id", "name", "-name"},
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
permissions := getPermissions(r)
if !permissions.Include("users:read") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
web.render(w, r, http.StatusOK, "users", struct {
AuthenticatedUser *data.User
Users []*data.User
CanManageUsers bool
}{
AuthenticatedUser: user,
Users: users,
CanManageUsers: true,
})
}
func (web *Web) ActivatePage(w http.ResponseWriter, r *http.Request) {
web.render(w, r, http.StatusOK, "activated", struct {
AuthenticatedUser *data.User
FormErrors []string
Token string
}{
AuthenticatedUser: getUser(r),
Token: r.URL.Query().Get("token"),
})
}
func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
token := r.PostFormValue("token")
_, err := web.App.ActivateUser(token)
if err != nil {
var msg string
if errors.Is(err, data.ErrRecordNotFound) {
msg = "Ungültiger oder abgelaufener Aktivierungstoken."
} else {
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
}{
AuthenticatedUser: getUser(r),
FormErrors: []string{msg},
Token: token,
})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (web *Web) DeleteUserAction(w http.ResponseWriter, r *http.Request) {
currentUser := getUser(r)
if currentUser.IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = web.App.DeleteUser(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if currentUser.ID == id {
clearCookie(w)
w.Header().Set("HX-Redirect", "/")
} else {
w.Header().Set("HX-Redirect", "/users")
}
w.WriteHeader(http.StatusOK)
}

42
cmd/party/web/votes.go Normal file
View File

@ -0,0 +1,42 @@
package web
import (
"errors"
"net/http"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) VoteAction(w http.ResponseWriter, r *http.Request) {
var input struct {
IssueID int64 `json:"issue_id"`
OptionID int64 `json:"option_id"`
Nonce []byte `json:"nonce"`
Signature []byte `json:"signature"`
}
if err := web.App.ReadJSON(w, r, &input); err != nil {
common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return
}
if err := web.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
common.WriteJSON(w, http.StatusNotFound, common.Envelope{"error": map[string]string{"message": "issue not found"}}, nil)
case errors.Is(err, data.ErrInvalidSignature):
common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
case errors.Is(err, data.ErrDuplicateVote):
common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
default:
web.App.LogError(r, err)
common.WriteJSON(w, http.StatusInternalServerError, common.Envelope{"error": map[string]string{"message": "internal server error"}}, nil)
}
return
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "vote successfully cast"}, nil); err != nil {
web.App.LogError(r, err)
}
}

42
cmd/party/web/web.go Normal file
View File

@ -0,0 +1,42 @@
package web
import (
"context"
"net/http"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
type Web struct {
App *common.Application
}
type contextKey string
const userKey contextKey = "web_user"
const permissionsKey contextKey = "web_permissions"
func setUser(r *http.Request, user *data.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userKey, user))
}
func 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
}

14
internal/crypto/vote.go Normal file
View File

@ -0,0 +1,14 @@
package crypto
import (
"crypto/sha256"
"encoding/binary"
)
func VoteMessage(issueID, optionID int64, nonce []byte) [32]byte {
buf := make([]byte, 16+len(nonce))
binary.BigEndian.PutUint64(buf[0:8], uint64(issueID))
binary.BigEndian.PutUint64(buf[8:16], uint64(optionID))
copy(buf[16:], nonce)
return sha256.Sum256(buf)
}

View File

@ -1,30 +1,56 @@
package data package data
import ( import (
"time"
"database/sql"
"encoding/pem"
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"crypto/rsa" "time"
"crypto/x509"
"github.com/lib/pq"
) )
type BlindSignRequest struct { type BlindSign struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
IssueID int64 `json:"issue_id"` IssueID int64 `json:"issue_id"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
} }
type BlindSignRequestModel struct { type BlindSignModel struct {
DB *sql.DB DB *sql.DB
} }
func (m BlindSignRequestModel) Insert(blind_sign *BlindSignRequest) error { func (m BlindSignModel) Get(userID int64, issueID int64) (*BlindSign, error) {
query :=
`SELECT user_id, issue_id, created
FROM blind_signs
WHERE user_id = $1 AND issue_id = $2`
args := []interface{}{
userID,
issueID,
}
var blindSign BlindSign
err := m.DB.QueryRow(query, args...).Scan(&blindSign.UserID, &blindSign.IssueID, &blindSign.Created)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &blindSign, nil
}
func (m BlindSignModel) Insert(blind_sign *BlindSign) error {
query := ` query := `
INSERT INTO blind_sign_requests (user_id, issue_id) INSERT INTO blind_signs (user_id, issue_id)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING created` RETURNING created`
@ -33,12 +59,16 @@ RETURNING created`
blind_sign.IssueID, blind_sign.IssueID,
} }
return m.DB.QueryRow(query, args...).Scan( err := m.DB.QueryRow(query, args...).Scan(&blind_sign.Created)
&blind_sign.Created, if pgErr, ok := err.(*pq.Error); ok {
) if pgErr.Code == "23505" {
return ErrDuplicateBlindSign
}
}
return err
} }
func (m BlindSignRequestModel) BlindSign(issueID int64, blindedVoteBytes []byte) ([]byte, error) { func (m BlindSignModel) BlindSign(issueID int64, blindedVoteBytes []byte) ([]byte, error) {
if issueID < 1 { if issueID < 1 {
return nil, ErrRecordNotFound return nil, ErrRecordNotFound
} }
@ -77,12 +107,3 @@ func (m BlindSignRequestModel) BlindSign(issueID int64, blindedVoteBytes []byte)
return sig.Bytes(), nil return sig.Bytes(), nil
} }
func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}

16
internal/data/helpers.go Normal file
View File

@ -0,0 +1,16 @@
package data
import(
"crypto/rsa"
"encoding/pem"
"errors"
"crypto/x509"
)
func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}

View File

@ -2,11 +2,12 @@ package data
import ( import (
"time" "time"
"party.at/party/internal/validator"
"database/sql" "database/sql"
"errors" "errors"
"context" "context"
"fmt" "fmt"
"party.at/party/internal/validator"
) )
type Issue struct { type Issue struct {
@ -38,8 +39,8 @@ type IssueModel struct {
} }
func (m IssueModel) Insert(issue *Issue) error { func (m IssueModel) Insert(issue *Issue) error {
query := ` query :=
INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem) `INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created, version` RETURNING id, created, version`
@ -60,14 +61,64 @@ RETURNING id, created, version`
) )
} }
// Add a placeholder method for fetching a specific record from the issues table. func (m IssueModel) InsertWithOptions(issue *Issue, options []*Option) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query :=
`INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created, version`
args := []any{
issue.Title,
issue.Description,
issue.StartTime,
issue.EndTime,
issue.N,
issue.E,
issue.PrivatePem,
}
err = tx.QueryRowContext(ctx, query, args...).Scan(&issue.ID, &issue.Created, &issue.Version)
if err != nil {
return err
}
for _, option := range options {
option.IssueID = issue.ID
err = tx.QueryRowContext(ctx,
`INSERT INTO options (issue_id, label)
VALUES ($1, $2)
RETURNING id, created, version`,
option.IssueID,
option.Label,
).Scan(&option.ID, &option.Created, &option.Version)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (m IssueModel) Get(id int64) (*Issue, error) { func (m IssueModel) Get(id int64) (*Issue, error) {
if id < 1 { if id < 1 {
return nil, ErrRecordNotFound return nil, ErrRecordNotFound
} }
query := ` query :=
SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version `SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version
FROM issues FROM issues
WHERE id = $1` WHERE id = $1`
@ -138,35 +189,25 @@ func (m IssueModel) Update(issue *Issue) error {
return nil return nil
} }
// Add a placeholder method for deleting a specific record from the issues table.
func (m IssueModel) Delete(id int64) error { func (m IssueModel) Delete(id int64) error {
if id < 1 { if id < 1 {
return ErrRecordNotFound return ErrRecordNotFound
} }
// Construct the SQL query to delete the record.
query := ` query := `
DELETE FROM issues DELETE FROM issues
WHERE id = $1` WHERE id = $1`
// Execute the SQL query using the Exec() method, passing in the id variable as
// the value for the placeholder parameter. The Exec() method returns a sql.Result
// object.
result, err := m.DB.Exec(query, id) result, err := m.DB.Exec(query, id)
if err != nil { if err != nil {
return err return err
} }
// Call the RowsAffected() method on the sql.Result object to get the number of rows
// affected by the query.
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
return err return err
} }
// If no rows were affected, we know that the issues table didn't contain a record
// with the provided ID at the moment we tried to delete it. In that case we
// return an ErrRecordNotFound error.
if rowsAffected == 0 { if rowsAffected == 0 {
return ErrRecordNotFound return ErrRecordNotFound
} }
@ -175,7 +216,6 @@ func (m IssueModel) Delete(id int64) error {
} }
func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, error) { func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, error) {
// Construct the SQL query to retrieve all issue records.
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT SELECT
COUNT(*) OVER(), id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version COUNT(*) OVER(), id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version
@ -193,7 +233,6 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e
filters.sortDirection(), filters.sortDirection(),
) )
// Create a context with a 3-second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel() defer cancel()
@ -204,20 +243,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e
return nil, Metadata{}, err return nil, Metadata{}, err
} }
// Importantly, defer a call to rows.Close() to ensure that the resultset is closed
// before GetAll() returns.
defer rows.Close() defer rows.Close()
totalRecords := 0 totalRecords := 0
issues := []*Issue{} issues := []*Issue{}
// Use rows.Next to iterate through the rows in the resultset.
for rows.Next() { for rows.Next() {
// Initialize an empty Issue struct to hold the data for an individual issue.
var issue Issue var issue Issue
// Scan the values from the row into the Issue struct. Again, note that we're
// using the pq.Array() adapter on the genres field here.
err := rows.Scan( err := rows.Scan(
&totalRecords, &totalRecords,
&issue.ID, &issue.ID,
@ -236,18 +269,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e
return nil, Metadata{}, err return nil, Metadata{}, err
} }
// Add the Issue struct to the slice.
issues = append(issues, &issue) issues = append(issues, &issue)
} }
// When the rows.Next() loop has finished, call rows.Err() to retrieve any error
// that was encountered during the iteration.
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return nil, Metadata{}, err return nil, Metadata{}, err
} }
metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize) metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)
// If everything went OK, then return the slice of issues.
return issues, metadata, nil return issues, metadata, nil
} }

View File

@ -8,7 +8,6 @@ import (
var ( var (
ErrRecordNotFound = errors.New("record not found") ErrRecordNotFound = errors.New("record not found")
ErrEditConflict = errors.New("edit conflict") ErrEditConflict = errors.New("edit conflict")
ErrInvalidBlindedVote = errors.New("invalid blinded vote")
) )
type Models struct { type Models struct {
@ -17,7 +16,10 @@ type Models struct {
Issues IssueModel Issues IssueModel
Tokens TokenModel Tokens TokenModel
Permissions PermissionModel Permissions PermissionModel
BlindSignRequests BlindSignRequestModel Roles RoleModel
BlindSigns BlindSignModel
Votes VoteModel
Options OptionModel
} }
func NewModels(db *sql.DB) Models { func NewModels(db *sql.DB) Models {
@ -27,6 +29,9 @@ func NewModels(db *sql.DB) Models {
Issues: IssueModel{DB: db}, Issues: IssueModel{DB: db},
Tokens: TokenModel{DB: db}, Tokens: TokenModel{DB: db},
Permissions: PermissionModel{DB: db}, Permissions: PermissionModel{DB: db},
BlindSignRequests: BlindSignRequestModel{DB: db}, Roles: RoleModel{DB: db},
BlindSigns: BlindSignModel{DB: db},
Votes: VoteModel{DB: db},
Options: OptionModel{DB: db},
} }
} }

100
internal/data/options.go Normal file
View File

@ -0,0 +1,100 @@
package data
import (
"errors"
"time"
"database/sql"
"party.at/party/internal/validator"
)
type Option struct {
ID int64 `json:"id"`
IssueID int64 `json:"issue_id"`
Label string `json:"label"`
Created time.Time `json:"created"`
Version int `json:"version"`
}
func ValidateOption(v *validator.Validator, option *Option) {
v.Check(option.Label != "", "options", "option labels must not be empty")
}
type OptionModel struct {
DB *sql.DB
}
func (m OptionModel) Insert(option *Option) error {
query := `
INSERT INTO options (issue_id, label)
VALUES ($1, $2)
RETURNING id, created, version`
args := []interface{}{
option.IssueID,
option.Label,
}
return m.DB.QueryRow(query, args...).Scan(
&option.ID,
&option.Created,
&option.Version,
)
}
func (m OptionModel) GetAllForIssue(issueID int64) ([]*Option, error) {
query := `
SELECT id, issue_id, label, created, version
FROM options
WHERE issue_id = $1
ORDER BY id`
rows, err := m.DB.Query(query, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
var options []*Option
for rows.Next() {
var option Option
err := rows.Scan(&option.ID, &option.IssueID, &option.Label, &option.Created, &option.Version)
if err != nil {
return nil, err
}
options = append(options, &option)
}
return options, rows.Err()
}
func (m OptionModel) Get(id int64) (*Option, error) {
if id < 1 {
return nil, ErrRecordNotFound
}
query := `
SELECT id, issue_id, label, created, version
FROM options
WHERE id = $1`
var option Option
err := m.DB.QueryRow(query, id).Scan(
&option.ID,
&option.IssueID,
&option.Label,
&option.Created,
&option.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &option, nil
}

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"time" "time"
"github.com/lib/pq"
) )
type Permissions []string type Permissions []string
@ -24,14 +23,15 @@ type PermissionModel struct {
} }
func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) { func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) {
query :=` query := `
SELECT permissions.code SELECT permissions.code
FROM permissions FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id INNER JOIN roles_permissions ON roles_permissions.permission_id = permissions.id
INNER JOIN users ON users_permissions.user_id = users.id INNER JOIN roles ON roles_permissions.role_id = roles.id
WHERE users.id = $1` INNER JOIN users_roles ON users_roles.role_id = roles.id
WHERE users_roles.user_id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
rows, err := m.DB.QueryContext(ctx, query, userID) rows, err := m.DB.QueryContext(ctx, query, userID)
@ -43,27 +43,10 @@ WHERE users.id = $1`
var permissions Permissions var permissions Permissions
for rows.Next() { for rows.Next() {
var permission string var permission string
err := rows.Scan(&permission) if err := rows.Scan(&permission); err != nil {
if err != nil {
return nil, err return nil, err
} }
permissions = append(permissions, permission) permissions = append(permissions, permission)
} }
if err = rows.Err(); err != nil { return permissions, rows.Err()
return nil, err
}
return permissions, nil
}
func (m PermissionModel) AddForUser(userID int64, codes ...string) error {
query :=`
INSERT INTO users_permissions
SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes))
return err
} }

64
internal/data/roles.go Normal file
View File

@ -0,0 +1,64 @@
package data
import (
"context"
"database/sql"
"time"
)
const (
RoleViewer = "viewer"
RoleContributor = "contributor"
RoleAdmin = "admin"
RoleMemberOfParliament = "member_of_parliament"
RolePartyLeadership = "party_leadership"
)
type Role struct {
ID int64
Code string
}
type RoleModel struct {
DB *sql.DB
}
func (m RoleModel) AssignToUser(userID int64, roleCode string) error {
query := `
INSERT INTO users_roles
SELECT $1, id FROM roles WHERE code = $2
ON CONFLICT DO NOTHING`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, userID, roleCode)
return err
}
func (m RoleModel) GetAllForUser(userID int64) ([]Role, error) {
query := `
SELECT roles.id, roles.code
FROM roles
INNER JOIN users_roles ON users_roles.role_id = roles.id
WHERE users_roles.user_id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var roles []Role
for rows.Next() {
var role Role
if err := rows.Scan(&role.ID, &role.Code); err != nil {
return nil, err
}
roles = append(roles, role)
}
return roles, rows.Err()
}

View File

@ -33,7 +33,6 @@ func generateToken(userID int64, userIdentityID int64, ttl time.Duration, scope
Scope: scope, Scope: scope,
} }
// Initialize a zero-valued byte slice with a length of 16 bytes.
randomBytes := make([]byte, 16) randomBytes := make([]byte, 16)
_, err := rand.Read(randomBytes) _, err := rand.Read(randomBytes)
@ -82,6 +81,16 @@ func (m TokenModel) Insert(token *Token) error {
return err return err
} }
func (m TokenModel) Delete(hash []byte) error {
query := `DELETE FROM tokens WHERE hash = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, hash)
return err
}
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error { func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
query :=` query :=`
DELETE FROM tokens DELETE FROM tokens

View File

@ -117,13 +117,8 @@ SELECT id, provider_id, user_id, provider_user, password, version
FROM user_identities FROM user_identities
WHERE id = $1` WHERE id = $1`
// Declare a User struct to hold the data returned by the query.
var userIdentity UserIdentity var userIdentity UserIdentity
// Execute the query using the QueryRow() method, passing in the provided id value
// as a placeholder parameter, and scan the response data into the fields of the
// User struct. Importantly, notice that we need to convert the scan target for the
// genres column using the pq.Array() adapter function again.
err := m.DB.QueryRow(query, id).Scan( err := m.DB.QueryRow(query, id).Scan(
&userIdentity.ID, &userIdentity.ID,
&userIdentity.ProviderID, &userIdentity.ProviderID,
@ -141,7 +136,7 @@ WHERE id = $1`
return nil, err return nil, err
} }
} }
// Otherwise, return a pointer to the User struct.
return &userIdentity, nil return &userIdentity, nil
} }
@ -156,10 +151,6 @@ FROM user_identities identity
JOIN users u on identity.user_id = u.id JOIN users u on identity.user_id = u.id
WHERE u.id = $1` WHERE u.id = $1`
// Execute the query using the QueryRow() method, passing in the provided id value
// as a placeholder parameter, and scan the response data into the fields of the
// User struct. Importantly, notice that we need to convert the scan target for the
// genres column using the pq.Array() adapter function again.
rows, err := m.DB.Query(query, user_id) rows, err := m.DB.Query(query, user_id)
if err != nil { if err != nil {
switch { switch {
@ -244,7 +235,6 @@ func (m UserIdentityModel) Update(user *UserIdentity) error {
WHERE id = $3 AND version = $4 WHERE id = $3 AND version = $4
RETURNING version` RETURNING version`
// Create an args slice containing the values for the placeholder parameters.
args := []interface{}{ args := []interface{}{
user.ProviderUserID, user.ProviderUserID,
user.Password.hash, user.Password.hash,
@ -275,29 +265,20 @@ func (m UserIdentityModel) Delete(id int64) error {
return ErrRecordNotFound return ErrRecordNotFound
} }
// Construct the SQL query to delete the record.
query := ` query := `
DELETE FROM user_identities DELETE FROM user_identities
WHERE id = $1` WHERE id = $1`
// Execute the SQL query using the Exec() method, passing in the id variable as
// the value for the placeholder parameter. The Exec() method returns a sql.Result
// object.
result, err := m.DB.Exec(query, id) result, err := m.DB.Exec(query, id)
if err != nil { if err != nil {
return err return err
} }
// Call the RowsAffected() method on the sql.Result object to get the number of rows
// affected by the query.
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
return err return err
} }
// If no rows were affected, we know that the issues table didn't contain a record
// with the provided ID at the moment we tried to delete it. In that case we
// return an ErrRecordNotFound error.
if rowsAffected == 0 { if rowsAffected == 0 {
return ErrRecordNotFound return ErrRecordNotFound
} }

View File

@ -2,17 +2,20 @@ package data
import ( import (
"context" "context"
"time"
"party.at/party/internal/validator"
"database/sql"
"github.com/lib/pq"
"errors"
"crypto/sha256" "crypto/sha256"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"party.at/party/internal/validator"
) )
var ( var (
ErrDuplicateEmail = errors.New("duplicate email") ErrDuplicateEmail = errors.New("duplicate email")
ErrDuplicateUser = errors.New("duplicate username") ErrDuplicateUser = errors.New("duplicate username")
ErrInvalidCredentials = errors.New("invalid credentials")
) )
var AnonymousUser = &User{} var AnonymousUser = &User{}
@ -128,8 +131,8 @@ func (m UserModel) Get(id int64) (*User, error) {
} }
// Define the SQL query for retrieving the issue data. // Define the SQL query for retrieving the issue data.
query :=` query :=
SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version `SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version
FROM users FROM users
WHERE id = $1` WHERE id = $1`
@ -307,6 +310,54 @@ func (m UserModel) Update(user *User) error {
return nil return nil
} }
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`,
filters.sortColumn(),
filters.sortDirection(),
)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, filters.limit(), filters.offset())
if err != nil {
return nil, Metadata{}, err
}
defer rows.Close()
var totalRecords int
var users []*User
for rows.Next() {
var user User
if err := rows.Scan(
&totalRecords,
&user.ID,
&user.Email,
&user.PhoneNumber,
&user.Country,
&user.Name,
&user.AltName,
&user.DateOfBirth,
&user.Address,
&user.Created,
&user.LastLogin,
&user.Activated,
&user.Version,
); err != nil {
return nil, Metadata{}, err
}
users = append(users, &user)
}
if err := rows.Err(); err != nil {
return nil, Metadata{}, err
}
return users, calculateMetadata(totalRecords, filters.Page, filters.PageSize), nil
}
func (m UserModel) Delete(id int64) error { func (m UserModel) Delete(id int64) error {
if id < 1 { if id < 1 {
return ErrRecordNotFound return ErrRecordNotFound

102
internal/data/votes.go Normal file
View File

@ -0,0 +1,102 @@
package data
import (
"errors"
"time"
"database/sql"
"math/big"
"context"
"party.at/party/internal/crypto"
"github.com/lib/pq"
)
var (
ErrDuplicateBlindSign = errors.New("user has already requested a blind signature for this issue")
ErrInvalidBlindedVote = errors.New("blinded_vote is out of valid range [1, n-1]")
ErrInvalidSignature = errors.New("signature verification failed")
ErrDuplicateVote = errors.New("this signature has already been used to cast a vote")
)
type Vote struct {
ID int64 `json:"id"`
OptionID int64 `json:"option_id"`
Nonce []byte `json:"nonce"`
Signature []byte `json:"signature"`
Created time.Time `json:"created"`
}
type VoteModel struct {
DB *sql.DB
}
func (m VoteModel) CountForOption(optionID int64) (int64, error) {
query := `
SELECT COUNT(v.id)
FROM options o
LEFT JOIN votes v ON v.option_id = o.id
WHERE o.id = $1
GROUP BY o.id`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
var count int64
err := m.DB.QueryRowContext(ctx, query, optionID).Scan(&count)
if err != nil {
return 0, err
}
return count, err
}
func (m VoteModel) Insert(vote *Vote, issue *Issue) error {
n := new(big.Int).SetBytes(issue.N)
e := issue.E
// Recompute m = SHA-256(issueID || optionID || nonce)
expected := crypto.VoteMessage(issue.ID, vote.OptionID, vote.Nonce)
// Verify: s^e mod n == m
sig := new(big.Int).SetBytes(vote.Signature)
eInt := big.NewInt(int64(e))
recovered := new(big.Int).Exp(sig, eInt, n)
expectedInt := new(big.Int).SetBytes(expected[:])
if recovered.Cmp(expectedInt) != 0 {
return ErrInvalidSignature
}
query :=
`INSERT INTO votes (option_id, nonce, signature)
VALUES ($1, $2, $3)
RETURNING id, created`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
args := []interface{}{
vote.OptionID,
vote.Nonce,
vote.Signature,
}
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&vote.ID, &vote.Created)
if pgErr, ok := err.(*pq.Error); ok {
if pgErr.Code == "23505" {
if pgErr.Constraint == "votes_signature_key" {
return ErrDuplicateVote
}
}
}
if err != nil {
return err
}
return nil
}

View File

@ -1,5 +1,5 @@
DROP TABLE IF EXISTS user_identities; DROP TABLE IF EXISTS user_identities;
DROP TABLE IF EXISTS auth_provider; DROP TABLE IF EXISTS auth_providers;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;

View File

@ -1,9 +1,7 @@
DROP INDEX IF EXISTS idx_votes_option_id; DROP INDEX IF EXISTS idx_votes_option_id;
DROP INDEX IF EXISTS idx_vote_tokens_issue_id;
DROP INDEX IF EXISTS idx_options_issue_id; DROP INDEX IF EXISTS idx_options_issue_id;
DROP TABLE IF EXISTS votes; DROP TABLE IF EXISTS votes;
DROP TABLE IF EXISTS blind_sign_requests; DROP TABLE IF EXISTS blind_signs;
DROP TABLE IF EXISTS vote_tokens;
DROP TABLE IF EXISTS options; DROP TABLE IF EXISTS options;
DROP TABLE IF EXISTS issues; DROP TABLE IF EXISTS issues;

View File

@ -19,16 +19,7 @@ CREATE TABLE IF NOT EXISTS options (
version INT NOT NULL DEFAULT 1 version INT NOT NULL DEFAULT 1
); );
CREATE TABLE IF NOT EXISTS vote_tokens ( CREATE TABLE IF NOT EXISTS blind_signs (
id BIGSERIAL PRIMARY KEY,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
used BOOLEAN NOT NULL DEFAULT FALSE,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
version INT NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS blind_sign_requests (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
created TIMESTAMPTZ NOT NULL DEFAULT now(), created TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -37,12 +28,11 @@ CREATE TABLE IF NOT EXISTS blind_sign_requests (
CREATE TABLE IF NOT EXISTS votes ( CREATE TABLE IF NOT EXISTS votes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token UUID NOT NULL UNIQUE REFERENCES vote_tokens(token) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES options(id) ON DELETE CASCADE, option_id BIGINT NOT NULL REFERENCES options(id) ON DELETE CASCADE,
created TIMESTAMPTZ NOT NULL DEFAULT now(), nonce BYTEA NOT NULL,
version INT NOT NULL DEFAULT 1 signature BYTEA NOT NULL UNIQUE,
created TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_votes_option_id ON votes(option_id); CREATE INDEX idx_votes_option_id ON votes(option_id);
CREATE INDEX idx_vote_tokens_issue_id ON vote_tokens(issue_id);
CREATE INDEX idx_options_issue_id ON options(issue_id); CREATE INDEX idx_options_issue_id ON options(issue_id);

View File

@ -9,6 +9,5 @@ CREATE TABLE IF NOT EXISTS users_permissions (
PRIMARY KEY (user_id, permission_id) PRIMARY KEY (user_id, permission_id)
); );
-- Add the two permissions to the table.
INSERT INTO permissions (code) INSERT INTO permissions (code)
VALUES ('issues:read'), ('issues:write'); VALUES ('issues:read'), ('issues:write'), ('issues:vote'), ('users:read');

View File

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS users_permissions (
user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE,
PRIMARY KEY (user_id, permission_id)
);
DROP TABLE users_roles;
DROP TABLE roles_permissions;
DROP TABLE roles;

View File

@ -0,0 +1,26 @@
CREATE TABLE roles (
id bigserial PRIMARY KEY,
code text UNIQUE NOT NULL
);
CREATE TABLE roles_permissions (
role_id bigint NOT NULL REFERENCES roles ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE users_roles (
user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
role_id bigint NOT NULL REFERENCES roles ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
INSERT INTO roles (code) VALUES ('viewer'), ('contributor'), ('admin');
INSERT INTO roles_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE (r.code = 'viewer' AND p.code = 'issues:read')
OR (r.code = 'contributor' AND p.code IN ('issues:read', 'issues:write', 'issues:vote'))
OR (r.code = 'admin' AND p.code IN ('issues:read', 'issues:write', 'issues:vote', 'users:read'));
DROP TABLE users_permissions;

View File

@ -0,0 +1 @@
DELETE FROM roles WHERE code IN ('member_of_parliament', 'party_leadership');

View File

@ -0,0 +1,5 @@
INSERT INTO roles (code) VALUES ('member_of_parliament'), ('party_leadership');
INSERT INTO roles_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code IN ('member_of_parliament', 'party_leadership');

View File

@ -1,38 +0,0 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{template "title" .}}</title>
<script>
const ws = new WebSocket("wss://localhost:8443/ws");
ws.onopen = () => console.log("Connected")
ws.onmessage = (event) => console.log(event.data)
ws.onclose = () => console.log("Closed")
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
const message = {
type: "ping",
timestamp: Date(),
random: Math.random()
};
ws.send(JSON.stringify(message))
}
}, 1000)
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Alfa+Slab+One&display=swap');
</style>
<link rel="stylesheet" href="/static/style.css"/>
</head>
<body>
{{template "body" .}}
<h1>Hello, {{.Name}}!</h1>
<h1>This is THE PARTY.</h1>
</body>
</html>
{{end}}

View File

@ -1,7 +0,0 @@
{{template "base" .}}
{{define "title"}}Home{{end}}
{{define "body"}}
<h1>The Party?</h1>
{{end}}

View File

@ -1,14 +0,0 @@
* {
font-family: "Alfa Slab One", serif;
font-weight: 400;
font-style: normal;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}

BIN
web/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,27 @@
{{template "base" .}}
{{define "title"}}Konto aktivieren{{end}}
{{define "body"}}
<div class="auth-section">
<div class="auth-card">
<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}}
<form method="POST" action="/users/activated">
<div class="form-group">
<label class="form-label" for="token">Aktivierungstoken</label>
<input class="form-input" id="token" name="token" type="text"
placeholder="Token aus der E-Mail einfügen" value="{{.Token}}">
</div>
<button class="btn btn--primary btn--full" type="submit">Aktivieren</button>
</form>
</div>
</div>
{{end}}

62
web/html/base.layout.tmpl Normal file
View File

@ -0,0 +1,62 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}} — DPÖ</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css"/>
</head>
<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;">
<a href="/" class="link">Start</a>
<a href="/issues" class="link">Abstimmungen</a>
<a href="/mps" class="link">Nationalrat</a>
<a href="/users/me" class="link">Mein Profil</a>
{{if hasPermission "users:read"}}<a href="/users" class="link">Users</a>{{end}}
<form method="POST" action="/logout" style="margin-left:auto;">
<button type="submit" class="btn">Abmelden</button>
</form>
</div>
</nav>
{{end}}
<div style="text-align:center; padding: 40px 0 24px;">
<img src="/static/logo.svg" alt="DPÖ Logo" style="height: 600px; width: auto;">
</div>
<main class="site-main">
<div class="container">
{{template "body" .}}
</div>
</main>
<footer class="site-footer">
<div class="container">
<div class="footer-inner">
<span class="footer-brand">DPÖ — Digitale Partei Österreich</span>
<span class="footer-copy">&copy; 2025</span>
</div>
</div>
</footer>
<script>
const ws = new WebSocket("wss://localhost:8443/ws");
ws.onopen = () => console.log("WS connected");
ws.onmessage = (event) => console.log(event.data);
ws.onclose = () => console.log("WS closed");
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", timestamp: Date(), random: Math.random() }));
}
}, 1000);
</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
</body>
</html>
{{end}}

31
web/html/home.page.tmpl Normal file
View File

@ -0,0 +1,31 @@
{{template "base" .}}
{{define "title"}}Übersicht{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Willkommen, {{.Name}}</h1>
<p class="page-subtitle">Ihre demokratische Plattform für Österreich</p>
</div>
<div class="card-grid">
<div class="card">
<div class="card-icon">&#128499;</div>
<h2 class="card-title">Aktuelle Abstimmungen</h2>
<p class="card-text">Beteiligen Sie sich an laufenden Abstimmungen und demokratischen Initiativen.</p>
<a href="/issues" class="btn btn--primary">Abstimmungen ansehen</a>
</div>
<div class="card">
<div class="card-icon">&#128100;</div>
<h2 class="card-title">Mein Profil</h2>
<p class="card-text">Verwalten Sie Ihre persönlichen Daten und Kontoeinstellungen.</p>
<a href="/users/me" class="btn btn--secondary">Profil ansehen</a>
</div>
<div class="card">
<div class="card-icon">&#128202;</div>
<h2 class="card-title">Ergebnisse</h2>
<p class="card-text">Sehen Sie die Ergebnisse abgeschlossener Abstimmungen ein.</p>
<a href="#" class="btn btn--secondary">Ergebnisse ansehen</a>
</div>
</div>
{{end}}

View File

@ -0,0 +1,54 @@
{{template "base" .}}
{{define "title"}}Willkommen{{end}}
{{define "body"}}
<div class="hero">
<div class="hero-content">
<div class="hero-label">Digitale Demokratie für Österreich</div>
<h1 class="hero-title">Gestalten Sie die<br><span class="accent">Zukunft Österreichs</span></h1>
<p class="hero-text">
Beteiligen Sie sich an der demokratischen Entscheidungsfindung —
sicher, transparent und vollständig digital.
</p>
</div>
</div>
<div class="auth-section">
<div class="auth-card">
<h2 class="auth-title">Anmelden</h2>
<p class="auth-subtitle">Melden Sie sich an, um an Abstimmungen teilzunehmen.</p>
{{if .FormErrors}}
<div class="alert alert--error">
{{range .FormErrors}}<p>{{.}}</p>{{end}}
</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label class="form-label" for="email">E-Mail-Adresse</label>
<input class="form-input" type="email" id="email" name="email"
placeholder="ihre@email.at" required autocomplete="email">
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input class="form-input" type="password" id="password" name="password"
placeholder="••••••••" required autocomplete="current-password">
</div>
<button class="btn btn--primary btn--full" type="submit">Anmelden</button>
</form>
<p class="auth-divider">Noch kein Konto? <a href="/register" class="link">Registrieren</a></p>
{{if .IsDevelopment}}
<form method="POST" action="/dev-login" style="margin-top:12px;">
<button class="btn btn--full" type="submit"
style="background:none; border:1px solid orange; color:orange;">
Dev: Neues Testkonto erstellen &amp; anmelden
</button>
</form>
{{end}}
</div>
</div>
{{end}}

208
web/html/issue.page.tmpl Normal file
View File

@ -0,0 +1,208 @@
{{template "base" .}}
{{define "title"}}{{.Issue.Title}}{{end}}
{{define "body"}}
<div style="margin-bottom:16px;">
<a href="/issues" class="link">&larr; Zurück</a>
</div>
<div class="card" style="margin-bottom:24px;">
<div class="card-title" style="font-size:22px;">{{.Issue.Title}}</div>
<div class="card-text" style="margin-top:8px;">{{.Issue.Description}}</div>
<div style="font-size:13px; color:var(--text-muted); margin-top:8px;">
{{.Issue.StartTime.Format "02.01.2006"}} {{.Issue.EndTime.Format "02.01.2006"}}
</div>
{{if .CanWriteIssues}}
<div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;">
<button class="btn btn--danger"
hx-delete="/issues/{{.Issue.ID}}"
hx-confirm="Abstimmung wirklich löschen?">Löschen</button>
</div>
<details style="margin-top:16px; border-top:1px solid var(--border); padding-top:16px;">
<summary style="cursor:pointer; font-weight:600; list-style:none;">Bearbeiten</summary>
<form hx-patch="/issues/{{.Issue.ID}}" style="margin-top:12px;">
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-input" name="title" type="text" value="{{.Issue.Title}}" required>
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-input" name="description" rows="3">{{.Issue.Description}}</textarea>
</div>
<div class="form-group">
<label class="form-label">Beginn</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="start_date" type="date"
value="{{.Issue.StartTime.Format "2006-01-02"}}" style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "start_clock" "value" (.Issue.StartTime.Format "15:04"))}}
</div>
</div>
<div class="form-group">
<label class="form-label">Ende</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="end_date" type="date"
value="{{.Issue.EndTime.Format "2006-01-02"}}" style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "end_clock" "value" (.Issue.EndTime.Format "15:04"))}}
</div>
</div>
<button class="btn btn--primary" type="submit">Speichern</button>
</form>
</details>
{{end}}
</div>
<h2 style="margin-bottom:16px;">Optionen</h2>
{{if .Issue.CanVote}}
<div class="card" style="margin-bottom:24px;">
<p style="margin-bottom:12px; font-weight:600;">Ihre Stimme abgeben</p>
{{range .Issue.Options}}
<label style="display:flex; align-items:center; gap:8px; margin-bottom:8px; cursor:pointer;">
<input type="radio" name="vote-option" value="{{.ID}}">
<span>{{.Label}}</span>
</label>
{{end}}
<div id="vote-status" style="font-size:13px; margin-top:8px;"></div>
<button class="btn btn--primary" style="margin-top:12px;" onclick="castVote()">Abstimmen</button>
</div>
{{end}}
<div class="card-grid">
{{range .Issue.Options}}
<div class="card">
<div class="card-title">{{.Label}}</div>
<div style="font-size:28px; font-weight:700; margin-top:8px;">{{.VoteCount}}</div>
<div style="font-size:13px; color:var(--text-muted);">Stimmen</div>
</div>
{{end}}
</div>
<script>
const issueID = {{.Issue.ID}};
// ── Blind-signature voting ──────────────────────────────────────────────────
function modPow(base, exp, mod) {
if (mod === 1n) return 0n;
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) result = (result * base) % mod;
exp >>= 1n;
base = (base * base) % mod;
}
return result;
}
function modInverse(a, m) {
let [old_r, r] = [a, m];
let [old_s, s] = [1n, 0n];
while (r !== 0n) {
const q = old_r / r;
[old_r, r] = [r, old_r - q * r];
[old_s, s] = [s, old_s - q * s];
}
return ((old_s % m) + m) % m;
}
function bigIntToBase64(n) {
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return btoa(String.fromCharCode(...bytes));
}
function base64ToBigInt(b64) {
const bin = atob(b64);
let hex = "";
for (let i = 0; i < bin.length; i++)
hex += bin.charCodeAt(i).toString(16).padStart(2, "0");
return BigInt("0x" + hex);
}
function bytesToBase64(bytes) {
return btoa(String.fromCharCode(...bytes));
}
async function castVote() {
const selected = document.querySelector('input[name="vote-option"]:checked');
if (!selected) { alert("Bitte eine Option auswählen."); return; }
const optionID = parseInt(selected.value);
const status = document.getElementById("vote-status");
status.textContent = "Wird verarbeitet…";
try {
// 1. Get public key
const pkRes = await fetch(`/issues/${issueID}/pubkey`);
if (!pkRes.ok) throw new Error("Öffentlicher Schlüssel nicht abrufbar.");
const { public_key } = await pkRes.json();
const N = BigInt("0x" + public_key.n);
const e = BigInt(public_key.e);
// 2. Generate nonce and compute vote message: SHA-256(issueID||optionID||nonce)
const nonce = crypto.getRandomValues(new Uint8Array(32));
const msgBuf = new Uint8Array(16 + nonce.length);
const view = new DataView(msgBuf.buffer);
view.setBigUint64(0, BigInt(issueID), false);
view.setBigUint64(8, BigInt(optionID), false);
msgBuf.set(nonce, 16);
const hashBuf = await crypto.subtle.digest("SHA-256", msgBuf);
const hashHex = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, "0")).join("");
const m = BigInt("0x" + hashHex);
// 3. Pick random blinding factor r in [2, N-1]
let r;
do {
const rb = crypto.getRandomValues(new Uint8Array(256));
r = BigInt("0x" + Array.from(rb).map(b => b.toString(16).padStart(2, "0")).join(""));
} while (r < 2n || r >= N);
// 4. Blind: m' = m * r^e mod N
const blinded = (m * modPow(r, e, N)) % N;
// 5. Request blind signature
const bsRes = await fetch(`/issues/${issueID}/blind-sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blinded_vote: Array.from(atob(bigIntToBase64(blinded))).map(c => c.charCodeAt(0)) }),
});
if (!bsRes.ok) {
const err = await bsRes.json();
throw new Error(err.error?.message ?? "Blind-Sign fehlgeschlagen.");
}
const { signed } = await bsRes.json();
const sPrime = base64ToBigInt(signed);
// 6. Unblind: s = s' * r^(-1) mod N
const signature = (sPrime * modInverse(r, N)) % N;
// 7. Cast vote
const voteRes = await fetch("/votes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
issue_id: issueID,
option_id: optionID,
nonce: bytesToBase64(nonce),
signature: bigIntToBase64(signature),
}),
});
if (!voteRes.ok) {
const err = await voteRes.json();
throw new Error(err.error?.message ?? "Abstimmung fehlgeschlagen.");
}
status.style.color = "green";
status.textContent = "Stimme erfolgreich abgegeben!";
setTimeout(() => window.location.reload(), 1500);
} catch (err) {
status.style.color = "red";
status.textContent = err.message;
}
}
</script>
{{end}}

101
web/html/issues.page.tmpl Normal file
View File

@ -0,0 +1,101 @@
{{template "base" .}}
{{define "title"}}Abstimmungen{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Abstimmungen</h1>
<p class="page-subtitle">Aktuelle Abstimmungen der Digitalen Partei Österreich</p>
</div>
{{if .Issues}}
<div class="card-grid">
{{range .Issues}}
<div class="card">
<div class="card-title">{{.Title}}</div>
<div class="card-text">{{.Description}}</div>
<div style="font-size:13px; color:var(--text-muted); margin-top:4px;">
{{.StartTime.Format "02.01.2006"}} {{.EndTime.Format "02.01.2006"}}
</div>
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
<a href="/issues/{{.ID}}" class="btn btn--primary">Details</a>
{{if $.CanWriteIssues}}
<button class="btn btn--danger"
hx-delete="/issues/{{.ID}}"
hx-confirm="Abstimmung wirklich löschen?">Löschen</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p style="color: var(--text-muted);">Keine Abstimmungen vorhanden.</p>
{{end}}
{{if .CanWriteIssues}}
<details style="margin-top:32px;">
<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;">
<h2 class="auth-title">Neue Abstimmung</h2>
<form method="POST" action="/issues">
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-input" name="title" type="text" required>
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-input" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Beginn</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="start_date" type="date" required style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "start_clock" "value" "12:00" "required" true)}}
</div>
</div>
<div class="form-group">
<label class="form-label">Ende</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="end_date" type="date" required style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "end_clock" "value" "12:00" "required" true)}}
</div>
</div>
<div class="form-group">
<label class="form-label">Optionen (mind. 2)</label>
<div id="options-list" style="display:flex; flex-direction:column; gap:8px;">
<input class="form-input" name="options" type="text" placeholder="Option 1" required>
<input class="form-input" name="options" type="text" placeholder="Option 2" required>
</div>
<button type="button" class="btn btn--secondary" style="margin-top:8px;" onclick="addOption()">+ Option hinzufügen</button>
</div>
<button class="btn btn--primary" type="submit">Erstellen</button>
</form>
<script>
(function() {
function pad(n) { return String(n).padStart(2, '0'); }
function fmtDate(d) {
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate());
}
const start = new Date();
const end = new Date(start);
end.setDate(end.getDate() + 7);
document.querySelector('input[name="start_date"]').value = fmtDate(start);
document.querySelector('input[name="end_date"]').value = fmtDate(end);
})();
function addOption() {
const list = document.getElementById('options-list');
const n = list.children.length + 1;
const input = document.createElement('input');
input.className = 'form-input';
input.name = 'options';
input.type = 'text';
input.placeholder = 'Option ' + n;
list.appendChild(input);
input.focus();
}
</script>
</div>
</details>
{{end}}
{{end}}

44
web/html/mps.page.tmpl Normal file
View File

@ -0,0 +1,44 @@
{{template "base" .}}
{{define "title"}}Nationalrat{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Nationalrat</h1>
<p class="page-subtitle">{{.Total}} Abgeordnete — XXVIII. Gesetzgebungsperiode</p>
</div>
{{range .Groups}}
<section style="margin-bottom: 40px;">
<h2 style="font-size: 18px; color: var(--dark); margin-bottom: 12px; display: flex; align-items: baseline; gap: 10px;">
{{.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);">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: var(--bg); border-bottom: 1px solid var(--border);">
<th style="text-align: left; padding: 10px 16px; font-weight: 600; color: var(--text-muted); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase;">Name</th>
<th style="text-align: left; padding: 10px 16px; font-weight: 600; color: var(--text-muted); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase;">Fraktion</th>
<th style="text-align: left; padding: 10px 16px; font-weight: 600; color: var(--text-muted); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase;">Wahlkreis</th>
</tr>
</thead>
<tbody>
{{range .Members}}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 10px 16px;">
<a href="https://www.parlament.gv.at{{.Path}}" target="_blank" rel="noopener"
style="color: var(--text); font-weight: 500; text-decoration: none;">
{{.FirstName}} {{.LastName}}
</a>
</td>
<td style="padding: 10px 16px; color: var(--text-muted); font-weight: 500;">{{.Faction}}</td>
<td style="padding: 10px 16px; color: var(--text-muted);">{{.Constituency}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{end}}

33
web/html/partials.tmpl Normal file
View File

@ -0,0 +1,33 @@
{{define "time-input"}}
<span style="display:inline-flex; align-items:center; gap:4px;">
<select class="form-input" style="width:68px; padding-left:8px; padding-right:4px; text-align:center;"></select>
<span style="font-weight:600; color:var(--text-muted);">:</span>
<select class="form-input" style="width:68px; padding-left:8px; padding-right:4px; text-align:center;"></select>
<input type="hidden" name="{{.name}}" value="{{.value}}">
</span>
<script>
(function() {
const wrap = document.currentScript.previousElementSibling;
const [hSel, mSel] = wrap.querySelectorAll('select');
const hidden = wrap.querySelector('input[type="hidden"]');
const parts = (hidden.value || '12:00').split(':');
const initH = parseInt(parts[0], 10) || 0;
const initM = parseInt(parts[1], 10) || 0;
for (let h = 0; h < 24; h++) {
const o = new Option(String(h).padStart(2, '0'), h);
if (h === initH) o.selected = true;
hSel.appendChild(o);
}
for (let m = 0; m < 60; m++) {
const o = new Option(String(m).padStart(2, '0'), m);
if (m === initM) o.selected = true;
mSel.appendChild(o);
}
function sync() {
hidden.value = String(+hSel.value).padStart(2, '0') + ':' + String(+mSel.value).padStart(2, '0');
}
hSel.addEventListener('change', sync);
mSel.addEventListener('change', sync);
})();
</script>
{{end}}

View File

@ -0,0 +1,27 @@
{{template "base" .}}
{{define "title"}}Mein Profil{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Mein Profil</h1>
</div>
<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>
<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>
</div>
</div>
{{end}}

View File

@ -0,0 +1,82 @@
{{template "base" .}}
{{define "title"}}Registrieren{{end}}
{{define "body"}}
<div class="page-header" style="text-align:center; border-bottom: none; margin-bottom: 32px;">
<h1 class="page-title">Konto erstellen</h1>
<p class="page-subtitle">Werden Sie Mitglied der Digitalen Partei Österreich</p>
</div>
<div class="auth-section">
<div class="auth-card" style="max-width: 520px;">
{{if .FormErrors}}
<div class="alert alert--error">
{{range .FormErrors}}<p>{{.}}</p>{{end}}
</div>
{{end}}
<form method="POST" action="/register">
<div style="display:grid; grid-template-columns:1fr 1fr; gap: 0 16px;">
<div class="form-group">
<label class="form-label" for="name">Vollständiger Name</label>
<input class="form-input" type="text" id="name" name="name"
placeholder="Maria Muster" required autocomplete="name">
</div>
<div class="form-group">
<label class="form-label" for="alt_name">Alternativer Name <span style="font-weight:400; color:var(--text-muted)">(optional)</span></label>
<input class="form-input" type="text" id="alt_name" name="alt_name"
placeholder="Spitzname">
</div>
</div>
<div class="form-group">
<label class="form-label" for="email">E-Mail-Adresse</label>
<input class="form-input" type="email" id="email" name="email"
placeholder="ihre@email.at" required autocomplete="email">
</div>
<div class="form-group">
<label class="form-label" for="username">Benutzername</label>
<input class="form-input" type="text" id="username" name="username"
placeholder="maria.muster" required autocomplete="username">
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input class="form-input" type="password" id="password" name="password"
placeholder="••••••••" required autocomplete="new-password">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap: 0 16px;">
<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"
placeholder="+43 660 123 4567" autocomplete="tel">
</div>
<div class="form-group">
<label class="form-label" for="date_of_birth">Geburtsdatum</label>
<input class="form-input" type="date" id="date_of_birth" name="date_of_birth" required>
</div>
</div>
<div class="form-group">
<label class="form-label" for="address">Adresse</label>
<input class="form-input" type="text" id="address" name="address"
placeholder="Musterstraße 1, 1010 Wien" autocomplete="street-address">
</div>
<div class="form-group">
<label class="form-label" for="country">Land</label>
<input class="form-input" type="text" id="country" name="country"
placeholder="AT" value="AT" maxlength="2" autocomplete="country">
</div>
<button class="btn btn--primary btn--full" type="submit">Registrieren</button>
</form>
<p class="auth-divider">Bereits ein Konto? <a href="/" class="link">Anmelden</a></p>
</div>
</div>
{{end}}

34
web/html/users.page.tmpl Normal file
View File

@ -0,0 +1,34 @@
{{template "base" .}}
{{define "title"}}Mitglieder{{end}}
{{define "body"}}
<div class="page-header">
<h1 class="page-title">Mitglieder</h1>
<p class="page-subtitle">Registrierte Mitglieder der Digitalen Partei Österreich</p>
</div>
{{if .Users}}
<div class="card-grid">
{{range .Users}}
<div class="card">
<div class="card-title">{{.Name}}{{if .AltName}} / {{.AltName}}{{end}}</div>
<div class="card-text">{{.Email}}</div>
<div style="font-size:13px; color:var(--text-muted); margin-top:4px;">
{{.Country}} &middot; Beigetreten {{.Created.Format "02.01.2006"}}
</div>
{{if not .Activated}}
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">Nicht aktiviert</div>
{{end}}
{{if $.CanManageUsers}}
<button class="btn btn--danger" style="margin-top:12px;"
hx-delete="/users/{{.ID}}"
hx-confirm="Mitglied wirklich löschen?">Löschen</button>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p style="color: var(--text-muted);">Keine Mitglieder vorhanden.</p>
{{end}}
{{end}}

BIN
web/static/.DS_Store vendored Normal file

Binary file not shown.

27
web/static/logo-small.svg Normal file
View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1920 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,228.879757,-47.582268)">
<g transform="matrix(1.11124,0,0,1.11124,193.39495,66.343431)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,166.609966,39.558446)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,139.824982,12.773462)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,113.039997,-14.011522)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,86.255013,-40.796506)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,59.470029,-67.581491)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.11124,0,0,1.11124,32.685044,-94.366475)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

116
web/static/logo.svg Normal file
View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1920 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.414874,0,0,0.414874,275.863996,142.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,265.863996,132.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,255.863996,122.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,245.863996,112.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,235.863996,102.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,225.863996,92.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(241,228,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.414874,0,0,0.414874,215.863996,82.331813)">
<path d="M489.044,829C678.878,829 818.583,718.058 845.703,541.372C871.178,367.973 761.879,253.744 576.976,253.744L361.665,253.744L263.05,829L489.044,829ZM552.322,409.063C624.64,409.063 673.947,446.044 659.977,541.372C644.363,639.987 572.867,672.859 498.083,672.859L475.073,672.859L520.272,409.063L552.322,409.063Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M931.169,317.844C931.169,366.329 970.615,398.379 1019.923,398.379C1069.231,398.379 1108.677,366.329 1108.677,317.844C1108.677,269.358 1069.231,237.308 1019.923,237.308C970.615,237.308 931.169,269.358 931.169,317.844ZM921.308,450.974L856.386,829L1008.418,829L1073.34,450.974L921.308,450.974Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M1071.696,856.941C1075.805,965.418 1133.331,1030.34 1269.749,1029.518C1400.414,1029.518 1494.921,959.665 1517.109,820.782L1579.565,450.974L1429.177,450.974L1418.494,510.144C1397.127,469.054 1357.681,442.756 1301.799,442.756C1205.649,442.756 1117.717,517.54 1103.746,630.947C1090.597,741.068 1151.41,820.782 1247.56,820.782C1296.868,820.782 1341.245,800.237 1374.117,766.544L1365.077,819.96C1357.681,869.268 1323.165,907.071 1273.036,906.249C1239.342,906.249 1225.372,893.1 1220.441,857.763L1071.696,856.941ZM1268.927,632.591C1274.679,594.788 1308.373,566.026 1349.463,566.026C1380.691,566.026 1401.236,589.858 1398.771,625.195L1396.305,639.987C1386.444,674.503 1351.928,698.335 1319.878,697.513C1283.719,695.047 1263.996,668.75 1268.927,632.591Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M1665.854,317.844C1665.854,366.329 1705.3,398.379 1754.608,398.379C1803.915,398.379 1843.362,366.329 1843.362,317.844C1843.362,269.358 1803.915,237.308 1754.608,237.308C1705.3,237.308 1665.854,269.358 1665.854,317.844ZM1655.992,450.974L1591.071,829L1743.103,829L1808.024,450.974L1655.992,450.974Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M1849.114,450.974L1826.926,578.353L1894.313,578.353L1851.579,829L2001.968,829L2044.701,578.353L2112.91,578.353L2135.099,450.974L2066.89,450.974L2089.9,319.487L1939.512,319.487L1916.501,450.974L1849.114,450.974Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M2127.703,639.165C2114.554,748.464 2172.901,836.396 2269.873,836.396C2320.824,836.396 2365.201,814.208 2398.895,778.049L2389.855,829L2548.462,829L2613.383,450.974L2454.777,450.974L2444.094,515.074C2422.727,471.519 2384.103,442.756 2327.399,442.756C2230.427,442.756 2141.673,525.758 2127.703,639.165ZM2293.705,639.987C2300.279,602.185 2336.438,565.204 2375.063,566.026C2409.578,567.669 2427.658,602.185 2422.727,639.165C2416.153,679.433 2378.35,714.771 2342.191,713.127C2305.21,712.305 2287.953,675.324 2293.705,639.987Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M2735.009,188L2625.71,829L2783.495,829L2893.615,188L2735.009,188Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M3089.203,837.218C3194.392,837.218 3269.176,797.772 3310.265,716.414L3168.917,693.404C3151.659,718.058 3128.649,730.385 3093.312,730.385C3058.796,730.385 3037.429,709.018 3035.786,672.859L3315.196,672.859C3320.949,653.136 3324.236,630.947 3324.236,612.046C3324.236,509.322 3260.958,442.756 3134.401,442.756C2981.547,442.756 2879.645,535.619 2879.645,664.641C2879.645,771.474 2961.003,837.218 3089.203,837.218ZM3117.965,541.372C3148.372,541.372 3161.521,561.095 3163.164,595.61L3052.222,595.61C3066.192,557.808 3084.272,541.372 3117.965,541.372Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M361.665,1434.825L263.05,2010.081L441.379,2010.081L471.786,1829.286L520.272,1829.286C669.017,1827.643 771.741,1759.434 783.246,1632.056C794.751,1506.321 721.612,1436.468 576.976,1434.825L361.665,1434.825ZM561.362,1577.817C599.164,1578.639 619.709,1598.362 613.956,1631.234C607.382,1666.571 574.51,1685.472 535.064,1685.472L496.44,1685.472L515.341,1577.817L561.362,1577.817Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M771.741,1820.247C758.592,1929.545 816.94,2017.477 913.912,2017.477C964.863,2017.477 1009.24,1995.289 1042.933,1959.13L1033.894,2010.081L1192.5,2010.081L1257.422,1632.056L1098.815,1632.056L1088.132,1696.156C1066.765,1652.6 1028.141,1623.838 971.437,1623.838C874.465,1623.838 785.712,1706.839 771.741,1820.247ZM937.744,1821.068C944.318,1783.266 980.477,1746.285 1019.101,1747.107C1053.617,1748.75 1071.696,1783.266 1066.765,1820.247C1060.191,1860.515 1022.388,1895.852 986.229,1894.208C949.249,1893.386 931.991,1856.406 937.744,1821.068Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M1570.526,1793.949L1653.527,1658.353C1640.378,1638.63 1618.19,1622.194 1590.249,1622.194C1549.981,1622.194 1508.069,1650.957 1478.485,1692.047L1489.168,1632.056L1334.671,1632.056L1269.749,2010.081L1423.424,2010.081L1453.009,1840.791C1462.049,1802.167 1495.742,1770.939 1529.436,1771.761C1548.337,1771.761 1560.664,1782.444 1570.526,1793.949Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M1664.21,1632.056L1642.022,1759.434L1709.409,1759.434L1666.676,2010.081L1817.064,2010.081L1859.797,1759.434L1928.006,1759.434L1950.195,1632.056L1881.986,1632.056L1904.996,1500.568L1754.608,1500.568L1731.597,1632.056L1664.21,1632.056Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M2154.822,2018.299C2260.012,2018.299 2334.795,1978.853 2375.885,1897.495L2234.536,1874.485C2217.278,1899.139 2194.268,1911.466 2158.931,1911.466C2124.415,1911.466 2103.049,1890.099 2101.405,1853.94L2380.815,1853.94C2386.568,1834.217 2389.855,1812.029 2389.855,1793.127C2389.855,1690.403 2326.577,1623.838 2200.021,1623.838C2047.167,1623.838 1945.264,1716.7 1945.264,1845.722C1945.264,1952.556 2026.622,2018.299 2154.822,2018.299ZM2183.585,1722.453C2213.991,1722.453 2227.14,1742.176 2228.783,1776.691L2117.841,1776.691C2131.812,1738.889 2149.891,1722.453 2183.585,1722.453Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
<path d="M2486.005,1498.925C2486.005,1547.411 2525.451,1579.461 2574.759,1579.461C2624.067,1579.461 2663.513,1547.411 2663.513,1498.925C2663.513,1450.439 2624.067,1418.389 2574.759,1418.389C2525.451,1418.389 2486.005,1450.439 2486.005,1498.925ZM2476.144,1632.056L2411.222,2010.081L2563.254,2010.081L2628.176,1632.056L2476.144,1632.056Z" style="fill:rgb(18,19,38);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 44 KiB

440
web/static/style.css Normal file
View File

@ -0,0 +1,440 @@
:root {
--accent: #C8961A;
--accent-light: #F5E8A8;
--accent-hover: #A67A10;
--dark: #18181F;
--dark-2: #232330;
--text: #26262F;
--text-muted: #64647A;
--bg: #F5F5F3;
--bg-card: #FFFFFF;
--border: #E3E3EB;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.07), 0 4px 14px rgba(0,0,0,0.05);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Source Sans 3', system-ui, -apple-system, sans-serif;
font-size: 16px;
color: var(--text);
background: var(--bg);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
h1, h2, h3, h4 {
font-family: 'Montserrat', system-ui, sans-serif;
font-weight: 700;
line-height: 1.2;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.15s;
}
a:hover {
color: var(--accent-hover);
}
/* ── Layout ── */
.container {
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header ── */
.site-header {
background: var(--dark);
border-bottom: 3px solid var(--accent);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 120px;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
text-decoration: none;
}
.brand-abbr {
font-family: 'Montserrat', sans-serif;
font-size: 20px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.brand-name {
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
color: rgba(255,255,255,0.45);
letter-spacing: 0.01em;
}
.main-nav {
display: flex;
align-items: center;
gap: 4px;
}
.nav-link {
font-family: 'Source Sans 3', sans-serif;
font-size: 32px;
font-weight: 500;
color: rgba(255,255,255,0.7);
padding: 6px 12px;
border-radius: var(--radius);
border: none;
background: none;
cursor: pointer;
transition: color 0.15s, background 0.15s;
text-decoration: none;
}
.nav-link:hover {
color: var(--accent);
background: rgba(200, 150, 26, 0.1);
}
.nav-separator {
width: 1px;
height: 18px;
background: rgba(255,255,255,0.12);
margin: 0 6px;
}
.nav-user {
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--accent-light);
padding: 6px 10px;
}
.nav-link--logout {
color: rgba(255,255,255,0.35);
font-size: 13px;
}
/* ── Main ── */
.site-main {
flex: 1;
padding: 48px 0 64px;
}
/* ── Page header ── */
.page-header {
margin-bottom: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--border);
}
.page-title {
font-size: 30px;
color: var(--dark);
margin-bottom: 4px;
}
.page-subtitle {
color: var(--text-muted);
font-size: 15px;
}
/* ── Card grid ── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 10px;
transition: box-shadow 0.15s, transform 0.15s;
}
.card:hover {
box-shadow: 0 4px 20px rgba(0,0,0,0.10);
transform: translateY(-1px);
}
.card-icon {
font-size: 26px;
line-height: 1;
}
.card-title {
font-size: 17px;
font-weight: 600;
color: var(--dark);
}
.card-text {
color: var(--text-muted);
font-size: 14px;
flex: 1;
}
/* ── Hero ── */
.hero {
padding: 80px 0 64px;
border-bottom: 1px solid var(--border);
margin-bottom: 56px;
text-align: center;
}
.hero-content {
display: flex;
flex-direction: column;
align-items: center;
}
.hero-label {
display: inline-block;
color: var(--accent);
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
margin-bottom: 28px;
}
.hero-title {
font-size: 58px;
color: var(--dark);
max-width: 720px;
margin-bottom: 20px;
line-height: 1.1;
}
.hero-title .accent {
color: var(--accent);
}
.hero-text {
color: var(--text-muted);
font-size: 18px;
max-width: 520px;
line-height: 1.65;
}
/* ── Auth ── */
.auth-section {
display: flex;
justify-content: center;
}
.auth-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 36px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow);
}
.auth-title {
font-size: 22px;
color: var(--dark);
margin-bottom: 4px;
}
.auth-subtitle {
color: var(--text-muted);
font-size: 14px;
margin-bottom: 24px;
}
.auth-divider {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin-top: 18px;
}
/* ── Forms ── */
.form-group {
margin-bottom: 14px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 5px;
letter-spacing: 0.01em;
}
.form-input {
width: 100%;
padding: 9px 13px;
font-family: 'Source Sans 3', sans-serif;
font-size: 15px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
appearance: none;
}
.form-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(200, 150, 26, 0.14);
background: #fff;
}
.form-input::placeholder {
color: var(--text-muted);
opacity: 0.6;
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 20px;
font-family: 'Source Sans 3', sans-serif;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius);
border: none;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
text-decoration: none;
letter-spacing: 0.01em;
margin-top: 6px;
}
.btn:active {
transform: scale(0.98);
}
.btn--primary {
background: var(--accent);
color: #1a1008;
}
.btn--primary:hover {
background: var(--accent-hover);
color: #1a1008;
box-shadow: 0 2px 8px rgba(200,150,26,0.3);
}
.btn--secondary {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
.btn--secondary:hover {
background: var(--border);
color: var(--text);
}
.btn--full {
width: 100%;
margin-top: 4px;
}
/* ── Alerts ── */
.alert {
border-radius: var(--radius);
padding: 12px 16px;
font-size: 14px;
margin-bottom: 16px;
}
.alert--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
/* ── Links ── */
.link {
color: var(--accent);
font-weight: 600;
}
.link:hover {
color: var(--accent-hover);
}
/* ── Footer ── */
.site-footer {
background: var(--dark-2);
border-top: 1px solid rgba(255,255,255,0.05);
padding: 18px 0;
}
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-brand {
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
color: rgba(255,255,255,0.35);
}
.footer-copy {
font-family: 'Source Sans 3', sans-serif;
font-size: 13px;
color: rgba(255,255,255,0.2);
}
/* ── Responsive ── */
@media (max-width: 600px) {
.brand-name { display: none; }
.hero-title { font-size: 36px; }
.auth-card { padding: 24px; }
.card-grid { grid-template-columns: 1fr; }
}