diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..71ecf7f Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/launch.json b/.vscode/launch.json index e2eb434..266cb0f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,8 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/cmd/api", + "program": "${workspaceFolder}/cmd/party", + "cwd": "${workspaceFolder}", "envFile": "${workspaceFolder}/.env", "args": ["--env=development"] } diff --git a/cmd/api/context.go b/cmd/api/context.go deleted file mode 100644 index d2572c8..0000000 --- a/cmd/api/context.go +++ /dev/null @@ -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 -} diff --git a/cmd/api/db.go b/cmd/api/db.go deleted file mode 100644 index 3439f1f..0000000 --- a/cmd/api/db.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/api/errors.go b/cmd/api/errors.go deleted file mode 100644 index b470581..0000000 --- a/cmd/api/errors.go +++ /dev/null @@ -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) -} diff --git a/cmd/api/healthcheck.go b/cmd/api/healthcheck.go deleted file mode 100644 index e8de93b..0000000 --- a/cmd/api/healthcheck.go +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/cmd/api/helpers.go b/cmd/api/helpers.go deleted file mode 100644 index 25841bd..0000000 --- a/cmd/api/helpers.go +++ /dev/null @@ -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 """. 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 -} diff --git a/cmd/api/issues.go b/cmd/api/issues.go deleted file mode 100644 index 887d278..0000000 --- a/cmd/api/issues.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/api/issues_test.go b/cmd/api/issues_test.go deleted file mode 100644 index c19d14b..0000000 --- a/cmd/api/issues_test.go +++ /dev/null @@ -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)) - } - }) - } -} diff --git a/cmd/api/main.go b/cmd/api/main.go deleted file mode 100644 index d9d66c7..0000000 --- a/cmd/api/main.go +++ /dev/null @@ -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Ö ", "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 -} diff --git a/cmd/api/middleware.go b/cmd/api/middleware.go deleted file mode 100644 index 8321fbf..0000000 --- a/cmd/api/middleware.go +++ /dev/null @@ -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) - }) -} diff --git a/cmd/api/routes.go b/cmd/api/routes.go deleted file mode 100644 index afd3d3d..0000000 --- a/cmd/api/routes.go +++ /dev/null @@ -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))))) -} diff --git a/cmd/api/testutils_test.go b/cmd/api/testutils_test.go deleted file mode 100644 index a844114..0000000 --- a/cmd/api/testutils_test.go +++ /dev/null @@ -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()) -} diff --git a/cmd/api/tokens.go b/cmd/api/tokens.go deleted file mode 100644 index e3eb913..0000000 --- a/cmd/api/tokens.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/api/users.go b/cmd/api/users.go deleted file mode 100644 index 26607c5..0000000 --- a/cmd/api/users.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/party/api/api.go b/cmd/party/api/api.go new file mode 100644 index 0000000..7fd214d --- /dev/null +++ b/cmd/party/api/api.go @@ -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) + }) +} diff --git a/cmd/party/api/handler.go b/cmd/party/api/handler.go new file mode 100644 index 0000000..dc9e147 --- /dev/null +++ b/cmd/party/api/handler.go @@ -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", +// }) +// } diff --git a/cmd/party/api/healthcheck.go b/cmd/party/api/healthcheck.go new file mode 100644 index 0000000..3902635 --- /dev/null +++ b/cmd/party/api/healthcheck.go @@ -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) + } +} diff --git a/cmd/party/api/issues.go b/cmd/party/api/issues.go new file mode 100644 index 0000000..ddc5c6c --- /dev/null +++ b/cmd/party/api/issues.go @@ -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) + } +} diff --git a/cmd/party/api/middleware.go b/cmd/party/api/middleware.go new file mode 100644 index 0000000..acd889b --- /dev/null +++ b/cmd/party/api/middleware.go @@ -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) + }) +} diff --git a/cmd/party/api/tokens.go b/cmd/party/api/tokens.go new file mode 100644 index 0000000..d8c55cd --- /dev/null +++ b/cmd/party/api/tokens.go @@ -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) + } +} diff --git a/cmd/party/api/users.go b/cmd/party/api/users.go new file mode 100644 index 0000000..cfa7383 --- /dev/null +++ b/cmd/party/api/users.go @@ -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) + } +} diff --git a/cmd/party/api/votes.go b/cmd/party/api/votes.go new file mode 100644 index 0000000..17924dc --- /dev/null +++ b/cmd/party/api/votes.go @@ -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) + } +} diff --git a/cmd/party/common/application.go b/cmd/party/common/application.go new file mode 100644 index 0000000..aab00a2 --- /dev/null +++ b/cmd/party/common/application.go @@ -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() + }() +} + diff --git a/cmd/party/common/errors.go b/cmd/party/common/errors.go new file mode 100644 index 0000000..7282713 --- /dev/null +++ b/cmd/party/common/errors.go @@ -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 +) diff --git a/cmd/party/common/handler.go b/cmd/party/common/handler.go new file mode 100644 index 0000000..30221da --- /dev/null +++ b/cmd/party/common/handler.go @@ -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(), + }) +} diff --git a/cmd/party/common/helpers.go b/cmd/party/common/helpers.go new file mode 100644 index 0000000..70ccd4a --- /dev/null +++ b/cmd/party/common/helpers.go @@ -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 +} diff --git a/cmd/party/common/issues.go b/cmd/party/common/issues.go new file mode 100644 index 0000000..4c077ab --- /dev/null +++ b/cmd/party/common/issues.go @@ -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 +} diff --git a/cmd/party/common/middleware.go b/cmd/party/common/middleware.go new file mode 100644 index 0000000..b742ffc --- /dev/null +++ b/cmd/party/common/middleware.go @@ -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) + }) +} diff --git a/cmd/party/common/tokens.go b/cmd/party/common/tokens.go new file mode 100644 index 0000000..24c7d5f --- /dev/null +++ b/cmd/party/common/tokens.go @@ -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[:]) +} diff --git a/cmd/party/common/users.go b/cmd/party/common/users.go new file mode 100644 index 0000000..17dc693 --- /dev/null +++ b/cmd/party/common/users.go @@ -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 +} diff --git a/cmd/party/common/votes.go b/cmd/party/common/votes.go new file mode 100644 index 0000000..da7cd92 --- /dev/null +++ b/cmd/party/common/votes.go @@ -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) +} diff --git a/cmd/api/handlers.go b/cmd/party/handlers.go similarity index 79% rename from cmd/api/handlers.go rename to cmd/party/handlers.go index 9bd127c..55523bd 100644 --- a/cmd/api/handlers.go +++ b/cmd/party/handlers.go @@ -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 { diff --git a/cmd/api/helpers_test.go b/cmd/party/helpers_test.go similarity index 88% rename from cmd/api/helpers_test.go rename to cmd/party/helpers_test.go index 7ba564a..50db8ba 100644 --- a/cmd/api/helpers_test.go +++ b/cmd/party/helpers_test.go @@ -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) } diff --git a/cmd/party/issues_test.go b/cmd/party/issues_test.go new file mode 100644 index 0000000..60422bc --- /dev/null +++ b/cmd/party/issues_test.go @@ -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) + } + } +} diff --git a/cmd/party/main.go b/cmd/party/main.go new file mode 100644 index 0000000..25787d6 --- /dev/null +++ b/cmd/party/main.go @@ -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Ö ", "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) + } +} diff --git a/cmd/party/parlament/client.go b/cmd/party/parlament/client.go new file mode 100644 index 0000000..3176877 --- /dev/null +++ b/cmd/party/parlament/client.go @@ -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] + "..." +} diff --git a/cmd/party/parlament/committees.go b/cmd/party/parlament/committees.go new file mode 100644 index 0000000..98f3818 --- /dev/null +++ b/cmd/party/parlament/committees.go @@ -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 +} diff --git a/cmd/party/parlament/documents.go b/cmd/party/parlament/documents.go new file mode 100644 index 0000000..6d28567 --- /dev/null +++ b/cmd/party/parlament/documents.go @@ -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 +} diff --git a/cmd/party/parlament/events.go b/cmd/party/parlament/events.go new file mode 100644 index 0000000..aed2f15 --- /dev/null +++ b/cmd/party/parlament/events.go @@ -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 +} diff --git a/cmd/party/parlament/government.go b/cmd/party/parlament/government.go new file mode 100644 index 0000000..3c0a044 --- /dev/null +++ b/cmd/party/parlament/government.go @@ -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 +} diff --git a/cmd/party/parlament/parlament_test.go b/cmd/party/parlament/parlament_test.go new file mode 100644 index 0000000..9532748 --- /dev/null +++ b/cmd/party/parlament/parlament_test.go @@ -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) +} diff --git a/cmd/party/parlament/parliamentarians.go b/cmd/party/parlament/parliamentarians.go new file mode 100644 index 0000000..70f1f99 --- /dev/null +++ b/cmd/party/parlament/parliamentarians.go @@ -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 +} diff --git a/cmd/party/parlament/participatory.go b/cmd/party/parlament/participatory.go new file mode 100644 index 0000000..6b3b8e8 --- /dev/null +++ b/cmd/party/parlament/participatory.go @@ -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 +} diff --git a/cmd/party/parlament/press.go b/cmd/party/parlament/press.go new file mode 100644 index 0000000..62b9910 --- /dev/null +++ b/cmd/party/parlament/press.go @@ -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 +} diff --git a/cmd/party/parlament/sessions.go b/cmd/party/parlament/sessions.go new file mode 100644 index 0000000..8e198fa --- /dev/null +++ b/cmd/party/parlament/sessions.go @@ -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 +} diff --git a/cmd/party/roles_test.go b/cmd/party/roles_test.go new file mode 100644 index 0000000..bde0144 --- /dev/null +++ b/cmd/party/roles_test.go @@ -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) + } + }) +} diff --git a/cmd/party/routes.go b/cmd/party/routes.go new file mode 100644 index 0000000..284740c --- /dev/null +++ b/cmd/party/routes.go @@ -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 +} \ No newline at end of file diff --git a/cmd/api/server.go b/cmd/party/server.go similarity index 54% rename from cmd/api/server.go rename to cmd/party/server.go index 5464815..5036b19 100644 --- a/cmd/api/server.go +++ b/cmd/party/server.go @@ -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, }) diff --git a/cmd/party/testutils_test.go b/cmd/party/testutils_test.go new file mode 100644 index 0000000..a0ead73 --- /dev/null +++ b/cmd/party/testutils_test.go @@ -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) +} diff --git a/cmd/party/vote_test.go b/cmd/party/vote_test.go new file mode 100644 index 0000000..07d655e --- /dev/null +++ b/cmd/party/vote_test.go @@ -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") + } +} diff --git a/cmd/party/web/home.go b/cmd/party/web/home.go new file mode 100644 index 0000000..c97f28f --- /dev/null +++ b/cmd/party/web/home.go @@ -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) +} diff --git a/cmd/party/web/issues.go b/cmd/party/web/issues.go new file mode 100644 index 0000000..91b8d73 --- /dev/null +++ b/cmd/party/web/issues.go @@ -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) + } +} diff --git a/cmd/party/web/middleware.go b/cmd/party/web/middleware.go new file mode 100644 index 0000000..ce030a2 --- /dev/null +++ b/cmd/party/web/middleware.go @@ -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) + }) +} diff --git a/cmd/party/web/mps.go b/cmd/party/web/mps.go new file mode 100644 index 0000000..627e5cb --- /dev/null +++ b/cmd/party/web/mps.go @@ -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), + }) +} diff --git a/cmd/party/web/templates.go b/cmd/party/web/templates.go new file mode 100644 index 0000000..0b2986a --- /dev/null +++ b/cmd/party/web/templates.go @@ -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) + } +} diff --git a/cmd/party/web/users.go b/cmd/party/web/users.go new file mode 100644 index 0000000..9d25c52 --- /dev/null +++ b/cmd/party/web/users.go @@ -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) +} diff --git a/cmd/party/web/votes.go b/cmd/party/web/votes.go new file mode 100644 index 0000000..7e0c112 --- /dev/null +++ b/cmd/party/web/votes.go @@ -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) + } +} diff --git a/cmd/party/web/web.go b/cmd/party/web/web.go new file mode 100644 index 0000000..58dea01 --- /dev/null +++ b/cmd/party/web/web.go @@ -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 +} diff --git a/internal/crypto/vote.go b/internal/crypto/vote.go new file mode 100644 index 0000000..52e16e0 --- /dev/null +++ b/internal/crypto/vote.go @@ -0,0 +1,14 @@ +package crypto + +import ( + "crypto/sha256" + "encoding/binary" +) + +func VoteMessage(issueID, optionID int64, nonce []byte) [32]byte { + buf := make([]byte, 16+len(nonce)) + binary.BigEndian.PutUint64(buf[0:8], uint64(issueID)) + binary.BigEndian.PutUint64(buf[8:16], uint64(optionID)) + copy(buf[16:], nonce) + return sha256.Sum256(buf) +} diff --git a/internal/data/blind_sign_requests.go b/internal/data/blind_signs.go similarity index 55% rename from internal/data/blind_sign_requests.go rename to internal/data/blind_signs.go index 80d5aa7..86659d9 100644 --- a/internal/data/blind_sign_requests.go +++ b/internal/data/blind_signs.go @@ -1,30 +1,56 @@ package data import ( - "time" - "database/sql" - "encoding/pem" "context" + "database/sql" "errors" "fmt" "math/big" - "crypto/rsa" - "crypto/x509" + "time" + + "github.com/lib/pq" ) -type BlindSignRequest struct { +type BlindSign struct { UserID int64 `json:"user_id"` IssueID int64 `json:"issue_id"` Created time.Time `json:"created"` } -type BlindSignRequestModel struct { +type BlindSignModel struct { DB *sql.DB } -func (m BlindSignRequestModel) Insert(blind_sign *BlindSignRequest) error { +func (m BlindSignModel) Get(userID int64, issueID int64) (*BlindSign, error) { + query := +`SELECT user_id, issue_id, created +FROM blind_signs +WHERE user_id = $1 AND issue_id = $2` + + args := []interface{}{ + userID, + issueID, + } + + var blindSign BlindSign + + err := m.DB.QueryRow(query, args...).Scan(&blindSign.UserID, &blindSign.IssueID, &blindSign.Created) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, err + } + } + + return &blindSign, nil +} + +func (m BlindSignModel) Insert(blind_sign *BlindSign) error { query := ` -INSERT INTO blind_sign_requests (user_id, issue_id) +INSERT INTO blind_signs (user_id, issue_id) VALUES ($1, $2) RETURNING created` @@ -33,12 +59,16 @@ RETURNING created` blind_sign.IssueID, } - return m.DB.QueryRow(query, args...).Scan( - &blind_sign.Created, - ) + err := m.DB.QueryRow(query, args...).Scan(&blind_sign.Created) + if pgErr, ok := err.(*pq.Error); ok { + if pgErr.Code == "23505" { + return ErrDuplicateBlindSign + } + } + return err } -func (m BlindSignRequestModel) BlindSign(issueID int64, blindedVoteBytes []byte) ([]byte, error) { +func (m BlindSignModel) BlindSign(issueID int64, blindedVoteBytes []byte) ([]byte, error) { if issueID < 1 { return nil, ErrRecordNotFound } @@ -77,12 +107,3 @@ func (m BlindSignRequestModel) BlindSign(issueID int64, blindedVoteBytes []byte) return sig.Bytes(), nil } - -func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { - block, _ := pem.Decode(pemBytes) - if block == nil { - return nil, errors.New("failed to decode PEM block") - } - return x509.ParsePKCS1PrivateKey(block.Bytes) -} - diff --git a/internal/data/helpers.go b/internal/data/helpers.go new file mode 100644 index 0000000..c3245d1 --- /dev/null +++ b/internal/data/helpers.go @@ -0,0 +1,16 @@ +package data + +import( + "crypto/rsa" + "encoding/pem" + "errors" + "crypto/x509" +) + +func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} diff --git a/internal/data/issues.go b/internal/data/issues.go index 367e10f..b0ed107 100644 --- a/internal/data/issues.go +++ b/internal/data/issues.go @@ -2,11 +2,12 @@ package data import ( "time" - "party.at/party/internal/validator" "database/sql" "errors" "context" "fmt" + + "party.at/party/internal/validator" ) type Issue struct { @@ -38,8 +39,8 @@ type IssueModel struct { } func (m IssueModel) Insert(issue *Issue) error { - query := ` -INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem) + query := +`INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created, version` @@ -60,14 +61,64 @@ RETURNING id, created, version` ) } -// Add a placeholder method for fetching a specific record from the issues table. +func (m IssueModel) InsertWithOptions(issue *Issue, options []*Option) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + query := +`INSERT INTO issues (title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, created, version` + + args := []any{ + issue.Title, + issue.Description, + issue.StartTime, + issue.EndTime, + issue.N, + issue.E, + issue.PrivatePem, + } + + err = tx.QueryRowContext(ctx, query, args...).Scan(&issue.ID, &issue.Created, &issue.Version) + if err != nil { + return err + } + + for _, option := range options { + option.IssueID = issue.ID + err = tx.QueryRowContext(ctx, +`INSERT INTO options (issue_id, label) +VALUES ($1, $2) +RETURNING id, created, version`, + option.IssueID, + option.Label, + ).Scan(&option.ID, &option.Created, &option.Version) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + return nil +} + func (m IssueModel) Get(id int64) (*Issue, error) { if id < 1 { return nil, ErrRecordNotFound } - query := ` -SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version + query := +`SELECT id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version FROM issues WHERE id = $1` @@ -138,35 +189,25 @@ func (m IssueModel) Update(issue *Issue) error { return nil } -// Add a placeholder method for deleting a specific record from the issues table. func (m IssueModel) Delete(id int64) error { if id < 1 { return ErrRecordNotFound } - // Construct the SQL query to delete the record. query := ` DELETE FROM issues WHERE id = $1` - // Execute the SQL query using the Exec() method, passing in the id variable as - // the value for the placeholder parameter. The Exec() method returns a sql.Result - // object. result, err := m.DB.Exec(query, id) if err != nil { return err } - // Call the RowsAffected() method on the sql.Result object to get the number of rows - // affected by the query. rowsAffected, err := result.RowsAffected() if err != nil { return err } - // If no rows were affected, we know that the issues table didn't contain a record - // with the provided ID at the moment we tried to delete it. In that case we - // return an ErrRecordNotFound error. if rowsAffected == 0 { return ErrRecordNotFound } @@ -175,7 +216,6 @@ func (m IssueModel) Delete(id int64) error { } func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, error) { - // Construct the SQL query to retrieve all issue records. query := fmt.Sprintf(` SELECT COUNT(*) OVER(), id, title, description, start_time, end_time, rsa_n, rsa_e, rsa_private_pem, created, version @@ -193,7 +233,6 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e filters.sortDirection(), ) - // Create a context with a 3-second timeout. ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) defer cancel() @@ -204,20 +243,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e return nil, Metadata{}, err } - // Importantly, defer a call to rows.Close() to ensure that the resultset is closed - // before GetAll() returns. defer rows.Close() totalRecords := 0 issues := []*Issue{} - // Use rows.Next to iterate through the rows in the resultset. for rows.Next() { - // Initialize an empty Issue struct to hold the data for an individual issue. var issue Issue - // Scan the values from the row into the Issue struct. Again, note that we're - // using the pq.Array() adapter on the genres field here. err := rows.Scan( &totalRecords, &issue.ID, @@ -236,18 +269,14 @@ func (m IssueModel) GetAll(title string, filters Filters) ([]*Issue, Metadata, e return nil, Metadata{}, err } - // Add the Issue struct to the slice. issues = append(issues, &issue) } - // When the rows.Next() loop has finished, call rows.Err() to retrieve any error - // that was encountered during the iteration. if err = rows.Err(); err != nil { return nil, Metadata{}, err } metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize) - // If everything went OK, then return the slice of issues. return issues, metadata, nil } diff --git a/internal/data/models.go b/internal/data/models.go index a7ecda2..acb7123 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -8,7 +8,6 @@ import ( var ( ErrRecordNotFound = errors.New("record not found") ErrEditConflict = errors.New("edit conflict") - ErrInvalidBlindedVote = errors.New("invalid blinded vote") ) type Models struct { @@ -17,7 +16,10 @@ type Models struct { Issues IssueModel Tokens TokenModel Permissions PermissionModel - BlindSignRequests BlindSignRequestModel + Roles RoleModel + BlindSigns BlindSignModel + Votes VoteModel + Options OptionModel } func NewModels(db *sql.DB) Models { @@ -27,6 +29,9 @@ func NewModels(db *sql.DB) Models { Issues: IssueModel{DB: db}, Tokens: TokenModel{DB: db}, Permissions: PermissionModel{DB: db}, - BlindSignRequests: BlindSignRequestModel{DB: db}, + Roles: RoleModel{DB: db}, + BlindSigns: BlindSignModel{DB: db}, + Votes: VoteModel{DB: db}, + Options: OptionModel{DB: db}, } } diff --git a/internal/data/options.go b/internal/data/options.go new file mode 100644 index 0000000..aa157a2 --- /dev/null +++ b/internal/data/options.go @@ -0,0 +1,100 @@ +package data + +import ( + "errors" + "time" + "database/sql" + + "party.at/party/internal/validator" +) + +type Option struct { + ID int64 `json:"id"` + IssueID int64 `json:"issue_id"` + Label string `json:"label"` + Created time.Time `json:"created"` + Version int `json:"version"` +} + +func ValidateOption(v *validator.Validator, option *Option) { + v.Check(option.Label != "", "options", "option labels must not be empty") +} + +type OptionModel struct { + DB *sql.DB +} + +func (m OptionModel) Insert(option *Option) error { + query := ` +INSERT INTO options (issue_id, label) +VALUES ($1, $2) +RETURNING id, created, version` + + args := []interface{}{ + option.IssueID, + option.Label, + } + + return m.DB.QueryRow(query, args...).Scan( + &option.ID, + &option.Created, + &option.Version, + ) +} + +func (m OptionModel) GetAllForIssue(issueID int64) ([]*Option, error) { + query := ` +SELECT id, issue_id, label, created, version +FROM options +WHERE issue_id = $1 +ORDER BY id` + + rows, err := m.DB.Query(query, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + + var options []*Option + for rows.Next() { + var option Option + err := rows.Scan(&option.ID, &option.IssueID, &option.Label, &option.Created, &option.Version) + if err != nil { + return nil, err + } + options = append(options, &option) + } + return options, rows.Err() +} + +func (m OptionModel) Get(id int64) (*Option, error) { + if id < 1 { + return nil, ErrRecordNotFound + } + + query := ` +SELECT id, issue_id, label, created, version +FROM options +WHERE id = $1` + + var option Option + + err := m.DB.QueryRow(query, id).Scan( + &option.ID, + &option.IssueID, + &option.Label, + &option.Created, + &option.Version, + ) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, err + } + } + + return &option, nil +} diff --git a/internal/data/permissions.go b/internal/data/permissions.go index 77248a4..34c4dc4 100644 --- a/internal/data/permissions.go +++ b/internal/data/permissions.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "time" - "github.com/lib/pq" ) type Permissions []string @@ -24,14 +23,15 @@ type PermissionModel struct { } func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) { - query :=` + query := ` SELECT permissions.code FROM permissions -INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id -INNER JOIN users ON users_permissions.user_id = users.id -WHERE users.id = $1` +INNER JOIN roles_permissions ON roles_permissions.permission_id = permissions.id +INNER JOIN roles ON roles_permissions.role_id = roles.id +INNER JOIN users_roles ON users_roles.role_id = roles.id +WHERE users_roles.user_id = $1` - ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() rows, err := m.DB.QueryContext(ctx, query, userID) @@ -43,27 +43,10 @@ WHERE users.id = $1` var permissions Permissions for rows.Next() { var permission string - err := rows.Scan(&permission) - if err != nil { + if err := rows.Scan(&permission); err != nil { return nil, err } - permissions = append(permissions, permission) } - if err = rows.Err(); err != nil { - return nil, err - } - - return permissions, nil -} - -func (m PermissionModel) AddForUser(userID int64, codes ...string) error { - query :=` -INSERT INTO users_permissions -SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)` - - ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) - defer cancel() - _, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes)) - return err + return permissions, rows.Err() } diff --git a/internal/data/roles.go b/internal/data/roles.go new file mode 100644 index 0000000..a1d4600 --- /dev/null +++ b/internal/data/roles.go @@ -0,0 +1,64 @@ +package data + +import ( + "context" + "database/sql" + "time" +) + +const ( + RoleViewer = "viewer" + RoleContributor = "contributor" + RoleAdmin = "admin" + RoleMemberOfParliament = "member_of_parliament" + RolePartyLeadership = "party_leadership" +) + +type Role struct { + ID int64 + Code string +} + +type RoleModel struct { + DB *sql.DB +} + +func (m RoleModel) AssignToUser(userID int64, roleCode string) error { + query := ` +INSERT INTO users_roles +SELECT $1, id FROM roles WHERE code = $2 +ON CONFLICT DO NOTHING` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, userID, roleCode) + return err +} + +func (m RoleModel) GetAllForUser(userID int64) ([]Role, error) { + query := ` +SELECT roles.id, roles.code +FROM roles +INNER JOIN users_roles ON users_roles.role_id = roles.id +WHERE users_roles.user_id = $1` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + rows, err := m.DB.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []Role + for rows.Next() { + var role Role + if err := rows.Scan(&role.ID, &role.Code); err != nil { + return nil, err + } + roles = append(roles, role) + } + return roles, rows.Err() +} diff --git a/internal/data/tokens.go b/internal/data/tokens.go index 78ac62d..da541ea 100644 --- a/internal/data/tokens.go +++ b/internal/data/tokens.go @@ -33,7 +33,6 @@ func generateToken(userID int64, userIdentityID int64, ttl time.Duration, scope Scope: scope, } - // Initialize a zero-valued byte slice with a length of 16 bytes. randomBytes := make([]byte, 16) _, err := rand.Read(randomBytes) @@ -82,6 +81,16 @@ func (m TokenModel) Insert(token *Token) error { return err } +func (m TokenModel) Delete(hash []byte) error { + query := `DELETE FROM tokens WHERE hash = $1` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, hash) + return err +} + func (m TokenModel) DeleteAllForUser(scope string, userID int64) error { query :=` DELETE FROM tokens diff --git a/internal/data/user_identities.go b/internal/data/user_identities.go index 9d7ae34..d4313aa 100644 --- a/internal/data/user_identities.go +++ b/internal/data/user_identities.go @@ -117,13 +117,8 @@ SELECT id, provider_id, user_id, provider_user, password, version FROM user_identities WHERE id = $1` - // Declare a User struct to hold the data returned by the query. var userIdentity UserIdentity - // Execute the query using the QueryRow() method, passing in the provided id value - // as a placeholder parameter, and scan the response data into the fields of the - // User struct. Importantly, notice that we need to convert the scan target for the - // genres column using the pq.Array() adapter function again. err := m.DB.QueryRow(query, id).Scan( &userIdentity.ID, &userIdentity.ProviderID, @@ -141,7 +136,7 @@ WHERE id = $1` return nil, err } } - // Otherwise, return a pointer to the User struct. + return &userIdentity, nil } @@ -156,10 +151,6 @@ FROM user_identities identity JOIN users u on identity.user_id = u.id WHERE u.id = $1` - // Execute the query using the QueryRow() method, passing in the provided id value - // as a placeholder parameter, and scan the response data into the fields of the - // User struct. Importantly, notice that we need to convert the scan target for the - // genres column using the pq.Array() adapter function again. rows, err := m.DB.Query(query, user_id) if err != nil { switch { @@ -244,7 +235,6 @@ func (m UserIdentityModel) Update(user *UserIdentity) error { WHERE id = $3 AND version = $4 RETURNING version` - // Create an args slice containing the values for the placeholder parameters. args := []interface{}{ user.ProviderUserID, user.Password.hash, @@ -275,29 +265,20 @@ func (m UserIdentityModel) Delete(id int64) error { return ErrRecordNotFound } - // Construct the SQL query to delete the record. query := ` DELETE FROM user_identities WHERE id = $1` - // Execute the SQL query using the Exec() method, passing in the id variable as - // the value for the placeholder parameter. The Exec() method returns a sql.Result - // object. result, err := m.DB.Exec(query, id) if err != nil { return err } - // Call the RowsAffected() method on the sql.Result object to get the number of rows - // affected by the query. rowsAffected, err := result.RowsAffected() if err != nil { return err } - // If no rows were affected, we know that the issues table didn't contain a record - // with the provided ID at the moment we tried to delete it. In that case we - // return an ErrRecordNotFound error. if rowsAffected == 0 { return ErrRecordNotFound } diff --git a/internal/data/users.go b/internal/data/users.go index 3032a71..0b2a85a 100644 --- a/internal/data/users.go +++ b/internal/data/users.go @@ -2,17 +2,20 @@ package data import ( "context" - "time" - "party.at/party/internal/validator" - "database/sql" - "github.com/lib/pq" - "errors" "crypto/sha256" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/lib/pq" + "party.at/party/internal/validator" ) var ( - ErrDuplicateEmail = errors.New("duplicate email") - ErrDuplicateUser = errors.New("duplicate username") + ErrDuplicateEmail = errors.New("duplicate email") + ErrDuplicateUser = errors.New("duplicate username") + ErrInvalidCredentials = errors.New("invalid credentials") ) var AnonymousUser = &User{} @@ -128,8 +131,8 @@ func (m UserModel) Get(id int64) (*User, error) { } // Define the SQL query for retrieving the issue data. - query :=` -SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version + query := +`SELECT id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version FROM users WHERE id = $1` @@ -307,6 +310,54 @@ func (m UserModel) Update(user *User) error { return nil } +func (m UserModel) GetAll(filters Filters) ([]*User, Metadata, error) { + query := fmt.Sprintf(` + SELECT COUNT(*) OVER(), id, email, phone_number, country, name, alt_name, date_of_birth, address, created, last_login, activated, version + FROM users + ORDER BY %s %s, id ASC + LIMIT $1 OFFSET $2`, + filters.sortColumn(), + filters.sortDirection(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + rows, err := m.DB.QueryContext(ctx, query, filters.limit(), filters.offset()) + if err != nil { + return nil, Metadata{}, err + } + defer rows.Close() + + var totalRecords int + var users []*User + for rows.Next() { + var user User + if err := rows.Scan( + &totalRecords, + &user.ID, + &user.Email, + &user.PhoneNumber, + &user.Country, + &user.Name, + &user.AltName, + &user.DateOfBirth, + &user.Address, + &user.Created, + &user.LastLogin, + &user.Activated, + &user.Version, + ); err != nil { + return nil, Metadata{}, err + } + users = append(users, &user) + } + if err := rows.Err(); err != nil { + return nil, Metadata{}, err + } + return users, calculateMetadata(totalRecords, filters.Page, filters.PageSize), nil +} + func (m UserModel) Delete(id int64) error { if id < 1 { return ErrRecordNotFound diff --git a/internal/data/votes.go b/internal/data/votes.go new file mode 100644 index 0000000..df9ce33 --- /dev/null +++ b/internal/data/votes.go @@ -0,0 +1,102 @@ +package data + +import ( + "errors" + "time" + "database/sql" + "math/big" + "context" + + "party.at/party/internal/crypto" + + "github.com/lib/pq" +) + +var ( + ErrDuplicateBlindSign = errors.New("user has already requested a blind signature for this issue") + ErrInvalidBlindedVote = errors.New("blinded_vote is out of valid range [1, n-1]") + ErrInvalidSignature = errors.New("signature verification failed") + ErrDuplicateVote = errors.New("this signature has already been used to cast a vote") +) + + +type Vote struct { + ID int64 `json:"id"` + OptionID int64 `json:"option_id"` + Nonce []byte `json:"nonce"` + Signature []byte `json:"signature"` + Created time.Time `json:"created"` +} + +type VoteModel struct { + DB *sql.DB +} + + +func (m VoteModel) CountForOption(optionID int64) (int64, error) { + query := ` +SELECT COUNT(v.id) +FROM options o +LEFT JOIN votes v ON v.option_id = o.id +WHERE o.id = $1 +GROUP BY o.id` + + ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) + defer cancel() + + var count int64 + + err := m.DB.QueryRowContext(ctx, query, optionID).Scan(&count) + if err != nil { + return 0, err + } + + return count, err +} + +func (m VoteModel) Insert(vote *Vote, issue *Issue) error { + + n := new(big.Int).SetBytes(issue.N) + e := issue.E + + // Recompute m = SHA-256(issueID || optionID || nonce) + expected := crypto.VoteMessage(issue.ID, vote.OptionID, vote.Nonce) + + // Verify: s^e mod n == m + sig := new(big.Int).SetBytes(vote.Signature) + eInt := big.NewInt(int64(e)) + recovered := new(big.Int).Exp(sig, eInt, n) + expectedInt := new(big.Int).SetBytes(expected[:]) + + if recovered.Cmp(expectedInt) != 0 { + return ErrInvalidSignature + } + + query := +`INSERT INTO votes (option_id, nonce, signature) +VALUES ($1, $2, $3) +RETURNING id, created` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + args := []interface{}{ + vote.OptionID, + vote.Nonce, + vote.Signature, + } + + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&vote.ID, &vote.Created) + if pgErr, ok := err.(*pq.Error); ok { + if pgErr.Code == "23505" { + if pgErr.Constraint == "votes_signature_key" { + return ErrDuplicateVote + } + } + } + if err != nil { + return err + } + + return nil +} diff --git a/migrations/000001_create_users_table.down.sql b/migrations/000001_create_users_table.down.sql index 9b5985e..4d12a76 100644 --- a/migrations/000001_create_users_table.down.sql +++ b/migrations/000001_create_users_table.down.sql @@ -1,5 +1,5 @@ DROP TABLE IF EXISTS user_identities; -DROP TABLE IF EXISTS auth_provider; +DROP TABLE IF EXISTS auth_providers; DROP TABLE IF EXISTS users; diff --git a/migrations/000002_create_additional_tables.down.sql b/migrations/000002_create_additional_tables.down.sql index e514226..ee26af9 100644 --- a/migrations/000002_create_additional_tables.down.sql +++ b/migrations/000002_create_additional_tables.down.sql @@ -1,9 +1,7 @@ DROP INDEX IF EXISTS idx_votes_option_id; -DROP INDEX IF EXISTS idx_vote_tokens_issue_id; DROP INDEX IF EXISTS idx_options_issue_id; DROP TABLE IF EXISTS votes; -DROP TABLE IF EXISTS blind_sign_requests; -DROP TABLE IF EXISTS vote_tokens; +DROP TABLE IF EXISTS blind_signs; DROP TABLE IF EXISTS options; DROP TABLE IF EXISTS issues; diff --git a/migrations/000002_create_additional_tables.up.sql b/migrations/000002_create_additional_tables.up.sql index e225d53..2f9c879 100644 --- a/migrations/000002_create_additional_tables.up.sql +++ b/migrations/000002_create_additional_tables.up.sql @@ -19,16 +19,7 @@ CREATE TABLE IF NOT EXISTS options ( version INT NOT NULL DEFAULT 1 ); -CREATE TABLE IF NOT EXISTS vote_tokens ( - id BIGSERIAL PRIMARY KEY, - issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, - token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), - used BOOLEAN NOT NULL DEFAULT FALSE, - created TIMESTAMPTZ NOT NULL DEFAULT now(), - version INT NOT NULL DEFAULT 1 -); - -CREATE TABLE IF NOT EXISTS blind_sign_requests ( +CREATE TABLE IF NOT EXISTS blind_signs ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, created TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -36,13 +27,12 @@ CREATE TABLE IF NOT EXISTS blind_sign_requests ( ); CREATE TABLE IF NOT EXISTS votes ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - token UUID NOT NULL UNIQUE REFERENCES vote_tokens(token) ON DELETE CASCADE, + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, option_id BIGINT NOT NULL REFERENCES options(id) ON DELETE CASCADE, - created TIMESTAMPTZ NOT NULL DEFAULT now(), - version INT NOT NULL DEFAULT 1 + nonce BYTEA NOT NULL, + signature BYTEA NOT NULL UNIQUE, + created TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_votes_option_id ON votes(option_id); -CREATE INDEX idx_vote_tokens_issue_id ON vote_tokens(issue_id); CREATE INDEX idx_options_issue_id ON options(issue_id); diff --git a/migrations/000005_add_permissions.up.sql b/migrations/000005_add_permissions.up.sql index f6fa9eb..60f7bbb 100644 --- a/migrations/000005_add_permissions.up.sql +++ b/migrations/000005_add_permissions.up.sql @@ -9,6 +9,5 @@ CREATE TABLE IF NOT EXISTS users_permissions ( PRIMARY KEY (user_id, permission_id) ); --- Add the two permissions to the table. INSERT INTO permissions (code) -VALUES ('issues:read'), ('issues:write'); +VALUES ('issues:read'), ('issues:write'), ('issues:vote'), ('users:read'); diff --git a/migrations/000006_add_roles.down.sql b/migrations/000006_add_roles.down.sql new file mode 100644 index 0000000..ba161a1 --- /dev/null +++ b/migrations/000006_add_roles.down.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS users_permissions ( + user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, + permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE, + PRIMARY KEY (user_id, permission_id) +); + +DROP TABLE users_roles; +DROP TABLE roles_permissions; +DROP TABLE roles; diff --git a/migrations/000006_add_roles.up.sql b/migrations/000006_add_roles.up.sql new file mode 100644 index 0000000..e58fd6e --- /dev/null +++ b/migrations/000006_add_roles.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE roles ( + id bigserial PRIMARY KEY, + code text UNIQUE NOT NULL +); + +CREATE TABLE roles_permissions ( + role_id bigint NOT NULL REFERENCES roles ON DELETE CASCADE, + permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE users_roles ( + user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, + role_id bigint NOT NULL REFERENCES roles ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +INSERT INTO roles (code) VALUES ('viewer'), ('contributor'), ('admin'); + +INSERT INTO roles_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE (r.code = 'viewer' AND p.code = 'issues:read') + OR (r.code = 'contributor' AND p.code IN ('issues:read', 'issues:write', 'issues:vote')) + OR (r.code = 'admin' AND p.code IN ('issues:read', 'issues:write', 'issues:vote', 'users:read')); + +DROP TABLE users_permissions; diff --git a/migrations/000007_add_political_roles.down.sql b/migrations/000007_add_political_roles.down.sql new file mode 100644 index 0000000..9513dc6 --- /dev/null +++ b/migrations/000007_add_political_roles.down.sql @@ -0,0 +1 @@ +DELETE FROM roles WHERE code IN ('member_of_parliament', 'party_leadership'); diff --git a/migrations/000007_add_political_roles.up.sql b/migrations/000007_add_political_roles.up.sql new file mode 100644 index 0000000..bf624fa --- /dev/null +++ b/migrations/000007_add_political_roles.up.sql @@ -0,0 +1,5 @@ +INSERT INTO roles (code) VALUES ('member_of_parliament'), ('party_leadership'); + +INSERT INTO roles_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('member_of_parliament', 'party_leadership'); diff --git a/ui/html/base.layout.tmpl b/ui/html/base.layout.tmpl deleted file mode 100644 index 668c30f..0000000 --- a/ui/html/base.layout.tmpl +++ /dev/null @@ -1,38 +0,0 @@ -{{define "base"}} - - - - - {{template "title" .}} - - - - - - {{template "body" .}} - -

Hello, {{.Name}}!

-

This is THE PARTY.

- - -{{end}} diff --git a/ui/html/home.page.tmpl b/ui/html/home.page.tmpl deleted file mode 100644 index 3e04634..0000000 --- a/ui/html/home.page.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{template "base" .}} - -{{define "title"}}Home{{end}} - -{{define "body"}} -

The Party?

-{{end}} diff --git a/ui/static/style.css b/ui/static/style.css deleted file mode 100644 index aa8c771..0000000 --- a/ui/static/style.css +++ /dev/null @@ -1,14 +0,0 @@ -* { - font-family: "Alfa Slab One", serif; - font-weight: 400; - font-style: normal; -} - -body { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100vh; - margin: 0; -} diff --git a/web/.DS_Store b/web/.DS_Store new file mode 100644 index 0000000..ff266e5 Binary files /dev/null and b/web/.DS_Store differ diff --git a/web/html/activated.page.tmpl b/web/html/activated.page.tmpl new file mode 100644 index 0000000..5ae4f64 --- /dev/null +++ b/web/html/activated.page.tmpl @@ -0,0 +1,27 @@ +{{template "base" .}} + +{{define "title"}}Konto aktivieren{{end}} + +{{define "body"}} +
+
+

Konto aktivieren

+

Geben Sie Ihren Aktivierungstoken ein, um Ihr Konto freizuschalten.

+ + {{if .FormErrors}} +
+ {{range .FormErrors}}

{{.}}

{{end}} +
+ {{end}} + +
+
+ + +
+ +
+
+
+{{end}} diff --git a/web/html/base.layout.tmpl b/web/html/base.layout.tmpl new file mode 100644 index 0000000..b6f174e --- /dev/null +++ b/web/html/base.layout.tmpl @@ -0,0 +1,62 @@ +{{define "base"}} + + + + + + {{template "title" .}} — DPÖ + + + + + + + {{if .AuthenticatedUser}} + + {{end}} + +
+ DPÖ Logo +
+ +
+
+ {{template "body" .}} +
+
+ +
+
+ +
+
+ + + + + +{{end}} diff --git a/web/html/home.page.tmpl b/web/html/home.page.tmpl new file mode 100644 index 0000000..f8ff5e8 --- /dev/null +++ b/web/html/home.page.tmpl @@ -0,0 +1,31 @@ +{{template "base" .}} + +{{define "title"}}Übersicht{{end}} + +{{define "body"}} + + +
+
+
🗳
+

Aktuelle Abstimmungen

+

Beteiligen Sie sich an laufenden Abstimmungen und demokratischen Initiativen.

+ Abstimmungen ansehen +
+
+
👤
+

Mein Profil

+

Verwalten Sie Ihre persönlichen Daten und Kontoeinstellungen.

+ Profil ansehen +
+
+
📊
+

Ergebnisse

+

Sehen Sie die Ergebnisse abgeschlossener Abstimmungen ein.

+ Ergebnisse ansehen +
+
+{{end}} diff --git a/web/html/home_anonymous.page.tmpl b/web/html/home_anonymous.page.tmpl new file mode 100644 index 0000000..c63c9b2 --- /dev/null +++ b/web/html/home_anonymous.page.tmpl @@ -0,0 +1,54 @@ +{{template "base" .}} + +{{define "title"}}Willkommen{{end}} + +{{define "body"}} +
+
+
Digitale Demokratie für Österreich
+

Gestalten Sie die
Zukunft Österreichs

+

+ Beteiligen Sie sich an der demokratischen Entscheidungsfindung — + sicher, transparent und vollständig digital. +

+
+
+ +
+
+

Anmelden

+

Melden Sie sich an, um an Abstimmungen teilzunehmen.

+ + {{if .FormErrors}} +
+ {{range .FormErrors}}

{{.}}

{{end}} +
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ +

Noch kein Konto? Registrieren

+ + {{if .IsDevelopment}} +
+ +
+ {{end}} +
+
+{{end}} diff --git a/web/html/issue.page.tmpl b/web/html/issue.page.tmpl new file mode 100644 index 0000000..2065327 --- /dev/null +++ b/web/html/issue.page.tmpl @@ -0,0 +1,208 @@ +{{template "base" .}} + +{{define "title"}}{{.Issue.Title}}{{end}} + +{{define "body"}} +
+ ← Zurück +
+ +
+
{{.Issue.Title}}
+
{{.Issue.Description}}
+
+ {{.Issue.StartTime.Format "02.01.2006"}} – {{.Issue.EndTime.Format "02.01.2006"}} +
+ {{if .CanWriteIssues}} +
+ +
+
+ Bearbeiten +
+
+ + +
+
+ + +
+
+ +
+ + {{template "time-input" (dict "name" "start_clock" "value" (.Issue.StartTime.Format "15:04"))}} +
+
+
+ +
+ + {{template "time-input" (dict "name" "end_clock" "value" (.Issue.EndTime.Format "15:04"))}} +
+
+ +
+
+ {{end}} +
+ +

Optionen

+ +{{if .Issue.CanVote}} +
+

Ihre Stimme abgeben

+ {{range .Issue.Options}} + + {{end}} +
+ +
+{{end}} + +
+ {{range .Issue.Options}} +
+
{{.Label}}
+
{{.VoteCount}}
+
Stimmen
+
+ {{end}} +
+ + +{{end}} diff --git a/web/html/issues.page.tmpl b/web/html/issues.page.tmpl new file mode 100644 index 0000000..b49d3b7 --- /dev/null +++ b/web/html/issues.page.tmpl @@ -0,0 +1,101 @@ +{{template "base" .}} + +{{define "title"}}Abstimmungen{{end}} + +{{define "body"}} + + +{{if .Issues}} +
+ {{range .Issues}} +
+
{{.Title}}
+
{{.Description}}
+
+ {{.StartTime.Format "02.01.2006"}} – {{.EndTime.Format "02.01.2006"}} +
+
+ Details + {{if $.CanWriteIssues}} + + {{end}} +
+
+ {{end}} +
+{{else}} +

Keine Abstimmungen vorhanden.

+{{end}} + +{{if .CanWriteIssues}} +
+ + Neue Abstimmung +
+

Neue Abstimmung

+
+
+ + +
+
+ + +
+
+ +
+ + {{template "time-input" (dict "name" "start_clock" "value" "12:00" "required" true)}} +
+
+
+ +
+ + {{template "time-input" (dict "name" "end_clock" "value" "12:00" "required" true)}} +
+
+
+ +
+ + +
+ +
+ +
+ +
+
+{{end}} +{{end}} diff --git a/web/html/mps.page.tmpl b/web/html/mps.page.tmpl new file mode 100644 index 0000000..9c293e0 --- /dev/null +++ b/web/html/mps.page.tmpl @@ -0,0 +1,44 @@ +{{template "base" .}} + +{{define "title"}}Nationalrat{{end}} + +{{define "body"}} + + +{{range .Groups}} +
+

+ {{.Name}} + {{len .Members}} Mandate +

+
+ + + + + + + + + + {{range .Members}} + + + + + + {{end}} + +
NameFraktionWahlkreis
+ + {{.FirstName}} {{.LastName}} + + {{.Faction}}{{.Constituency}}
+
+
+{{end}} +{{end}} diff --git a/web/html/partials.tmpl b/web/html/partials.tmpl new file mode 100644 index 0000000..f670214 --- /dev/null +++ b/web/html/partials.tmpl @@ -0,0 +1,33 @@ +{{define "time-input"}} + + + : + + + + +{{end}} diff --git a/web/html/profile.page.tmpl b/web/html/profile.page.tmpl new file mode 100644 index 0000000..f7fa45b --- /dev/null +++ b/web/html/profile.page.tmpl @@ -0,0 +1,27 @@ +{{template "base" .}} + +{{define "title"}}Mein Profil{{end}} + +{{define "body"}} + + +
+
{{.User.Name}}{{if .User.AltName}} / {{.User.AltName}}{{end}}
+
+
E-Mail   {{.User.Email}}
+
Telefon   {{.User.PhoneNumber}}
+
Land   {{.User.Country}}
+
Adresse   {{.User.Address}}
+
Geburtsdatum   {{.User.DateOfBirth.Format "02.01.2006"}}
+
Mitglied seit   {{.User.Created.Format "02.01.2006"}}
+
+ +
+ +
+
+{{end}} diff --git a/web/html/register.page.tmpl b/web/html/register.page.tmpl new file mode 100644 index 0000000..c98ff32 --- /dev/null +++ b/web/html/register.page.tmpl @@ -0,0 +1,82 @@ +{{template "base" .}} + +{{define "title"}}Registrieren{{end}} + +{{define "body"}} + + +
+
+ + {{if .FormErrors}} +
+ {{range .FormErrors}}

{{.}}

{{end}} +
+ {{end}} + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +

Bereits ein Konto? Anmelden

+
+
+{{end}} diff --git a/web/html/users.page.tmpl b/web/html/users.page.tmpl new file mode 100644 index 0000000..dec4c15 --- /dev/null +++ b/web/html/users.page.tmpl @@ -0,0 +1,34 @@ +{{template "base" .}} + +{{define "title"}}Mitglieder{{end}} + +{{define "body"}} + + +{{if .Users}} +
+ {{range .Users}} +
+
{{.Name}}{{if .AltName}} / {{.AltName}}{{end}}
+
{{.Email}}
+
+ {{.Country}} · Beigetreten {{.Created.Format "02.01.2006"}} +
+ {{if not .Activated}} +
Nicht aktiviert
+ {{end}} + {{if $.CanManageUsers}} + + {{end}} +
+ {{end}} +
+{{else}} +

Keine Mitglieder vorhanden.

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