moved a lot

This commit is contained in:
vfs committed 2026-05-09 14:40:14 +02:00
1 parent 5772a7d855
commit 136a1c34b3
98 files changed
+6950 -2183

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
package main
import (
"context"
"net/http"
"party.at/party/internal/data"
)
type contextKey string
const userContextKey = "user"
func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
return r.WithContext(ctx)
}
func (app *application) contextGetUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userContextKey).(*data.User)
if !ok {
panic("missing user value in request context")
}
return user
}
-141
View File
@@ -1,141 +0,0 @@
package main
import (
// "context"
"database/sql"
"log"
)
func database_init_dont_use(conn *sql.DB) {
sql := `DROP TABLE IF EXISTS vote;
DROP TABLE IF EXISTS vote_token;
DROP TABLE IF EXISTS option;
DROP TABLE IF EXISTS issues;
DROP TABLE IF EXISTS account;`
_, err := conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE account (
id BIGINT primary key generated always as identity,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
first_name TEXT not null,
last_name TEXT not null,
created TIMESTAMPTZ DEFAULT now() NOT NULL,
last_login TIMESTAMPTZ
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('vik', '$argon2id$v=19$m=65536,t=1,p=32$+dQ9uB7kKL7t7G3bI+TOMw$Wvic27W6SYH6Fx2Pp84irhVJ/blVh5qINlkv58bpgEc', 'Vicente', 'Ferrari Smith', 'vikhenzo@gmail.com', '2024-05-24 13:21:48.179');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('Al Orjales', '$argon2id$v=19$m=65536,t=1,p=1$rQODKJ0+mUZ6v6ChUAcr4Q$x0cDjym/QB9lFFq/77FPv7R90Ao5gldb9cuNprBpGAs', '', '', '', '2024-09-26 17:43:37.879');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('mkBflwkpe', '$argon2id$v=19$m=65536,t=1,p=1$BWcWdp8bhgS84LWqUCb2IA$DGF/FzQbSNHnfZraE9F2qvfdBGf5XB81+w00QgY/jG0', 'zWkxKTNolTgJwO', 'OahedOBLSo', 'bellrebekaou@gmail.com', '2024-11-25 03:00:18.211');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('TzBeIMeRjxrfsM', '$argon2id$v=19$m=65536,t=1,p=1$ZIpNaO6RPeGncWe9cw8Iog$di0qjf8G0HlcZE8Hl+krNDlBeMrtfuGMwFWAlAnEMNs', 'ZBbLOWXqQlr', 'pIomimIQ', 'denielkgb21@gmail.com', '2024-11-19 13:20:33.177');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('TeAOvwIdnfqpxy', '$argon2id$v=19$m=65536,t=1,p=1$GRL555iybL1S8GjPq8jlOg$YnuwBxHAT8/I+cU548CYuhwaJdcEXe+R2PQbYpV7UKQ', 'RzqsaECTYrz', 'zreWiEtZGOeRNI', 'aolelonnum@yahoo.com', '2024-11-26 01:19:37.780');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('XIdQMFvxmT', '$argon2id$v=19$m=65536,t=1,p=1$PIcX7GObR0wlgRTmcMRUug$Kpc7SAv5K1PRxBtqioV4uoCZlkvGebkBmYyXCwoTgmM', 'ayZKALohBYmBx', 'smILWvtOvb', 'heilwoodsf@gmail.com', '2024-11-23 00:41:59.471');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('yIQvINFS', '$argon2id$v=19$m=65536,t=1,p=1$SJxMxkotEr+lfGeaCs7vlA$fffslijaMuPy+XPBEdv9iPqLbt66H/qJbGUHGrbpdz0', 'nKXBOwRbtXmMedo', 'KvMechDKtPPMgM', 'cortezzavira@gmail.com', '2024-11-24 06:29:58.378');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('HEjcEByjlPjtaGE', '$argon2id$v=19$m=65536,t=1,p=1$Hu2qnPxi0nz0OHx97h5j5Q$xh7HwKBIi9mp+WWU7rS9MfnDohtJqrv0EUrF2mVpnto', 'yJrQikhSHRYNgdu', 'GttOfPxSPoVWWl', 'jacobsheizitn4963@gmail.com', '2024-11-27 00:07:53.617');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('DggewDRjXu', '$argon2id$v=19$m=65536,t=1,p=1$MzFSqUr5J5IRC930empHrw$y7kogPP715NOtazsXqjR56LQGF2Eaes9CyAoPjRm3xk', 'uMezBkaQcfBbD', 'YoaQNBvZgHvTaFf', 'kinharrispa@gmail.com', '2024-11-27 21:55:53.037');
INSERT INTO account
(username, password_hash, first_name, last_name, email, created)
VALUES('yyZGFAsmPEBoSf', '$argon2id$v=19$m=65536,t=1,p=1$vLVm5ol6GQkXPU0BPut92g$Kyvp7/dl3lGUszXRiXfgsgB/IY0EKulZVpVttXQaDDU', 'svVjyPUaAkN', 'mdngeHlf', 'giyahyd4141@gmail.com', '2024-11-28 19:02:32.806');`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE issues (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE option (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE vote_token (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE TABLE vote (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token UUID NOT NULL UNIQUE REFERENCES vote_token(token) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES option(id) ON DELETE CASCADE,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
sql = `CREATE INDEX idx_votes_option_id ON vote(option_id);
CREATE INDEX idx_vote_tokens_issue_id ON vote_token(issue_id);
CREATE INDEX idx_options_issue_id ON option(issue_id);`
_, err = conn.Exec(sql)
if err != nil {
log.Fatal(err)
}
}
-104
View File
@@ -1,104 +0,0 @@
package main
import (
"fmt"
"net/http"
)
// The logError() method is a generic helper for logging an error message. Later in the
// book we'll upgrade this to use structured logging, and record additional information
// about the request including the HTTP method and URL.
func (app *application) logError(r *http.Request, err error) {
app.logger.PrintError(err, map[string]string{
"request_method": r.Method,
"request_url": r.URL.String(),
})
}
// The errorResponse() method is a generic helper for sending JSON-formatted error
// messages to the client with a given status code. Note that we're using an interface{}
// type for the message parameter, rather than just a string type, as this gives us
// more flexibility over the values that we can include in the response.
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) {
env := envelope{"error": message}
// Write the response using the writeJSON() helper. If this happens to return an
// error then log it, and fall back to sending the client an empty response with a
// 500 Internal Server Error status code.
err := app.writeJSON(w, status, env, nil)
if err != nil {
app.logError(r, err)
w.WriteHeader(500)
}
}
// The serverErrorResponse() method will be used when our application encounters an
// unexpected problem at runtime. It logs the detailed error message, then uses the
// errorResponse() helper to send a 500 Internal Server Error status code and JSON
// response (containing a generic error message) to the client.
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
app.logError(r, err)
message :=
"the server encountered a problem and could not process your request"
app.errorResponse(w, r, http.StatusInternalServerError, message)
}
// The notFoundResponse() method will be used to send a 404 Not Found status code and
// JSON response to the client.
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
message := "the requested resource could not be found"
app.errorResponse(w, r, http.StatusNotFound, message)
}
// The methodNotAllowedResponse() method will be used to send a 405 Method Not Allowed
// status code and JSON response to the client.
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
}
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
}
func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
message := "unable to update the record due to an edit conflict, please try again"
app.errorResponse(w, r, http.StatusConflict, message)
}
func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
message := "rate limit exceeded"
app.errorResponse(w, r, http.StatusTooManyRequests, message)
}
func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
message := "invalid authentication credentials"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Bearer")
message := "invalid or missing authentication token"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
message :="you must be authenticated to access this resource"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
message := "your user account must be activated to access this resource"
app.errorResponse(w, r, http.StatusForbidden, message)
}
func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) {
message := "your user account doesn't have the necessary permissions to access this resource"
app.errorResponse(w, r, http.StatusForbidden, message)
}
-22
View File
@@ -1,22 +0,0 @@
package main
import (
// "encoding/json"
//"fmt"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
env := envelope{
"status": "available",
"system_info": map[string]string{
"environment": app.config.env,
"version": version,
},
}
err := app.writeJSON(w, http.StatusOK, envelope{"health_check": env}, nil)
if err != nil {
app.serverErrorResponse(w,r, err)
}
}
-212
View File
@@ -1,212 +0,0 @@
package main
import (
"net/http"
"encoding/json"
"strings"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"party.at/party/internal/validator"
"github.com/julienschmidt/httprouter"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"crypto/rand"
)
type envelope map[string]interface{}
func (app *application) readString(qs url.Values, key string, defaultValue string) string {
// Extract the value for a given key from the query string. If no key exists this
// will return the empty string "".
s := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if s == "" {
return defaultValue
}
// Otherwise return the string.
return s
}
func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string {
// Extract the value from the query string.
csv := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if csv == "" {
return defaultValue
}
// Otherwise parse the value into a []string slice and return it.
return strings.Split(csv, ",")
}
func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
// Extract the value from the query string.
s := qs.Get(key)
// If no key exists (or the value is empty) then return the default value.
if s == "" {
return defaultValue
}
// Try to convert the value to an int. If this fails, add an error message to the
// validator instance and return the default value.
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
// Otherwise, return the converted integer value.
return i
}
func (app *application) readIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
// Encode the data to JSON, returning the error if there was one.
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
// Append a newline to make it easier to view in terminal applications.
js = append(js, '\n')
// At this point, we know that we won't encounter any more errors before writing the
// response, so it's safe to add any headers that we want to include. We loop
// through the header map and add each header to the http.ResponseWriter header map.
// Note that it's OK if the provided header map is nil. Go doesn't throw an error
// if you try to range over (or generally, read from) a nil map.
for key, value := range headers {
w.Header()[key] = value
}
// Add the "Content-Type: application/json" header, then write the status code and
// JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
maxBytes := 1048576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
// Initialize the json.Decoder, and call the DisallowUnknownFields() method on it
// before decoding. This means that if the JSON from the client now includes any
// field which cannot be mapped to the target destination, the decoder will return
// an error instead of just ignoring the field.
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
// Decode the request body to the destination.
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body must not be empty")
// If the JSON contains a field which cannot be mapped to the target destination
// then Decode() will now return an error message in the format "json: unknown
// field "<name>"". We check for this, extract the field name from the error,
// and interpolate it into our custom error message. Note that there's an open
// issue at https://github.com/golang/go/issues/29035 regarding turning this
// into a distinct error type in the future.
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
// If the request body exceeds 1MB in size the decode will now fail with the
// error "http: request body too large". There is an open issue about turning
// this into a distinct error type at https://github.com/golang/go/issues/30715.
case err.Error() == "http: request body too large":
return fmt.Errorf("body must not be larger than %d bytes", maxBytes)
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
// Call Decode() again, using a pointer to an empty anonymous struct as the
// destination. If the request body only contained a single JSON value this will
// return an io.EOF error. So if we get anything else, we know that there is
// additional data in the request body and we return our own custom error message.
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func (app *application) background(fn func()) {
// Launch a background goroutine.
go func() {
// Recover any panic.
defer func() {
if err := recover(); err != nil {
app.logger.PrintError(fmt.Errorf("%s", err), nil)
}
}()
// Execute the arbitrary function that we passed as the parameter.
fn()
}()
}
func GenerateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
// Encode private key as PEM
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privDER,
})
return key.N.Bytes(), key.E, privPEM, err
}
-295
View File
@@ -1,295 +0,0 @@
package main
import (
// "encoding/json"
"fmt"
// "html/template"
// "log"
"net/http"
"time"
// "github.com/julienschmidt/httprouter"
// "strconv"
"errors"
"party.at/party/internal/data"
"party.at/party/internal/validator"
"encoding/hex"
)
func (app *application) listIssuesHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Title = app.readString(qs, "title", "")
// input.Genres = app.readCSV(qs, "genres", []string{})
input.Filters.Page = app.readInt(qs, "page", 1, v)
input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)
input.Filters.Sort = app.readString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "title", "-title", "description", "-description"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
issues, metadata, err := app.models.Issues.GetAll(input.Title, input.Filters)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"issues": issues, "metadata": metadata}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) createIssueHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
}
err := app.readJSON(w, r, &input)
if err != nil {
// Use the new badRequestResponse() helper.
app.badRequestResponse(w, r, err)
return
}
n, e, private_pem, err := GenerateIssueKey()
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
issue := &data.Issue{
Title: input.Title,
Description: input.Description,
StartTime: input.StartTime,
EndTime: input.EndTime,
N: n,
E: e,
PrivatePem: private_pem,
}
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Issues.Insert(issue)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID))
err = app.writeJSON(w, http.StatusCreated, envelope{"issue": issue}, headers)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) readIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Encode the struct to JSON and send it as the HTTP response.
err = app.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
var input struct {
Title *string `json:"title"`
Description *string `json:"description"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
err = app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
if input.Title != nil { issue.Title = *input.Title }
if input.Description != nil { issue.Description = *input.Description }
if input.StartTime != nil { issue.StartTime = *input.StartTime }
if input.StartTime != nil { issue.EndTime = *input.EndTime }
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Issues.Update(issue)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Write the updated issue record in a JSON response.
err = app.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteIssueHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
// Delete the issue from the database, sending a 404 Not Found response to the
// client if there isn't a matching record.
err = app.models.Issues.Delete(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Return a 200 OK status code along with a success message.
err = app.writeJSON(w, http.StatusOK, envelope{"message": "issue successfully deleted"}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) readIssuePubKeyHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
type response struct {
N string `json:"n"`
E int `json:"e"`
}
res := response{
N: hex.EncodeToString(issue.N),
E: issue.E,
}
err = app.writeJSON(w, http.StatusOK, envelope{"public_key": res}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) blindSignIssueVoteHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
issue, err := app.models.Issues.Get(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
type response struct {
N string `json:"n"`
E int `json:"e"`
}
res := response{
N: hex.EncodeToString(issue.N),
E: issue.E,
}
err = app.writeJSON(w, http.StatusOK, envelope{"public_key": res}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
)
func TestReadIssueHandler(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, app.routes())
defer ts.Close()
token := ts.registerAndLogin(t, uniqueEmail(), "pa$$word123")
req := map[string]any{
"title": "An old silent pond...",
"description": "A frog jumps into the pond",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", token, req)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
var resp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
}
json.Unmarshal(res, &resp)
issueID := resp.Issue.ID
tests := []struct {
name string
urlPath string
wantCode int
wantBody []byte
}{
{"Valid ID", "/v1/issues/" + strconv.Itoa(int(issueID)), http.StatusOK, []byte("An old silent pond...")},
{"Non-existent ID", "/v1/issues/" + strconv.Itoa(int(issueID + 1)), http.StatusNotFound, nil},
{"Negative ID", "/v1/issues/-1", http.StatusNotFound, nil},
{"Decimal ID", "/v1/issues/1.23", http.StatusNotFound, nil},
{"String ID", "/v1/issues/foo", http.StatusNotFound, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.getWithToken(t, tt.urlPath, token)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body to contain %q; got %q", tt.wantBody, string(body))
}
})
}
}
-252
View File
@@ -1,252 +0,0 @@
package main
import (
"context"
"log"
"strings"
"expvar"
"runtime"
//"html/template"
"flag"
"net/http"
"fmt"
"github.com/gorilla/websocket"
"os"
"time"
"database/sql"
_ "github.com/lib/pq"
"party.at/party/internal/data"
"party.at/party/internal/mailer"
"party.at/party/internal/jsonlog"
"crypto/rand"
"crypto/aes"
"crypto/cipher"
)
var version string
var buildTime string
type config struct {
port int
env string
db struct {
dsn string
maxOpenConns int
maxIdleConns int
maxIdleTime string
}
limiter struct {
rps float64
burst int
enabled bool
}
smtp struct {
host string
port int
username string
password string
sender string
}
cors struct {
trustedOrigins []string
}
}
type application struct {
config config
logger *jsonlog.Logger
models data.Models
mailer mailer.Mailer
}
type Message struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Random float64 `json:"random"`
}
//var page_template = template.Must(template.ParseFiles("../../ui/html/index.html"))
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // allow all origins
}
func main() {
// 32 bytes = AES-256
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
panic(err)
}
message := []byte("hello secure world")
nonce, ciphertext, err := encrypt(key, message)
if err != nil {
panic(err)
}
plaintext, err := decrypt(key, nonce, ciphertext)
if err != nil {
panic(err)
}
fmt.Printf("Original: %s\n", message)
fmt.Printf("Decrypted: %s\n", plaintext)
var cfg config
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.StringVar(&cfg.env, "env", "production", "Environment (development|staging|production)")
flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("PARTY_DB_DSN"), "PostgreSQL DSN")
//addr := flag.String("addr", ":8443", "HTTP network address")
flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time")
flag.StringVar(&cfg.smtp.host, "smtp-host", "smtp.mailtrap.io", "SMTP host")
flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
flag.StringVar(&cfg.smtp.username, "smtp-username", "98cf60028d7fcb", "SMTP username")
flag.StringVar(&cfg.smtp.password, "smtp-password", "b9d4a35372e971", "SMTP password")
flag.StringVar(&cfg.smtp.sender, "smtp-sender", "DPÖ <no-reply@party.at>", "SMTP sender")
flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
cfg.cors.trustedOrigins = strings.Fields(val)
return nil
})
displayVersion := flag.Bool("version", false, "Display version and exit")
flag.Parse()
if *displayVersion {
fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Build time:\t%s\n", buildTime)
os.Exit(0)
}
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
log.Printf("%s\n", cfg.db.dsn)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
expvar.NewString("version").Set(version)
expvar.Publish("goroutines", expvar.Func(func() interface{} {
return runtime.NumGoroutine()
}))
expvar.Publish("database", expvar.Func(func() interface{} {
return db.Stats()
}))
expvar.Publish("timestamp", expvar.Func(func() interface{} {
return time.Now().Unix()
}))
app := &application{
config: cfg,
logger: logger,
models: data.NewModels(db),
mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender),
}
log.Println("Hello, Sailor!")
err = app.serve()
if err != nil {
logger.PrintFatal(err, nil)
}
}
func openDB(cfg config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.db.dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.db.maxOpenConns)
db.SetMaxIdleConns(cfg.db.maxIdleConns)
duration, err := time.ParseDuration(cfg.db.maxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use PingContext() to establish a new connection to the database, passing in the
// context we created above as a parameter. If the connection couldn't be
// established successfully within the 5 second deadline, then this will return an
// error.
err = db.PingContext(ctx)
if err != nil {
return nil, err
}
return db, nil
}
func encrypt(key [32]byte, plaintext []byte) (nonce, ciphertext []byte, err error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
panic(err)
}
ciphertext = aead.Seal(nil, nonce, plaintext, nil)
return nonce, ciphertext, nil
}
func decrypt(key [32]byte, nonce, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
-280
View File
@@ -1,280 +0,0 @@
package main
import (
"fmt"
"net"
"net/http"
"sync"
"time"
"strings"
"errors"
"expvar"
"strconv"
"github.com/felixge/httpsnoop"
"golang.org/x/time/rate"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create a deferred function (which will always be run in the event of a panic
// as Go unwinds the stack).
defer func() {
// Use the builtin recover function to check if there has been a panic or
// not.
if err := recover(); err != nil {
// If there was a panic, set a "Connection: close" header on the
// response. This acts as a trigger to make Go's HTTP server
// automatically close the current connection after a response has been
// sent.
w.Header().Set("Connection", "close")
// The value returned by recover() has the type interface{}, so we use
// fmt.Errorf() to normalize it into an error and call our
// serverErrorResponse() helper. In turn, this will log the error using
// our custom Logger type at the ERROR level and send the client a 500
// Internal Server Error response.
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (app *application) rateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
var clients = make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
// Lock the mutex to prevent any rate limiter checks from happening while
// the cleanup is taking place.
mu.Lock()
// Loop through all clients. If they haven't been seen within the last three
// minutes, delete the corresponding entry from the map.
for ip, client := range clients {
if time.Since(client.lastSeen) > 3 * time.Minute {
delete(clients, ip)
}
}
// Importantly, unlock the mutex when the cleanup is complete.
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.config.limiter.enabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(app.config.limiter.rps), app.config.limiter.burst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
app.rateLimitExceededResponse(w, r)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
authorizationHeader := r.Header.Get("Authorization")
if authorizationHeader == "" {
r = app.contextSetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
app.invalidAuthenticationTokenResponse(w, r)
return
}
token := headerParts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
app.invalidAuthenticationTokenResponse(w, r)
return
}
userIdentity, err := app.models.UserIdentities.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidAuthenticationTokenResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
user, err := app.models.Users.Get(userIdentity.UserID)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Call the contextSetUser() helper to add the user information to the request
// context.
r = app.contextSetUser(r, user)
// Call the next handler in the chain.
next.ServeHTTP(w, r)
})
}
func (app *application) requireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := app.contextGetUser(r)
if user.IsAnonymous() {
app.authenticationRequiredResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
// Rather than returning this http.HandlerFunc we assign it to the variable fn.
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := app.contextGetUser(r)
// Check that a user is activated.
if !user.Activated {
app.inactiveAccountResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
// Wrap fn with the requireAuthenticatedUser() middleware before returning it.
return app.requireAuthenticatedUser(fn)
}
func (app *application) requirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
// Retrieve the user from the request context.
user := app.contextGetUser(r)
// Get the slice of permissions for the user.
permissions, err := app.models.Permissions.GetAllForUser(user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Check if the slice includes the required permission. If it doesn't, then
// return a 403 Forbidden response.
if !permissions.Include(code) {
app.notPermittedResponse(w, r)
return
}
// Otherwise they have the required permission so we call the next handler in
// the chain.
next.ServeHTTP(w, r)
}
// Wrap this with the requireActivatedUser() middleware before returning it.
return app.requireActivatedUser(fn)
}
func (app *application) enableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin")
w.Header().Add("Vary", "Access-Control-Request-Method")
origin := r.Header.Get("Origin")
if origin != "" && len(app.config.cors.trustedOrigins) != 0 {
// Loop through the list of trusted origins, checking to see if the request
// origin exactly matches one of them.
for i := range app.config.cors.trustedOrigins {
if origin == app.config.cors.trustedOrigins[i] {
// If there is a match, then set a "Access-Control-Allow-Origin"
// response header with the request origin as the value.
w.Header().Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
// Set the necessary preflight response headers, as discussed
// previously.
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers","Authorization, Content-Type")
// Write the headers along with a 200 OK status and return from
// the middleware with no further action.
w.WriteHeader(http.StatusOK)
return
}
}
}
}
next.ServeHTTP(w, r)
})
}
func (app *application) metrics(next http.Handler) http.Handler {
// Initialize the new expvar variables when the middleware chain is first built.
totalRequestsReceived := expvar.NewInt("total_requests_received")
totalResponsesSent := expvar.NewInt("total_responses_sent")
totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_μs")
totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
totalRequestsReceived.Add(1)
// Call the next handler in the chain.
metrics := httpsnoop.CaptureMetrics(next, w, r)
// On the way back up the middleware chain, increment the number of responses
// sent by 1.
totalResponsesSent.Add(1)
totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds())
totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1)
})
}
-44
View File
@@ -1,44 +0,0 @@
package main
import (
"net/http"
"expvar"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
fileServer := http.FileServer(http.Dir("ui/static"))
router.HandlerFunc(http.MethodGet, "/", home)
router.HandlerFunc(http.MethodGet, "/ws", ws)
router.Handler (http.MethodGet, "/static/", http.StripPrefix("/static", fileServer))
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodGet, "/v1/issues", app.requirePermission("issues:read", app.listIssuesHandler))
router.HandlerFunc(http.MethodPost, "/v1/issues", app.requirePermission("issues:write", app.createIssueHandler))
router.HandlerFunc(http.MethodGet, "/v1/issues/:id", app.requirePermission("issues:read", app.readIssueHandler))
router.HandlerFunc(http.MethodPatch, "/v1/issues/:id", app.requirePermission("issues:write", app.updateIssueHandler))
router.HandlerFunc(http.MethodDelete, "/v1/issues/:id", app.requirePermission("issues:write", app.deleteIssueHandler))
router.HandlerFunc(http.MethodGet, "/v1/issues/:id/pubkey", app.requirePermission("issues:read", app.readIssuePubKeyHandler))
router.HandlerFunc(http.MethodPost, "/v1/issues/:id/blind-sign", app.requirePermission("issues:read", app.blindSignIssueVoteHandler))
router.HandlerFunc(http.MethodPost, "/v1/users", app.createUserHandler)
// router.HandlerFunc(http.MethodGet, "/v1/users/:id", app.readUserHandler)
// router.HandlerFunc(http.MethodPatch, "/v1/users/:id", app.updateUserHandler)
router.HandlerFunc(http.MethodDelete, "/v1/users/:id", app.deleteUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
router.Handler (http.MethodGet, "/debug/vars", expvar.Handler())
return app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))))
}
-202
View File
@@ -1,202 +0,0 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"os"
"encoding/json"
"bytes"
"fmt"
"time"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
)
func newTestApplication(t *testing.T) *application {
cfg := config{}
cfg.db.dsn = "postgres://party:password@localhost:5432/party?sslmode=disable"
cfg.db.maxOpenConns = 25
cfg.db.maxIdleConns = 25
cfg.db.maxIdleTime = "15m"
cfg.limiter.enabled = false
cfg.env = "development"
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
t.Cleanup(func() {db.Close()})
return &application{
logger: logger,
models: data.NewModels(db),
config: cfg,
}
}
type testServer struct {
*httptest.Server
app *application
}
func newTestServer(t *testing.T, app *application, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts, app}
}
func (ts *testServer) postJSON(t *testing.T, path string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header, respBody
}
func (ts *testServer) get(t *testing.T, path string) (int, http.Header, []byte) {
t.Helper()
rs, err := ts.Client().Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
// registers a user, activates them, logs in, returns the bearer token
func (ts *testServer) registerAndLogin(t *testing.T, email, password string) string {
t.Helper()
// 1. Register
registerBody := map[string]any{
"email": email,
"password": password,
"username": email,
"name": "Test User",
"alt_name" : "",
"provider_id": 1,
}
code, _, body := ts.postJSON(t, "/v1/users", registerBody)
if code != http.StatusCreated {
t.Fatalf("register: want 201 got %d: %s", code, body)
}
// 2. Activate — if your flow requires it, either hit the endpoint
// or directly flip the activated flag in the test DB
// ts.activateUser(t, email)
// 3. Login
loginBody := map[string]string{"email": email, "password": password}
code, _, body = ts.postJSON(t, "/v1/tokens/authentication", loginBody)
if code != http.StatusCreated {
t.Fatalf("login: want 201 got %d: %s", code, body)
}
// 4. Parse token out of response
var resp struct {
AuthenticationToken struct {
Token string `json:"token"`
} `json:"authentication_token"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("parse token: %v", err)
}
return resp.AuthenticationToken.Token
}
// activateUser directly updates the DB — avoids needing a real email flow
// func (ts *testServer) activateUser(t *testing.T, email string) {
// t.Helper()
// _, err := ts.app.db.Exec("UPDATE users SET activated = true WHERE email = $1", email)
// if err != nil {
// t.Fatalf("activate user: %v", err)
// }
// }
// like ts.get but adds Authorization header
func (ts *testServer) getWithToken(t *testing.T, path, token string) (int, http.Header, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) postJSONWithToken(t *testing.T, path, token string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
respBody, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, respBody
}
func uniqueEmail() string {
return fmt.Sprintf("test_%d@example.com", time.Now().UnixNano())
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"errors"
"net/http"
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the email and password provided by the client.
v := validator.New()
data.ValidateEmail(v, input.Email)
data.ValidatePasswordPlaintext(v, input.Password)
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
identities, err := app.models.UserIdentities.GetByUser(user.ID)
var authenticatedIdentity *data.UserIdentity
for _, user_identity := range identities {
match, err := user_identity.Password.Matches(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if match {
authenticatedIdentity = user_identity
break
}
}
if authenticatedIdentity == nil {
app.invalidCredentialsResponse(w, r)
return
}
token, err := app.models.Tokens.New(user.ID, authenticatedIdentity.ID, 24 * time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
-224
View File
@@ -1,224 +0,0 @@
package main
import (
"errors"
"net/http"
"party.at/party/internal/data"
"party.at/party/internal/validator"
"time"
)
func (app *application) createUserHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
ProviderId int64 `json:"provider_id"`
Username string `json:"username"`
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
AltName *string `json:"alt_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Address string `json:"address"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
user := &data.User{
Email: input.Email,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
Activated: false,
}
if app.config.env == "development" {
user.Activated = true
}
userIdentity := &data.UserIdentity{
ProviderID: input.ProviderId,
ProviderUserID: input.Username,
}
err = userIdentity.Password.Set(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateUser(v, user); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
if data.ValidateUserIdentity(v, userIdentity); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
err = app.models.Users.ExecuteRegistrationTx(user, userIdentity)
if err != nil {
switch {
case errors.Is(err, data.ErrDuplicateEmail):
v.AddError("email", "a user with this email address already exists")
app.failedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrDuplicateUser):
v.AddError("username", "a user with this username already exists")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
err = app.models.Permissions.AddForUser(user.ID, "issues:read")
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if app.config.env == "development" {
err = app.models.Permissions.AddForUser(user.ID, "issues:write")
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
}
if app.config.env == "production" {
token, err := app.models.Tokens.New(user.ID, userIdentity.ID, 3 * 24 * time.Hour, data.ScopeActivation)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
app.background(func() {
data := map[string]interface{}{
"token": token.Plaintext,
"userID": user.ID,
}
err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
if err != nil {
app.logger.PrintError(err, nil)
}
})
}
authentication_token, err := app.models.Tokens.New(user.ID, userIdentity.ID, 24 * time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Write a JSON response containing the user data along with a 201 Created status
// code.
err = app.writeJSON(w, http.StatusCreated, envelope{"user": user, "authentication_token": authentication_token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteUserHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
// Delete the issue from the database, sending a 404 Not Found response to the
// client if there isn't a matching record.
err = app.models.Users.Delete(id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Return a 200 OK status code along with a success message.
err = app.writeJSON(w, http.StatusOK, envelope{"message": "user successfully deleted"}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
// Parse the plaintext activation token from the request body.
var input struct {
TokenPlaintext string `json:"token"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the plaintext token provided by the client.
v := validator.New()
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
// Retrieve the details of the user associated with the token using the
// GetForToken() method (which we will create in a minute). If no matching record
// is found, then we let the client know that the token they provided is not valid.
user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("token", "invalid or expired activation token")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// Update the user's activation status.
user.Activated = true
// Save the updated user record in our database, checking for any edit conflicts in
// the same way that we did for our movie records.
err = app.models.Users.Update(user)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
// If everything went successfully, then we delete all activation tokens for the
// user.
err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Send the updated user details to the client in a JSON response.
err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package api
import(
"context"
"errors"
"net/http"
"strings"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type Api struct {
App *common.Application
}
type contextKey string
const userKey contextKey = "user"
func SetUser(r *http.Request, user *data.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userKey, user))
}
func GetUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userKey).(*data.User)
if !ok {
panic("missing user value in request context")
}
return user
}
func (api *Api) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
authorizationHeader := r.Header.Get("Authorization")
if authorizationHeader == "" {
r = SetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
token := headerParts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
userIdentity, err := api.App.Models.UserIdentities.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.InvalidAuthenticationTokenResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
user, err := api.App.Models.Users.Get(userIdentity.UserID)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.InvalidCredentialsResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
r = SetUser(r, user)
next.ServeHTTP(w, r)
})
}
+366
View File
@@ -0,0 +1,366 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/internal/validator"
)
type envelope map[string]interface{}
type apiError struct {
Code common.ErrorCode `json:"code,omitempty"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// ── JSON helpers ─────────────────────────────────────────────────────────────
func (api *Api) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
func (api *Api) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
r.Body = http.MaxBytesReader(w, r.Body, 1048576)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body must not be empty")
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
case err.Error() == "http: request body too large":
return fmt.Errorf("body must not be larger than 1048576 bytes")
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func (api *Api) readIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
func (api *Api) readString(qs url.Values, key, defaultValue string) string {
if s := qs.Get(key); s != "" {
return s
}
return defaultValue
}
func (api *Api) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
s := qs.Get(key)
if s == "" {
return defaultValue
}
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
return i
}
// ── Error responses ──────────────────────────────────────────────────────────
func (api *Api) LogError(r *http.Request, err error) {
api.App.Logger.PrintError(err, map[string]string{
"request_method": r.Method,
"request_url": r.URL.String(),
})
}
func (api *Api) errorResponse(w http.ResponseWriter, r *http.Request, status int, ae apiError) {
if err := common.WriteJSON(w, status, common.Envelope{"error": ae}, nil); err != nil {
api.App.LogError(r, err)
w.WriteHeader(500)
}
}
func (api *Api) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
api.App.LogError(r, err)
api.errorResponse(w, r, http.StatusInternalServerError, apiError{
Message: "the server encountered a problem and could not process your request",
})
}
func (api *Api) NotFoundResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusNotFound, apiError{
Message: "the requested resource could not be found",
})
}
func (api *Api) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusMethodNotAllowed, apiError{
Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method),
})
}
func (api *Api) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
api.errorResponse(w, r, http.StatusBadRequest, apiError{Message: err.Error()})
}
func (api *Api) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
Code: common.ErrCodeValidationFailed,
Message: "validation failed",
Details: errors,
})
}
func (api *Api) EditConflictResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusConflict, apiError{
Code: common.ErrCodeEditConflict,
Message: "unable to update the record due to an edit conflict, please try again",
})
}
func (api *Api) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusUnauthorized, apiError{
Code: common.ErrCodeInvalidCredentials,
Message: "invalid authentication credentials",
})
}
func (api *Api) InvalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Bearer")
api.errorResponse(w, r, http.StatusUnauthorized, apiError{
Code: common.ErrCodeInvalidAuthToken,
Message: "invalid or missing authentication token",
})
}
func (api *Api) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusTooManyRequests, apiError{
Message: "rate limit exceeded",
})
}
func (api *Api) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusUnauthorized, apiError{
Code: common.ErrCodeAuthRequired,
Message: "you must be authenticated to access this resource",
})
}
func (api *Api) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusForbidden, apiError{
Code: common.ErrCodeInactiveAccount,
Message: "your user account must be activated to access this resource",
})
}
func (api *Api) NotPermittedResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusForbidden, apiError{
Code: common.ErrCodeNotPermitted,
Message: "your user account doesn't have the necessary permissions to access this resource",
})
}
func (api *Api) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusConflict, apiError{
Code: common.ErrCodeAlreadyVoted,
Message: "your user account already voted for this issue",
})
}
func (api *Api) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusConflict, apiError{
Code: common.ErrCodeAlreadyBlindSigned,
Message: "already requested a blind signature for this issue",
})
}
func (api *Api) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
Code: common.ErrCodeBlindedVoteRange,
Message: "blinded_vote is out of valid range",
})
}
func (api *Api) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
Code: common.ErrCodeInvalidSignature,
Message: "invalid signature",
})
}
func (api *Api) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) {
api.errorResponse(w, r, http.StatusConflict, apiError{
Code: common.ErrCodeVoteAlreadyCast,
Message: "this vote has already been cast",
})
}
// func (app *Application) errorResponse(w http.ResponseWriter, r *http.Request, status int, ae apiError) {
// if err := WriteJSON(w, status, Envelope{"error": ae}, nil); err != nil {
// app.LogError(r, err)
// w.WriteHeader(500)
// }
// }
// func (app *Application) ServerErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
// app.LogError(r, err)
// app.errorResponse(w, r, http.StatusInternalServerError, apiError{
// Message: "the server encountered a problem and could not process your request",
// })
// }
// func (app *Application) NotFoundResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusNotFound, apiError{
// Message: "the requested resource could not be found",
// })
// }
// func (app *Application) MethodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusMethodNotAllowed, apiError{
// Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method),
// })
// }
// func (app *Application) BadRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
// app.errorResponse(w, r, http.StatusBadRequest, apiError{Message: err.Error()})
// }
// func (app *Application) FailedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
// Code: ErrCodeValidationFailed,
// Message: "validation failed",
// Details: errors,
// })
// }
// func (app *Application) EditConflictResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusConflict, apiError{
// Code: ErrCodeEditConflict,
// Message: "unable to update the record due to an edit conflict, please try again",
// })
// }
// func (app *Application) InvalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusUnauthorized, apiError{
// Code: ErrCodeInvalidCredentials,
// Message: "invalid authentication credentials",
// })
// }
// func (app *Application) RateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusTooManyRequests, apiError{
// Message: "rate limit exceeded",
// })
// }
// func (app *Application) AuthenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusUnauthorized, apiError{
// Code: ErrCodeAuthRequired,
// Message: "you must be authenticated to access this resource",
// })
// }
// func (app *Application) InactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusForbidden, apiError{
// Code: ErrCodeInactiveAccount,
// Message: "your user account must be activated to access this resource",
// })
// }
// func (app *Application) NotPermittedResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusForbidden, apiError{
// Code: ErrCodeNotPermitted,
// Message: "your user account doesn't have the necessary permissions to access this resource",
// })
// }
// func (app *Application) AlreadyVotedResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusConflict, apiError{
// Code: ErrCodeAlreadyVoted,
// Message: "your user account already voted for this issue",
// })
// }
// func (app *Application) AlreadyBlindSignedResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusConflict, apiError{
// Code: ErrCodeAlreadyBlindSigned,
// Message: "already requested a blind signature for this issue",
// })
// }
// func (app *Application) BlindedVoteOutOfRangeResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
// Code: ErrCodeBlindedVoteRange,
// Message: "blinded_vote is out of valid range",
// })
// }
// func (app *Application) InvalidSignatureResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusUnprocessableEntity, apiError{
// Code: ErrCodeInvalidSignature,
// Message: "invalid signature",
// })
// }
// func (app *Application) VoteAlreadyCastResponse(w http.ResponseWriter, r *http.Request) {
// app.errorResponse(w, r, http.StatusConflict, apiError{
// Code: ErrCodeVoteAlreadyCast,
// Message: "this vote has already been cast",
// })
// }
+18
View File
@@ -0,0 +1,18 @@
package api
import "net/http"
import "party.at/party/cmd/party/common"
func (api *Api) Healthcheck(w http.ResponseWriter, r *http.Request) {
env := common.Envelope{
"status": "available",
"system_info": map[string]string{
"environment": api.App.Config.Env,
"version": "1",
},
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"health_check": env}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
+215
View File
@@ -0,0 +1,215 @@
package api
import (
"errors"
"fmt"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) ListIssues(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Title = common.ReadString(qs, "title", "")
input.Filters.Page = common.ReadInt(qs, "page", 1, v)
input.Filters.PageSize = common.ReadInt(qs, "page_size", 20, v)
input.Filters.Sort = common.ReadString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "title", "-title", "description", "-description"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
issues, metadata, err := api.App.FetchIssues(input.Title, input.Filters, GetUser(r))
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issues": issues, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) CreateIssue(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Options []string `json:"options"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
issue, options, err := api.App.CreateIssue(input.Title, input.Description, input.StartTime, input.EndTime, input.Options)
if err != nil {
var ve *common.ValidationError
if errors.As(err, &ve) {
api.FailedValidationResponse(w, r, ve.Errors)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/issues/%d", issue.ID))
if err = api.writeJSON(w, http.StatusCreated, envelope{"issue": issue, "options": options}, headers); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
result, err := api.App.GetIssue(id, GetUser(r))
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issue": result.IssueDetail, "options": result.Options}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) UpdateIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
var input struct {
Title *string `json:"title"`
Description *string `json:"description"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
if err = api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
issue, err := api.App.UpdateIssue(id, input.Title, input.Description, input.StartTime, input.EndTime)
if err != nil {
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
api.FailedValidationResponse(w, r, ve.Errors)
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrEditConflict):
api.EditConflictResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"issue": issue}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteIssue(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
if err = api.App.DeleteIssue(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"message": "issue successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadIssuePubKey(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
pubKey, err := api.App.GetIssuePublicKey(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"public_key": pubKey}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) BlindSignIssueVote(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
var input struct {
BlindedVote []byte `json:"blinded_vote"`
}
if err = api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
signed, err := api.App.BlindSign(id, input.BlindedVote, GetUser(r))
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrDuplicateBlindSign):
api.AlreadyBlindSignedResponse(w, r)
case errors.Is(err, data.ErrInvalidBlindedVote):
api.BlindedVoteOutOfRangeResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"signed": signed}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
+104
View File
@@ -0,0 +1,104 @@
package api
import "net/http"
import "fmt"
// import "party.at/party/cmd/party/common"
import "time"
import "sync"
import "golang.org/x/time/rate"
import "net"
func (api *Api) RecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
api.ServerErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (api *Api) RequireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetUser(r).IsAnonymous() {
api.AuthenticationRequiredResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (api *Api) RequireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !GetUser(r).Activated {
api.InactiveAccountResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
return api.RequireAuthenticatedUser(fn)
}
func (api *Api) RequirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
permissions, err := api.App.Models.Permissions.GetAllForUser(GetUser(r).ID)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if !permissions.Include(code) {
api.NotPermittedResponse(w, r)
return
}
next.ServeHTTP(w, r)
}
return api.RequireActivatedUser(fn)
}
func (api *Api) RateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
var clients = make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, c := range clients {
if time.Since(c.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.App.LimiterEnabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(api.App.LimiterRPS), api.App.LimiterBurst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
api.RateLimitExceededResponse(w, r)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"errors"
"net/http"
"strings"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) CreateAuthenticationToken(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
v := validator.New()
v.Check(input.Email != "", "email", "must be provided")
v.Check(input.Password != "", "password", "must be provided")
if !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
token, err := api.App.LoginUser(input.Email, input.Password)
if err != nil {
switch {
case errors.Is(err, data.ErrInvalidCredentials):
api.InvalidCredentialsResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteAuthenticationToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
token := parts[1]
v := validator.New()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
api.InvalidAuthenticationTokenResponse(w, r)
return
}
if err := api.App.DeleteToken(token); err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err := api.writeJSON(w, http.StatusOK, envelope{"message": "authentication token successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
+205
View File
@@ -0,0 +1,205 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
func (api *Api) ListUsers(w http.ResponseWriter, r *http.Request) {
var input struct {
data.Filters
}
v := validator.New()
qs := r.URL.Query()
input.Filters.Page = api.readInt(qs, "page", 1, v)
input.Filters.PageSize = api.readInt(qs, "page_size", 20, v)
input.Filters.Sort = api.readString(qs, "sort", "id")
input.Filters.SortSafelist = []string{"id", "-id", "name", "-name", "email", "-email", "created", "-created"}
if data.ValidateFilters(v, input.Filters); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
users, metadata, err := api.App.ListUsers(input.Filters)
if err != nil {
api.ServerErrorResponse(w, r, err)
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"users": users, "metadata": metadata}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) CreateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
ProviderId int64 `json:"provider_id"`
Username string `json:"username"`
PhoneNumber string `json:"phone_number"`
Country string `json:"country"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
AltName *string `json:"alt_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Address string `json:"address"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
user, authToken, err := api.App.RegisterUser(common.RegisterUserInput{
ProviderID: input.ProviderId,
Username: input.Username,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Email: input.Email,
Password: input.Password,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
})
if err != nil {
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
api.FailedValidationResponse(w, r, ve.Errors)
case errors.Is(err, data.ErrDuplicateEmail):
v := validator.New()
v.AddError("email", "a user with this email address already exists")
api.FailedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrDuplicateUser):
v := validator.New()
v.AddError("username", "a user with this username already exists")
api.FailedValidationResponse(w, r, v.Errors)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusCreated, envelope{"user": user, "authentication_token": authToken}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadUser(w http.ResponseWriter, r *http.Request) {
var id int64
if param := httprouter.ParamsFromContext(r.Context()).ByName("id"); param == "me" {
id = GetUser(r).ID
} else {
var err error
id, err = strconv.ParseInt(param, 10, 64)
if err != nil || id < 1 {
api.NotFoundResponse(w, r)
return
}
}
if GetUser(r).ID != id {
api.errorResponse(w, r, http.StatusForbidden, apiError{
Code: common.ErrCodeNotPermitted,
Message: "your user account doesn't have the necessary permissions to access this resource",
})
return
}
user, err := api.App.GetUser(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ReadAuthenticatedUser(w http.ResponseWriter, r *http.Request) {
user, err := api.App.GetUser(GetUser(r).ID)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) DeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := api.readIDParam(r)
if err != nil {
api.NotFoundResponse(w, r)
return
}
if err = api.App.DeleteUser(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
api.NotFoundResponse(w, r)
} else {
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"message": "user successfully deleted"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
func (api *Api) ActivateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
TokenPlaintext string `json:"token"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
v := validator.New()
if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
api.FailedValidationResponse(w, r, v.Errors)
return
}
user, err := api.App.ActivateUser(input.TokenPlaintext)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
v.AddError("token", "invalid or expired activation token")
api.FailedValidationResponse(w, r, v.Errors)
case errors.Is(err, data.ErrEditConflict):
api.EditConflictResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err = api.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"errors"
"net/http"
"party.at/party/internal/data"
)
func (api *Api) Vote(w http.ResponseWriter, r *http.Request) {
var input struct {
IssueID int64 `json:"issue_id"`
OptionID int64 `json:"option_id"`
Nonce []byte `json:"nonce"`
Signature []byte `json:"signature"`
}
if err := api.readJSON(w, r, &input); err != nil {
api.BadRequestResponse(w, r, err)
return
}
if err := api.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
api.NotFoundResponse(w, r)
case errors.Is(err, data.ErrInvalidSignature):
api.InvalidSignatureResponse(w, r)
case errors.Is(err, data.ErrDuplicateVote):
api.AlreadyVotedResponse(w, r)
default:
api.ServerErrorResponse(w, r, err)
}
return
}
if err := api.writeJSON(w, http.StatusCreated, envelope{"message": "vote successfully cast"}, nil); err != nil {
api.ServerErrorResponse(w, r, err)
}
}
+73
View File
@@ -0,0 +1,73 @@
package common
import (
"fmt"
"party.at/party/cmd/party/parlament"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
"party.at/party/internal/mailer"
)
type Config struct {
Port int
Env string
DB struct {
DSN string
MaxOpenConns int
MaxIdleConns int
MaxIdleTime string
}
Limiter struct {
RPS float64
Burst int
Enabled bool
}
SMTP struct {
Host string
Port int
Username string
Password string
Sender string
}
CORS struct {
TrustedOrigins []string
}
}
type Application struct {
Config Config
Logger *jsonlog.Logger
Models data.Models
Mailer mailer.Mailer
Parlament *parlament.Client
Env string
LimiterEnabled bool
LimiterRPS float64
LimiterBurst int
CORSTrustedOrigins []string
}
type ValidationError struct {
Errors map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
func (app *Application) background(fn func()) {
go func() {
defer func() {
if err := recover(); err != nil {
app.Logger.PrintError(fmt.Errorf("%s", err), nil)
}
}()
fn()
}()
}
+25
View File
@@ -0,0 +1,25 @@
package common
type ErrorCode int
const (
// 401 variants
ErrCodeInvalidCredentials ErrorCode = 4011
ErrCodeInvalidAuthToken ErrorCode = 4012
ErrCodeAuthRequired ErrorCode = 4013
// 403 variants
ErrCodeInactiveAccount ErrorCode = 4031
ErrCodeNotPermitted ErrorCode = 4032
// 409 variants
ErrCodeEditConflict ErrorCode = 4091
ErrCodeAlreadyVoted ErrorCode = 4092
ErrCodeAlreadyBlindSigned ErrorCode = 4093
ErrCodeVoteAlreadyCast ErrorCode = 4094
// 422 variants
ErrCodeValidationFailed ErrorCode = 4221
ErrCodeBlindedVoteRange ErrorCode = 4222
ErrCodeInvalidSignature ErrorCode = 4223
)
+105
View File
@@ -0,0 +1,105 @@
package common
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"party.at/party/internal/validator"
)
type envelope map[string]interface{}
type apiError struct {
Code ErrorCode `json:"code,omitempty"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// ── JSON helpers ─────────────────────────────────────────────────────────────
func (app *Application) ReadJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
r.Body = http.MaxBytesReader(w, r.Body, 1048576)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body must not be empty")
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
case err.Error() == "http: request body too large":
return fmt.Errorf("body must not be larger than 1048576 bytes")
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func ReadIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
func ReadString(qs url.Values, key, defaultValue string) string {
if s := qs.Get(key); s != "" {
return s
}
return defaultValue
}
func ReadInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
s := qs.Get(key)
if s == "" {
return defaultValue
}
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
return i
}
// ── Error responses ──────────────────────────────────────────────────────────
func (app *Application) LogError(r *http.Request, err error) {
app.Logger.PrintError(err, map[string]string{
"request_method": r.Method,
"request_url": r.URL.String(),
})
}
+128
View File
@@ -0,0 +1,128 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/julienschmidt/httprouter"
"party.at/party/internal/validator"
)
type Envelope map[string]interface{}
func readString(qs url.Values, key string, defaultValue string) string {
s := qs.Get(key)
if s == "" {
return defaultValue
}
return s
}
func readCSV(qs url.Values, key string, defaultValue []string) []string {
csv := qs.Get(key)
if csv == "" {
return defaultValue
}
return strings.Split(csv, ",")
}
func readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int {
s := qs.Get(key)
if s == "" {
return defaultValue
}
i, err := strconv.Atoi(s)
if err != nil {
v.AddError(key, "must be an integer value")
return defaultValue
}
return i
}
func readIDParam(r *http.Request) (int64, error) {
params := httprouter.ParamsFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid id parameter")
}
return id, nil
}
func WriteJSON(w http.ResponseWriter, status int, data Envelope, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
func readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
maxBytes := 1048576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body must not be empty")
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
case err.Error() == "http: request body too large":
return fmt.Errorf("body must not be larger than %d bytes", maxBytes)
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func GenerateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privDER,
})
return key.N.Bytes(), key.E, privPEM, err
}
+192
View File
@@ -0,0 +1,192 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type IssueDetail struct {
data.Issue
CanVote bool `json:"can_vote"`
}
type OptionResult struct {
data.Option
VoteCount int64 `json:"vote_count"`
}
type IssueWithOptions struct {
IssueDetail
Options []OptionResult
}
type PublicKey struct {
N string `json:"n"`
E int `json:"e"`
}
func (app *Application) FetchIssues(title string, filters data.Filters, user *data.User) ([]IssueDetail, data.Metadata, error) {
issues, metadata, err := app.Models.Issues.GetAll(title, filters)
if err != nil {
return nil, data.Metadata{}, err
}
details := make([]IssueDetail, len(issues))
for i, issue := range issues {
canVote := false
if !user.IsAnonymous() {
_, err = app.Models.BlindSigns.Get(user.ID, issue.ID)
switch {
case errors.Is(err, data.ErrRecordNotFound):
canVote = true
case err != nil:
return nil, data.Metadata{}, err
}
}
details[i] = IssueDetail{Issue: *issue, CanVote: canVote}
}
return details, metadata, nil
}
func (app *Application) CreateIssue(title, description string, startTime, endTime time.Time, optionLabels []string) (*data.Issue, []*data.Option, error) {
issue := &data.Issue{
Title: title,
Description: description,
StartTime: startTime,
EndTime: endTime,
}
options := make([]*data.Option, len(optionLabels))
for i, label := range optionLabels {
options[i] = &data.Option{Label: label}
}
v := validator.New()
v.Check(len(optionLabels) >= 2, "options", "must provide at least two options")
v.Check(len(optionLabels) <= 10, "options", "must not provide more than ten options")
for _, option := range options {
data.ValidateOption(v, option)
}
if data.ValidateIssue(v, issue); !v.Valid() {
return nil, nil, &ValidationError{Errors: v.Errors}
}
n, e, privatePEM, err := generateIssueKey()
if err != nil {
return nil, nil, err
}
issue.N = n
issue.E = e
issue.PrivatePem = privatePEM
if err = app.Models.Issues.InsertWithOptions(issue, options); err != nil {
return nil, nil, err
}
return issue, options, nil
}
func (app *Application) GetIssue(id int64, user *data.User) (*IssueWithOptions, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
canVote := false
if !user.IsAnonymous() {
_, err = app.Models.BlindSigns.Get(user.ID, issue.ID)
switch {
case errors.Is(err, data.ErrRecordNotFound):
canVote = true
case err != nil:
return nil, err
}
}
options, err := app.Models.Options.GetAllForIssue(id)
if err != nil {
return nil, err
}
results := make([]OptionResult, len(options))
for i, o := range options {
voteCount, err := app.Models.Votes.CountForOption(o.ID)
if err != nil {
return nil, err
}
results[i] = OptionResult{Option: *o, VoteCount: voteCount}
}
return &IssueWithOptions{
IssueDetail: IssueDetail{Issue: *issue, CanVote: canVote},
Options: results,
}, nil
}
func (app *Application) UpdateIssue(id int64, title, description *string, startTime, endTime *time.Time) (*data.Issue, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
if title != nil { issue.Title = *title }
if description != nil { issue.Description = *description }
if startTime != nil { issue.StartTime = *startTime }
if endTime != nil { issue.EndTime = *endTime }
v := validator.New()
if data.ValidateIssue(v, issue); !v.Valid() {
return nil, &ValidationError{Errors: v.Errors}
}
if err = app.Models.Issues.Update(issue); err != nil {
return nil, err
}
return issue, nil
}
func (app *Application) DeleteIssue(id int64) error {
return app.Models.Issues.Delete(id)
}
func (app *Application) GetIssuePublicKey(id int64) (*PublicKey, error) {
issue, err := app.Models.Issues.Get(id)
if err != nil {
return nil, err
}
return &PublicKey{N: hex.EncodeToString(issue.N), E: issue.E}, nil
}
func (app *Application) BlindSign(issueID int64, blindedVote []byte, user *data.User) ([]byte, error) {
issue, err := app.Models.Issues.Get(issueID)
if err != nil {
return nil, err
}
blindSign := &data.BlindSign{UserID: user.ID, IssueID: issue.ID}
if err = app.Models.BlindSigns.Insert(blindSign); err != nil {
return nil, err
}
return app.Models.BlindSigns.BlindSign(issue.ID, blindedVote)
}
func generateIssueKey() ([]byte, int, []byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, 0, nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
return key.N.Bytes(), key.E, privPEM, nil
}
+58
View File
@@ -0,0 +1,58 @@
package common
import (
"expvar"
"net/http"
"strconv"
"sync"
"github.com/felixge/httpsnoop"
)
var (
metricsOnce sync.Once
metricTotalRequests *expvar.Int
metricTotalResponses *expvar.Int
metricTotalProcessingTime *expvar.Int
metricResponsesByStatus *expvar.Map
)
func (app *Application) Metrics(next http.Handler) http.Handler {
metricsOnce.Do(func() {
metricTotalRequests = expvar.NewInt("total_requests_received")
metricTotalResponses = expvar.NewInt("total_responses_sent")
metricTotalProcessingTime = expvar.NewInt("total_processing_time_μs")
metricResponsesByStatus = expvar.NewMap("total_responses_sent_by_status")
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metricTotalRequests.Add(1)
m := httpsnoop.CaptureMetrics(next, w, r)
metricTotalResponses.Add(1)
metricTotalProcessingTime.Add(m.Duration.Microseconds())
metricResponsesByStatus.Add(strconv.Itoa(m.Code), 1)
})
}
func (app *Application) EnableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin")
w.Header().Add("Vary", "Access-Control-Request-Method")
origin := r.Header.Get("Origin")
if origin != "" && len(app.CORSTrustedOrigins) != 0 {
for i := range app.CORSTrustedOrigins {
if origin == app.CORSTrustedOrigins[i] {
w.Header().Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.WriteHeader(http.StatusOK)
return
}
}
}
}
next.ServeHTTP(w, r)
})
}
+55
View File
@@ -0,0 +1,55 @@
package common
import (
"crypto/sha256"
"errors"
"time"
"party.at/party/internal/data"
)
func (app *Application) LoginUser(email, password string) (*data.Token, error) {
user, err := app.Models.Users.GetByEmail(email)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
return nil, data.ErrInvalidCredentials
}
return nil, err
}
identities, err := app.Models.UserIdentities.GetByUser(user.ID)
if err != nil {
return nil, err
}
var authenticatedIdentity *data.UserIdentity
for _, identity := range identities {
match, err := identity.Password.Matches(password)
if err != nil {
return nil, err
}
if match {
authenticatedIdentity = identity
break
}
}
if authenticatedIdentity == nil {
return nil, data.ErrInvalidCredentials
}
return app.Models.Tokens.New(user.ID, authenticatedIdentity.ID, 24*time.Hour, data.ScopeAuthentication)
}
func (app *Application) GetUserFromToken(plaintext string) (*data.User, error) {
userIdentity, err := app.Models.UserIdentities.GetForToken(data.ScopeAuthentication, plaintext)
if err != nil {
return nil, err
}
return app.Models.Users.Get(userIdentity.UserID)
}
func (app *Application) DeleteToken(plaintext string) error {
hash := sha256.Sum256([]byte(plaintext))
return app.Models.Tokens.Delete(hash[:])
}
+115
View File
@@ -0,0 +1,115 @@
package common
import (
"time"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
type RegisterUserInput struct {
ProviderID int64
Username string
PhoneNumber string
Country string
Email string
Password string
Name string
AltName *string
DateOfBirth time.Time
Address string
}
func (app *Application) RegisterUser(input RegisterUserInput) (*data.User, *data.Token, error) {
user := &data.User{
Email: input.Email,
PhoneNumber: input.PhoneNumber,
Country: input.Country,
Name: input.Name,
AltName: input.AltName,
DateOfBirth: input.DateOfBirth,
Address: input.Address,
Activated: app.Config.Env == "development",
}
userIdentity := &data.UserIdentity{
ProviderID: input.ProviderID,
ProviderUserID: input.Username,
}
if err := userIdentity.Password.Set(input.Password); err != nil {
return nil, nil, err
}
v := validator.New()
data.ValidateUser(v, user)
data.ValidateUserIdentity(v, userIdentity)
if !v.Valid() {
return nil, nil, &ValidationError{Errors: v.Errors}
}
if err := app.Models.Users.ExecuteRegistrationTx(user, userIdentity); err != nil {
return nil, nil, err
}
role := "viewer"
if app.Config.Env == "development" {
role = "admin"
}
if err := app.Models.Roles.AssignToUser(user.ID, role); err != nil {
return nil, nil, err
}
if app.Config.Env == "production" {
token, err := app.Models.Tokens.New(user.ID, userIdentity.ID, 3*24*time.Hour, data.ScopeActivation)
if err != nil {
return nil, nil, err
}
app.background(func() {
templateData := map[string]interface{}{
"token": token.Plaintext,
"userID": user.ID,
}
if err := app.Mailer.Send(user.Email, "user_welcome.tmpl", templateData); err != nil {
app.Logger.PrintError(err, nil)
}
})
}
authToken, err := app.Models.Tokens.New(user.ID, userIdentity.ID, 24*time.Hour, data.ScopeAuthentication)
if err != nil {
return nil, nil, err
}
return user, authToken, nil
}
func (app *Application) GetUser(id int64) (*data.User, error) {
return app.Models.Users.Get(id)
}
func (app *Application) ListUsers(filters data.Filters) ([]*data.User, data.Metadata, error) {
return app.Models.Users.GetAll(filters)
}
func (app *Application) DeleteUser(id int64) error {
return app.Models.Users.Delete(id)
}
func (app *Application) ActivateUser(tokenPlaintext string) (*data.User, error) {
user, err := app.Models.Users.GetForToken(data.ScopeActivation, tokenPlaintext)
if err != nil {
return nil, err
}
user.Activated = true
if err = app.Models.Users.Update(user); err != nil {
return nil, err
}
if err = app.Models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID); err != nil {
return nil, err
}
return user, nil
}
+18
View File
@@ -0,0 +1,18 @@
package common
import "party.at/party/internal/data"
func (app *Application) CastVote(issueID, optionID int64, nonce, signature []byte) error {
issue, err := app.Models.Issues.Get(issueID)
if err != nil {
return err
}
vote := &data.Vote{
OptionID: optionID,
Nonce: nonce,
Signature: signature,
}
return app.Models.Votes.Insert(vote, issue)
}
@@ -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
View File
@@ -0,0 +1,100 @@
package main
import (
"bytes"
"net/http"
"strconv"
"testing"
"time"
)
func TestReadIssueHandler(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
token := ts.registerAndLogin(t, uniqueEmail(), "pa$$word123")
req := map[string]any{
"title": "An old silent pond...",
"description": "A frog jumps into the pond",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Option A", "Option B", "Option C"},
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", token, req)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
var createResp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
}
mustUnmarshal(t, res, &createResp)
issueID := createResp.Issue.ID
tests := []struct {
name string
urlPath string
wantCode int
wantBody []byte
}{
{"Valid ID", "/v1/issues/" + strconv.Itoa(int(issueID)), http.StatusOK, []byte("An old silent pond...")},
{"Non-existent ID", "/v1/issues/" + strconv.Itoa(int(issueID+1)), http.StatusNotFound, nil},
{"Negative ID", "/v1/issues/-1", http.StatusNotFound, nil},
{"Decimal ID", "/v1/issues/1.23", http.StatusNotFound, nil},
{"String ID", "/v1/issues/foo", http.StatusNotFound, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.getWithToken(t, tt.urlPath, token)
if code != tt.wantCode {
t.Errorf("want %d; got %d", tt.wantCode, code)
}
if !bytes.Contains(body, tt.wantBody) {
t.Errorf("want body to contain %q; got %q", tt.wantBody, string(body))
}
})
}
// verify options are present on a successful read
code, _, body := ts.getWithToken(t, "/v1/issues/" + i64str(issueID), token)
if code != http.StatusOK {
t.Fatalf("read issue: want 200 got %d: %s", code, body)
}
var readResp struct {
Options []struct {
ID int64 `json:"id"`
Label string `json:"label"`
VoteCount int64 `json:"vote_count"`
} `json:"options"`
}
mustUnmarshal(t, body, &readResp)
if len(readResp.Options) != 3 {
t.Fatalf("want 3 options, got %d: %s", len(readResp.Options), body)
}
for _, o := range readResp.Options {
if o.VoteCount != 0 {
t.Errorf("option %q: want vote_count 0, got %d", o.Label, o.VoteCount)
}
}
labels := map[string]bool{}
for _, o := range readResp.Options {
labels[o.Label] = true
}
for _, want := range []string{"Option A", "Option B", "Option C"} {
if !labels[want] {
t.Errorf("missing option %q in response", want)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"expvar"
"flag"
"fmt"
"log"
"os"
"runtime"
"strings"
"time"
"database/sql"
"net/http"
_ "github.com/lib/pq"
"github.com/gorilla/websocket"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
"party.at/party/internal/mailer"
"party.at/party/cmd/party/parlament"
)
var version string
var buildTime string
type Message struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Random float64 `json:"random"`
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func main() {
test()
var cfg common.Config
flag.IntVar(&cfg.Port, "port", 4000, "API server port")
flag.StringVar(&cfg.Env, "env", "production", "Environment (development|staging|production)")
flag.StringVar(&cfg.DB.DSN, "db-dsn", os.Getenv("PARTY_DB_DSN"), "PostgreSQL DSN")
flag.IntVar(&cfg.DB.MaxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
flag.IntVar(&cfg.DB.MaxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
flag.StringVar(&cfg.DB.MaxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time")
flag.StringVar(&cfg.SMTP.Host, "smtp-host", "smtp.mailtrap.io", "SMTP host")
flag.IntVar(&cfg.SMTP.Port, "smtp-port", 25, "SMTP port")
flag.StringVar(&cfg.SMTP.Username, "smtp-username", "98cf60028d7fcb", "SMTP username")
flag.StringVar(&cfg.SMTP.Password, "smtp-password", "b9d4a35372e971", "SMTP password")
flag.StringVar(&cfg.SMTP.Sender, "smtp-sender", "DPÖ <no-reply@party.at>", "SMTP sender")
flag.Float64Var(&cfg.Limiter.RPS, "limiter-rps", 2, "Rate limiter maximum requests per second")
flag.IntVar(&cfg.Limiter.Burst, "limiter-burst", 4, "Rate limiter maximum burst")
flag.BoolVar(&cfg.Limiter.Enabled, "limiter-enabled", true, "Enable rate limiter")
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
cfg.CORS.TrustedOrigins = strings.Fields(val)
return nil
})
displayVersion := flag.Bool("version", false, "Display version and exit")
flag.Parse()
if *displayVersion {
fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Build time:\t%s\n", buildTime)
os.Exit(0)
}
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
log.Printf("%s\n", cfg.DB.DSN)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
expvar.NewString("version").Set(version)
expvar.Publish("goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))
expvar.Publish("database", expvar.Func(func() any { return db.Stats() }))
expvar.Publish("timestamp", expvar.Func(func() any { return time.Now().Unix() }))
app := &common.Application{
Config: cfg,
Logger: logger,
Models: data.NewModels(db),
Mailer: mailer.New(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Sender),
Parlament: parlament.NewClient(),
}
log.Println("Hello, Sailor!")
if err = serve(app); err != nil {
logger.PrintFatal(err, nil)
}
}
func openDB(cfg common.Config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.DB.DSN)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.DB.MaxOpenConns)
db.SetMaxIdleConns(cfg.DB.MaxIdleConns)
duration, err := time.ParseDuration(cfg.DB.MaxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
func test() {
client := parlament.NewClient()
sessions, total, err := client.ListSessions(parlament.SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
panic(err)
}
fmt.Printf("Found %d sessions\n\n", total)
for i, s := range sessions {
if i >= 10 {
break
}
fmt.Printf("%s | %s\n", s.Date, s.Title)
}
}
+138
View File
@@ -0,0 +1,138 @@
package parlament
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const baseURL = "https://www.parlament.gv.at"
// Client is a thin HTTP client for the Austrian parliament open-data API.
// No authentication is required; all endpoints are public.
type Client struct {
http *http.Client
}
func NewClient() *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
}
}
// filterResponse is the raw shape returned by every list/filter endpoint.
type filterResponse struct {
Pages int `json:"pages"`
Count int `json:"count"`
Header []colMeta `json:"header"`
Rows [][]any `json:"rows"`
}
// colMeta only captures what we actually use; other fields vary by endpoint
// and have inconsistent types (e.g. sortable is "1"/"0" on newer endpoints).
type colMeta struct {
Label string `json:"label"`
Hidden bool `json:"hidden"`
}
// colIndex builds a label→position map so rows can be accessed by column name.
func colIndex(headers []colMeta) map[string]int {
m := make(map[string]int, len(headers))
for i, h := range headers {
m[h.Label] = i
}
return m
}
func strCol(row []any, idx map[string]int, label string) string {
i, ok := idx[label]
if !ok || i >= len(row) {
return ""
}
if s, ok := row[i].(string); ok {
return s
}
return fmt.Sprintf("%v", row[i])
}
// postFilter calls the legacy FBEZ-style filter endpoint.
func (c *Client) postFilter(fbez string, payload any) (*filterResponse, error) {
url := fmt.Sprintf("%s/Filter/api/json/post?jsMode=EVAL&FBEZ=%s&listeId=undefined&showAll=true", baseURL, fbez)
return c.post(url, payload)
}
// postDataset calls the newer numeric-dataset-ID filter endpoint.
func (c *Client) postDataset(datasetID int, payload any) (*filterResponse, error) {
url := fmt.Sprintf("%s/Filter/api/filter/data/%d?js=eval&showAll=true", baseURL, datasetID)
return c.post(url, payload)
}
func (c *Client) post(url string, payload any) (*filterResponse, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", baseURL+"/")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; research-bot)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result filterResponse
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("unexpected response (not JSON): %s", truncate(string(raw), 200))
}
return &result, nil
}
// GetDetail fetches a single record by appending ?json=TRUE to any detail page path.
// path example: "/gegenstand/NR/I/I_00458"
func (c *Client) GetDetail(path string) (map[string]any, error) {
url := baseURL + path + "?json=TRUE"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", baseURL+"/")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; research-bot)")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+65
View File
@@ -0,0 +1,65 @@
package parlament
// CommitteeMembership records a person's role on a parliamentary committee.
type CommitteeMembership struct {
Period string // legislative period, e.g. "XXVIII"
Role string // "Funktion" — e.g. "Mitglied", "Obmann"
Committee string // short committee name, e.g. "Hauptausschuss"
Chamber string // "nrbr" — "NR" or "BR"
Body string // "Gremium" — full body name, e.g. "Nationalrat"
PersonID string // "PAD_INTERN" — internal numeric person identifier
FromDate string // "fromDate" — ISO-style timestamp of membership start
// Path is the portal-relative URL for the committee, usable with Client.GetDetail.
Path string // "URL" column
}
type CommitteeMembershipFilter struct {
// Period filters by legislative period, e.g. "XXVIII".
Period []string
// PersonID filters by the internal person identifier ("PAD_INTERN").
PersonID []string
// Committee filters by committee name.
Committee []string
// Chamber filters by chamber: "NR" or "BR".
Chamber []string
}
// ListCommitteeMemberships returns committee membership records (dataset 250).
func (c *Client) ListCommitteeMemberships(f CommitteeMembershipFilter) ([]CommitteeMembership, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["GP"] = f.Period
}
if len(f.PersonID) > 0 {
payload["PAD_INTERN"] = f.PersonID
}
if len(f.Committee) > 0 {
payload["AUSSCHUSS"] = f.Committee
}
if len(f.Chamber) > 0 {
payload["NRBR"] = f.Chamber
}
resp, err := c.postDataset(250, payload)
if err != nil {
return nil, 0, err
}
// "Ausschuss" appears twice: index 3 has the name with date "(24.10.2024)",
// index 8 has the short name without date. colIndex picks the last (index 8).
idx := colIndex(resp.Header)
memberships := make([]CommitteeMembership, 0, len(resp.Rows))
for _, row := range resp.Rows {
memberships = append(memberships, CommitteeMembership{
Period: strCol(row, idx, "GP"),
Role: strCol(row, idx, "Funktion"),
Committee: strCol(row, idx, "Ausschuss"), // last occurrence = short name
Chamber: strCol(row, idx, "nrbr"),
Body: strCol(row, idx, "Gremium"),
PersonID: strCol(row, idx, "PAD_INTERN"),
FromDate: strCol(row, idx, "fromDate"),
Path: strCol(row, idx, "URL"),
})
}
return memberships, resp.Count, nil
}
+129
View File
@@ -0,0 +1,129 @@
package parlament
// DocumentType selects which legislative document category to query.
type DocumentType string
const (
DocResolution DocumentType = "BNR" // Beschlüsse (resolutions)
DocMotion DocumentType = "ANTR" // Anträge (motions)
DocGovBill DocumentType = "RV" // Regierungsvorlagen (government bills)
DocCitizenInitiative DocumentType = "BI" // Bürgerinitiativen (citizen initiatives)
DocPetition DocumentType = "PET" // Petitionen (petitions)
DocWrittenQuestion DocumentType = "ANFR" // Schriftliche Anfragen (written questions)
DocEUStatement DocumentType = "EU" // EU-Stellungnahmen (EU committee statements)
)
// Document is a legislative item (resolution, motion, or government bill).
type Document struct {
Date string
Title string // "Betreff" column
Number string // "Nummer" column, e.g. "69/BNR"
GPCode string
Type string // "Art" column
Topics string
VoteResult string // "Abstimmung 3. Lesung"
VotesFor string // "Dafür" — party names
VotesAgainst string // "Dagegen" — party names
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "HIS_URL" column
}
type DocumentFilter struct {
// Chamber is "NR" (Nationalrat) or "BR" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
// Type narrows to a specific document category (DocResolution, DocMotion, DocGovBill).
Type DocumentType
// Topics is a free-text topic filter.
Topics []string
// DateFrom in YYYY-MM-DD format.
DateFrom string
}
// ListDocuments returns legislative documents matching the given filter.
func (c *Client) ListDocuments(f DocumentFilter) ([]Document, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NRBR"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if f.Type != "" {
payload["VHG"] = []string{string(f.Type)}
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
if f.DateFrom != "" {
payload["DATUM_VON"] = []string{f.DateFrom}
}
resp, err := c.postDataset(101, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
docs := make([]Document, 0, len(resp.Rows))
for _, row := range resp.Rows {
docs = append(docs, Document{
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Betreff"),
Number: strCol(row, idx, "Nummer"),
GPCode: strCol(row, idx, "GP_CODE"),
Type: strCol(row, idx, "Art"),
Topics: strCol(row, idx, "THEMEN"),
VoteResult: strCol(row, idx, "Abstimmung 3. Lesung"),
VotesFor: strCol(row, idx, "Dafür"),
VotesAgainst: strCol(row, idx, "Dagegen"),
Path: strCol(row, idx, "HIS_URL"),
})
}
return docs, resp.Count, nil
}
// Protocol is a stenographic protocol of a plenary session.
type Protocol struct {
Date string
Session string
Chamber string // "NBVS" column, e.g. "NRSITZ"
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "uri" column
}
type ProtocolFilter struct {
// Chamber is "NRSITZ" (Nationalrat) or "BRSITZ" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
}
// ListProtocols returns stenographic protocols matching the given filter.
func (c *Client) ListProtocols(f ProtocolFilter) ([]Protocol, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NBVS"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
resp, err := c.postDataset(211, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
protocols := make([]Protocol, 0, len(resp.Rows))
for _, row := range resp.Rows {
protocols = append(protocols, Protocol{
Date: strCol(row, idx, "Datum"),
Session: strCol(row, idx, "Sitzung"),
Chamber: strCol(row, idx, "NBVS"),
Path: strCol(row, idx, "uri"),
})
}
return protocols, resp.Count, nil
}
+65
View File
@@ -0,0 +1,65 @@
package parlament
import "fmt"
// Event is a parliamentary event from the public schedule.
type Event struct {
// Date is the ISO-formatted start datetime (e.g. "2025-12-17T13:00:00").
Date string
DateTo string // end datetime; empty if single-day event
Title string
Type string // "Terminart" — e.g. "Plenarsitzung", "Ausschusssitzung"
Venue string // "Ort" — e.g. "Bundesratssaal"
Topics string // "Themen" — JSON-encoded array
Body string // "Gremium" — parliamentary body (e.g. "Bundesrat")
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string
}
type EventFilter struct {
// Body filters by parliamentary body name ("Gremium"), e.g. "Nationalrat".
Body []string
// EventType filters by event category, e.g. "Plenarsitzung".
EventType []string
// DateFrom and DateTo define an inclusive date range (YYYY-MM-DD).
// Both must be set together; setting only one is a no-op.
DateFrom string
DateTo string
}
// ListEvents returns parliamentary events matching the given filter (dataset 600).
func (c *Client) ListEvents(f EventFilter) ([]Event, int, error) {
payload := map[string]any{}
if len(f.Body) > 0 {
payload["GREMIUM"] = f.Body
}
if len(f.EventType) > 0 {
payload["TERMINART"] = f.EventType
}
if f.DateFrom != "" && f.DateTo != "" {
payload["DATERANGE"] = []string{fmt.Sprintf("%s,%s", f.DateFrom, f.DateTo)}
}
resp, err := c.postDataset(600, payload)
if err != nil {
return nil, 0, err
}
// Dataset 600 has duplicate column names. colIndex resolves each to its last
// occurrence: "Datum" → index 15 (ISO datetime), "Terminart" → index 6.
idx := colIndex(resp.Header)
events := make([]Event, 0, len(resp.Rows))
for _, row := range resp.Rows {
events = append(events, Event{
Date: strCol(row, idx, "Datum"), // last "Datum" = ISO datetime
DateTo: strCol(row, idx, "Datum Bis"),
Title: strCol(row, idx, "Bezeichnung"),
Type: strCol(row, idx, "Terminart"),
Venue: strCol(row, idx, "Ort"),
Topics: strCol(row, idx, "Themen"),
Body: strCol(row, idx, "Gremium"),
Path: strCol(row, idx, "Link"),
})
}
return events, resp.Count, nil
}
+65
View File
@@ -0,0 +1,65 @@
package parlament
// GovernmentMember is a federal minister or state secretary.
type GovernmentMember struct {
Name string
Role string // current role with date range, e.g. "Bundesminister für Inneres\n03.03.2025 -"
RoleShort string // clean role title without the date suffix ("funk_text")
Party string // "partei_code", e.g. "ÖVP"
PartyFull string // "partei", e.g. "Österreichische Volkspartei"
Gender string // "geschlecht" — "männlich" / "weiblich"
State string // "bundesland" — federal state if a state-level official
Active bool
ActiveFrom string // "funk_ab_date" ISO datetime
ActiveTo string // "funk_bis_date" ISO datetime; empty if still active
PhotoURL string // "portrait" — partial URL like "/dokument/bild/..."
BiographyPath string // "biografielink" — portal-relative path usable with GetDetail
}
type GovernmentMemberFilter struct {
// Active, when true, restricts results to currently serving members.
Active bool
// Party filters by party code, e.g. "ÖVP", "SPÖ".
Party []string
// State filters by Austrian federal state name.
State []string
}
// ListGovernmentMembers returns federal ministers and state secretaries (dataset 408).
func (c *Client) ListGovernmentMembers(f GovernmentMemberFilter) ([]GovernmentMember, int, error) {
payload := map[string]any{}
if f.Active {
payload["aktiv"] = []string{"aktiv"}
}
if len(f.Party) > 0 {
payload["partei_code"] = f.Party
}
if len(f.State) > 0 {
payload["bundesland"] = f.State
}
resp, err := c.postDataset(408, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
members := make([]GovernmentMember, 0, len(resp.Rows))
for _, row := range resp.Rows {
members = append(members, GovernmentMember{
Name: strCol(row, idx, "name"),
Role: strCol(row, idx, "funktion"),
RoleShort: strCol(row, idx, "funk_text"),
Party: strCol(row, idx, "partei_code"),
PartyFull: strCol(row, idx, "partei"),
Gender: strCol(row, idx, "geschlecht"),
State: strCol(row, idx, "bundesland"),
Active: strCol(row, idx, "aktiv") == "aktiv",
ActiveFrom: strCol(row, idx, "funk_ab_date"),
ActiveTo: strCol(row, idx, "funk_bis_date"),
PhotoURL: strCol(row, idx, "portrait"),
BiographyPath: strCol(row, idx, "biografielink"),
})
}
return members, resp.Count, nil
}
+654
View File
@@ -0,0 +1,654 @@
package parlament
import (
"testing"
)
// These tests hit the live parlament.gv.at API.
// Run with: go test ./cmd/party/parlament/ -v
// Skip with: go test -short
func newTestClient(t *testing.T) *Client {
t.Helper()
if testing.Short() {
t.Skip("skipping live API test in short mode")
}
return NewClient()
}
// assertNonEmpty fails if any session has a blank field.
func assertSessionsPopulated(t *testing.T, sessions []Session) {
t.Helper()
for i, s := range sessions {
if s.Date == "" {
t.Errorf("session[%d] missing Date", i)
}
if s.Title == "" {
t.Errorf("session[%d] missing Title", i)
}
if s.Path == "" {
t.Errorf("session[%d] missing Path", i)
}
}
}
func TestListSessions(t *testing.T) {
c := newTestClient(t)
sessions, total, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
if total == 0 {
t.Fatal("expected at least one session, got 0")
}
if len(sessions) == 0 {
t.Fatal("expected rows, got none")
}
assertSessionsPopulated(t, sessions)
t.Logf("total=%d rows=%d", total, len(sessions))
for i, s := range sessions {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", s.Date, s.Title, s.Path)
}
}
func TestListSessions_TotalMatchesRows(t *testing.T) {
c := newTestClient(t)
sessions, total, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
if total != len(sessions) {
t.Errorf("total=%d but got %d rows (showAll=true should return everything)", total, len(sessions))
}
}
func TestListSessions_BothChambers(t *testing.T) {
c := newTestClient(t)
nr, nrTotal, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("NR: %v", err)
}
br, brTotal, err := c.ListSessions(SessionFilter{Chamber: []string{"BR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("BR: %v", err)
}
assertSessionsPopulated(t, nr)
assertSessionsPopulated(t, br)
t.Logf("NR sessions: %d, BR sessions: %d", nrTotal, brTotal)
if nrTotal == 0 || brTotal == 0 {
t.Error("expected results for both chambers")
}
}
func TestListSessions_PeriodFilter(t *testing.T) {
c := newTestClient(t)
_, xxvii, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVII"}})
if err != nil {
t.Fatalf("XXVII: %v", err)
}
_, xxviii, err := c.ListSessions(SessionFilter{Chamber: []string{"NR"}, Period: []string{"XXVIII"}})
if err != nil {
t.Fatalf("XXVIII: %v", err)
}
// XXVII was a full 5-year term; XXVIII is still in progress — XXVII should have more
t.Logf("XXVII=%d XXVIII=%d", xxvii, xxviii)
if xxvii <= xxviii {
t.Errorf("expected XXVII (%d) to have more sessions than ongoing XXVIII (%d)", xxvii, xxviii)
}
}
func TestListParliamentarians(t *testing.T) {
c := newTestClient(t)
mps, total, err := c.ListParliamentarians(ParliamentarianFilter{
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListParliamentarians: %v", err)
}
if total == 0 {
t.Fatal("expected at least one MP, got 0")
}
t.Logf("total=%d rows=%d", total, len(mps))
for i, mp := range mps {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", mp.Name, mp.Faction, mp.Constituency)
}
for i, mp := range mps {
if mp.Name == "" {
t.Errorf("mp[%d] missing Name", i)
}
if mp.Path == "" {
t.Errorf("mp[%d] missing Path", i)
}
}
}
func TestGetDetail_Parliamentarian(t *testing.T) {
c := newTestClient(t)
mps, _, err := c.ListParliamentarians(ParliamentarianFilter{})
if err != nil || len(mps) == 0 {
t.Fatalf("ListParliamentarians: %v", err)
}
// Find the first MP with a valid path.
var path string
for _, mp := range mps {
if mp.Path != "" {
path = mp.Path
break
}
}
if path == "" {
t.Fatal("no MP has a Path")
}
detail, err := c.GetDetail(path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", path, err)
}
for _, key := range []string{"meta", "pagetype", "content"} {
if _, ok := detail[key]; !ok {
t.Errorf("detail missing key %q", key)
}
}
t.Logf("path: %s", path)
}
func TestListDocuments_Resolutions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocResolution,
})
if err != nil {
t.Fatalf("ListDocuments(resolutions): %v", err)
}
if total == 0 {
t.Fatal("expected at least one resolution")
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
for i, d := range docs {
if d.Date == "" {
t.Errorf("doc[%d] missing Date", i)
}
if d.Number == "" {
t.Errorf("doc[%d] missing Number", i)
}
if d.Path == "" {
t.Errorf("doc[%d] missing Path", i)
}
}
}
func TestListDocuments_Motions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocMotion,
})
if err != nil {
t.Fatalf("ListDocuments(motions): %v", err)
}
if total == 0 {
t.Fatal("expected at least one motion")
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListDocuments_GovBills(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocGovBill,
})
if err != nil {
t.Fatalf("ListDocuments(gov bills): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
for i, d := range docs {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListProtocols(t *testing.T) {
c := newTestClient(t)
protocols, total, err := c.ListProtocols(ProtocolFilter{
Chamber: []string{"NRSITZ"},
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListProtocols: %v", err)
}
if total == 0 {
t.Fatal("expected at least one protocol")
}
t.Logf("total=%d rows=%d", total, len(protocols))
for i, p := range protocols {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", p.Date, p.Session, p.Path)
}
for i, p := range protocols {
if p.Date == "" {
t.Errorf("protocol[%d] missing Date", i)
}
if p.Session == "" {
t.Errorf("protocol[%d] missing Session", i)
}
if p.Path == "" {
t.Errorf("protocol[%d] missing Path", i)
}
}
}
func TestGetDetail_Document(t *testing.T) {
c := newTestClient(t)
docs, _, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocResolution,
})
if err != nil || len(docs) == 0 {
t.Fatalf("ListDocuments: %v", err)
}
first := docs[0]
if first.Path == "" {
t.Skip("document has no path, skipping detail fetch")
}
detail, err := c.GetDetail(first.Path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", first.Path, err)
}
for _, key := range []string{"meta", "pagetype", "content"} {
if _, ok := detail[key]; !ok {
t.Errorf("detail missing key %q", key)
}
}
t.Logf("path: %s", first.Path)
}
func TestListDocuments_CitizenInitiatives(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Type: DocCitizenInitiative,
})
if err != nil {
t.Fatalf("ListDocuments(citizen initiatives): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
if total == 0 {
t.Fatal("expected at least one citizen initiative")
}
for i, d := range docs {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", d.Date, d.Number, d.Title)
}
}
func TestListDocuments_WrittenQuestions(t *testing.T) {
c := newTestClient(t)
docs, total, err := c.ListDocuments(DocumentFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
Type: DocWrittenQuestion,
})
if err != nil {
t.Fatalf("ListDocuments(written questions): %v", err)
}
t.Logf("total=%d rows=%d", total, len(docs))
}
func TestListEvents(t *testing.T) {
c := newTestClient(t)
events, total, err := c.ListEvents(EventFilter{
DateFrom: "2025-01-01",
DateTo: "2025-03-31",
})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if total == 0 {
t.Fatal("expected at least one event")
}
if len(events) == 0 {
t.Fatal("expected rows")
}
t.Logf("total=%d rows=%d", total, len(events))
for i, e := range events {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | %s", e.Date, e.Type, e.Body, e.Title)
}
for i, e := range events {
if e.Date == "" {
t.Errorf("event[%d] missing Date", i)
}
if e.Title == "" {
t.Errorf("event[%d] missing Title", i)
}
}
}
func TestListEvents_ByBody(t *testing.T) {
c := newTestClient(t)
events, total, err := c.ListEvents(EventFilter{
Body: []string{"Nationalrat"},
DateFrom: "2025-01-01",
DateTo: "2025-12-31",
})
if err != nil {
t.Fatalf("ListEvents(Nationalrat): %v", err)
}
t.Logf("Nationalrat events in 2025: %d", total)
if total == 0 {
t.Fatal("expected Nationalrat events")
}
for _, e := range events {
if e.Body != "Nationalrat" {
t.Errorf("expected Body=Nationalrat, got %q", e.Body)
break
}
}
}
func TestListGovernmentMembers(t *testing.T) {
c := newTestClient(t)
members, total, err := c.ListGovernmentMembers(GovernmentMemberFilter{})
if err != nil {
t.Fatalf("ListGovernmentMembers: %v", err)
}
if total == 0 {
t.Fatal("expected at least one government member")
}
t.Logf("total=%d rows=%d", total, len(members))
for i, m := range members {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", m.Name, m.RoleShort, m.Party)
}
for i, m := range members {
if m.Name == "" {
t.Errorf("member[%d] missing Name", i)
}
}
}
func TestListGovernmentMembers_Active(t *testing.T) {
c := newTestClient(t)
_, allTotal, err := c.ListGovernmentMembers(GovernmentMemberFilter{})
if err != nil {
t.Fatalf("all: %v", err)
}
active, activeTotal, err := c.ListGovernmentMembers(GovernmentMemberFilter{Active: true})
if err != nil {
t.Fatalf("active: %v", err)
}
t.Logf("all=%d active=%d", allTotal, activeTotal)
if activeTotal == 0 {
t.Fatal("expected active government members")
}
if activeTotal >= allTotal {
t.Errorf("active (%d) should be fewer than all (%d)", activeTotal, allTotal)
}
for i, m := range active {
if !m.Active {
t.Errorf("active[%d] %q has Active=false", i, m.Name)
}
}
}
func TestListPressReleases(t *testing.T) {
c := newTestClient(t)
releases, total, err := c.ListPressReleases(PressReleaseFilter{
Year: []string{"2025"},
})
if err != nil {
t.Fatalf("ListPressReleases: %v", err)
}
if total == 0 {
t.Fatal("expected at least one press release")
}
t.Logf("total=%d rows=%d", total, len(releases))
for i, r := range releases {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s", r.Date, r.Number, r.Title)
}
for i, r := range releases {
if r.Date == "" {
t.Errorf("release[%d] missing Date", i)
}
if r.Title == "" {
t.Errorf("release[%d] missing Title", i)
}
if r.Path == "" {
t.Errorf("release[%d] missing Path", i)
}
}
}
func TestListCommitteeMemberships(t *testing.T) {
c := newTestClient(t)
memberships, total, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
Chamber: []string{"NR"},
})
if err != nil {
t.Fatalf("ListCommitteeMemberships: %v", err)
}
if total == 0 {
t.Fatal("expected at least one membership")
}
t.Logf("total=%d rows=%d", total, len(memberships))
for i, m := range memberships {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | %s", m.Period, m.Role, m.Committee, m.PersonID)
}
for i, m := range memberships {
if m.Committee == "" {
t.Errorf("membership[%d] missing Committee", i)
}
if m.PersonID == "" {
t.Errorf("membership[%d] missing PersonID", i)
}
}
}
func TestListCommitteeMemberships_ByPerson(t *testing.T) {
c := newTestClient(t)
// Get one MP's PAD_INTERN and check their committee memberships.
mps, _, err := c.ListParliamentarians(ParliamentarianFilter{Period: []string{"XXVIII"}})
if err != nil || len(mps) == 0 {
t.Fatalf("ListParliamentarians: %v", err)
}
memberships, _, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
})
if err != nil || len(memberships) == 0 {
t.Fatalf("ListCommitteeMemberships: %v", err)
}
// Look up the first known PersonID and filter to just that person.
personID := memberships[0].PersonID
byPerson, total, err := c.ListCommitteeMemberships(CommitteeMembershipFilter{
Period: []string{"XXVIII"},
PersonID: []string{personID},
})
if err != nil {
t.Fatalf("ListCommitteeMemberships(person %s): %v", personID, err)
}
t.Logf("personID=%s committees=%d", personID, total)
for _, m := range byPerson {
if m.PersonID != personID {
t.Errorf("unexpected PersonID %q (want %q)", m.PersonID, personID)
}
}
}
func TestListParticipatoryItems(t *testing.T) {
c := newTestClient(t)
items, total, err := c.ListParticipatoryItems(ParticipatoryFilter{
Active: true,
})
if err != nil {
t.Fatalf("ListParticipatoryItems: %v", err)
}
if total == 0 {
t.Fatal("expected at least one active participatory item")
}
t.Logf("total=%d rows=%d", total, len(items))
for i, it := range items {
if i >= 5 {
break
}
t.Logf(" %s | %s | %s | supporters=%s", it.Date, it.Type, it.Number, it.Supporters)
}
for i, it := range items {
if !it.Active {
t.Errorf("item[%d] %q has Active=false despite AKTIV=J filter", i, it.Number)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title", i)
}
}
}
func TestListParticipatoryItems_ByPeriod(t *testing.T) {
c := newTestClient(t)
items, total, err := c.ListParticipatoryItems(ParticipatoryFilter{
Period: []string{"XXVIII"},
})
if err != nil {
t.Fatalf("ListParticipatoryItems(XXVIII): %v", err)
}
t.Logf("XXVIII participatory items: %d", total)
if total == 0 {
t.Fatal("expected participatory items for XXVIII")
}
for i, it := range items {
if i >= 3 {
break
}
t.Logf(" %s | %s | %s", it.Date, it.Category, it.Title)
}
}
func TestGetDetail(t *testing.T) {
c := newTestClient(t)
// Fetch the latest session from ListSessions and get its detail page.
sessions, _, err := c.ListSessions(SessionFilter{
Chamber: []string{"NR"},
Period: []string{"XXVIII"},
})
if err != nil || len(sessions) == 0 {
t.Fatalf("ListSessions: %v", err)
}
latest := sessions[0]
if latest.Path == "" {
t.Fatalf("session has no path: %+v", latest)
}
detail, err := c.GetDetail(latest.Path)
if err != nil {
t.Fatalf("GetDetail(%q): %v", latest.Path, err)
}
if len(detail) == 0 {
t.Fatal("expected non-empty detail map")
}
t.Logf("path: %s", latest.Path)
ks := make([]string, 0, len(detail))
for k := range detail {
ks = append(ks, k)
}
t.Logf("top-level keys: %v", ks)
}
+118
View File
@@ -0,0 +1,118 @@
package parlament
// Parliamentarian is a member of the Austrian parliament (dataset 409, historical).
type Parliamentarian struct {
Name string
Faction string // "Klub/Fraktion" column
Period string // "Gesetzgebungsperiode" column
Constituency string // "Wahlkreis" column
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "Bio" column
}
type ParliamentarianFilter struct {
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
// Maps to the gp_text_full_short filter dimension.
Period []string
}
// ListParliamentarians returns MPs matching the given filter (dataset 409, all periods).
func (c *Client) ListParliamentarians(f ParliamentarianFilter) ([]Parliamentarian, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["gp_text_full_short"] = f.Period
}
resp, err := c.postDataset(409, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
mps := make([]Parliamentarian, 0, len(resp.Rows))
for _, row := range resp.Rows {
mps = append(mps, Parliamentarian{
Name: strCol(row, idx, "Name"),
Faction: strCol(row, idx, "Klub/Fraktion"),
Period: strCol(row, idx, "Gesetzgebungsperiode"),
Constituency: strCol(row, idx, "Wahlkreis"),
Path: strCol(row, idx, "Bio"),
})
}
return mps, resp.Count, nil
}
// Member is a richer parliamentarian record from dataset 401, which supports
// filtering by active status, legislative period, and parliamentary body.
type Member struct {
Name string // "Nachname Vorname" format
FirstName string
LastName string
Faction string // party abbreviation, e.g. "FPÖ"
Role string // current role, e.g. "Abgeordnete zum Nationalrat"
Constituency string // "Wahlkreis"
// PhotoURL is the partial image path (prepend baseURL to resolve).
PhotoURL string // e.g. "/dokument/bild/201448/20144872_180.jpg"
// Path is the portal-relative biography URL, usable with Client.GetDetail.
Path string
Active bool
}
// MemberType distinguishes the kind of parliamentary mandate.
const (
MemberTypeNR = "NR" // Nationalrat deputy
MemberTypeBR = "BR" // Bundesrat member
MemberTypePres = "MPO" // President / Vice-President
MemberTypeGovt = "BuREG" // Federal government member
)
type MemberFilter struct {
// Period is the legislative period, e.g. "XXVIII".
Period []string
// Active, when true, restricts to currently serving members.
Active bool
// Type filters by mandate kind (MemberTypeNR, MemberTypeBR, etc.).
Type []string
// Faction filters by party abbreviation, e.g. "FPÖ".
Faction []string
}
// ListMembers returns parliamentary members using dataset 401, which supports
// richer filtering than ListParliamentarians (active status, mandate type).
func (c *Client) ListMembers(f MemberFilter) ([]Member, int, error) {
payload := map[string]any{}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if f.Active {
payload["AKTIV"] = []string{"J"}
}
if len(f.Type) > 0 {
payload["persart"] = f.Type
}
if len(f.Faction) > 0 {
payload["frak"] = f.Faction
}
resp, err := c.postDataset(401, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
members := make([]Member, 0, len(resp.Rows))
for _, row := range resp.Rows {
members = append(members, Member{
Name: strCol(row, idx, "Name"),
FirstName: strCol(row, idx, "vorname"),
LastName: strCol(row, idx, "nachname"),
Faction: strCol(row, idx, "frak"),
Role: strCol(row, idx, "Funktion"),
Constituency: strCol(row, idx, "Wahlkreis"),
PhotoURL: strCol(row, idx, "Image"),
Path: strCol(row, idx, "link"),
Active: strCol(row, idx, "aktiv") == "J",
})
}
return members, resp.Count, nil
}
+83
View File
@@ -0,0 +1,83 @@
package parlament
// ParticipatoryType distinguishes citizen-participation item categories.
type ParticipatoryType string
const (
ParticipatoryMinisterialDraft ParticipatoryType = "MEG" // Ministerialentwurf (public consultation)
ParticipatoryPetition ParticipatoryType = "PET" // Petition
ParticipatoryInitiative ParticipatoryType = "BIG" // Bürgerinitiative (citizen initiative)
)
// ParticipatoryItem is a participatory democracy item: a public consultation,
// petition, or citizen initiative open for comment or signature collection.
type ParticipatoryItem struct {
Period string
Date string // "Datum" — display date, e.g. "04.05.2026"
Title string // "Betreff"
Number string // "Nr." — e.g. "101/ME"
Type string // "DOKTYP" — e.g. "MEG", "PET", "BIG"
Category string // "Gegenstand" — human-readable type, e.g. "Ministerialentwurf"
Supporters string // "Unterstützungen" — signature count (as string)
Statements string // "Stellungnahmen" — submitted opinions count
Topics string // "Themen" — JSON-encoded array
Active bool // "Aktiv?" == "J"
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string // "b" column
}
type ParticipatoryFilter struct {
// Active, when true, restricts to items currently open for participation.
Active bool
// Period filters by legislative period, e.g. "XXVIII".
Period []string
// Type filters by document category (e.g. ParticipatoryPetition).
Type []ParticipatoryType
// Topics filters by subject area.
Topics []string
}
// ListParticipatoryItems returns participatory democracy items (dataset 143).
func (c *Client) ListParticipatoryItems(f ParticipatoryFilter) ([]ParticipatoryItem, int, error) {
payload := map[string]any{}
if f.Active {
payload["AKTIV"] = []string{"J"}
}
if len(f.Period) > 0 {
payload["GP_CODE"] = f.Period
}
if len(f.Type) > 0 {
types := make([]string, len(f.Type))
for i, t := range f.Type {
types[i] = string(t)
}
payload["DOKTYP"] = types
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
resp, err := c.postDataset(143, payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
items := make([]ParticipatoryItem, 0, len(resp.Rows))
for _, row := range resp.Rows {
items = append(items, ParticipatoryItem{
Period: strCol(row, idx, "GP_CODE"),
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Betreff"),
Number: strCol(row, idx, "Nr."),
Type: strCol(row, idx, "DOKTYP"),
Category: strCol(row, idx, "Gegenstand"),
Supporters: strCol(row, idx, "Unterstützungen"),
Statements: strCol(row, idx, "Stellungnahmen"),
Topics: strCol(row, idx, "Themen"),
Active: strCol(row, idx, "Aktiv?") == "J",
Path: strCol(row, idx, "b"),
})
}
return items, resp.Count, nil
}
+59
View File
@@ -0,0 +1,59 @@
package parlament
// PressRelease is a parliamentary correspondence item (Parlamentskorrespondenz).
type PressRelease struct {
// Date is the ISO-formatted publication date (e.g. "2025-12-30T00:00:00").
Date string
Number string // e.g. "PK1235"
Title string
Subtitle string
Topics string // "Abo-Themen" — JSON-encoded array, e.g. ["Bildung"]
Keywords string // "Stichworte" — JSON-encoded array of finer-grained tags
// Path is the portal-relative URL, usable with Client.GetDetail.
Path string
}
type PressReleaseFilter struct {
// Year filters by publication year, e.g. "2025".
Year []string
// Topics filters by broad subject area.
Topics []string
// Keywords filters by finer-grained subject tags ("STW").
Keywords []string
}
// ListPressReleases returns parliamentary press releases matching the filter (dataset 110).
func (c *Client) ListPressReleases(f PressReleaseFilter) ([]PressRelease, int, error) {
payload := map[string]any{}
if len(f.Year) > 0 {
payload["JAHR"] = f.Year
}
if len(f.Topics) > 0 {
payload["THEMEN"] = f.Topics
}
if len(f.Keywords) > 0 {
payload["STW"] = f.Keywords
}
resp, err := c.postDataset(110, payload)
if err != nil {
return nil, 0, err
}
// Dataset 110 has duplicate column names. colIndex resolves each to its last
// occurrence: "Datum" → index 11 (ISO date), "Titel" → index 5 (clean plain text).
idx := colIndex(resp.Header)
releases := make([]PressRelease, 0, len(resp.Rows))
for _, row := range resp.Rows {
releases = append(releases, PressRelease{
Date: strCol(row, idx, "Datum"), // last "Datum" = ISO date
Number: strCol(row, idx, "Nr."),
Title: strCol(row, idx, "Titel"), // last "Titel" = plain text
Subtitle: strCol(row, idx, "Untertitel"),
Topics: strCol(row, idx, "Abo-Themen"),
Keywords: strCol(row, idx, "Stichworte"),
Path: strCol(row, idx, "Link"),
})
}
return releases, resp.Count, nil
}
+48
View File
@@ -0,0 +1,48 @@
package parlament
// Session is a plenary session (Nationalrat or Bundesrat).
type Session struct {
Date string
Title string
Agenda string
GPCode string
// Path is the portal-relative URL for this session, usable with Client.GetDetail.
Path string
}
type SessionFilter struct {
// Chamber is "NR" (Nationalrat) or "BR" (Bundesrat).
Chamber []string
// Period is the legislative period in Roman numerals, e.g. "XXVIII".
Period []string
}
// ListSessions returns plenary sessions matching the given filter.
// Leave a field nil/empty to omit that dimension.
func (c *Client) ListSessions(f SessionFilter) ([]Session, int, error) {
payload := map[string]any{}
if len(f.Chamber) > 0 {
payload["NRBRBV"] = f.Chamber
}
if len(f.Period) > 0 {
payload["GP"] = f.Period
}
resp, err := c.postFilter("WFP_007", payload)
if err != nil {
return nil, 0, err
}
idx := colIndex(resp.Header)
sessions := make([]Session, 0, len(resp.Rows))
for _, row := range resp.Rows {
sessions = append(sessions, Session{
Date: strCol(row, idx, "Datum"),
Title: strCol(row, idx, "Sitzung"),
Agenda: strCol(row, idx, "Tagesordnung"),
GPCode: strCol(row, idx, "gp_code"),
Path: strCol(row, idx, "pfad"),
})
}
return sessions, resp.Count, nil
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"net/http"
"testing"
"time"
)
func TestRoles(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
issueBody := map[string]any{
"title": "Role test issue",
"description": "Testing role-based access.",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Option A", "Option B"},
}
// Seed a contributor so we have an issue to read against.
adminEmail := uniqueEmail()
adminToken := ts.registerAndLogin(t, adminEmail, "pa$$word123")
code, _, res := ts.postJSONWithToken(t, "/v1/issues", adminToken, issueBody)
if code != http.StatusCreated {
t.Fatalf("seed issue: want 201 got %d: %s", code, res)
}
// -------------------------------------------------------------------------
// No role — all protected routes must return 403.
// -------------------------------------------------------------------------
t.Run("no role is forbidden", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("look up user: %v", err)
}
if _, err = ts.app.Models.Roles.DB.Exec(`DELETE FROM users_roles WHERE user_id = $1`, user.ID); err != nil {
t.Fatalf("strip roles: %v", err)
}
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusForbidden {
t.Errorf("GET /v1/issues: want 403, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusForbidden {
t.Errorf("POST /v1/issues: want 403, got %d", code)
}
})
// -------------------------------------------------------------------------
// Viewer — can read, cannot write.
// -------------------------------------------------------------------------
t.Run("viewer can read but not write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "viewer")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusForbidden {
t.Errorf("POST /v1/issues: want 403, got %d", code)
}
})
// -------------------------------------------------------------------------
// Contributor — full read/write access.
// -------------------------------------------------------------------------
t.Run("contributor can read and write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "contributor")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusCreated {
t.Errorf("POST /v1/issues: want 201, got %d", code)
}
})
// -------------------------------------------------------------------------
// Admin — same permissions as contributor for now.
// -------------------------------------------------------------------------
t.Run("admin can read and write", func(t *testing.T) {
email := uniqueEmail()
token := ts.registerAndLogin(t, email, "pa$$word123")
ts.setRole(t, email, "admin")
if code, _, _ := ts.getWithToken(t, "/v1/issues", token); code != http.StatusOK {
t.Errorf("GET /v1/issues: want 200, got %d", code)
}
if code, _, _ := ts.postJSONWithToken(t, "/v1/issues", token, issueBody); code != http.StatusCreated {
t.Errorf("POST /v1/issues: want 201, got %d", code)
}
})
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"expvar"
"net/http"
"github.com/julienschmidt/httprouter"
"party.at/party/cmd/party/common"
"party.at/party/cmd/party/api"
"party.at/party/cmd/party/web"
)
func routes(app *common.Application) http.Handler {
// ── API router (Bearer token auth) ──────────────────────────────────────
api := api.Api{
App: app,
}
apiRouter := httprouter.New()
apiRouter.NotFound = http.HandlerFunc(api.NotFoundResponse)
apiRouter.MethodNotAllowed = http.HandlerFunc(api.MethodNotAllowedResponse)
apiRouter.HandlerFunc(http.MethodGet, "/v1/healthcheck", api.Healthcheck)
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues", api.RequirePermission("issues:read", api.ListIssues))
apiRouter.HandlerFunc(http.MethodPost, "/v1/issues", api.RequirePermission("issues:write", api.CreateIssue))
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues/:id", api.RequirePermission("issues:read", api.ReadIssue))
apiRouter.HandlerFunc(http.MethodPatch, "/v1/issues/:id", api.RequirePermission("issues:write", api.UpdateIssue))
apiRouter.HandlerFunc(http.MethodDelete, "/v1/issues/:id", api.RequirePermission("issues:write", api.DeleteIssue))
apiRouter.HandlerFunc(http.MethodGet, "/v1/issues/:id/pubkey", api.RequirePermission("issues:read", api.ReadIssuePubKey))
apiRouter.HandlerFunc(http.MethodPost, "/v1/issues/:id/blind-sign", api.RequirePermission("issues:read", api.BlindSignIssueVote))
apiRouter.HandlerFunc(http.MethodPost, "/v1/votes", api.Vote)
apiRouter.HandlerFunc(http.MethodPost, "/v1/users", api.CreateUser)
apiRouter.HandlerFunc(http.MethodGet, "/v1/users", api.RequirePermission("users:read", api.ListUsers))
apiRouter.HandlerFunc(http.MethodGet, "/v1/users/:id", api.RequireAuthenticatedUser(api.ReadUser))
apiRouter.HandlerFunc(http.MethodDelete, "/v1/users/:id", api.DeleteUser)
apiRouter.HandlerFunc(http.MethodPut, "/v1/users/activated", api.ActivateUser)
apiRouter.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", api.CreateAuthenticationToken)
apiRouter.HandlerFunc(http.MethodDelete, "/v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken))
apiRouter.Handler(http.MethodGet, "/debug/vars", expvar.Handler())
apiChain := app.Metrics(api.RecoverPanic(app.EnableCORS(api.RateLimit(api.Authenticate(apiRouter)))))
// ── Web router (cookie auth) ─────────────────────────────────────────────
web := web.Web{
App: app,
}
webRouter := httprouter.New()
webRouter.HandlerFunc(http.MethodGet, "/", web.Home)
webRouter.HandlerFunc(http.MethodGet, "/register", web.Register)
webRouter.HandlerFunc(http.MethodPost, "/register", web.RegisterUserPage)
webRouter.HandlerFunc(http.MethodGet, "/issues", web.IssuesPage)
webRouter.HandlerFunc(http.MethodPost, "/issues", web.CreateIssueAction)
webRouter.HandlerFunc(http.MethodGet, "/issues/:id", web.IssuePage)
webRouter.HandlerFunc(http.MethodPatch, "/issues/:id", web.UpdateIssueAction)
webRouter.HandlerFunc(http.MethodDelete, "/issues/:id", web.DeleteIssueAction)
webRouter.HandlerFunc(http.MethodGet, "/issues/:id/pubkey", web.GetIssuePubKey)
webRouter.HandlerFunc(http.MethodPost, "/issues/:id/blind-sign", web.BlindSignIssue)
webRouter.HandlerFunc(http.MethodPost, "/votes", web.VoteAction)
webRouter.HandlerFunc(http.MethodGet, "/users", web.UsersPage)
webRouter.HandlerFunc(http.MethodGet, "/users/me", web.ProfilePage)
webRouter.HandlerFunc(http.MethodGet, "/users/activated", web.ActivatePage)
webRouter.HandlerFunc(http.MethodPost, "/users/activated", web.ActivateUserAction)
webRouter.HandlerFunc(http.MethodDelete, "/users/:id", web.DeleteUserAction)
webRouter.HandlerFunc(http.MethodGet, "/mps", web.MembersOfParliamentPage)
webRouter.HandlerFunc(http.MethodPost, "/login", web.Login)
webRouter.HandlerFunc(http.MethodPost, "/dev-login", web.DevLogin)
webRouter.HandlerFunc(http.MethodPost, "/logout", web.Logout)
webRouter.HandlerFunc(http.MethodGet, "/ws", ws)
webRouter.ServeFiles("/static/*filepath", http.Dir("web/static"))
webChain := app.Metrics(web.WebRecoverPanic(web.WebRateLimit(web.AuthenticateCookie(webRouter))))
// ── Top-level mux ────────────────────────────────────────────────────────
mux := http.NewServeMux()
mux.Handle("/v1/", apiChain)
mux.Handle("/debug/", apiChain)
mux.Handle("/", webChain)
return mux
}
+13 -16
View File
@@ -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,27 +27,23 @@ 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(),
})
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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
View File
@@ -0,0 +1,233 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
"party.at/party/internal/jsonlog"
)
func newTestApplication(t *testing.T) *common.Application {
var cfg common.Config
cfg.DB.DSN = "postgres://party:password@localhost:5432/party?sslmode=disable"
cfg.DB.MaxOpenConns = 25
cfg.DB.MaxIdleConns = 25
cfg.DB.MaxIdleTime = "15m"
cfg.Limiter.Enabled = false
cfg.Env = "development"
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
db, err := openDB(cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
t.Cleanup(func() { db.Close() })
app := &common.Application{
Logger: logger,
Models: data.NewModels(db),
Config: cfg,
}
return app
}
type testServer struct {
*httptest.Server
app *common.Application
}
func newTestServer(t *testing.T, app *common.Application, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts, app}
}
func (ts *testServer) postJSON(t *testing.T, path string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header, respBody
}
func (ts *testServer) get(t *testing.T, path string) (int, http.Header, []byte) {
t.Helper()
rs, err := ts.Client().Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) registerAndLogin(t *testing.T, email, password string) string {
t.Helper()
registerBody := map[string]any{
"email": email,
"password": password,
"username": email,
"name": "Test User",
"alt_name": "",
"provider_id": 1,
}
code, _, body := ts.postJSON(t, "/v1/users", registerBody)
if code != http.StatusCreated {
t.Fatalf("register: want 201 got %d: %s", code, body)
}
loginBody := map[string]string{"email": email, "password": password}
code, _, body = ts.postJSON(t, "/v1/tokens/authentication", loginBody)
if code != http.StatusCreated {
t.Fatalf("login: want 201 got %d: %s", code, body)
}
var resp struct {
AuthenticationToken struct {
Token string `json:"token"`
} `json:"authentication_token"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("parse token: %v", err)
}
return resp.AuthenticationToken.Token
}
func (ts *testServer) getWithToken(t *testing.T, path, token string) (int, http.Header, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, body
}
func (ts *testServer) postJSONWithToken(t *testing.T, path, token string, body any) (int, http.Header, []byte) {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rs, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
respBody, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
return rs.StatusCode, rs.Header, respBody
}
func uniqueEmail() string {
return fmt.Sprintf("test_%d@example.com", time.Now().UnixNano())
}
func (ts *testServer) assignRole(t *testing.T, email string, roleCode string) {
t.Helper()
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("assignRole: look up user %q: %v", email, err)
}
if err = ts.app.Models.Roles.AssignToUser(user.ID, roleCode); err != nil {
t.Fatalf("assignRole: assign %q to user %d: %v", roleCode, user.ID, err)
}
}
func (ts *testServer) setRole(t *testing.T, email, roleCode string) {
t.Helper()
user, err := ts.app.Models.Users.GetByEmail(email)
if err != nil {
t.Fatalf("setRole: look up user %q: %v", email, err)
}
if _, err = ts.app.Models.Roles.DB.Exec(`DELETE FROM users_roles WHERE user_id = $1`, user.ID); err != nil {
t.Fatalf("setRole: clear roles for user %d: %v", user.ID, err)
}
if err = ts.app.Models.Roles.AssignToUser(user.ID, roleCode); err != nil {
t.Fatalf("setRole: assign %q to user %d: %v", roleCode, user.ID, err)
}
}
func generateBlindingFactor(n *big.Int) (*big.Int, error) {
one := big.NewInt(1)
for {
r, err := randBigInt(n)
if err != nil {
return nil, err
}
if r.Cmp(one) <= 0 {
continue
}
gcd := new(big.Int).GCD(nil, nil, r, n)
if gcd.Cmp(one) == 0 {
return r, nil
}
}
}
func randBigInt(max *big.Int) (*big.Int, error) {
b := make([]byte, (max.BitLen()+7)/8)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
r := new(big.Int).SetBytes(b)
return r.Mod(r, max), nil
}
func mustUnmarshal(t *testing.T, body []byte, v any) {
t.Helper()
if err := json.Unmarshal(body, v); err != nil {
t.Fatalf("unmarshal: %v\nbody: %s", err, body)
}
}
func i64str(n int64) string {
return strconv.FormatInt(n, 10)
}
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"crypto/rand"
"math/big"
"net/http"
"testing"
"time"
"party.at/party/internal/crypto"
)
func getVotes(t *testing.T, ts *testServer, issueID int64, optionID int64, adminToken string) int64 {
// -----------------------------------------------------------------------
// 8. Tally
// -----------------------------------------------------------------------
code, _, res := ts.getWithToken(t, "/v1/issues/" + i64str(issueID), adminToken)
if code != http.StatusOK {
t.Fatalf("read issue: want 200 got %d: %s", code, res)
}
var resp struct {
Options []struct {
ID int64 `json:"id"`
VoteCount int64 `json:"vote_count"`
} `json:"options"`
}
mustUnmarshal(t, res, &resp)
for _, o := range resp.Options {
if o.ID == optionID {
return o.VoteCount
}
}
return 0
}
func vote(t *testing.T, ts *testServer, issueID int64, optionID int64) {
voterEmail := uniqueEmail()
voterToken := ts.registerAndLogin(t, voterEmail, "pa$$word123")
ts.assignRole(t, voterEmail, "contributor")
code, _, res := ts.getWithToken(t, "/v1/issues/" + i64str(issueID)+"/pubkey", voterToken)
if code != http.StatusOK {
t.Fatalf("get pubkey: want 200 got %d: %s", code, res)
}
var pubKeyResp struct {
PublicKey struct {
N string `json:"n"`
E int `json:"e"`
} `json:"public_key"`
}
mustUnmarshal(t, res, &pubKeyResp)
nInt, ok := new(big.Int).SetString(pubKeyResp.PublicKey.N, 16)
if !ok {
t.Fatal("failed to parse public key modulus")
}
eInt := big.NewInt(int64(pubKeyResp.PublicKey.E))
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
t.Fatalf("generate nonce: %v", err)
}
m := crypto.VoteMessage(issueID, optionID, nonce)
mInt := new(big.Int).SetBytes(m[:])
r, err := generateBlindingFactor(nInt)
if err != nil {
t.Fatalf("generate blinding factor: %v", err)
}
// After fetching pubkey:
t.Logf("n from pubkey (first 8 bytes): %x", nInt.Bytes()[:8])
// m' = m * r^e mod n
re := new(big.Int).Exp(r, eInt, nInt)
mBlind := new(big.Int).Mod(new(big.Int).Mul(mInt, re), nInt)
// -----------------------------------------------------------------------
// 4. Client sends blinded vote to server (authenticated)
// -----------------------------------------------------------------------
code, _, res = ts.postJSONWithToken(t, "/v1/issues/" + i64str(issueID)+"/blind-sign", voterToken, map[string]any{
"blinded_vote": mBlind.Bytes(),
})
if code != http.StatusOK {
t.Fatalf("blind-sign: want 200 got %d: %s", code, res)
}
var blindSigResp struct {
Signed []byte `json:"signed"`
}
mustUnmarshal(t, res, &blindSigResp)
// -----------------------------------------------------------------------
// 5. Client unblinds: s = s' * r^-1 mod n
// -----------------------------------------------------------------------
t.Logf("raw blind-sign response: %s", res)
sPrime := new(big.Int).SetBytes(blindSigResp.Signed)
// After getting blind sig back, before unblinding:
t.Logf("s' (first 8 bytes): %x", sPrime.Bytes()[:8])
rInv := new(big.Int).ModInverse(r, nInt)
if rInv == nil {
t.Fatal("could not compute modular inverse of r")
}
s := new(big.Int).Mod(new(big.Int).Mul(sPrime, rInv), nInt)
// Sanity check: verify s^e mod n == m before submitting
recovered := new(big.Int).Exp(s, eInt, nInt)
if recovered.Cmp(mInt) != 0 {
t.Fatal("unblinded signature verification failed: s^e mod n != m")
}
// After unblinding:
t.Logf("s (first 8 bytes): %x", s.Bytes()[:8])
t.Logf("m (expected): %x", mInt.Bytes())
t.Logf("s^e mod n: %x", recovered.Bytes())
// -----------------------------------------------------------------------
// 6. Client logs out -- anonymous submission has no auth header
// -----------------------------------------------------------------------
code, _, res = ts.postJSON(t, "/v1/votes", map[string]any{
"issue_id": issueID,
"option_id": optionID,
"nonce": nonce,
"signature": s.Bytes(),
})
if code != http.StatusCreated {
t.Fatalf("submit vote: want 201 got %d: %s", code, res)
}
// -----------------------------------------------------------------------
// 7. Replay attack -- same signature should be rejected
// -----------------------------------------------------------------------
code, _, _ = ts.postJSON(t, "/v1/votes", map[string]any{
"issue_id": issueID,
"option_id": optionID,
"nonce": nonce,
"signature": s.Bytes(),
})
if code != http.StatusConflict {
t.Errorf("replay attack: want 409 got %d", code)
}
}
func TestVote(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app, routes(app))
defer ts.Close()
// -----------------------------------------------------------------------
// 1. Admin registers, logs in, creates an issue with options
// -----------------------------------------------------------------------
adminEmail := uniqueEmail()
adminToken := ts.registerAndLogin(t, adminEmail, "pa$$word123")
issueBody := map[string]any{
"title": "Should we adopt a four-day work week?",
"description": "Vote yes or no.",
"start_time": time.Now().Format(time.RFC3339),
"end_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"options": []string{"Yes", "No"},
}
code, _, res := ts.postJSONWithToken(t, "/v1/issues", adminToken, issueBody)
if code != http.StatusCreated {
t.Fatalf("create issue: want 201 got %d: %s", code, res)
}
var issueResp struct {
Issue struct {
ID int64 `json:"id"`
} `json:"issue"`
Options []struct {
ID int64 `json:"id"`
Label string `json:"label"`
} `json:"options"`
}
mustUnmarshal(t, res, &issueResp)
issueID := issueResp.Issue.ID
var yesOptionID, noOptionID int64
for _, opt := range issueResp.Options {
switch opt.Label {
case "Yes":
yesOptionID = opt.ID
case "No":
noOptionID = opt.ID
}
}
if yesOptionID == 0 || noOptionID == 0 {
t.Fatalf("expected Yes and No options in response, got: %v", issueResp.Options)
}
// -----------------------------------------------------------------------
// 2. Normal user registers, logs in, fetches the public key
// -----------------------------------------------------------------------
count := getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 0 {
t.Fatalf("expected 0 yes votes")
}
count = getVotes(t, ts, issueID, noOptionID, adminToken)
if count != 0 {
t.Fatalf("expected 0 no votes")
}
vote(t, ts, issueID, yesOptionID)
count = getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 1 {
t.Fatalf("expected 1 yes votes")
}
vote(t, ts, issueID, yesOptionID)
count = getVotes(t, ts, issueID, yesOptionID, adminToken)
if count != 2 {
t.Fatalf("expected 2 yes votes")
}
vote(t, ts, issueID, noOptionID)
count = getVotes(t, ts, issueID, noOptionID, adminToken)
if count != 1 {
t.Fatalf("expected 1 no votes")
}
}
+100
View File
@@ -0,0 +1,100 @@
package web
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) Home(w http.ResponseWriter, r *http.Request) {
if !getUser(r).IsAnonymous() {
http.Redirect(w, r, "/issues", http.StatusSeeOther)
return
}
web.render(w, r, http.StatusOK, "home_anonymous", struct {
AuthenticatedUser *data.User
FormErrors []string
IsDevelopment bool
}{
IsDevelopment: web.App.Config.Env == "development",
})
}
func (web *Web) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
token, err := web.App.LoginUser(r.PostFormValue("email"), r.PostFormValue("password"))
if err != nil {
var msg string
if errors.Is(err, data.ErrInvalidCredentials) {
msg = "Ungültige E-Mail-Adresse oder Passwort."
} else {
web.App.LogError(r, err)
msg = "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
}
web.render(w, r, http.StatusUnprocessableEntity, "home_anonymous", struct {
AuthenticatedUser *data.User
FormErrors []string
IsDevelopment bool
}{
FormErrors: []string{msg},
IsDevelopment: web.App.Config.Env == "development",
})
return
}
setCookie(w, token.Plaintext, token.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) DevLogin(w http.ResponseWriter, r *http.Request) {
if web.App.Config.Env != "development" {
http.NotFound(w, r)
return
}
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
suffix := hex.EncodeToString(b)
_, authToken, err := web.App.RegisterUser(common.RegisterUserInput{
ProviderID: 1,
Username: fmt.Sprintf("dev-%s", suffix),
Email: fmt.Sprintf("dev-%s@test.local", suffix),
Password: "devpassword123",
Name: fmt.Sprintf("Dev User %s", suffix),
DateOfBirth: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
Country: "AT",
PhoneNumber: "+43000000000",
Address: "Teststraße 1, 1010 Wien",
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
setCookie(w, authToken.Plaintext, authToken.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(cookieName); err == nil {
_ = web.App.DeleteToken(cookie.Value)
}
clearCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+263
View File
@@ -0,0 +1,263 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) IssuesPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
issues, _, err := web.App.FetchIssues("", data.Filters{
Page: 1,
PageSize: 50,
Sort: "-id",
SortSafelist: []string{"id", "-id", "title", "-title"},
}, user)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issues", struct {
AuthenticatedUser *data.User
Issues []common.IssueDetail
CanWriteIssues bool
}{
AuthenticatedUser: user,
Issues: issues,
CanWriteIssues: permissions.Include("issues:write"),
})
}
func (web *Web) CreateIssueAction(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
startTime, err := time.Parse("2006-01-02 15:04", r.PostFormValue("start_date")+" "+r.PostFormValue("start_clock"))
if err != nil {
startTime = time.Time{}
}
endTime, err := time.Parse("2006-01-02 15:04", r.PostFormValue("end_date")+" "+r.PostFormValue("end_clock"))
if err != nil {
endTime = time.Time{}
}
var options []string
for _, v := range r.PostForm["options"] {
if trimmed := strings.TrimSpace(v); trimmed != "" {
options = append(options, trimmed)
}
}
issue, _, err := web.App.CreateIssue(
r.PostFormValue("title"),
r.PostFormValue("description"),
startTime,
endTime,
options,
)
if err != nil {
web.App.LogError(r, err)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%d", issue.ID), http.StatusSeeOther)
}
func (web *Web) IssuePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
result, err := web.App.GetIssue(id, user)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
web.render(w, r, http.StatusOK, "issue", struct {
AuthenticatedUser *data.User
Issue *common.IssueWithOptions
CanWriteIssues bool
}{
AuthenticatedUser: user,
Issue: result,
CanWriteIssues: permissions.Include("issues:write"),
})
}
func (web *Web) UpdateIssueAction(w http.ResponseWriter, r *http.Request) {
if getUser(r).IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
var title, description *string
var startTime, endTime *time.Time
if v := r.FormValue("title"); v != "" {
title = &v
}
if v := r.FormValue("description"); v != "" {
description = &v
}
if d, c := r.FormValue("start_date"), r.FormValue("start_clock"); d != "" && c != "" {
if t, parseErr := time.Parse("2006-01-02 15:04", d+" "+c); parseErr == nil {
startTime = &t
}
}
if d, c := r.FormValue("end_date"), r.FormValue("end_clock"); d != "" && c != "" {
if t, parseErr := time.Parse("2006-01-02 15:04", d+" "+c); parseErr == nil {
endTime = &t
}
}
if _, err = web.App.UpdateIssue(id, title, description, startTime, endTime); err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%d", id))
w.WriteHeader(http.StatusOK)
}
func (web *Web) DeleteIssueAction(w http.ResponseWriter, r *http.Request) {
if getUser(r).IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = web.App.DeleteIssue(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
w.Header().Set("HX-Redirect", "/issues")
w.WriteHeader(http.StatusOK)
}
func (web *Web) GetIssuePubKey(w http.ResponseWriter, r *http.Request) {
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
pubKey, err := web.App.GetIssuePublicKey(id)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"public_key": pubKey}, nil); err != nil {
web.App.LogError(r, err)
}
}
func (web *Web) BlindSignIssue(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
common.WriteJSON(w, http.StatusUnauthorized, common.Envelope{"error": map[string]string{"message": "authentication required"}}, nil)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
var input struct {
BlindedVote []byte `json:"blinded_vote"`
}
if err = web.App.ReadJSON(w, r, &input); err != nil {
common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return
}
signed, err := web.App.BlindSign(id, input.BlindedVote, user)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
http.NotFound(w, r)
case errors.Is(err, data.ErrDuplicateBlindSign):
common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": "bereits eine Blindsignatur für diese Abstimmung angefordert"}}, nil)
case errors.Is(err, data.ErrInvalidBlindedVote):
common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
default:
web.App.LogError(r, err)
common.WriteJSON(w, http.StatusInternalServerError, common.Envelope{"error": map[string]string{"message": "internal server error"}}, nil)
}
return
}
if err = common.WriteJSON(w, http.StatusOK, common.Envelope{"signed": signed}, nil); err != nil {
web.App.LogError(r, err)
}
}
+148
View File
@@ -0,0 +1,148 @@
package web
import (
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"party.at/party/internal/data"
"party.at/party/internal/validator"
)
const cookieName = "session"
func (web *Web) AuthenticateCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err != nil {
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
v := validator.New()
if data.ValidateTokenPlaintext(v, cookie.Value); !v.Valid() {
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
userIdentity, err := web.App.Models.UserIdentities.GetForToken(data.ScopeAuthentication, cookie.Value)
if err != nil {
if !errors.Is(err, data.ErrRecordNotFound) {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
user, err := web.App.Models.Users.Get(userIdentity.UserID)
if err != nil {
if !errors.Is(err, data.ErrRecordNotFound) {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
clearCookie(w)
r = setUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
r = setUser(r, user)
permissions, _ := web.App.Models.Permissions.GetAllForUser(user.ID)
r = setPermissions(r, permissions)
next.ServeHTTP(w, r)
})
}
func setCookie(w http.ResponseWriter, token string, expiry time.Time) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Expires: expiry,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Expires: time.Unix(0, 0),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (web *Web) WebRecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
web.App.LogError(r, fmt.Errorf("%s", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (web *Web) WebRateLimit(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var mu sync.Mutex
clients := make(map[string]*client)
go func() {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, c := range clients {
if time.Since(c.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if web.App.LimiterEnabled {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
mu.Lock()
if _, found := clients[ip]; !found {
clients[ip] = &client{limiter: rate.NewLimiter(rate.Limit(web.App.LimiterRPS), web.App.LimiterBurst)}
}
clients[ip].lastSeen = time.Now()
if !clients[ip].limiter.Allow() {
mu.Unlock()
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
mu.Unlock()
}
next.ServeHTTP(w, r)
})
}
+63
View File
@@ -0,0 +1,63 @@
package web
import (
"net/http"
"sort"
"party.at/party/cmd/party/parlament"
"party.at/party/internal/data"
)
type factionGroup struct {
Name string
Members []parlament.Member
}
func (web *Web) MembersOfParliamentPage(w http.ResponseWriter, r *http.Request) {
members, _, err := web.App.Parlament.ListMembers(parlament.MemberFilter{
Period: []string{"XXVIII"},
Active: true,
Type: []string{parlament.MemberTypeNR},
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Group by faction, preserving insertion order for stable sort later.
groupMap := make(map[string]*factionGroup)
var factionOrder []string
for _, m := range members {
if _, exists := groupMap[m.Faction]; !exists {
groupMap[m.Faction] = &factionGroup{Name: m.Faction}
factionOrder = append(factionOrder, m.Faction)
}
groupMap[m.Faction].Members = append(groupMap[m.Faction].Members, m)
}
// Sort factions by descending seat count.
sort.Slice(factionOrder, func(i, j int) bool {
return len(groupMap[factionOrder[i]].Members) > len(groupMap[factionOrder[j]].Members)
})
// Sort members within each faction by last name.
groups := make([]factionGroup, 0, len(factionOrder))
for _, name := range factionOrder {
g := groupMap[name]
sort.Slice(g.Members, func(i, j int) bool {
return g.Members[i].LastName < g.Members[j].LastName
})
groups = append(groups, *g)
}
web.render(w, r, http.StatusOK, "mps", struct {
AuthenticatedUser *data.User
Groups []factionGroup
Total int
}{
AuthenticatedUser: getUser(r),
Groups: groups,
Total: len(members),
})
}
+39
View File
@@ -0,0 +1,39 @@
package web
import (
"fmt"
"html/template"
"net/http"
)
func (web *Web) render(w http.ResponseWriter, r *http.Request, status int, page string, data interface{}) {
funcs := template.FuncMap{
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict requires an even number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
m[fmt.Sprint(pairs[i])] = pairs[i+1]
}
return m, nil
},
"hasPermission": func(code string) bool {
return getPermissions(r).Include(code)
},
}
ts, err := template.New("base").Funcs(funcs).ParseFiles(
"web/html/base.layout.tmpl",
"web/html/partials.tmpl",
"web/html/"+page+".page.tmpl",
)
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
if err = ts.ExecuteTemplate(w, "base", data); err != nil {
web.App.LogError(r, err)
}
}
+214
View File
@@ -0,0 +1,214 @@
package web
import (
"errors"
"net/http"
"time"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) Register(w http.ResponseWriter, r *http.Request) {
web.render(w, r, http.StatusOK, "register", struct {
AuthenticatedUser *data.User
FormErrors []string
}{
AuthenticatedUser: getUser(r),
})
}
func (web *Web) RegisterUserPage(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
var altName *string
if s := r.PostFormValue("alt_name"); s != "" {
altName = &s
}
dob, err := time.Parse("2006-01-02", r.PostFormValue("date_of_birth"))
if err != nil {
dob = time.Time{}
}
_, authToken, err := web.App.RegisterUser(common.RegisterUserInput{
ProviderID: 1,
Username: r.PostFormValue("username"),
Email: r.PostFormValue("email"),
Password: r.PostFormValue("password"),
Name: r.PostFormValue("name"),
AltName: altName,
DateOfBirth: dob,
Country: r.PostFormValue("country"),
PhoneNumber: r.PostFormValue("phone_number"),
Address: r.PostFormValue("address"),
})
if err != nil {
var formErrors []string
var ve *common.ValidationError
switch {
case errors.As(err, &ve):
for _, msg := range ve.Errors {
formErrors = append(formErrors, msg)
}
case errors.Is(err, data.ErrDuplicateEmail):
formErrors = []string{"Diese E-Mail-Adresse wird bereits verwendet."}
case errors.Is(err, data.ErrDuplicateUser):
formErrors = []string{"Dieser Benutzername wird bereits verwendet."}
default:
web.App.LogError(r, err)
formErrors = []string{"Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."}
}
web.render(w, r, http.StatusUnprocessableEntity, "register", struct {
AuthenticatedUser *data.User
FormErrors []string
}{
AuthenticatedUser: getUser(r),
FormErrors: formErrors,
})
return
}
setCookie(w, authToken.Plaintext, authToken.Expiry)
http.Redirect(w, r, "/issues", http.StatusSeeOther)
}
func (web *Web) ProfilePage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
fullUser, err := web.App.GetUser(user.ID)
if err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
web.render(w, r, http.StatusOK, "profile", struct {
AuthenticatedUser *data.User
User *data.User
}{
AuthenticatedUser: user,
User: fullUser,
})
}
func (web *Web) UsersPage(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if user.IsAnonymous() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
users, _, err := web.App.ListUsers(data.Filters{
Page: 1,
PageSize: 100,
Sort: "id",
SortSafelist: []string{"id", "-id", "name", "-name"},
})
if err != nil {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
permissions := getPermissions(r)
if !permissions.Include("users:read") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
web.render(w, r, http.StatusOK, "users", struct {
AuthenticatedUser *data.User
Users []*data.User
CanManageUsers bool
}{
AuthenticatedUser: user,
Users: users,
CanManageUsers: true,
})
}
func (web *Web) ActivatePage(w http.ResponseWriter, r *http.Request) {
web.render(w, r, http.StatusOK, "activated", struct {
AuthenticatedUser *data.User
FormErrors []string
Token string
}{
AuthenticatedUser: getUser(r),
Token: r.URL.Query().Get("token"),
})
}
func (web *Web) ActivateUserAction(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
token := r.PostFormValue("token")
_, err := web.App.ActivateUser(token)
if err != nil {
var msg string
if errors.Is(err, data.ErrRecordNotFound) {
msg = "Ungültiger oder abgelaufener Aktivierungstoken."
} else {
web.App.LogError(r, err)
msg = "Aktivierung fehlgeschlagen. Bitte versuchen Sie es erneut."
}
web.render(w, r, http.StatusUnprocessableEntity, "activated", struct {
AuthenticatedUser *data.User
FormErrors []string
Token string
}{
AuthenticatedUser: getUser(r),
FormErrors: []string{msg},
Token: token,
})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (web *Web) DeleteUserAction(w http.ResponseWriter, r *http.Request) {
currentUser := getUser(r)
if currentUser.IsAnonymous() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
id, err := common.ReadIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
if err = web.App.DeleteUser(id); err != nil {
if errors.Is(err, data.ErrRecordNotFound) {
http.NotFound(w, r)
} else {
web.App.LogError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if currentUser.ID == id {
clearCookie(w)
w.Header().Set("HX-Redirect", "/")
} else {
w.Header().Set("HX-Redirect", "/users")
}
w.WriteHeader(http.StatusOK)
}
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"errors"
"net/http"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
func (web *Web) VoteAction(w http.ResponseWriter, r *http.Request) {
var input struct {
IssueID int64 `json:"issue_id"`
OptionID int64 `json:"option_id"`
Nonce []byte `json:"nonce"`
Signature []byte `json:"signature"`
}
if err := web.App.ReadJSON(w, r, &input); err != nil {
common.WriteJSON(w, http.StatusBadRequest, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
return
}
if err := web.App.CastVote(input.IssueID, input.OptionID, input.Nonce, input.Signature); err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
common.WriteJSON(w, http.StatusNotFound, common.Envelope{"error": map[string]string{"message": "issue not found"}}, nil)
case errors.Is(err, data.ErrInvalidSignature):
common.WriteJSON(w, http.StatusUnprocessableEntity, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
case errors.Is(err, data.ErrDuplicateVote):
common.WriteJSON(w, http.StatusConflict, common.Envelope{"error": map[string]string{"message": err.Error()}}, nil)
default:
web.App.LogError(r, err)
common.WriteJSON(w, http.StatusInternalServerError, common.Envelope{"error": map[string]string{"message": "internal server error"}}, nil)
}
return
}
if err := common.WriteJSON(w, http.StatusOK, common.Envelope{"message": "vote successfully cast"}, nil); err != nil {
web.App.LogError(r, err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"context"
"net/http"
"party.at/party/cmd/party/common"
"party.at/party/internal/data"
)
type Web struct {
App *common.Application
}
type contextKey string
const userKey contextKey = "web_user"
const permissionsKey contextKey = "web_permissions"
func setUser(r *http.Request, user *data.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userKey, user))
}
func getUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userKey).(*data.User)
if !ok {
return data.AnonymousUser
}
return user
}
func setPermissions(r *http.Request, permissions data.Permissions) *http.Request {
return r.WithContext(context.WithValue(r.Context(), permissionsKey, permissions))
}
func getPermissions(r *http.Request) data.Permissions {
permissions, ok := r.Context().Value(permissionsKey).(data.Permissions)
if !ok {
return data.Permissions{}
}
return permissions
}