diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fe802ffa..cf6a165f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -5,9 +5,9 @@ name: Server on: push: - branches: ["main"] + branches: ["main", "develop"] pull_request: - branches: ["main"] + branches: ["main", "develop"] workflow_dispatch: {} jobs: diff --git a/.github/workflows/next.yml b/.github/workflows/next.yml index 9a7b8c0a..8f5f7796 100644 --- a/.github/workflows/next.yml +++ b/.github/workflows/next.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - develop pull_request: branches: - main + - develop workflow_dispatch: inputs: node-version: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e9d79455..76f20522 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2175,9 +2175,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -9403,12 +9403,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", diff --git a/server/auth/passwords.go b/server/auth/passwords.go index 58c2960d..1746be02 100644 --- a/server/auth/passwords.go +++ b/server/auth/passwords.go @@ -4,13 +4,15 @@ import ( "crypto/hmac" "crypto/sha256" "fmt" + "github.com/FachschaftMathPhysInfo/kummerkasten/utils" "golang.org/x/crypto/bcrypt" "log" - "os" ) +var envConf = utils.EnvConfig + func HashPassword(password string) (string, error) { - toHash := []byte(password + os.Getenv("PEPPER")) + toHash := []byte(password + envConf.Pepper) secretHmac := hmac.New(sha256.New, toHash) secretHmac.Write(toHash) hash, err := bcrypt.GenerateFromPassword(toHash, bcrypt.DefaultCost) @@ -23,7 +25,7 @@ func HashPassword(password string) (string, error) { } func VerifyPassword(storedHash, providedPassword string) error { - toHash := []byte(providedPassword + os.Getenv("PEPPER")) + toHash := []byte(providedPassword + envConf.Pepper) secretHmac := hmac.New(sha256.New, toHash) secretHmac.Write(toHash) diff --git a/server/db/init.go b/server/db/init.go index 667a5cd4..bcc67df8 100644 --- a/server/db/init.go +++ b/server/db/init.go @@ -4,8 +4,8 @@ import ( "context" "database/sql" "fmt" + "github.com/FachschaftMathPhysInfo/kummerkasten/utils" "log" - "os" "time" "github.com/FachschaftMathPhysInfo/kummerkasten/models" @@ -16,10 +16,11 @@ import ( ) var ( - db *bun.DB - sqldb *sql.DB - err error - tables = []interface{}{ + envConf = utils.EnvConfig + db *bun.DB + sqldb *sql.DB + err error + tables = []interface{}{ (*models.User)(nil), (*models.Label)(nil), (*models.Setting)(nil), @@ -39,11 +40,11 @@ const PingIntervalDBConnection = 5 * time.Second func Init(ctx context.Context) (*sql.DB, *bun.DB) { dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", - os.Getenv("POSTGRES_USER"), - os.Getenv("POSTGRES_PASSWORD"), - os.Getenv("POSTGRES_HOST"), - os.Getenv("POSTGRES_PORT"), - os.Getenv("POSTGRES_DB")) + envConf.PostgresUser, + envConf.PostgresPassword, + envConf.PostgresHost, + envConf.PostgresPort, + envConf.PostgresDB) sqldb = sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn))) diff --git a/server/db/seed.go b/server/db/seed.go index 8bdf35c8..f0b0e038 100644 --- a/server/db/seed.go +++ b/server/db/seed.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "os" "time" "github.com/FachschaftMathPhysInfo/kummerkasten/auth" @@ -21,12 +20,200 @@ func SeedData(ctx context.Context, db *bun.DB) error { if err := createSettings(ctx, db); err != nil { return err } + if err := createDefaultLabels(ctx, db); err != nil { + return err + } + + if envConf.Env != "PROD" { + if err := seedTestData(ctx, db); err != nil { + return err + } + } + + return nil +} + +func createAdminUser(ctx context.Context, db *bun.DB) error { + var err error + mail := envConf.AdminMail + password := envConf.AdminPassword + envMode := envConf.Env + defaultPassword := "admin" + defaultMail := "admin@kummer.kasten" + + if mail == "" { + mail = defaultMail + } + + exists, err := db.NewSelect().Model((*models.User)(nil)).Where("mail = ?", mail).Exists(ctx) + + if err != nil { + log.Printf("Error scanning for admin user: %v", err) + return err + } + + if exists { + log.Printf("Admin user with email %s already exists, skipping creation", mail) + + adminUser := new(models.User) + + err = db.NewSelect(). + Model(adminUser). + Where("mail = ?", mail). + Scan(ctx) + if err != nil { + return err + } + + isStoredPasswordCorrectErr := auth.VerifyPassword(adminUser.Password, password) + + if isStoredPasswordCorrectErr != nil { + log.Print("New admin password detected, updating user") + + _, err := db.NewDelete().Model((*models.Session)(nil)).Where("user_id = ?", adminUser.ID).Exec(ctx) + + if err != nil { + log.Printf("error: %v", err) + log.Printf("Failed invalidating admin sessions on admin password update") + return err + } + + newPassword, err := auth.HashPassword(password) + + if err != nil { + log.Printf("error: %v", err) + log.Printf("Failed updating admin password, aborting") + return err + } + + adminUser.Password = newPassword + + _, err = db.NewUpdate().Model(adminUser).WherePK().Exec(ctx) + + if err != nil { + log.Printf("error: %v", err) + log.Printf("Failed updating admin password, aborting") + return err + } + + log.Printf("Admin password updated successfully") + } - if os.Getenv("ENV") != "DEV" { - fmt.Printf("Skipping test data seeding (ENV != DEV)") return nil } + if password == "" { + if envMode == "PROD" { + password, err = utils.RandString(32) + if err != nil { + return err + } + } else { + password = defaultPassword + } + } + + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + + admin := &models.User{ + Mail: mail, + Firstname: "Admin", + Lastname: "Kummerkasten", + Password: hash, + Role: model.UserRoleAdmin, + CreatedAt: time.Now(), + LastModified: time.Now(), + } + + if _, err := db.NewInsert().Model(admin).Exec(ctx); err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + log.Printf("Admin user created with email: %s", mail) + log.Printf("Admin user created with password: %s", password) + return nil +} + +func createSettings(ctx context.Context, db *bun.DB) error { + const contactLinkKey = "FOOTER_CONTACT_LINK" + const legalNoticeKey = "FOOTER_LEGAL_NOTICE_LINK" + const aboutSectionTextKey = "ABOUT_SECTION_TEXT" + + settings := []*models.Setting{ + {Key: contactLinkKey, Value: "https://mathphys.stura.uni-heidelberg.de/kontakt/"}, + {Key: legalNoticeKey, Value: "https://mathphys.stura.uni-heidelberg.de/"}, + {Key: aboutSectionTextKey, Value: "Der Kummerkasten ist das Feedbacksammlungssystem der Fachschaft. Er hilft bei Problemen in Vorlesungen (und bei Problemen mit anderen Institutionen, denen Studenten im Unialltag begegnen). \nDen Digitalen Kummerkasten findest du hier. Der analoge Kummerkasten steht im Gang vor dem Fachschaftsraum (bei den Flyern vor der Teeküche)."}, + } + + keys := []string{contactLinkKey, legalNoticeKey, aboutSectionTextKey} + existing := make([]*models.Setting, 0) + + if err := db.NewSelect(). + Model(&existing). + Where("key IN (?)", bun.In(keys)). + Scan(ctx); err != nil { + return fmt.Errorf("failed to fetch settings: %w", err) + } + + existingKeys := make(map[string]bool) + for _, s := range existing { + existingKeys[s.Key] = true + } + + toInsert := make([]*models.Setting, 0) + for _, s := range settings { + if !existingKeys[s.Key] { + toInsert = append(toInsert, s) + } else { + log.Printf("Skipping seeding for existing setting: %s", s.Key) + } + } + + if len(toInsert) > 0 { + if _, err := db.NewInsert().Model(&toInsert).Exec(ctx); err != nil { + return fmt.Errorf("failed to insert settings: %w", err) + } + } + + return nil +} + +func createDefaultLabels(ctx context.Context, db *bun.DB) error { + labels := []*models.Label{ + { + Name: "dozent*in", + Color: "#474770", + FormLabel: true, + }, + { + Name: "veranstaltung", + Color: "#47704e", + FormLabel: true, + }, + { + Name: "fachschaft", + Color: "#477068", + FormLabel: true, + }, + { + Name: "sonstiges", + Color: "#6a4770", + FormLabel: true, + }, + } + + if err := insertData(ctx, db, (*models.Label)(nil), labels, "Labels"); err != nil { + return err + } + + return nil +} + +func seedTestData(ctx context.Context, db *bun.DB) error { + if err := seedTestUsers(ctx, db); err != nil { return err } @@ -341,105 +528,6 @@ func SeedData(ctx context.Context, db *bun.DB) error { return nil } -func createAdminUser(ctx context.Context, db *bun.DB) error { - var err error - - mail := os.Getenv("ADMIN_MAIL") - if mail == "" { - mail = "admin@kummer.kasten" - } - - exists, err := db.NewSelect(). - Model((*models.User)(nil)). - Where("mail = ?", mail). - Exists(ctx) - if err != nil { - return err - } - if exists { - log.Printf("Admin user with email %s already exists, skipping creation\n", mail) - return nil - } - - password := "admin" - if os.Getenv("ENV") == "PROD" { - if os.Getenv("ADMIN_PASSWORD") != "" { - password = os.Getenv("ADMIN_PASSWORD") - } else { - password, err = utils.RandString(32) - if err != nil { - return err - } - } - } - hash, err := auth.HashPassword(password) - if err != nil { - return err - } - - admin := &models.User{ - Mail: mail, - Firstname: "Admin", - Lastname: "Kummerkasten", - Password: hash, - Role: model.UserRoleAdmin, - CreatedAt: time.Now(), - LastModified: time.Now(), - } - - if _, err := db.NewInsert().Model(admin).Exec(ctx); err != nil { - return fmt.Errorf("failed to create admin user: %w", err) - } - - log.Printf("Admin user created with email: %s", mail) - log.Printf("Admin user created with password: %s", password) - return nil -} - -func createSettings(ctx context.Context, db *bun.DB) error { - const contactLinkKey = "FOOTER_CONTACT_LINK" - const legalNoticeKey = "FOOTER_LEGAL_NOTICE_LINK" - const aboutSectionTextKey = "ABOUT_SECTION_TEXT" - - settings := []*models.Setting{ - {Key: contactLinkKey, Value: "https://mathphys.stura.uni-heidelberg.de/kontakt/"}, - {Key: legalNoticeKey, Value: "https://mathphys.stura.uni-heidelberg.de/"}, - {Key: aboutSectionTextKey, Value: "Der Kummerkasten ist das Feedbacksammlungssystem der Fachschaft. Er hilft bei Problemen in Vorlesungen (und bei Problemen mit anderen Institutionen, denen Studenten im Unialltag begegnen). \nDen Digitalen Kummerkasten findest du hier. Der analoge Kummerkasten steht im Gang vor dem Fachschaftsraum (bei den Flyern vor der Teeküche)."}, - } - - keys := []string{contactLinkKey, legalNoticeKey, aboutSectionTextKey} - existing := make([]*models.Setting, 0) - - if err := db.NewSelect(). - Model(&existing). - Where("key IN (?)", bun.In(keys)). - Scan(ctx); err != nil { - return fmt.Errorf("failed to fetch settings: %w", err) - } - - existingKeys := make(map[string]bool) - for _, s := range existing { - existingKeys[s.Key] = true - } - - toInsert := make([]*models.Setting, 0) - for _, s := range settings { - if !existingKeys[s.Key] { - toInsert = append(toInsert, s) - } else { - log.Printf("Skipping seeding for existing setting: %s", s.Key) - } - } - - if len(toInsert) > 0 { - if _, err := db.NewInsert().Model(&toInsert).Exec(ctx); err != nil { - return fmt.Errorf("failed to insert settings: %w", err) - } - } - - return nil -} - func seedTestUsers(ctx context.Context, db *bun.DB) error { testEmails := []string{ "cheffe@kummerkasten.local", diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index 21b3a232..18bf0427 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -10,7 +10,6 @@ import ( "fmt" "log" "net/http" - "os" "regexp" "strings" "time" @@ -20,6 +19,7 @@ import ( "github.com/FachschaftMathPhysInfo/kummerkasten/graph/utils" "github.com/FachschaftMathPhysInfo/kummerkasten/middleware" "github.com/FachschaftMathPhysInfo/kummerkasten/models" + env "github.com/FachschaftMathPhysInfo/kummerkasten/utils" "github.com/google/uuid" "github.com/uptrace/bun" ) @@ -457,7 +457,7 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id string, user model Value: newSid, Path: "/", HttpOnly: true, - Secure: os.Getenv("ENV") != "DEV", + Secure: env.EnvConfig.Env != "DEV", SameSite: http.SameSiteLaxMode, Expires: expiresAt, }) @@ -926,7 +926,7 @@ func (r *mutationResolver) UpdateQuestionAnswerPair(ctx context.Context, id stri return qAP.ID, nil } -// UpdateQuestionAnswerPairBatchPositons is the resolver for the updateQuestionAnswerPairBatchPositons field. +// UpdateQuestionAnswerPairBatchPositions is the resolver for the updateQuestionAnswerPairBatchPositons field. func (r *mutationResolver) UpdateQuestionAnswerPairBatchPositions(ctx context.Context, questionAnswerPairs []*model.UpdateQuestionAnswerPairPosition) (bool, error) { amountQAPsInDB, err := r.DB.NewSelect().Model((*model.QuestionAnswerPair)(nil)).Count(ctx) if err != nil { @@ -1268,9 +1268,9 @@ func (r *queryResolver) Login(ctx context.Context, mail string, password string) Name: "sid", Value: newSid, Path: "/", - Domain: os.Getenv("PUBLIC_DOMAIN"), + Domain: env.EnvConfig.PublicDomain, HttpOnly: true, - Secure: os.Getenv("ENV") != "DEV", + Secure: env.EnvConfig.Env != "DEV", SameSite: http.SameSiteLaxMode, Expires: expiresAt, }) @@ -1318,7 +1318,6 @@ func (r *queryResolver) LoginCheck(ctx context.Context, sid *string) (*model.Use } if sessions == nil { - log.Printf("Found no session for user with id: %v", *sid) return nil, nil } diff --git a/server/server.go b/server/server.go index f377fe88..8de79784 100644 --- a/server/server.go +++ b/server/server.go @@ -2,11 +2,11 @@ package main import ( "context" + "github.com/FachschaftMathPhysInfo/kummerkasten/utils" "github.com/gorilla/websocket" "github.com/robfig/cron" "log" "net/http" - "os" "time" "github.com/99designs/gqlgen/graphql/handler" @@ -30,8 +30,9 @@ import ( const port = "8080" +var envConf = utils.EnvConfig + var ( - env = os.Getenv("ENV") frontendUrl, _ = url.Parse("http://localhost:3000") cronjob *cron.Cron srv *handler.Server @@ -42,7 +43,7 @@ var ( ) func main() { - if env == "DEV" { + if envConf.Env == "DEV" { log.Print("====== WARNING ======") log.Print("Software is starting in DEV mode, which is insecure in production") log.Print("====== ======= ======") @@ -64,7 +65,7 @@ func main() { router.Mount("/api", getAPIRouter()) - if env == "DEV" { + if envConf.Env == "DEV" { router.Handle("/playground", playground.Handler("GraphQL playground", "/api")) } @@ -95,9 +96,9 @@ func initGraphQL() { } func initCors() { - var allowedOrigins = []string{os.Getenv("PUBLIC_DOMAIN")} + var allowedOrigins = []string{envConf.PublicDomain} - if env == "DEV" { + if envConf.Env == "DEV" { allowedOrigins = append(allowedOrigins, "localhost:3000", "localhost:8080") } @@ -105,7 +106,7 @@ func initCors() { AllowedOrigins: allowedOrigins, AllowedHeaders: []string{"*"}, AllowCredentials: true, - Debug: os.Getenv("DEBUG") == "true", + Debug: false, }) } @@ -145,7 +146,7 @@ func initCron() { }) srv.Use(extension.Introspection{}) - if os.Getenv("DEBUG") != "" { + if envConf.Env == "DEV" { http.Handle("/playground", playground.Handler("GraphQL playground", "/query")) http.Handle("/query", srv) diff --git a/server/utils/environment.go b/server/utils/environment.go new file mode 100644 index 00000000..d68c353c --- /dev/null +++ b/server/utils/environment.go @@ -0,0 +1,61 @@ +package utils + +import ( + "log" + "os" +) + +const ( + EnvPostgresUser = "POSTGRES_USER" + EnvPostgresPassword = "POSTGRES_PASSWORD" + EnvPostgresDB = "POSTGRES_DB" + EnvPostgresPort = "POSTGRES_PORT" + EnvPostgresHost = "POSTGRES_HOST" + EnvAdminMail = "ADMIN_MAIL" + EnvAdminPassword = "ADMIN_PASSWORD" + EnvPepper = "PEPPER" + EnvPublicDomain = "PUBLIC_DOMAIN" + EnvEnv = "ENV" +) + +type Config struct { + PostgresUser string + PostgresPassword string + PostgresDB string + PostgresPort string + PostgresHost string + AdminMail string + AdminPassword string + Pepper string + PublicDomain string + Env string +} + +func loadEnvConfig() *Config { + cfg := &Config{ + PostgresUser: mustGet(EnvPostgresUser), + PostgresPassword: mustGet(EnvPostgresPassword), + PostgresDB: mustGet(EnvPostgresDB), + PostgresPort: mustGet(EnvPostgresPort), + PostgresHost: mustGet(EnvPostgresHost), + AdminMail: os.Getenv(EnvAdminMail), + AdminPassword: os.Getenv(EnvAdminPassword), + Pepper: os.Getenv(EnvPepper), + PublicDomain: mustGet(EnvPublicDomain), + Env: mustGet(EnvEnv), + } + + return cfg +} + +func mustGet(key string) string { + value := os.Getenv(key) + + if value == "" { + log.Fatalf("entry missing but required for environment variable: %s", key) + } + + return value +} + +var EnvConfig = loadEnvConfig()