feat: improve app security and error handling

Improve overall app security by:
- using dynamic statements for all sql querries
- introducing environment variables for initial admin password
- introducing enironment variable for cors address
- improving error handling
This commit is contained in:
Patryk Hegenberg 2025-11-09 12:13:47 +01:00
parent 95057c1b8d
commit 3ac1947106
11 changed files with 1333 additions and 453 deletions

205
backend/errors.go Normal file
View file

@ -0,0 +1,205 @@
package main
import (
"fmt"
"net/http"
)
type ErrorCode string
const (
// Authentifizierung
ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrTokenExpired ErrorCode = "TOKEN_EXPIRED"
ErrAccessDenied ErrorCode = "ACCESS_DENIED"
// Validierung
ErrInvalidInput ErrorCode = "INVALID_INPUT"
ErrMissingField ErrorCode = "MISSING_FIELD"
ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT"
ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT"
// Ressourcen
ErrNotFound ErrorCode = "NOT_FOUND"
ErrAlreadyExists ErrorCode = "ALREADY_EXISTS"
ErrCannotDelete ErrorCode = "CANNOT_DELETE"
ErrProtectedUser ErrorCode = "PROTECTED_USER"
ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR"
// Datenbank
ErrDatabase ErrorCode = "DATABASE_ERROR"
ErrTransaction ErrorCode = "TRANSACTION_ERROR"
ErrQueryFailed ErrorCode = "QUERY_FAILED"
// Server
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
)
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
UserMsg string `json:"user_message"`
HTTPStatus int `json:"-"`
Internal error `json:"-"`
}
func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError {
return &AppError{
Code: code,
Message: message,
UserMsg: userMsg,
HTTPStatus: httpStatus,
Internal: internal,
}
}
func ErrInvalidCredentialsMsg() *AppError {
return NewAppError(
ErrInvalidCredentials,
"Invalid username or password",
"Benutzername oder Passwort ungültig",
http.StatusUnauthorized,
nil,
)
}
func ErrUnauthorizedMsg() *AppError {
return NewAppError(
ErrUnauthorized,
"Unauthorized access",
"Keine Berechtigung für diese Aktion",
http.StatusUnauthorized,
nil,
)
}
func ErrTokenExpiredMsg() *AppError {
return NewAppError(
ErrTokenExpired,
"Token has expired",
"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an",
http.StatusUnauthorized,
nil,
)
}
func ErrAccessDeniedMsg() *AppError {
return NewAppError(
ErrAccessDenied,
"Access denied - admin privileges required",
"Zugriff verweigert. Administrator-Rechte erforderlich",
http.StatusForbidden,
nil,
)
}
func ErrInvalidInputMsg(field string) *AppError {
return NewAppError(
ErrInvalidInput,
fmt.Sprintf("Invalid input for field: %s", field),
fmt.Sprintf("Ungültige Eingabe im Feld: %s", field),
http.StatusBadRequest,
nil,
)
}
func ErrMissingFieldMsg(field string) *AppError {
return NewAppError(
ErrMissingField,
fmt.Sprintf("Required field missing: %s", field),
fmt.Sprintf("Pflichtfeld fehlt: %s", field),
http.StatusBadRequest,
nil,
)
}
func ErrNotFoundMsg(resource string) *AppError {
return NewAppError(
ErrNotFound,
fmt.Sprintf("%s not found", resource),
fmt.Sprintf("%s nicht gefunden", resource),
http.StatusNotFound,
nil,
)
}
func ErrAlreadyExistsMsg(resource string) *AppError {
return NewAppError(
ErrAlreadyExists,
fmt.Sprintf("%s already exists", resource),
fmt.Sprintf("%s existiert bereits", resource),
http.StatusConflict,
nil,
)
}
func ErrCannotDeleteMsg(resource, reason string) *AppError {
return NewAppError(
ErrCannotDelete,
fmt.Sprintf("Cannot delete %s: %s", resource, reason),
fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason),
http.StatusBadRequest,
nil,
)
}
func ErrProtectedUserMsg() *AppError {
return NewAppError(
ErrProtectedUser,
"Cannot modify protected admin user",
"Der Admin-Benutzer ist geschützt und kann nicht geändert werden",
http.StatusForbidden,
nil,
)
}
func ErrNoActiveSchoolYearMsg() *AppError {
return NewAppError(
ErrNoActiveSchool,
"No active school year configured",
"Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr",
http.StatusNotFound,
nil,
)
}
func ErrDatabaseMsg(internal error) *AppError {
return NewAppError(
ErrDatabase,
"Database operation failed",
"Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut",
http.StatusInternalServerError,
internal,
)
}
func ErrInternalMsg(internal error) *AppError {
return NewAppError(
ErrInternal,
"Internal server error",
"Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut",
http.StatusInternalServerError,
internal,
)
}
type ErrorResponse struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
func (e *AppError) ToResponse() ErrorResponse {
return ErrorResponse{
Code: e.Code,
Message: e.UserMsg,
}
}

View file

@ -12,7 +12,9 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

View file

@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -11,6 +13,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=

View file

@ -3,10 +3,13 @@ package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
@ -15,24 +18,60 @@ type App struct {
DB *sql.DB
}
func HandleError(c echo.Context, err *AppError) error {
log.Printf("[%s] %s", err.Code, err.Error())
return c.JSON(err.HTTPStatus, err.ToResponse())
}
func getClaims(c echo.Context) (*Claims, error) {
user, ok := c.Get("user").(*jwt.Token)
if !ok {
return nil, fmt.Errorf("JWT token missing or invalid")
}
claims, ok := user.Claims.(*Claims)
if !ok {
return nil, fmt.Errorf("failed to parse JWT claims")
}
return claims, nil
}
func isDuplicateError(err error) bool {
return err != nil && (err.Error() == "UNIQUE constraint failed" ||
strings.Contains(err.Error(), "UNIQUE") ||
strings.Contains(err.Error(), "duplicate"))
}
func (app *App) LoginHandler(c echo.Context) error {
var req LoginRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
return HandleError(c, ErrInvalidInputMsg("Login-Daten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
}
user, err := GetUserByUsername(app.DB, req.Username)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
if err == sql.ErrNoRows {
return HandleError(c, ErrInvalidCredentialsMsg())
}
return HandleError(c, ErrDatabaseMsg(err))
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
return HandleError(c, ErrInvalidCredentialsMsg())
}
token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "error creating token")
return HandleError(c, ErrInternalMsg(err))
}
response := LoginResponse{
@ -47,7 +86,7 @@ func (app *App) LoginHandler(c echo.Context) error {
func (app *App) GetSchedulesHandler(c echo.Context) error {
schedules, err := GetAllSchedules(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, schedules)
}
@ -55,24 +94,40 @@ func (app *App) GetSchedulesHandler(c echo.Context) error {
func (app *App) CreateScheduleHandler(c echo.Context) error {
var schedule Schedule
if err := c.Bind(&schedule); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten"))
}
if schedule.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if schedule.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
if schedule.Title == "" {
return HandleError(c, ErrMissingFieldMsg("Titel"))
}
if err := CreateSchedule(app.DB, &schedule); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"})
return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"})
}
func (app *App) DeleteScheduleHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
}
if err := DeleteSchedule(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Stundenplan"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
@ -81,7 +136,7 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error {
func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
hours, err := GetYearlyHoursSummary(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if hours == nil {
hours = []WeeklyHours{}
@ -90,11 +145,6 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
}
func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
isAdmin, _ := c.Get("is_admin").(bool)
if !isAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others")
}
var req struct {
UserID int `json:"user_id"`
Date string `json:"date"`
@ -103,7 +153,17 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if req.UserID == 0 {
return HandleError(c, ErrMissingFieldMsg("Benutzer"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.Hours == 0 {
return HandleError(c, ErrMissingFieldMsg("Stunden"))
}
entry := TimeEntry{
@ -115,16 +175,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
}
if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusCreated)
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetUsersHandler(c echo.Context) error {
users, err := GetAllUsers(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if users == nil {
users = []User{}
@ -135,39 +195,62 @@ func (app *App) GetUsersHandler(c echo.Context) error {
func (app *App) DeleteUserHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if id == 1 {
return HandleError(c, ErrProtectedUserMsg())
}
if err := DeleteUser(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) CreateTimeEntryHandler(c echo.Context) error {
userID := c.Get("user_id").(int)
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var entry TimeEntry
if err := c.Bind(&entry); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
entry.UserID = userID
if entry.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if entry.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if entry.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
entry.UserID = claims.UserID
if err := CreateTimeEntry(app.DB, &entry); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"})
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"})
}
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
userID := c.Get("user_id").(int)
entries, err := GetTimeEntriesByUser(app.DB, userID)
claims, err := getClaims(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrUnauthorizedMsg())
}
entries, err := GetTimeEntriesByUser(app.DB, claims.UserID)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
@ -179,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeekDates(c echo.Context) error {
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if week < 1 || week > 53 {
return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)"))
}
dates := calculateWeekDates(year, week)
@ -192,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error {
}
func (app *App) CheckWeekHasEntries(c echo.Context) error {
userID := c.Get("user_id").(int)
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week)
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
@ -215,7 +305,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error {
func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
entries, err := GetAllTimeEntries(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
@ -226,7 +316,7 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
hours, err := GetWeeklyHours(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if hours == nil {
hours = []WeeklyHours{}
@ -235,20 +325,23 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
}
func (app *App) DeleteWeekEntries(c echo.Context) error {
userID := c.Get("user_id").(int)
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
@ -310,52 +403,70 @@ type BatchTimeEntryRequest struct {
}
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
userID := c.Get("user_id").(int)
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var req BatchTimeEntryRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if len(req.Entries) == 0 {
return HandleError(c, ErrMissingFieldMsg("Zeiteinträge"))
}
tx, err := app.DB.Begin()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "transaction error")
return HandleError(c, ErrDatabaseMsg(err))
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "prepare error")
return HandleError(c, ErrDatabaseMsg(err))
}
defer stmt.Close()
for _, entry := range req.Entries {
_, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
_, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "insert error")
return HandleError(c, ErrDatabaseMsg(err))
}
}
if err := tx.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "commit error")
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"})
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"})
}
func (app *App) UpdateUserHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if userID == 1 {
return HandleError(c, ErrProtectedUserMsg())
}
var req UpdateUserRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
}
if req.YearlyHours <= 0 {
return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)"))
}
if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
@ -364,21 +475,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error {
func (app *App) ResetPasswordHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
var req ResetPasswordRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return HandleError(c, ErrInvalidInputMsg("Passwort-Daten"))
}
if len(req.NewPassword) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
return HandleError(c, ErrInternalMsg(err))
}
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
@ -387,16 +505,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error {
func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
}
var req UpdateTimeEntryRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if req.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
@ -405,22 +536,31 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
}
if err := DeleteTimeEntry(app.DB, entryID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetMyInfoHandler(c echo.Context) error {
userID := c.Get("user_id").(int)
user, err := GetUserByID(app.DB, userID)
claims, err := getClaims(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrUnauthorizedMsg())
}
user, err := GetUserByID(app.DB, claims.UserID)
if err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, user)
@ -429,12 +569,22 @@ func (app *App) GetMyInfoHandler(c echo.Context) error {
func (app *App) CreateUserHandler(c echo.Context) error {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
}
if len(req.Password) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
return HandleError(c, ErrInternalMsg(err))
}
if req.YearlyHours == 0 {
@ -442,7 +592,10 @@ func (app *App) CreateUserHandler(c echo.Context) error {
}
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Benutzername"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusCreated)
@ -451,7 +604,7 @@ func (app *App) CreateUserHandler(c echo.Context) error {
func (app *App) GetSchoolYearsHandler(c echo.Context) error {
years, err := GetAllSchoolYears(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if years == nil {
years = []SchoolYear{}
@ -462,11 +615,24 @@ func (app *App) GetSchoolYearsHandler(c echo.Context) error {
func (app *App) CreateSchoolYearHandler(c echo.Context) error {
var req CreateSchoolYearRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return HandleError(c, ErrInvalidInputMsg("Schuljahr-Daten"))
}
if req.Name == "" {
return HandleError(c, ErrMissingFieldMsg("Name"))
}
if req.StartDate == "" {
return HandleError(c, ErrMissingFieldMsg("Startdatum"))
}
if req.EndDate == "" {
return HandleError(c, ErrMissingFieldMsg("Enddatum"))
}
if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Schuljahr"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusCreated)
@ -475,11 +641,14 @@ func (app *App) CreateSchoolYearHandler(c echo.Context) error {
func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID")
return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID"))
}
if err := SetActiveSchoolYear(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Schuljahr"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
@ -488,7 +657,7 @@ func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
year, err := GetActiveSchoolYear(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
if year == nil {
return c.JSON(http.StatusOK, map[string]any{"active": false})
@ -497,24 +666,22 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
}
func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error {
isAdmin, _ := c.Get("is_admin").(bool)
if !isAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs")
}
schoolYear, err := GetActiveSchoolYear(app.DB)
if err != nil || schoolYear == nil {
return echo.NewHTTPError(http.StatusNotFound, "No active school year found")
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if schoolYear == nil {
return HandleError(c, ErrNoActiveSchoolYearMsg())
}
summary, err := GetYearlyHoursSummary(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return HandleError(c, ErrDatabaseMsg(err))
}
pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error())
return HandleError(c, ErrInternalMsg(err))
}
filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name)

View file

@ -4,6 +4,7 @@ import (
"log"
"net/http"
"os"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
@ -24,8 +25,20 @@ func main() {
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// CORS Configuration
allowOrigins := []string{"*"} // Default for development
if os.Getenv("ENVIRONMENT") == "production" {
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
if origins != "" {
allowOrigins = strings.Split(origins, ",")
} else {
log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.")
}
}
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowOrigins: allowOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
}))

View file

@ -1,17 +1,13 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/time/rate"
@ -28,104 +24,43 @@ func init() {
}
func createToken(userID int, username string, isAdmin bool) (string, error) {
claims := Claims{
claims := &Claims{
UserID: userID,
Username: username,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
},
}
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
claimsWithExp := map[string]any{
"user_id": claims.UserID,
"username": claims.Username,
"is_admin": claims.IsAdmin,
"exp": time.Now().Add(2 * time.Hour).Unix(),
}
payload, _ := json.Marshal(claimsWithExp)
payloadEncoded := base64.RawURLEncoding.EncodeToString(payload)
message := header + "." + payloadEncoded
h := hmac.New(sha256.New, jwtSecret)
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return message + "." + signature, nil
}
func verifyToken(tokenString string) (*Claims, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid token format")
}
message := parts[0] + "." + parts[1]
h := hmac.New(sha256.New, jwtSecret)
h.Write([]byte(message))
expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
if parts[2] != expectedSignature {
return nil, fmt.Errorf("invalid signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
var claimsMap map[string]any
if err := json.Unmarshal(payload, &claimsMap); err != nil {
return nil, err
}
if exp, ok := claimsMap["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return nil, fmt.Errorf("token expired")
}
}
claims := &Claims{
UserID: int(claimsMap["user_id"].(float64)),
Username: claimsMap["username"].(string),
IsAdmin: claimsMap["is_admin"].(bool),
}
return claims, nil
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func JWTMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := verifyToken(tokenString)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("is_admin", claims.IsAdmin)
c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username)
return next(c)
}
}
return echojwt.WithConfig(echojwt.Config{
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(Claims)
},
SigningKey: jwtSecret,
})
}
func AdminMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
isAdmin, ok := c.Get("is_admin").(bool)
if !ok || !isAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Access denied")
user, ok := c.Get("user").(*jwt.Token)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid")
}
claims, ok := user.Claims.(*Claims)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims")
}
if !claims.IsAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required")
}
return next(c)
}

View file

@ -1,6 +1,9 @@
package main
import "time"
import (
"github.com/golang-jwt/jwt/v5"
"time"
)
type TimeEntry struct {
ID int `json:"id"`
@ -96,4 +99,5 @@ type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}

View file

@ -1,149 +1,338 @@
<!DOCTYPE html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Zeiterfassung</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<style>
/* Toast-Container */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
/* Basis-Toast */
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
pointer-events: all;
min-width: 320px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.toast:hover {
transform: translateX(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Toast-Content */
.toast-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toast-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.toast-message {
font-size: 0.95rem;
line-height: 1.4;
color: #2c3e50;
font-weight: 500;
}
/* Close-Button */
.toast-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
margin-left: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s ease;
font-size: 1rem;
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.7);
}
/* Toast-Typen */
.toast-error {
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-left-color: #e53e3e;
}
.toast-error .toast-icon {
color: #e53e3e;
}
.toast-success {
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
border-left-color: #38a169;
}
.toast-success .toast-icon {
color: #38a169;
}
.toast-info {
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
border-left-color: #3182ce;
}
.toast-info .toast-icon {
color: #3182ce;
}
.toast-warning {
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
border-left-color: #dd6b20;
}
.toast-warning .toast-icon {
color: #dd6b20;
}
/* Animationen */
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.dismissing {
animation: slideOut 0.3s ease-in forwards;
}
/* Mobile Anpassungen */
@media screen and (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.toast {
min-width: auto;
width: 100%;
}
.toast-message {
font-size: 0.9rem;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.toast-message {
color: #1a202c;
}
.toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.8);
}
}
body {
min-height: 100vh;
}
.table-container {
overflow-x: auto;
}
@media screen and (max-width: 768px) {
.level {
flex-direction: column;
}
.level-left, .level-right {
.level-left,
.level-right {
width: 100%;
}
.level-item {
justify-content: center;
margin-bottom: 0.5rem;
}
.buttons {
flex-wrap: wrap;
}
.button {
margin-bottom: 0.5rem;
}
}
.fa-spinner {
animation: fa-spin 1s infinite linear;
}
@keyframes fa-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="elm"></div>
<script src="/elm.js"></script>
<script>
function getStoredData() {
try {
const data = localStorage.getItem('timetracking');
const data = localStorage.getItem("timetracking");
if (data) {
return JSON.parse(data);
}
} catch (e) {
console.error('Failed to parse stored data:', e);
console.error("Failed to parse stored data:", e);
}
return { token: null, isAdmin: false };
return {token: null, isAdmin: false};
}
function saveData(token, isAdmin) {
try {
localStorage.setItem('timetracking', JSON.stringify({
token: token,
isAdmin: isAdmin
}));
localStorage.setItem(
"timetracking",
JSON.stringify({
token: token,
isAdmin: isAdmin,
}),
);
} catch (e) {
console.error('Failed to save data:', e);
console.error("Failed to save data:", e);
}
}
function clearData() {
try {
localStorage.removeItem('timetracking');
localStorage.removeItem("timetracking");
} catch (e) {
console.error('Failed to clear data:', e);
console.error("Failed to clear data:", e);
}
}
const storedData = getStoredData();
const app = Elm.Main.init({
node: document.getElementById('elm'),
node: document.getElementById("elm"),
flags: {
token: storedData.token,
isAdmin: storedData.isAdmin
}
isAdmin: storedData.isAdmin,
},
});
app.ports.saveToken.subscribe(function(data) {
app.ports.saveToken.subscribe(function (data) {
saveData(data.token, data.isAdmin);
});
app.ports.removeToken.subscribe(function() {
app.ports.removeToken.subscribe(function () {
clearData();
});
app.ports.confirmDelete.subscribe(function(message) {
app.ports.confirmDelete.subscribe(function (message) {
const confirmed = confirm(message);
app.ports.confirmDeleteResponse.send(confirmed);
});
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener("DOMContentLoaded", () => {
function setupBurgerMenu() {
const burgers = document.querySelectorAll('.navbar-burger');
burgers.forEach(burger => {
burger.addEventListener('click', () => {
const burgers = document.querySelectorAll(".navbar-burger");
burgers.forEach((burger) => {
burger.addEventListener("click", () => {
const target = burger.dataset.target;
const menu = document.getElementById(target);
if (menu) {
burger.classList.toggle('is-active');
menu.classList.toggle('is-active');
burger.classList.toggle("is-active");
menu.classList.toggle("is-active");
}
});
});
}
setupBurgerMenu();
const observer = new MutationObserver((mutations) => {
setupBurgerMenu();
});
observer.observe(document.getElementById('elm'), {
observer.observe(document.getElementById("elm"), {
childList: true,
subtree: true
subtree: true,
});
});
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
navigator.serviceWorker.register('/sw.js').catch(() => {
console.log('Service Worker registration failed');
if (
"serviceWorker" in navigator &&
window.location.protocol === "https:"
) {
navigator.serviceWorker.register("/sw.js").catch(() => {
console.log("Service Worker registration failed");
});
}
</script>
</body>
</html>