diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f48889d803edc4f1d12617a7667d21312def625 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + myaktion: + build: ./src/myaktion + ports: + - "8000:8000" + environment: + - DB_CONNECT=mariadb:3306 + - LOG_LEVEL=info + mariadb: + image: mariadb:10.5 + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=myaktion \ No newline at end of file diff --git a/go.work b/go.work index 19c8897bd2b739a73a2cf80d186f3e2d6411a607..dcea92909512d2a9c9a27bf8ce438202182d95c5 100644 --- a/go.work +++ b/go.work @@ -1,3 +1,4 @@ go 1.24.2 use ./src/myaktion +use ./src/genjwt diff --git a/src/genjwt/go.mod b/src/genjwt/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..fbb800d8aa73aff1d43b0bd4d38382dc1e4501a1 --- /dev/null +++ b/src/genjwt/go.mod @@ -0,0 +1,5 @@ +module gitlab.reutlingen-university.de/kober/myaktion-go/src/genjwt + +go 1.24.2 + +require github.com/golang-jwt/jwt/v5 v5.2.2 // indirect diff --git a/src/genjwt/go.sum b/src/genjwt/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..daf1a90942c73b1fbb46ca4352915e82cb1e3213 --- /dev/null +++ b/src/genjwt/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= diff --git a/src/genjwt/main.go b/src/genjwt/main.go new file mode 100644 index 0000000000000000000000000000000000000000..c41ae02fd3940e8246d2af22b8a65233cedeb61e --- /dev/null +++ b/src/genjwt/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = []byte("myaktion-go-secret-key") + +func createToken(username string) (string, error) { + claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": username, + "iss": "myaktion-go", + "aud": "organizer", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + fmt.Printf("Token claims added: %+v\n", claims) + tokenString, err := claims.SignedString(secretKey) + if err != nil { + return "", err + } + return tokenString, nil +} + +func main() { + if len(os.Args) >= 2 { + organizerName := os.Args[1] + tokenString, err := createToken(organizerName) + if err != nil { + fmt.Printf("Unable to create token: %+v\n", err) + return + } else { + fmt.Printf("%+v\n", tokenString) + } + } else { + fmt.Println("Please pass a username as command-line argument.") + } +} diff --git a/src/myaktion/Dockerfile b/src/myaktion/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8a3eb7c9b369087447e3e5c22cc51a760abfcba5 --- /dev/null +++ b/src/myaktion/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.24 +WORKDIR /go/src/app +COPY . . +RUN go mod download +RUN go install +RUN chmod +x ./wait-for-it.sh ./docker-entrypoint.sh +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["myaktion"] +EXPOSE 8000 \ No newline at end of file diff --git a/src/myaktion/docker-entrypoint.sh b/src/myaktion/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..672603351642e19a0c5e7be8fda91e2b3c2876ec --- /dev/null +++ b/src/myaktion/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +# Abort on any error (including if wait-for-it fails). +set -e + +# Wait for DB +if [ -n "$DB_CONNECT" ]; then +/go/src/app/wait-for-it.sh "$DB_CONNECT" -t 20 +fi + +# Run the main container command. +exec "$@" \ No newline at end of file diff --git a/src/myaktion/handler/campaign.go b/src/myaktion/handler/campaign.go index cfacb25aafd357ed698c1f83955654c215ce3be4..5039f0d4fb87de895e906f262c0396f3ebc67867 100644 --- a/src/myaktion/handler/campaign.go +++ b/src/myaktion/handler/campaign.go @@ -27,7 +27,7 @@ func CreateCampaign(w http.ResponseWriter, r *http.Request) { } func GetCampaigns(w http.ResponseWriter, r *http.Request) { - var campaigns, err = service.GetCampaigns() + var campaigns, err = service.GetCampaigns(getOrganizerName(r)) if err != nil { log.Errorf("Error calling service GetCampaigns: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -74,39 +74,41 @@ func UpdateCampaign(w http.ResponseWriter, r *http.Request) { sendJson(w, campaign) } -func DeleteCampaign(w http.ResponseWriter, r *http.Request) { +func PatchCampaign(w http.ResponseWriter, r *http.Request) { + var campaign *model.Campaign + campaign, err := getCampaign(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } id, err := getId(r) if err != nil { log.Errorf("Failed to extract ID from request: %v", err) http.Error(w, "Invalid campaign ID", http.StatusBadRequest) return } - if err := service.DeleteCampaign(id); err != nil { - log.Errorf("Error deleting campaign with ID %d: %v", id, err) + + if err := service.PatchCampaign(id, campaign); err != nil { + log.Errorf("Error patching campaign with ID %d: %v", id, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - sendJson(w, result{Success: "Campaign deleted successfully"}) + sendJson(w, result{Success: "Campaign patched successfully"}) } -func AddDonation(w http.ResponseWriter, r *http.Request) { +func DeleteCampaign(w http.ResponseWriter, r *http.Request) { id, err := getId(r) if err != nil { log.Errorf("Failed to extract ID from request: %v", err) http.Error(w, "Invalid campaign ID", http.StatusBadRequest) return } - donation, err := getDonation(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := service.AddDonation(id, donation); err != nil { - log.Errorf("Error adding donation to campaign with ID %d: %v", id, err) + if err := service.DeleteCampaign(id); err != nil { + log.Errorf("Error deleting campaign with ID %d: %v", id, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - sendJson(w, donation) + sendJson(w, result{Success: "Campaign deleted successfully"}) } func getCampaign(r *http.Request) (*model.Campaign, error) { @@ -116,15 +118,10 @@ func getCampaign(r *http.Request) (*model.Campaign, error) { log.Errorf("Can't serialize request body to campaign struct: %v", err) return nil, err } + campaign.OrganizerName = getOrganizerName(r) return &campaign, nil } -func getDonation(r *http.Request) (*model.Donation, error) { - var donation model.Donation - err := json.NewDecoder(r.Body).Decode(&donation) - if err != nil { - log.Errorf("Can't serialize request body to donation struct: %v", err) - return nil, err - } - return &donation, nil +func getOrganizerName(r *http.Request) string { + return r.Context().Value("organizerName").(string) } diff --git a/src/myaktion/handler/donation.go b/src/myaktion/handler/donation.go new file mode 100644 index 0000000000000000000000000000000000000000..6ae5a5b6aa83aace512b6ee6ae8618f99fde56cf --- /dev/null +++ b/src/myaktion/handler/donation.go @@ -0,0 +1,41 @@ +package handler + +import ( + "encoding/json" + "net/http" + + log "github.com/sirupsen/logrus" + + "gitlab.reutlingen-university.de/kober/myaktion-go/src/myaktion/model" + "gitlab.reutlingen-university.de/kober/myaktion-go/src/myaktion/service" +) + +func AddDonation(w http.ResponseWriter, r *http.Request) { + id, err := getId(r) + if err != nil { + log.Errorf("Failed to extract ID from request: %v", err) + http.Error(w, "Invalid campaign ID", http.StatusBadRequest) + return + } + donation, err := getDonation(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := service.AddDonation(id, donation); err != nil { + log.Errorf("Error adding donation to campaign with ID %d: %v", id, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sendJson(w, donation) +} + +func getDonation(r *http.Request) (*model.Donation, error) { + var donation model.Donation + err := json.NewDecoder(r.Body).Decode(&donation) + if err != nil { + log.Errorf("Can't serialize request body to donation struct: %v", err) + return nil, err + } + return &donation, nil +} diff --git a/src/myaktion/handler/utils.go b/src/myaktion/handler/utils.go index 16ca09203a916fc2a4301545f76e4136f45d6f1e..c3489789333eb0b6256ed6e27739acfc79dcdf76 100644 --- a/src/myaktion/handler/utils.go +++ b/src/myaktion/handler/utils.go @@ -5,9 +5,8 @@ import ( "net/http" "strconv" - log "github.com/sirupsen/logrus" - "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" ) // REST services that just return a success message (like e.g. delete services) can send an instance of type result diff --git a/src/myaktion/main.go b/src/myaktion/main.go index fd0f6512a83f9d27afdc1f58cace3567f40f32c0..2d62a211921540ac9b158feb65755aa68d4a12cc 100644 --- a/src/myaktion/main.go +++ b/src/myaktion/main.go @@ -2,9 +2,13 @@ package main import ( //"fmt" + "context" + "fmt" "net/http" "os" + "strings" + "github.com/golang-jwt/jwt" log "github.com/sirupsen/logrus" "github.com/gorilla/mux" @@ -24,14 +28,64 @@ func init() { log.SetLevel(level) } +var secretKey = []byte("myaktion-go-secret-key") + +func authMW(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Authorization") + if tokenString == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + log.Info(tokenString) + tokenString = strings.Replace(tokenString, "Bearer ", "", 1) + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(secretKey), nil + }) + if err != nil { + log.Errorf("Can' parse token string: %+v\n", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + if !token.Valid { + log.Errorf("Token is not valid: %+v\n", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + log.Errorf("Can' extract claims from token: %+v\n", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + organizerName, ok := claims["sub"].(string) + if !ok { + log.Errorf("Can' read organizerName from token: %+v\n", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + if organizerName == "" { + log.Errorf("OrganizerName is an empty string: %+v\n", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), "organizerName", organizerName) + next(w, r.WithContext(ctx)) + } +} + func main() { log.Infoln("Starting My-Aktion API server") router := mux.NewRouter() router.HandleFunc("/health", handler.Health).Methods("GET") - router.HandleFunc("/campaigns", handler.CreateCampaign).Methods("POST") - router.HandleFunc("/campaigns", handler.GetCampaigns).Methods("GET") + router.HandleFunc("/campaigns", authMW(handler.CreateCampaign)).Methods("POST") + router.HandleFunc("/campaigns", authMW(handler.GetCampaigns)).Methods("GET") router.HandleFunc("/campaigns/{id}", handler.GetCampaign).Methods("GET") router.HandleFunc("/campaigns/{id}", handler.UpdateCampaign).Methods("PUT") + router.HandleFunc("/campaigns/{id}", handler.PatchCampaign).Methods("PATCH") router.HandleFunc("/campaigns/{id}", handler.DeleteCampaign).Methods("DELETE") router.HandleFunc("/campaigns/{id}/donations", handler.AddDonation).Methods("POST") if err := http.ListenAndServe(":8000", router); err != nil { diff --git a/src/myaktion/model/campaign.go b/src/myaktion/model/campaign.go index 47c8cd42fe5da7b724635ac643e28185e95933e3..c0dd3a698c6e19fc7ff07b93e886dc125ca868c8 100644 --- a/src/myaktion/model/campaign.go +++ b/src/myaktion/model/campaign.go @@ -1,6 +1,8 @@ package model -import "gorm.io/gorm" +import ( + "gorm.io/gorm" +) type Campaign struct { gorm.Model @@ -12,3 +14,10 @@ type Campaign struct { Donations []Donation `gorm:"foreignKey:CampaignID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Account Account `gorm:"embedded;embeddedPrefix:account_"` } + +func (campaign *Campaign) AfterFind(tx *gorm.DB) (err error) { + for _, donation := range campaign.Donations { + campaign.AmountDonatedSoFar += donation.Amount + } + return +} diff --git a/src/myaktion/service/campaign.go b/src/myaktion/service/campaign.go index 7e73ec4fdf4d2b14de3e0a0473f14f3b3df4e85b..3b2632a3853876bc8a08a9f05b11b4036335eda9 100644 --- a/src/myaktion/service/campaign.go +++ b/src/myaktion/service/campaign.go @@ -17,9 +17,9 @@ func CreateCampaign(campaign *model.Campaign) error { return nil } -func GetCampaigns() ([]model.Campaign, error) { +func GetCampaigns(organizerName string) ([]model.Campaign, error) { var campaigns []model.Campaign - result := db.DB.Preload("Donations").Find(&campaigns) + result := db.DB.Preload("Donations").Where("organizer_name = ?", organizerName).Find(&campaigns) if result.Error != nil { return nil, result.Error } @@ -56,13 +56,11 @@ func DeleteCampaign(id uint) error { return nil } -func AddDonation(campaignID uint, donation *model.Donation) error { - donation.CampaignID = campaignID - result := db.DB.Create(donation) +func PatchCampaign(id uint, campaign *model.Campaign) error { + result := db.DB.Model(&model.Campaign{}).Where("id = ?", id).Updates(campaign) if result.Error != nil { return result.Error } - log.Infof("Successfully stored new donation with ID %v in database.", donation.ID) - log.Tracef("Stored: %v", donation) + log.Infof("Successfully patched campaign with ID %v in database.", id) return nil } diff --git a/src/myaktion/service/donation.go b/src/myaktion/service/donation.go new file mode 100644 index 0000000000000000000000000000000000000000..e422e92040e209440482a4f0580a8d2a30327036 --- /dev/null +++ b/src/myaktion/service/donation.go @@ -0,0 +1,19 @@ +package service + +import ( + log "github.com/sirupsen/logrus" + + "gitlab.reutlingen-university.de/kober/myaktion-go/src/myaktion/db" + "gitlab.reutlingen-university.de/kober/myaktion-go/src/myaktion/model" +) + +func AddDonation(campaignID uint, donation *model.Donation) error { + donation.CampaignID = campaignID + result := db.DB.Create(donation) + if result.Error != nil { + return result.Error + } + log.Infof("Successfully stored new donation with ID %v in database.", donation.ID) + log.Tracef("Stored: %v", donation) + return nil +} diff --git a/src/myaktion/wait-for-it.sh b/src/myaktion/wait-for-it.sh new file mode 100644 index 0000000000000000000000000000000000000000..d990e0d364f576ee83cd699707076ca49ad36a4d --- /dev/null +++ b/src/myaktion/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi