From c3bc06fe82e682dca7b5acb2f1375ae8ea96af9e Mon Sep 17 00:00:00 2001 From: z1glr Date: Tue, 7 Jan 2025 16:37:37 +0000 Subject: [PATCH] started working on the backend --- backend/.devcontainer/devcontainer.json | 4 +- backend/.vscode/launch.json | 15 ++ backend/go.mod | 28 +++ backend/go.sum | 54 +++++ backend/logs/backend.log | 27 +++ backend/main.go | 9 + backend/pkg/config/config.go | 95 ++++++++ .../{config.go => pkg/config/configYAML.go} | 15 +- backend/pkg/db/assignments/assignments.go | 30 +++ .../pkg/db/availabilites/availabilities.go | 59 +++++ .../db/availabilites/userAvailabilities.go | 36 +++ backend/pkg/db/db.go | 225 ++++++++++++++++++ backend/pkg/db/events/events.go | 48 ++++ backend/pkg/db/tasks/tasks.go | 59 +++++ backend/pkg/db/users/users.go | 52 ++++ backend/pkg/lib/lib.go | 30 +++ backend/pkg/logger/logger.go | 70 ++++++ backend/pkg/router/events.go | 33 +++ backend/pkg/router/login.go | 80 +++++++ backend/pkg/router/router.go | 221 +++++++++++++++++ backend/pkg/router/user.go | 13 + client/next.config.ts | 14 +- client/package-lock.json | 54 +++++ client/package.json | 2 + client/src/app/Overview.tsx | 15 ++ client/src/app/globals.css | 4 - client/src/app/page.tsx | 53 ++++- client/src/lib.ts | 56 +++++ client/tailwind.config.ts | 20 +- init.sql | 9 + setup/.gitignore | 1 + setup/pkg/config/config.go | 1 + {backend => setup}/setup.go | 5 +- setup/setup.sql | 54 ++--- 34 files changed, 1437 insertions(+), 54 deletions(-) create mode 100644 backend/.vscode/launch.json create mode 100644 backend/go.sum create mode 100644 backend/logs/backend.log create mode 100644 backend/main.go create mode 100644 backend/pkg/config/config.go rename backend/{config.go => pkg/config/configYAML.go} (83%) create mode 100644 backend/pkg/db/assignments/assignments.go create mode 100644 backend/pkg/db/availabilites/availabilities.go create mode 100644 backend/pkg/db/availabilites/userAvailabilities.go create mode 100644 backend/pkg/db/db.go create mode 100644 backend/pkg/db/events/events.go create mode 100644 backend/pkg/db/tasks/tasks.go create mode 100644 backend/pkg/db/users/users.go create mode 100644 backend/pkg/lib/lib.go create mode 100644 backend/pkg/logger/logger.go create mode 100644 backend/pkg/router/events.go create mode 100644 backend/pkg/router/login.go create mode 100644 backend/pkg/router/router.go create mode 100644 backend/pkg/router/user.go create mode 100644 client/src/lib.ts create mode 100644 init.sql create mode 120000 setup/pkg/config/config.go rename {backend => setup}/setup.go (96%) diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json index 3495528..d2e683a 100644 --- a/backend/.devcontainer/devcontainer.json +++ b/backend/.devcontainer/devcontainer.json @@ -3,13 +3,13 @@ { "name": "Go", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm" + "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm", // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], + // "forwardPorts": [8080] // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "go version", diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 0000000..e40fcdd --- /dev/null +++ b/backend/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index ed452b7..009b507 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,31 @@ module github.com/johannesbuehl/golunteer/backend go 1.23.4 + +require github.com/go-sql-driver/mysql v1.8.1 + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jfarleyx/go-simple-cache v1.1.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.29.0 // indirect +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/gofiber/fiber/v2 v2.52.6 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/rs/zerolog v1.33.0 + golang.org/x/crypto v0.32.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..d1d7597 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,54 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= +github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jfarleyx/go-simple-cache v1.1.0 h1:R3sEDKnZwyrXbXiu1UswmuNPUK15uOGaIkaJEqs3OmU= +github.com/jfarleyx/go-simple-cache v1.1.0/go.mod h1:inxrRa6HjKThMm7/gARxbNMId0DMDf27u5N1c0Nz0Bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/logs/backend.log b/backend/logs/backend.log new file mode 100644 index 0000000..0d34584 --- /dev/null +++ b/backend/logs/backend.log @@ -0,0 +1,27 @@ +{"level":"debug","time":1736261973,"message":"HTTP GET request: \"/api/events\""} +{"level":"error","time":1736261977,"message":"error while reading data for event with id 1: Error 1241 (21000): Operand should contain 1 column(s)"} +{"level":"debug","time":1736262010,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736262031,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736263786,"message":"HTTP GET request: \"/api/events\""} +{"level":"error","time":1736263809,"message":"error while populating event with id = 1: Error 1054 (42S22): Unknown column 'USER_ASSIGNMENTS.userID' in 'ON'"} +{"level":"debug","time":1736263844,"message":"HTTP GET request: \"/api/events\""} +{"level":"error","time":1736263850,"message":"error while populating event with id = 1: Error 1054 (42S22): Unknown column 'USERS.id' in 'ON'"} +{"level":"debug","time":1736263865,"message":"HTTP GET request: \"/api/events\""} +{"level":"error","time":1736263865,"message":"error while populating event with id = 1: missing destination name eventID in *[]assignments.assignemntDB"} +{"level":"debug","time":1736263897,"message":"HTTP GET request: \"/api/events\""} +{"level":"error","time":1736263911,"message":"error while populating event with id = 1: missing destination name eventID in *[]assignments.assignemntDB"} +{"level":"debug","time":1736263915,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736263949,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264022,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264029,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264046,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264049,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264080,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264124,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264162,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264165,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264204,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264212,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264299,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264321,"message":"HTTP GET request: \"/api/events\""} +{"level":"debug","time":1736264349,"message":"HTTP GET request: \"/api/events\""} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..be712da --- /dev/null +++ b/backend/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/johannesbuehl/golunteer/backend/pkg/router" +) + +func main() { + router.Listen() +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go new file mode 100644 index 0000000..7b095bb --- /dev/null +++ b/backend/pkg/config/config.go @@ -0,0 +1,95 @@ +package config + +import ( + "bytes" + "fmt" + "log" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/johannesbuehl/golunteer/backend/pkg/lib" + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" +) + +type ReservationConfig struct { + Expiration time.Duration +} + +type ConfigStruct struct { + ConfigYaml + LogLevel zerolog.Level + SessionExpire time.Duration +} + +var Config ConfigStruct + +type Payload struct { + jwt.RegisteredClaims + CustomClaims map[string]any +} + +func (config ConfigStruct) SignJWT(val any) (string, error) { + valMap, err := lib.StrucToMap(val) + + if err != nil { + return "", err + } + + payload := Payload{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.SessionExpire)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + CustomClaims: valMap, + } + + t := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + return t.SignedString([]byte(config.ClientSession.JwtSignature)) +} + +func loadConfig() ConfigStruct { + Config := ConfigYaml{} + + yamlFile, err := os.ReadFile("config.yaml") + if err != nil { + panic(fmt.Sprintf("Error opening config-file: %q", err)) + } + + reader := bytes.NewReader(yamlFile) + + dec := yaml.NewDecoder(reader) + dec.KnownFields(true) + err = dec.Decode(&Config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing config-file: %v", err) + os.Exit(1) + } + + if logLevel, err := zerolog.ParseLevel(Config.LogLevel); err != nil { + panic(fmt.Errorf("can't parse log-level: %v", err)) + } else { + var configStruct ConfigStruct + + // parse the durations + if session_expire, err := time.ParseDuration(Config.ClientSession.Expire); err != nil { + log.Fatalf(`Error parsing "client_session.expire": %v`, err) + + // parse the templates + } else { + configStruct = ConfigStruct{ + ConfigYaml: Config, + LogLevel: logLevel, + SessionExpire: session_expire, + } + } + + return configStruct + } +} + +func init() { + Config = loadConfig() +} diff --git a/backend/config.go b/backend/pkg/config/configYAML.go similarity index 83% rename from backend/config.go rename to backend/pkg/config/configYAML.go index 16f7aca..57befc8 100644 --- a/backend/config.go +++ b/backend/pkg/config/configYAML.go @@ -1,4 +1,4 @@ -package main +package config import ( "bytes" @@ -19,6 +19,9 @@ type ConfigYaml struct { Password string `yaml:"password"` Database string `yaml:"database"` } `yaml:"database"` + Server struct { + Port int `yaml:"port"` + } `yaml:"server"` ClientSession struct { JwtSignature string `yaml:"jwt_signature"` Expire string `yaml:"expire"` @@ -30,9 +33,9 @@ type CacheConfig struct { Purge time.Duration } -var config ConfigYaml +var YamlConfig ConfigYaml -func loadConfig() ConfigYaml { +func _loadConfig() ConfigYaml { config := ConfigYaml{} yamlFile, err := os.ReadFile(CONFIG_PATH) @@ -53,12 +56,12 @@ func loadConfig() ConfigYaml { return config } -func writeConfig() { +func WriteConfig() { buf := bytes.Buffer{} enc := yaml.NewEncoder(&buf) enc.SetIndent(2) // Can set default indent here on the encoder - if err := enc.Encode(&config); err != nil { + if err := enc.Encode(&YamlConfig); err != nil { panic(err) } else { if err := os.WriteFile(CONFIG_PATH, buf.Bytes(), 0644); err != nil { @@ -68,5 +71,5 @@ func writeConfig() { } func init() { - config = loadConfig() + YamlConfig = _loadConfig() } diff --git a/backend/pkg/db/assignments/assignments.go b/backend/pkg/db/assignments/assignments.go new file mode 100644 index 0000000..23f5fbb --- /dev/null +++ b/backend/pkg/db/assignments/assignments.go @@ -0,0 +1,30 @@ +package assignments + +import ( + "github.com/johannesbuehl/golunteer/backend/pkg/db" +) + +type assignments map[string]string + +type assignemntDB struct { + TaskName string `db:"taskName"` + UserName string `db:"userName"` +} + +func Event(eventID int) (assignments, error) { + // get the assignments from the database + var assignmentRows []assignemntDB + + if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil { + return nil, err + } else { + // transform the rows into the returned map + eventAssignments := assignments{} + + for _, assignment := range assignmentRows { + eventAssignments[assignment.TaskName] = assignment.UserName + } + + return eventAssignments, nil + } +} diff --git a/backend/pkg/db/availabilites/availabilities.go b/backend/pkg/db/availabilites/availabilities.go new file mode 100644 index 0000000..7244cdf --- /dev/null +++ b/backend/pkg/db/availabilites/availabilities.go @@ -0,0 +1,59 @@ +package availabilites + +import ( + "fmt" + "time" + + cache "github.com/jfarleyx/go-simple-cache" + "github.com/johannesbuehl/golunteer/backend/pkg/db" +) + +type availabilitesDB struct { + Id int `db:"id"` + Text string `db:"text"` + Disabled bool `db:"disabled"` +} + +type Availability struct { + Text string + Disabled bool +} + +var c *cache.Cache + +func Keys() (map[int]Availability, error) { + if availabilities, hit := c.Get("availabilites"); !hit { + refresh() + + return nil, fmt.Errorf("availabilites not stored cached") + } else { + return availabilities.(map[int]Availability), nil + } +} + +func refresh() { + // get the availabilitesRaw from the database + var availabilitesRaw []availabilitesDB + + if err := db.DB.Select(&availabilitesRaw, "SELECT * FROM AVAILABILITIES"); err == nil { + // convert the result in a map + availabilites := map[int]Availability{} + + for _, a := range availabilitesRaw { + availabilites[a.Id] = Availability{ + Text: a.Text, + Disabled: a.Disabled, + } + } + + c.Set("availabilites", availabilites) + } +} + +func init() { + c = cache.New(24 * time.Hour) + + c.OnExpired(refresh) + + refresh() +} diff --git a/backend/pkg/db/availabilites/userAvailabilities.go b/backend/pkg/db/availabilites/userAvailabilities.go new file mode 100644 index 0000000..7340291 --- /dev/null +++ b/backend/pkg/db/availabilites/userAvailabilities.go @@ -0,0 +1,36 @@ +package availabilites + +import ( + "github.com/johannesbuehl/golunteer/backend/pkg/db" + "github.com/johannesbuehl/golunteer/backend/pkg/db/users" +) + +type eventAvailabilites struct { + userName string `db:"userName"` + AvailabilityID int `db:"availabilityID"` +} + +func Event(eventID int) (map[string]string, error) { + // get the availabilites for the event + var availabilitesRows []eventAvailabilites + + if err := db.DB.Select(&availabilitesRows, "SELECT (userID, availabilityID) FROM USER_AVAILABILITES WHERE eventID = ?", eventID); err != nil { + return nil, err + } else { + // transform the result into a map + eventAvailabilities := map[string]string{} + + // get the availabilites + if availabilitesMap, err := Keys(); err != nil { + return nil, err + } else if usersMap, err := users.Get(); err != nil { + return nil, err + } else { + for _, a := range availabilitesRows { + eventAvailabilities[usersMap[a.userName].Name] = availabilitesMap[a.AvailabilityID].Text + } + + return eventAvailabilities, nil + } + } +} diff --git a/backend/pkg/db/db.go b/backend/pkg/db/db.go new file mode 100644 index 0000000..0563ef4 --- /dev/null +++ b/backend/pkg/db/db.go @@ -0,0 +1,225 @@ +package db + +import ( + "database/sql" + "fmt" + "reflect" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + _config "github.com/johannesbuehl/golunteer/backend/pkg/config" + _logger "github.com/johannesbuehl/golunteer/backend/pkg/logger" +) + +var logger = _logger.Logger +var config = _config.Config + +// connection to database +var DB *sqlx.DB + +func init() { + // setup the database-connection + sqlConfig := mysql.Config{ + AllowNativePasswords: true, + Net: "tcp", + User: config.Database.User, + Passwd: config.Database.Password, + Addr: config.Database.Host, + DBName: config.Database.Database, + } + + // connect to the database + DB = sqlx.MustOpen("mysql", sqlConfig.FormatDSN()) + DB.SetMaxIdleConns(10) + DB.SetMaxIdleConns(100) + DB.SetConnMaxLifetime(time.Minute) + +} + +// query the database +func SelectOld[T any](table string, where string, args ...any) ([]T, error) { + // validate columns against struct T + tType := reflect.TypeOf(new(T)).Elem() + columns := make([]string, tType.NumField()) + + validColumns := make(map[string]any) + for ii := 0; ii < tType.NumField(); ii++ { + field := tType.Field(ii) + validColumns[strings.ToLower(field.Name)] = struct{}{} + columns[ii] = strings.ToLower(field.Name) + } + + for _, col := range columns { + if _, ok := validColumns[strings.ToLower(col)]; !ok { + return nil, fmt.Errorf("invalid column: %s for struct type %T", col, new(T)) + } + } + + // create the query + completeQuery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(columns, ", "), table) + + if where != "" && where != "*" { + completeQuery = fmt.Sprintf("%s WHERE %s", completeQuery, where) + } + + var rows *sql.Rows + var err error + + if len(args) > 0 { + DB.Ping() + + rows, err = DB.Query(completeQuery, args...) + } else { + DB.Ping() + + rows, err = DB.Query(completeQuery) + } + + if err != nil { + logger.Error().Msgf("database access failed with error %v", err) + + return nil, err + } + + defer rows.Close() + results := []T{} + + for rows.Next() { + var lineResult T + + scanArgs := make([]any, len(columns)) + v := reflect.ValueOf(&lineResult).Elem() + + for ii, col := range columns { + field := v.FieldByName(col) + + if field.IsValid() && field.CanSet() { + scanArgs[ii] = field.Addr().Interface() + } else { + logger.Warn().Msgf("Field %s not found in struct %T", col, lineResult) + scanArgs[ii] = new(any) // save dummy value + } + } + + // scan the row into the struct + if err := rows.Scan(scanArgs...); err != nil { + logger.Warn().Msgf("Scan-error: %v", err) + + return nil, err + } + + results = append(results, lineResult) + } + + if err := rows.Err(); err != nil { + logger.Error().Msgf("rows-error: %v", err) + return nil, err + } else { + return results, nil + } +} + +// insert data intot the databse +func Insert(table string, vals any) error { + // extract columns from vals + v := reflect.ValueOf(vals) + t := v.Type() + + columns := make([]string, t.NumField()) + values := make([]any, t.NumField()) + + for ii := 0; ii < t.NumField(); ii++ { + fieldValue := v.Field(ii) + + field := t.Field(ii) + + columns[ii] = strings.ToLower(field.Name) + values[ii] = fieldValue.Interface() + } + + placeholders := strings.Repeat(("?, "), len(columns)) + placeholders = strings.TrimSuffix(placeholders, ", ") + + completeQuery := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), placeholders) + + _, err := DB.Exec(completeQuery, values...) + + return err +} + +// update data in the database +func Update(table string, set, where any) error { + setV := reflect.ValueOf(set) + setT := setV.Type() + + setColumns := make([]string, setT.NumField()) + setValues := make([]any, setT.NumField()) + + for ii := 0; ii < setT.NumField(); ii++ { + fieldValue := setV.Field(ii) + + field := setT.Field(ii) + + setColumns[ii] = strings.ToLower(field.Name) + " = ?" + setValues[ii] = fieldValue.Interface() + } + + whereV := reflect.ValueOf(where) + whereT := whereV.Type() + + whereColumns := make([]string, whereT.NumField()) + whereValues := make([]any, whereT.NumField()) + + for ii := 0; ii < whereT.NumField(); ii++ { + fieldValue := whereV.Field(ii) + + // skip empty (zero) values + if !fieldValue.IsZero() { + field := whereT.Field(ii) + + whereColumns[ii] = strings.ToLower(field.Name) + " = ?" + whereValues[ii] = fmt.Sprint(fieldValue.Interface()) + } + } + + sets := strings.Join(setColumns, ", ") + wheres := strings.Join(whereColumns, " AND ") + + placeholderValues := append(setValues, whereValues...) + + completeQuery := fmt.Sprintf("UPDATE %s SET %s WHERE %s", table, sets, wheres) + + _, err := DB.Exec(completeQuery, placeholderValues...) + + return err +} + +// remove data from the database +func Delete(table string, vals any) error { + // extract columns from vals + v := reflect.ValueOf(vals) + t := v.Type() + + columns := make([]string, t.NumField()) + values := make([]any, t.NumField()) + + for ii := 0; ii < t.NumField(); ii++ { + fieldValue := v.Field(ii) + + // skip empty (zero) values + if !fieldValue.IsZero() { + field := t.Field(ii) + + columns[ii] = strings.ToLower(field.Name) + " = ?" + values[ii] = fmt.Sprint(fieldValue.Interface()) + } + } + + completeQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", table, strings.Join(columns, ", ")) + + _, err := DB.Exec(completeQuery, values...) + + return err +} diff --git a/backend/pkg/db/events/events.go b/backend/pkg/db/events/events.go new file mode 100644 index 0000000..5246cfe --- /dev/null +++ b/backend/pkg/db/events/events.go @@ -0,0 +1,48 @@ +package events + +import ( + "github.com/johannesbuehl/golunteer/backend/pkg/db" + "github.com/johannesbuehl/golunteer/backend/pkg/db/assignments" +) + +type Event struct { + eventDataDB + Tasks []string + Assignments map[string]string +} + +type eventDataDB struct { + Id int `db:"id"` + Date string `db:"date"` + Description string `db:"description"` +} + +// transform the database-entry to an Event +func (e *eventDataDB) Event() (Event, error) { + // get the availabilites associated with the event + if assignemnts, err := assignments.Event(e.Id); err != nil { + return Event{}, err + } else { + return Event{ + eventDataDB: *e, + Assignments: assignemnts, + }, nil + } +} + +// get all the event ids +func All() (map[int]eventDataDB, error) { + var dbRows []eventDataDB + + if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS"); err != nil { + return nil, err + } else { + eventsMap := map[int]eventDataDB{} + + for _, idRow := range dbRows { + eventsMap[idRow.Id] = idRow + } + + return eventsMap, nil + } +} diff --git a/backend/pkg/db/tasks/tasks.go b/backend/pkg/db/tasks/tasks.go new file mode 100644 index 0000000..2f36311 --- /dev/null +++ b/backend/pkg/db/tasks/tasks.go @@ -0,0 +1,59 @@ +package tasks + +import ( + "fmt" + "time" + + cache "github.com/jfarleyx/go-simple-cache" + "github.com/johannesbuehl/golunteer/backend/pkg/db" +) + +type tasksDB struct { + Id int `db:"id"` + Text string `db:"text"` + Disabled bool `db:"disabled"` +} + +type Task struct { + Text string + Disabled bool +} + +var c *cache.Cache + +func Get() (map[int]Task, error) { + if tasks, hit := c.Get("tasks"); !hit { + refresh() + + return nil, fmt.Errorf("tasks not stored cached") + } else { + return tasks.(map[int]Task), nil + } +} + +func refresh() { + // get the tasksRaw from the database + var tasksRaw []tasksDB + + if err := db.DB.Select(&tasksRaw, "SELECT * FROM TASKS"); err == nil { + // convert the result in a map + tasks := map[int]Task{} + + for _, a := range tasksRaw { + tasks[a.Id] = Task{ + Text: a.Text, + Disabled: a.Disabled, + } + } + + c.Set("tasks", tasks) + } +} + +func init() { + c = cache.New(24 * time.Hour) + + c.OnExpired(refresh) + + refresh() +} diff --git a/backend/pkg/db/users/users.go b/backend/pkg/db/users/users.go new file mode 100644 index 0000000..d7ad4e8 --- /dev/null +++ b/backend/pkg/db/users/users.go @@ -0,0 +1,52 @@ +package users + +import ( + "fmt" + "time" + + cache "github.com/jfarleyx/go-simple-cache" + "github.com/johannesbuehl/golunteer/backend/pkg/db" +) + +type User struct { + Name string `db:"text"` + Password []byte `db:"password"` + TokenID string `db:"tokenID"` + Admin bool `db:"disabled"` +} + +var c *cache.Cache + +func Get() (map[string]User, error) { + if users, hit := c.Get("users"); !hit { + refresh() + + return nil, fmt.Errorf("users not stored cached") + } else { + return users.(map[string]User), nil + } +} + +func refresh() { + // get the usersRaw from the database + var usersRaw []User + + if err := db.DB.Select(&usersRaw, "SELECT * FROM USERS"); err == nil { + // convert the result in a map + users := map[string]User{} + + for _, user := range usersRaw { + users[user.Name] = user + } + + c.Set("users", users) + } +} + +func init() { + c = cache.New(24 * time.Hour) + + c.OnExpired(refresh) + + refresh() +} diff --git a/backend/pkg/lib/lib.go b/backend/pkg/lib/lib.go new file mode 100644 index 0000000..9b8c81f --- /dev/null +++ b/backend/pkg/lib/lib.go @@ -0,0 +1,30 @@ +package lib + +import ( + "fmt" + "reflect" +) + +func StrucToMap(data any) (map[string]any, error) { + result := make(map[string]any) + + v := reflect.ValueOf(data) + + if v.Kind() != reflect.Struct { + return nil, fmt.Errorf("expected a struct but got %T", data) + } + + for ii := 0; ii < v.NumField(); ii++ { + field := v.Type().Field(ii) + value := v.Field(ii) + + // skip unexported fields + if field.PkgPath != "" { + continue + } + + result[field.Tag.Get("json")] = value.Interface() + } + + return result, nil +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..106e584 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,70 @@ +package logger + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/johannesbuehl/golunteer/backend/pkg/config" + "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" +) + +var Logger zerolog.Logger + +type specificLevelWriter struct { + io.Writer + Level zerolog.Level +} + +func (w specificLevelWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { + if l >= w.Level { + return w.Write(p) + } else { + return len(p), nil + } +} + +func init() { + // try to set the log-level + zerolog.SetGlobalLevel(config.Config.LogLevel) + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + // create the console output + outputConsole := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.DateTime, + FormatLevel: func(i interface{}) string { + return strings.ToUpper(fmt.Sprintf("| %-6s|", i)) + }, + FormatFieldName: func(i interface{}) string { + return fmt.Sprintf("%s", i) + }, + NoColor: true, + } + + // create the logfile output + outputLog := &lumberjack.Logger{ + Filename: "logs/backend.log", + MaxAge: 7, + LocalTime: true, + } + + // create a multi-output-writer + multi := zerolog.MultiLevelWriter( + specificLevelWriter{ + Writer: outputConsole, + Level: config.Config.LogLevel, + }, + specificLevelWriter{ + Writer: outputLog, + Level: config.Config.LogLevel, + }, + ) + + // create a logger-instance + Logger = zerolog.New(multi).With().Timestamp().Logger() + +} diff --git a/backend/pkg/router/events.go b/backend/pkg/router/events.go new file mode 100644 index 0000000..c415bbb --- /dev/null +++ b/backend/pkg/router/events.go @@ -0,0 +1,33 @@ +package router + +import ( + "github.com/gofiber/fiber/v2" + "github.com/johannesbuehl/golunteer/backend/pkg/db/events" +) + +func getEvents(c *fiber.Ctx) responseMessage { + response := responseMessage{} + + // get all eventRows + if eventRows, err := events.All(); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("events retrieving failed: %v", err) + } else { + // get the data for all the allEvents + allEvents := []events.Event{} + + for _, eventRow := range eventRows { + if e, err := eventRow.Event(); err != nil { + logger.Error().Msgf("error while populating event with id = %d: %v", eventRow.Id, err) + } else { + allEvents = append(allEvents, e) + } + + // response.Data = struct{ Events []events.Event }{Events: allEvents} + response.Data = allEvents + } + } + + return response +} diff --git a/backend/pkg/router/login.go b/backend/pkg/router/login.go new file mode 100644 index 0000000..c68215c --- /dev/null +++ b/backend/pkg/router/login.go @@ -0,0 +1,80 @@ +package router + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/johannesbuehl/golunteer/backend/pkg/db" +) + +type UserLogin struct { + UserID int `json:"userID"` + Name string `json:"name"` + LoggedIn bool `json:"loggedIn"` +} + +// handle welcome-messages from clients +func handleWelcome(c *fiber.Ctx) error { + logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) + + response := responseMessage{} + response.Data = UserLogin{ + LoggedIn: false, + } + + if ok, err := checkUser(c); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Warn().Msgf("can't check user: %v", err) + } else if !ok { + response.Status = fiber.StatusNoContent + } else { + if uid, _, err := extractJWT(c); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Error().Msgf("can't extract JWT: %v", err) + } else { + if users, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", strconv.Itoa(uid)); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't get users from database: %v", err) + } else { + if len(users) != 1 { + response.Status = fiber.StatusForbidden + response.Message = "unknown user" + + removeSessionCookie(c) + } else { + user := users[0] + + response.Data = UserLogin{ + UserID: user.UserID, + Name: user.Name, + LoggedIn: true, + } + } + + logger.Debug().Msgf("welcomed user with uid = %v", uid) + } + } + } + + return response.send(c) +} + +func handleLogin(c *fiber.Ctx) error { + panic("not implemented yet") +} + +// handles logout-requests +func handleLogout(c *fiber.Ctx) error { + logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) + + removeSessionCookie(c) + + return responseMessage{ + Data: UserLogin{ + LoggedIn: false, + }, + }.send(c) +} diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go new file mode 100644 index 0000000..bd83802 --- /dev/null +++ b/backend/pkg/router/router.go @@ -0,0 +1,221 @@ +package router + +import ( + "fmt" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + _config "github.com/johannesbuehl/golunteer/backend/pkg/config" + "github.com/johannesbuehl/golunteer/backend/pkg/db" + _logger "github.com/johannesbuehl/golunteer/backend/pkg/logger" +) + +var logger = _logger.Logger +var config = _config.Config + +var app *fiber.App + +// general message for REST-responses +type responseMessage struct { + Status int + Message string + Data any +} + +// answer the client request with the response-message +func (result responseMessage) send(c *fiber.Ctx) error { + // if the status-code is in the error-region, return an error + if result.Status >= 400 { + // if available, include the message + if result.Message != "" { + return fiber.NewError(result.Status, result.Message) + } else { + return fiber.NewError(result.Status) + } + } else { + // if there is data, send it as JSON + if result.Data != nil { + c.JSON(result.Data) + + // if there is a message, send it instead + } else if result.Message != "" { + c.SendString(result.Message) + } + + return c.SendStatus(result.Status) + } +} + +func init() { + // setup fiber + app = fiber.New(fiber.Config{ + AppName: "johannes-pv", + DisableStartupMessage: true, + }) + + // map with the individual methods + handleMethods := map[string]func(path string, handlers ...func(*fiber.Ctx) error) fiber.Router{ + "GET": app.Get, + "POST": app.Post, + "PATCH": app.Patch, + "DELETE": app.Delete, + } + + // map with the individual registered endpoints + endpoints := map[string]map[string]func(*fiber.Ctx) responseMessage{ + "GET": {"events": getEvents}, + "POST": {}, + "PATCH": {}, + "DELETE": {}, + } + + // handle specific requests special + app.Get("/api/welcome", handleWelcome) + app.Post("/api/login", handleLogin) + app.Get("/api/logout", handleLogout) + + // register the registered endpoints + for method, handlers := range endpoints { + for address, handler := range handlers { + handleMethods[method]("/api/"+address, func(c *fiber.Ctx) error { + logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) + + return handler(c).send(c) + }) + } + } +} + +func Listen() { + // start the server + err := app.Listen(fmt.Sprintf(":%d", config.Server.Port)) + + fmt.Println(err) +} + +func setSessionCookie(c *fiber.Ctx, jwt *string) { + var value string + + if jwt == nil { + value = c.Cookies("session") + } else { + value = *jwt + } + + c.Cookie(&fiber.Cookie{ + Name: "session", + Value: value, + HTTPOnly: true, + SameSite: "strict", + MaxAge: int(config.SessionExpire.Seconds()), + }) +} + +// removes the session-coockie from a request +func removeSessionCookie(c *fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: "session", + Value: "", + HTTPOnly: true, + SameSite: "strict", + Expires: time.Unix(0, 0), + }) +} + +// payload of the JSON webtoken +type JWTPayload struct { + UserID int `json:"userID"` + TokenID string `json:"tokenID"` +} + +// complete JSON webtoken +type JWT struct { + _config.Payload + CustomClaims JWTPayload +} + +// extracts the json webtoken from the request +// +// @returns (userID, tokenID, error) +func extractJWT(c *fiber.Ctx) (int, string, error) { + // get the session-cookie + cookie := c.Cookies("session") + + token, err := jwt.ParseWithClaims(cookie, &JWT{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected JWT signing method: %v", token.Header["alg"]) + } + + return []byte(config.ClientSession.JwtSignature), nil + }) + + if err != nil { + return -1, "", err + } + + // extract the claims from the JWT + if claims, ok := token.Claims.(*JWT); ok && token.Valid { + return claims.CustomClaims.UserID, claims.CustomClaims.TokenID, nil + } else { + return -1, "", fmt.Errorf("invalid JWT") + } +} + +// user-entry in the database +type UserDB struct { + UserID int `json:"userID"` + Name string `json:"name"` + Password []byte `json:"password"` + Admin bool `json:"admin"` + TokenID string `json:"tokenID"` +} + +// checks wether the request is from a valid user +func checkUser(c *fiber.Ctx) (bool, error) { + uid, tid, err := extractJWT(c) + + if err != nil { + return false, nil + } + + // retrieve the user from the database + response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid) + + if err != nil { + return false, err + } + + // if exactly one user came back and the tID is valid, the user is authorized + if len(response) == 1 && response[0].TokenID == tid { + // reset the expiration of the cookie + setSessionCookie(c, nil) + + return true, err + } else { + return false, err + } +} + +// checks wether the request is from the admin +func checkAdmin(c *fiber.Ctx) (bool, error) { + uid, tokenID, err := extractJWT(c) + + if err != nil { + return false, err + } + + // retrieve the user from the database + response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid) + + if err != nil { + return false, err + } + + // if exactly one user came back and its name is "admin", the user is the admin + if len(response) != 1 { + return false, fmt.Errorf("user doesn't exist") + } else { + return response[0].Name == "admin" && response[0].TokenID == tokenID, err + } +} diff --git a/backend/pkg/router/user.go b/backend/pkg/router/user.go new file mode 100644 index 0000000..a36227a --- /dev/null +++ b/backend/pkg/router/user.go @@ -0,0 +1,13 @@ +package router + +import "golang.org/x/crypto/bcrypt" + +// hashes a password +func hashPassword(password string) ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) +} + +// validates a password against the password-rules +func validatePassword(password string) bool { + return len(password) >= 12 && len(password) <= 64 +} diff --git a/client/next.config.ts b/client/next.config.ts index d06c022..d8bcdb9 100644 --- a/client/next.config.ts +++ b/client/next.config.ts @@ -1,8 +1,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ - output: "export" + /* config options here */ + // output: "export", + rewrites: async () => ({ + beforeFiles: [], + afterFiles: [], + fallback: [ + { + source: "/api/:path*", + destination: "http://golunteer-frontend:8080/api/:path*", + }, + ], + }), }; export default nextConfig; diff --git a/client/package-lock.json b/client/package-lock.json index ba6cf19..567872c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,10 +15,12 @@ "@nextui-org/checkbox": "^2.3.8", "@nextui-org/date-picker": "^2.3.9", "@nextui-org/divider": "^2.2.5", + "@nextui-org/form": "^2.1.8", "@nextui-org/input": "^2.4.8", "@nextui-org/modal": "^2.2.7", "@nextui-org/radio": "^2.3.8", "@nextui-org/select": "^2.4.9", + "@nextui-org/switch": "^2.2.8", "@nextui-org/system": "^2.4.6", "@nextui-org/table": "^2.2.8", "@nextui-org/theme": "^2.4.5", @@ -1498,6 +1500,30 @@ "react-dom": ">=18 || >=19.0.0-rc.0" } }, + "node_modules/@nextui-org/switch": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@nextui-org/switch/-/switch-2.2.8.tgz", + "integrity": "sha512-wk9qQSOfUEtmdWR1omKjmEYzgMjJhVizvfW6Z0rKOiMUuSud2d4xYnUmZhU22cv2WtoPV//kBjXkYD/E/t6rdg==", + "license": "MIT", + "dependencies": { + "@nextui-org/react-utils": "2.1.3", + "@nextui-org/shared-utils": "2.1.2", + "@nextui-org/use-safe-layout-effect": "2.1.1", + "@react-aria/focus": "3.19.0", + "@react-aria/interactions": "3.22.5", + "@react-aria/switch": "3.6.10", + "@react-aria/utils": "3.26.0", + "@react-aria/visually-hidden": "3.8.18", + "@react-stately/toggle": "3.8.0", + "@react-types/shared": "3.26.0" + }, + "peerDependencies": { + "@nextui-org/system": ">=2.4.0", + "@nextui-org/theme": ">=2.4.3", + "react": ">=18 || >=19.0.0-rc.0", + "react-dom": ">=18 || >=19.0.0-rc.0" + } + }, "node_modules/@nextui-org/system": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.6.tgz", @@ -2179,6 +2205,22 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/switch": { + "version": "3.6.10", + "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.6.10.tgz", + "integrity": "sha512-FtaI9WaEP1tAmra1sYlAkYXg9x75P5UtgY8pSbe9+1WRyWbuE1QZT+RNCTi3IU4fZ7iJQmXH6+VaMyzPlSUagw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/toggle": "^3.10.10", + "@react-stately/toggle": "^3.8.0", + "@react-types/shared": "^3.26.0", + "@react-types/switch": "^3.5.7", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/table": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.16.0.tgz", @@ -2759,6 +2801,18 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-types/switch": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.7.tgz", + "integrity": "sha512-1IKiq510rPTHumEZuhxuazuXBa2Cuxz6wBIlwf3NCVmgWEvU+uk1ETG0sH2yymjwCqhtJDKXi+qi9HSgPEDwAg==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.26.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-types/table": { "version": "3.10.3", "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.10.3.tgz", diff --git a/client/package.json b/client/package.json index 2c4fc7b..0b5f20b 100644 --- a/client/package.json +++ b/client/package.json @@ -16,10 +16,12 @@ "@nextui-org/checkbox": "^2.3.8", "@nextui-org/date-picker": "^2.3.9", "@nextui-org/divider": "^2.2.5", + "@nextui-org/form": "^2.1.8", "@nextui-org/input": "^2.4.8", "@nextui-org/modal": "^2.2.7", "@nextui-org/radio": "^2.3.8", "@nextui-org/select": "^2.4.9", + "@nextui-org/switch": "^2.2.8", "@nextui-org/system": "^2.4.6", "@nextui-org/table": "^2.2.8", "@nextui-org/theme": "^2.4.5", diff --git a/client/src/app/Overview.tsx b/client/src/app/Overview.tsx index d8e26f9..370ddea 100644 --- a/client/src/app/Overview.tsx +++ b/client/src/app/Overview.tsx @@ -7,10 +7,25 @@ import AddEvent from "../components/Event/AddEvent"; import zustand from "../Zustand"; import { Button } from "@nextui-org/button"; import AssignmentTable from "@/components/Event/AssignmentTable"; +import { useAsyncList } from "@react-stately/data"; +import { apiCall } from "@/lib"; export default function EventVolunteer() { const [showAddItemDialogue, setShowAddItemDialogue] = useState(false); + // fetch the events from the server + useAsyncList({ + load: async () => { + const data = await apiCall("GET", "events"); + + console.debug(await data.json()); + + return { + items: [], + }; + }, + }); + return (

Overview

diff --git a/client/src/app/globals.css b/client/src/app/globals.css index 9ed8f05..e4b3b93 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -40,8 +40,4 @@ h6 { @apply font-headline text-highlight; } - - input { - @apply border-2 border-accent-1 bg-transparent; - } } diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index ef17c6f..43edf4f 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -1,5 +1,54 @@ -import EventVolunteer from "./Overview"; +"use client"; + +import { Input } from "@nextui-org/input"; +import { useState } from "react"; +import { ViewFilled, ViewOffFilled } from "@carbon/icons-react"; +import { Switch } from "@nextui-org/switch"; +import { Button } from "@nextui-org/button"; +import { Form } from "@nextui-org/form"; export default function Home() { - return ; + const [visibility, setVisibility] = useState(false); + + // return ; + return ( +
+

Login

+
e.preventDefault()} + > + + } + endContent={} + onValueChange={setVisibility} + isSelected={visibility} + /> + } + type={visibility ? "text" : "password"} + variant="bordered" + className="max-w-xs" + /> + + +
+ ); } diff --git a/client/src/lib.ts b/client/src/lib.ts new file mode 100644 index 0000000..8749367 --- /dev/null +++ b/client/src/lib.ts @@ -0,0 +1,56 @@ +type QueryParams = Record; + +export type APICallResult = Response & { json: () => Promise }; + +export async function apiCall( + method: "GET", + api: string, + params?: QueryParams +): Promise>; +export async function apiCall( + method: "POST" | "PATCH", + api: string, + params?: QueryParams, + body?: object +): Promise>; +export async function apiCall( + method: "DELETE", + api: string, + params?: QueryParams, + body?: object +): Promise>; +export async function apiCall( + method: "GET" | "POST" | "PATCH" | "DELETE", + api: string, + params?: QueryParams, + body?: object +): Promise> { + let url = window.origin + "/api/" + api; + + if (params) { + const urlsearchparams = new URLSearchParams( + Object.fromEntries( + Object.entries(params).map(([key, value]): [string, string] => { + if (typeof value !== "string") { + return [key, value.toString()]; + } else { + return [key, value]; + } + }) + ) + ); + + url += "?" + urlsearchparams.toString(); + } + + const response = await fetch(url, { + headers: { + "Content-Type": "application/json; charset=UTF-8" + }, + credentials: "include", + method, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + + return response; +} \ No newline at end of file diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 84e2c4a..37e9d65 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -24,16 +24,16 @@ export default { foreground: { DEFAULT: FOREGROUND, "50": FOREGROUND, - "100": "#fce8ff", - "200": "#fad0fe", - "300": "#f8abfc", - "400": "#f579f9", - "500": "#eb46ef", - "600": "#d226d3", - "700": "#af1cad", - "800": "#8f198c", - "900": "#751a70", - "950": "#4e044a", + "100": FOREGROUND, + "200": FOREGROUND, + "300": FOREGROUND, + "400": FOREGROUND, + "500": FOREGROUND, + "600": "#fce8ff", + "700": "#fad0fe", + "800": "#f8abfc", + "900": "#f579f9", + "950": "#eb46ef", }, "accent-1": ACCENT1, "accent-2": ACCENT2, diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..d6c2fe7 --- /dev/null +++ b/init.sql @@ -0,0 +1,9 @@ +INSERT INTO TASKS (text) VALUES ("Ton"), ("Livestream"), ("Kamera"), ("Licht"), ("Livestream Ton"); + +INSERT INTO AVAILABILITIES (text) VALUES ("Ja"), ("Eventuell"), ("Nein"); + +INSERT INTO EVENTS(date, description) VALUES ("2025-01-05T11:00", "Neuer Prädikant"); + +INSERT INTO USER_AVAILABILITIES (userName, eventID, availabilityID) VALUES ("admin", 1, 1); + +INSERT INTO USER_ASSIGNMENTS (eventID, taskID, userName) VALUES (1, 1, "admin"); \ No newline at end of file diff --git a/setup/.gitignore b/setup/.gitignore index 5b6b072..680c8c9 100644 --- a/setup/.gitignore +++ b/setup/.gitignore @@ -1 +1,2 @@ config.yaml +passwords \ No newline at end of file diff --git a/setup/pkg/config/config.go b/setup/pkg/config/config.go new file mode 120000 index 0000000..da509ee --- /dev/null +++ b/setup/pkg/config/config.go @@ -0,0 +1 @@ +../../../backend/pkg/config/configYAML.go \ No newline at end of file diff --git a/backend/setup.go b/setup/setup.go similarity index 96% rename from backend/setup.go rename to setup/setup.go index a1dc27f..c13f720 100644 --- a/backend/setup.go +++ b/setup/setup.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/go-sql-driver/mysql" + _config "github.com/johannesbuehl/golunteer/setup/pkg/config" "golang.org/x/crypto/bcrypt" ) @@ -29,6 +30,8 @@ func exit(e error) { } func main() { + config := &_config.YamlConfig + fmt.Println("connecting to database") // connect to the database @@ -114,5 +117,5 @@ func main() { config.ClientSession.JwtSignature = createPassword(100) // write the modified config-file - writeConfig() + _config.WriteConfig() } diff --git a/setup/setup.sql b/setup/setup.sql index cdc1e18..4aa6be7 100644 --- a/setup/setup.sql +++ b/setup/setup.sql @@ -1,44 +1,44 @@ CREATE TABLE TASKS ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - text varchar(64) NOT NULL, - disabled BOOL DEFAULT(false) + id INTEGER PRIMARY KEY AUTO_INCREMENT, + text varchar(64) NOT NULL, + disabled BOOL DEFAULT(false) ); CREATE TABLE AVAILABILITIES ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - text varchar(32) NOT NULL, - disabled BOOL DEFAULT(false) + id INTEGER PRIMARY KEY AUTO_INCREMENT, + text varchar(32) NOT NULL, + disabled BOOL DEFAULT(false) ); CREATE TABLE USERS ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - name varchar(64) NOT NULL, - password binary(60) NOT NULL, - admin BOOL NOT NULL DEFAULT(false) + name varchar(64) PRIMARY KEY, + password binary(60) NOT NULL, + admin BOOL NOT NULL DEFAULT(false), + tokenID varchar(64) DEFAULT NULL ); CREATE TABLE EVENTS ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - date DATETIME NOT NULL, - description TEXT DEFAULT("") + id INTEGER PRIMARY KEY AUTO_INCREMENT, + date DATETIME NOT NULL, + description TEXT DEFAULT("") ); CREATE TABLE USER_AVAILABILITIES ( - userID INTEGER NOT NULL, - eventID INTEGER NOT NULL, - availabilityID INTEGER NOT NULL, - PRIMARY KEY (userID, eventID), - FOREIGN KEY (userID) REFERENCES USERS(id) ON DELETE CASCADE, - FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE, - FOREIGN KEY (avaibID) REFERENCES AVAILABILITIES(id) ON DELETE RESTRICT, + userName varchar(64) NOT NULL, + eventID INTEGER NOT NULL, + availabilityID INTEGER NOT NULL, + PRIMARY KEY (userName, eventID), + FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (availabilityID) REFERENCES AVAILABILITIES(id) ON UPDATE CASCADE ); CREATE TABLE USER_ASSIGNMENTS ( - eventID INTEGER NOT NULL, - taskID INTEGER NOT NULL, - userID INTEGER NOT NULL, - PRIMARY KEY (eventID, taskID), - FOREIGN KEY (userID) REFERENCES USERS(id) ON DELETE CASCADE, - FOREIGN KEY (taskID) REFERENCES TASKS(id) ON DELETE RESTRICT, - FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE + eventID INTEGER NOT NULL, + taskID INTEGER NOT NULL, + userName varchar(64), + PRIMARY KEY (eventID, taskID), + FOREIGN KEY (eventID) REFERENCES EVENTS(id) ON DELETE CASCADE, + FOREIGN KEY (userName) REFERENCES USERS(name) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (taskID) REFERENCES TASKS(id) ON UPDATE CASCADE ); \ No newline at end of file