started working on the backend

This commit is contained in:
z1glr
2025-01-07 16:37:37 +00:00
parent 03b2b0e206
commit c3bc06fe82
34 changed files with 1437 additions and 54 deletions

View File

@@ -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",

15
backend/.vscode/launch.json vendored Normal file
View File

@@ -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}"
}
]
}

View File

@@ -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
)

54
backend/go.sum Normal file
View File

@@ -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=

27
backend/logs/backend.log Normal file
View File

@@ -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\""}

9
backend/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/johannesbuehl/golunteer/backend/pkg/router"
)
func main() {
router.Listen()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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
}
}
}

225
backend/pkg/db/db.go Normal file
View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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()
}

30
backend/pkg/lib/lib.go Normal file
View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -1,118 +0,0 @@
package main
import (
"database/sql"
"fmt"
"math/rand/v2"
"os"
"regexp"
"strings"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
func createPassword(l int) string {
passwordChars := [...]string{`A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, `I`, `J`, `K`, `L`, `M`, `N`, `O`, `P`, `Q`, `R`, `S`, `T`, `U`, `V`, `W`, `X`, `Y`, `Z`, `Ä`, `Ö`, `Ü`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `h`, `i`, `j`, `k`, `l`, `m`, `n`, `o`, `p`, `q`, `r`, `s`, `t`, `u`, `v`, `w`, `x`, `y`, `z`, `ä`, `ö`, `ü`, `ß`, `0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `!`, `"`, `§`, `$`, `%`, `&`, `/`, `(`, `)`, `=`, `?`, `@`, `{`, `}`, `[`, `]`, `#`, `+`, `'`, `*`, `,`, `.`, `-`, `;`, `:`, `_`, `<`, `>`, `|`, `°`}
var password string
for ii := 0; ii < l; ii++ {
password += passwordChars[rand.IntN(len(passwordChars))]
}
return password
}
func exit(e error) {
fmt.Printf("%v\n", e)
os.Exit(1)
}
func main() {
fmt.Println("connecting to database")
// connect to the database
sqlConfig := mysql.Config{
AllowNativePasswords: true,
Net: "tcp",
User: config.Database.User,
Passwd: config.Database.Password,
Addr: config.Database.Host,
DBName: config.Database.Database,
}
db, err := sql.Open("mysql", sqlConfig.FormatDSN())
if err != nil {
exit(err)
}
// load the sql-script
fmt.Println(`reading "setup.sql"`)
var sqlScriptCommands []byte
if c, err := os.ReadFile("setup.sql"); err != nil {
exit(err)
} else {
sqlScriptCommands = c
}
// read the currently availabe tables
fmt.Println("reading available tables in database")
if rows, err := db.Query("SHOW TABLES"); err != nil {
exit(err)
} else {
defer rows.Close()
fmt.Println("checking for already existing tables in database")
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
exit(err)
} else {
// check wether for the table there exists a create command
if match, err := regexp.Match(fmt.Sprintf(`(?i)^create table %s`, name), sqlScriptCommands); err != nil {
exit(err)
} else {
if match {
exit(fmt.Errorf("can't setup databases: table %q already exists", name))
}
}
}
}
}
// everything is good (so far), create the tables
fmt.Println("Creating the individual tables:")
for _, cmd := range strings.Split(string(sqlScriptCommands), ";") {
db.Exec(cmd)
}
fmt.Println("Creating admin-password:")
// create an admin-password
const passwordLength = 20
password := createPassword(passwordLength)
// hash the admin-password
if passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err != nil {
exit(err)
} else {
fmt.Println("\thashed password")
// create an admin-user
if _, err := db.Exec("INSERT INTO USERS (name, password) VALUES ('admin', ?)", passwordHash); err != nil {
exit(err)
}
fmt.Println("\twrote hashed password to database")
}
fmt.Printf("created user \"admin\" with password %s\n", password)
// create a jwt-signature
config.ClientSession.JwtSignature = createPassword(100)
// write the modified config-file
writeConfig()
}