moved a lot
This commit is contained in:
parent
5772a7d855
commit
136a1c34b3
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -9,7 +9,8 @@
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd/api",
|
||||
"program": "${workspaceFolder}/cmd/party",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"args": ["--env=development"]
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
141
cmd/api/db.go
141
cmd/api/db.go
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
252
cmd/api/main.go
252
cmd/api/main.go
@ -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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)))))
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
224
cmd/api/users.go
224
cmd/api/users.go
@ -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
83
cmd/party/api/api.go
Normal 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
366
cmd/party/api/handler.go
Normal 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",
|
||||
// })
|
||||
// }
|
||||
18
cmd/party/api/healthcheck.go
Normal file
18
cmd/party/api/healthcheck.go
Normal 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
215
cmd/party/api/issues.go
Normal 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
104
cmd/party/api/middleware.go
Normal 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
70
cmd/party/api/tokens.go
Normal 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
205
cmd/party/api/users.go
Normal 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
40
cmd/party/api/votes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
73
cmd/party/common/application.go
Normal file
73
cmd/party/common/application.go
Normal 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()
|
||||
}()
|
||||
}
|
||||
|
||||
25
cmd/party/common/errors.go
Normal file
25
cmd/party/common/errors.go
Normal 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
105
cmd/party/common/handler.go
Normal 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
128
cmd/party/common/helpers.go
Normal 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
192
cmd/party/common/issues.go
Normal 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
|
||||
}
|
||||
58
cmd/party/common/middleware.go
Normal file
58
cmd/party/common/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
55
cmd/party/common/tokens.go
Normal file
55
cmd/party/common/tokens.go
Normal 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
115
cmd/party/common/users.go
Normal 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
18
cmd/party/common/votes.go
Normal 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)
|
||||
}
|
||||
@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
// "encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
// "github.com/julienschmidt/httprouter"
|
||||
@ -14,35 +12,6 @@ import (
|
||||
// "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) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@ -8,11 +8,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
|
||||
"party.at/party/cmd/party/common"
|
||||
)
|
||||
|
||||
func TestReadIDParam(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
|
||||
const test_id int64 = 3
|
||||
|
||||
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)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
id, err := app.readIDParam(r)
|
||||
id, err := common.ReadIDParam(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
100
cmd/party/issues_test.go
Normal file
100
cmd/party/issues_test.go
Normal 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
148
cmd/party/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
138
cmd/party/parlament/client.go
Normal file
138
cmd/party/parlament/client.go
Normal 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] + "..."
|
||||
}
|
||||
65
cmd/party/parlament/committees.go
Normal file
65
cmd/party/parlament/committees.go
Normal 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
|
||||
}
|
||||
129
cmd/party/parlament/documents.go
Normal file
129
cmd/party/parlament/documents.go
Normal 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
|
||||
}
|
||||
65
cmd/party/parlament/events.go
Normal file
65
cmd/party/parlament/events.go
Normal 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
|
||||
}
|
||||
65
cmd/party/parlament/government.go
Normal file
65
cmd/party/parlament/government.go
Normal 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
|
||||
}
|
||||
654
cmd/party/parlament/parlament_test.go
Normal file
654
cmd/party/parlament/parlament_test.go
Normal 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)
|
||||
}
|
||||
118
cmd/party/parlament/parliamentarians.go
Normal file
118
cmd/party/parlament/parliamentarians.go
Normal 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
|
||||
}
|
||||
83
cmd/party/parlament/participatory.go
Normal file
83
cmd/party/parlament/participatory.go
Normal 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
|
||||
}
|
||||
59
cmd/party/parlament/press.go
Normal file
59
cmd/party/parlament/press.go
Normal 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
|
||||
}
|
||||
48
cmd/party/parlament/sessions.go
Normal file
48
cmd/party/parlament/sessions.go
Normal 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
101
cmd/party/roles_test.go
Normal 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
86
cmd/party/routes.go
Normal 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
|
||||
}
|
||||
@ -4,20 +4,21 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"party.at/party/cmd/party/common"
|
||||
)
|
||||
|
||||
func (app *application) serve() error {
|
||||
// Declare a HTTP server using the same settings as in our main() function.
|
||||
func serve(app *common.Application) error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", app.config.port),
|
||||
Handler: app.routes(),
|
||||
ErrorLog: log.New(app.logger, "", 0),
|
||||
Addr: fmt.Sprintf(":%d", app.Config.Port),
|
||||
Handler: routes(app),
|
||||
ErrorLog: log.New(app.Logger, "", 0),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
@ -26,14 +27,11 @@ func (app *application) serve() error {
|
||||
shutdownError := make(chan error)
|
||||
|
||||
go func() {
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
s := <-quit
|
||||
|
||||
app.logger.PrintInfo("shutting down server", map[string]string{
|
||||
app.Logger.PrintInfo("shutting down server", map[string]string{
|
||||
"signal": s.String(),
|
||||
})
|
||||
|
||||
@ -43,10 +41,9 @@ func (app *application) serve() error {
|
||||
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,
|
||||
"env": app.config.env,
|
||||
"env": app.Config.Env,
|
||||
})
|
||||
|
||||
err := srv.ListenAndServe()
|
||||
@ -59,7 +56,7 @@ func (app *application) serve() error {
|
||||
return err
|
||||
}
|
||||
|
||||
app.logger.PrintInfo("stopped server", map[string]string{
|
||||
app.Logger.PrintInfo("stopped server", map[string]string{
|
||||
"addr": srv.Addr,
|
||||
})
|
||||
|
||||
233
cmd/party/testutils_test.go
Normal file
233
cmd/party/testutils_test.go
Normal 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
241
cmd/party/vote_test.go
Normal 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
100
cmd/party/web/home.go
Normal 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
263
cmd/party/web/issues.go
Normal 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
148
cmd/party/web/middleware.go
Normal 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
63
cmd/party/web/mps.go
Normal 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),
|
||||
})
|
||||
}
|
||||
39
cmd/party/web/templates.go
Normal file
39
cmd/party/web/templates.go
Normal 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
214
cmd/party/web/users.go
Normal 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
42
cmd/party/web/votes.go
Normal 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
42
cmd/party/web/web.go
Normal 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
14
internal/crypto/vote.go
Normal 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)
|
||||
}
|
||||
@ -1,30 +1,56 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"time"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type BlindSignRequest struct {
|
||||
type BlindSign struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type BlindSignRequestModel struct {
|
||||
type BlindSignModel struct {
|
||||
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 := `
|
||||
INSERT INTO blind_sign_requests (user_id, issue_id)
|
||||
INSERT INTO blind_signs (user_id, issue_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING created`
|
||||
|
||||
@ -33,12 +59,16 @@ RETURNING created`
|
||||
blind_sign.IssueID,
|
||||
}
|
||||
|
||||
return m.DB.QueryRow(query, args...).Scan(
|
||||
&blind_sign.Created,
|
||||
)
|
||||
err := m.DB.QueryRow(query, args...).Scan(&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 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
@ -77,12 +107,3 @@ func (m BlindSignRequestModel) BlindSign(issueID int64, blindedVoteBytes []byte)
|
||||
|
||||
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
16
internal/data/helpers.go
Normal 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)
|
||||
}
|
||||
@ -2,11 +2,12 @@ package data
|
||||
|
||||
import (
|
||||
"time"
|
||||
"party.at/party/internal/validator"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"party.at/party/internal/validator"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
@ -38,8 +39,8 @@ type IssueModel struct {
|
||||
}
|
||||
|
||||
func (m IssueModel) Insert(issue *Issue) error {
|
||||
query := `
|
||||
INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem)
|
||||
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`
|
||||
|
||||
@ -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) {
|
||||
if id < 1 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version
|
||||
query :=
|
||||
`SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version
|
||||
FROM issues
|
||||
WHERE id = $1`
|
||||
|
||||
@ -138,35 +189,25 @@ func (m IssueModel) Update(issue *Issue) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add a placeholder method for deleting a specific record from the issues table.
|
||||
func (m IssueModel) Delete(id int64) error {
|
||||
if id < 1 {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
|
||||
// Construct the SQL query to delete the record.
|
||||
query := `
|
||||
DELETE FROM issues
|
||||
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)
|
||||
if err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
@ -175,7 +216,6 @@ func (m IssueModel) Delete(id int64) error {
|
||||
}
|
||||
|
||||
func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, error) {
|
||||
// Construct the SQL query to retrieve all issue records.
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
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(),
|
||||
)
|
||||
|
||||
// Create a context with a 3-second timeout.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
@ -204,20 +243,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e
|
||||
return nil, Metadata{}, err
|
||||
}
|
||||
|
||||
// Importantly, defer a call to rows.Close() to ensure that the resultset is closed
|
||||
// before GetAll() returns.
|
||||
defer rows.Close()
|
||||
|
||||
totalRecords := 0
|
||||
issues := []*Issue{}
|
||||
|
||||
// Use rows.Next to iterate through the rows in the resultset.
|
||||
for rows.Next() {
|
||||
// Initialize an empty Issue struct to hold the data for an individual 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(
|
||||
&totalRecords,
|
||||
&issue.ID,
|
||||
@ -236,18 +269,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e
|
||||
return nil, Metadata{}, err
|
||||
}
|
||||
|
||||
// Add the Issue struct to the slice.
|
||||
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 {
|
||||
return nil, Metadata{}, err
|
||||
}
|
||||
|
||||
metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)
|
||||
|
||||
// If everything went OK, then return the slice of issues.
|
||||
return issues, metadata, nil
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
var (
|
||||
ErrRecordNotFound = errors.New("record not found")
|
||||
ErrEditConflict = errors.New("edit conflict")
|
||||
ErrInvalidBlindedVote = errors.New("invalid blinded vote")
|
||||
)
|
||||
|
||||
type Models struct {
|
||||
@ -17,7 +16,10 @@ type Models struct {
|
||||
Issues IssueModel
|
||||
Tokens TokenModel
|
||||
Permissions PermissionModel
|
||||
BlindSignRequests BlindSignRequestModel
|
||||
Roles RoleModel
|
||||
BlindSigns BlindSignModel
|
||||
Votes VoteModel
|
||||
Options OptionModel
|
||||
}
|
||||
|
||||
func NewModels(db *sql.DB) Models {
|
||||
@ -27,6 +29,9 @@ func NewModels(db *sql.DB) Models {
|
||||
Issues: IssueModel{DB: db},
|
||||
Tokens: TokenModel{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
100
internal/data/options.go
Normal 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
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Permissions []string
|
||||
@ -27,9 +26,10 @@ func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) {
|
||||
query := `
|
||||
SELECT permissions.code
|
||||
FROM permissions
|
||||
INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id
|
||||
INNER JOIN users ON users_permissions.user_id = users.id
|
||||
WHERE users.id = $1`
|
||||
INNER JOIN roles_permissions ON roles_permissions.permission_id = permissions.id
|
||||
INNER JOIN roles ON roles_permissions.role_id = roles.id
|
||||
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()
|
||||
@ -43,27 +43,10 @@ WHERE users.id = $1`
|
||||
var permissions Permissions
|
||||
for rows.Next() {
|
||||
var permission string
|
||||
err := rows.Scan(&permission)
|
||||
if err != nil {
|
||||
if err := rows.Scan(&permission); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
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
|
||||
return permissions, rows.Err()
|
||||
}
|
||||
|
||||
64
internal/data/roles.go
Normal file
64
internal/data/roles.go
Normal 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()
|
||||
}
|
||||
@ -33,7 +33,6 @@ func generateToken(userID int64, userIdentityID int64, ttl time.Duration, scope
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
// Initialize a zero-valued byte slice with a length of 16 bytes.
|
||||
randomBytes := make([]byte, 16)
|
||||
|
||||
_, err := rand.Read(randomBytes)
|
||||
@ -82,6 +81,16 @@ func (m TokenModel) Insert(token *Token) error {
|
||||
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 {
|
||||
query :=`
|
||||
DELETE FROM tokens
|
||||
|
||||
@ -117,13 +117,8 @@ SELECT id, provider_id, user_id, provider_user, password, version
|
||||
FROM user_identities
|
||||
WHERE id = $1`
|
||||
|
||||
// Declare a User struct to hold the data returned by the query.
|
||||
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(
|
||||
&userIdentity.ID,
|
||||
&userIdentity.ProviderID,
|
||||
@ -141,7 +136,7 @@ WHERE id = $1`
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Otherwise, return a pointer to the User struct.
|
||||
|
||||
return &userIdentity, nil
|
||||
}
|
||||
|
||||
@ -156,10 +151,6 @@ FROM user_identities identity
|
||||
JOIN users u on identity.user_id = u.id
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
@ -244,7 +235,6 @@ func (m UserIdentityModel) Update(user *UserIdentity) error {
|
||||
WHERE id = $3 AND version = $4
|
||||
RETURNING version`
|
||||
|
||||
// Create an args slice containing the values for the placeholder parameters.
|
||||
args := []interface{}{
|
||||
user.ProviderUserID,
|
||||
user.Password.hash,
|
||||
@ -275,29 +265,20 @@ func (m UserIdentityModel) Delete(id int64) error {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
|
||||
// Construct the SQL query to delete the record.
|
||||
query := `
|
||||
DELETE FROM user_identities
|
||||
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)
|
||||
if err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
|
||||
@ -2,17 +2,20 @@ package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"party.at/party/internal/validator"
|
||||
"database/sql"
|
||||
"github.com/lib/pq"
|
||||
"errors"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"party.at/party/internal/validator"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateEmail = errors.New("duplicate email")
|
||||
ErrDuplicateUser = errors.New("duplicate username")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
var AnonymousUser = &User{}
|
||||
@ -128,8 +131,8 @@ func (m UserModel) Get(id int64) (*User, error) {
|
||||
}
|
||||
|
||||
// Define the SQL query for retrieving the issue data.
|
||||
query :=`
|
||||
SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version
|
||||
query :=
|
||||
`SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version
|
||||
FROM users
|
||||
WHERE id = $1`
|
||||
|
||||
@ -307,6 +310,54 @@ func (m UserModel) Update(user *User) error {
|
||||
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 {
|
||||
if id < 1 {
|
||||
return ErrRecordNotFound
|
||||
|
||||
102
internal/data/votes.go
Normal file
102
internal/data/votes.go
Normal 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
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
DROP TABLE IF EXISTS user_identities;
|
||||
|
||||
DROP TABLE IF EXISTS auth_provider;
|
||||
DROP TABLE IF EXISTS auth_providers;
|
||||
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
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 TABLE IF EXISTS votes;
|
||||
DROP TABLE IF EXISTS blind_sign_requests;
|
||||
DROP TABLE IF EXISTS vote_tokens;
|
||||
DROP TABLE IF EXISTS blind_signs;
|
||||
DROP TABLE IF EXISTS options;
|
||||
DROP TABLE IF EXISTS issues;
|
||||
|
||||
@ -19,16 +19,7 @@ CREATE TABLE IF NOT EXISTS options (
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vote_tokens (
|
||||
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 (
|
||||
CREATE TABLE IF NOT EXISTS blind_signs (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
@ -37,12 +28,11 @@ CREATE TABLE IF NOT EXISTS blind_sign_requests (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
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,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
version INT NOT NULL DEFAULT 1
|
||||
nonce BYTEA NOT NULL,
|
||||
signature BYTEA NOT NULL UNIQUE,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@ -9,6 +9,5 @@ CREATE TABLE IF NOT EXISTS users_permissions (
|
||||
PRIMARY KEY (user_id, permission_id)
|
||||
);
|
||||
|
||||
-- Add the two permissions to the table.
|
||||
INSERT INTO permissions (code)
|
||||
VALUES ('issues:read'), ('issues:write');
|
||||
VALUES ('issues:read'), ('issues:write'), ('issues:vote'), ('users:read');
|
||||
|
||||
9
migrations/000006_add_roles.down.sql
Normal file
9
migrations/000006_add_roles.down.sql
Normal 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;
|
||||
26
migrations/000006_add_roles.up.sql
Normal file
26
migrations/000006_add_roles.up.sql
Normal 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;
|
||||
1
migrations/000007_add_political_roles.down.sql
Normal file
1
migrations/000007_add_political_roles.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DELETE FROM roles WHERE code IN ('member_of_parliament', 'party_leadership');
|
||||
5
migrations/000007_add_political_roles.up.sql
Normal file
5
migrations/000007_add_political_roles.up.sql
Normal 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');
|
||||
@ -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}}
|
||||
@ -1,7 +0,0 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Home{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<h1>The Party?</h1>
|
||||
{{end}}
|
||||
@ -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
BIN
web/.DS_Store
vendored
Normal file
Binary file not shown.
27
web/html/activated.page.tmpl
Normal file
27
web/html/activated.page.tmpl
Normal 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
62
web/html/base.layout.tmpl
Normal 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">© 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
31
web/html/home.page.tmpl
Normal 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">🗳</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">👤</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">📊</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}}
|
||||
54
web/html/home_anonymous.page.tmpl
Normal file
54
web/html/home_anonymous.page.tmpl
Normal 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 & anmelden
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
208
web/html/issue.page.tmpl
Normal file
208
web/html/issue.page.tmpl
Normal file
@ -0,0 +1,208 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Issue.Title}}{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div style="margin-bottom:16px;">
|
||||
<a href="/issues" class="link">← 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
101
web/html/issues.page.tmpl
Normal 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
44
web/html/mps.page.tmpl
Normal 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
33
web/html/partials.tmpl
Normal 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}}
|
||||
27
web/html/profile.page.tmpl
Normal file
27
web/html/profile.page.tmpl
Normal 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> {{.User.Email}}</div>
|
||||
<div><span style="color:var(--text-muted);">Telefon</span> {{.User.PhoneNumber}}</div>
|
||||
<div><span style="color:var(--text-muted);">Land</span> {{.User.Country}}</div>
|
||||
<div><span style="color:var(--text-muted);">Adresse</span> {{.User.Address}}</div>
|
||||
<div><span style="color:var(--text-muted);">Geburtsdatum</span> {{.User.DateOfBirth.Format "02.01.2006"}}</div>
|
||||
<div><span style="color:var(--text-muted);">Mitglied seit</span> {{.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}}
|
||||
82
web/html/register.page.tmpl
Normal file
82
web/html/register.page.tmpl
Normal 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
34
web/html/users.page.tmpl
Normal 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}} · 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
BIN
web/static/.DS_Store
vendored
Normal file
Binary file not shown.
27
web/static/logo-small.svg
Normal file
27
web/static/logo-small.svg
Normal 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
116
web/static/logo.svg
Normal 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
440
web/static/style.css
Normal 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; }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user