diff --git a/backend/database.go b/backend/database.go index 8fe58c0..bfbe726 100644 --- a/backend/database.go +++ b/backend/database.go @@ -45,8 +45,10 @@ func createTables(db *sql.DB) { schedule_id INTEGER NOT NULL, date TEXT NOT NULL, type TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (schedule_id) REFERENCES schedules(id) )`, } @@ -58,9 +60,7 @@ func createTables(db *sql.DB) { } // Create default admin user (password: admin123) - // Hash generated with bcrypt hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) - fmt.Println(string(hash)) _, err := db.Exec(` INSERT OR IGNORE INTO users (id, username, password, is_admin) VALUES (?, ?, ?, ?)`, @@ -135,11 +135,17 @@ func DeleteSchedule(db *sql.DB, id int) error { } func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { - _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type) VALUES (?, ?, ?, ?)", - entry.UserID, entry.ScheduleID, entry.Date, entry.Type) + _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)", + entry.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) return err } +// func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { +// _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type) VALUES (?, ?, ?, ?)", +// entry.UserID, entry.ScheduleID, entry.Date, entry.Type) +// return err +// } + func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries WHERE user_id = ? ORDER BY date DESC, created_at DESC", userID) @@ -160,7 +166,12 @@ func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { } func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { - rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries ORDER BY date DESC, created_at DESC") + rows, err := db.Query(` + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + FROM time_entries te + JOIN users u ON te.user_id = u.id + ORDER BY te.date DESC, te.created_at DESC + `) if err != nil { return nil, err } @@ -169,10 +180,67 @@ func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { var entries []TimeEntry for rows.Next() { var e TimeEntry - if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.StartTime, &e.EndTime, &e.CreatedAt, &e.Username); err != nil { continue } entries = append(entries, e) } return entries, nil } + +// func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { +// rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries ORDER BY date DESC, created_at DESC") +// if err != nil { +// return nil, err +// } +// defer rows.Close() + +// var entries []TimeEntry +// for rows.Next() { +// var e TimeEntry +// if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { +// continue +// } +// entries = append(entries, e) +// } +// return entries, nil +// } +func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { + rows, err := db.Query(` + SELECT + te.user_id, + u.username, + CAST(strftime('%W', te.date) AS INTEGER) as week, + CAST(strftime('%Y', te.date) AS INTEGER) as year, + SUM( + (CAST(substr(te.end_time, 1, 2) AS REAL) + CAST(substr(te.end_time, 4, 2) AS REAL) / 60.0) - + (CAST(substr(te.start_time, 1, 2) AS REAL) + CAST(substr(te.start_time, 4, 2) AS REAL) / 60.0) + ) as total_hours + FROM time_entries te + JOIN users u ON te.user_id = u.id + GROUP BY te.user_id, u.username, week, year + ORDER BY year DESC, week DESC, u.username + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var hours []WeeklyHours + for rows.Next() { + var h WeeklyHours + if err := rows.Scan(&h.UserID, &h.Username, &h.Week, &h.Year, &h.TotalHours); err != nil { + continue + } + hours = append(hours, h) + } + return hours, nil +} + +func DeleteUser(db *sql.DB, id int) error { + if id == 1 { + return fmt.Errorf("cannot delete admin user") + } + _, err := db.Exec("DELETE FROM users WHERE id = ?", id) + return err +} diff --git a/backend/handlers.go b/backend/handlers.go index ae59045..04b9a73 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "log" "net/http" "strconv" @@ -126,25 +127,25 @@ func (app *App) GetUsersHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(users) } -func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { - userIDStr := r.Header.Get("X-User-ID") - userID, _ := strconv.Atoi(userIDStr) +// func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { +// userIDStr := r.Header.Get("X-User-ID") +// userID, _ := strconv.Atoi(userIDStr) - var entry TimeEntry - if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } +// var entry TimeEntry +// if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { +// http.Error(w, err.Error(), http.StatusBadRequest) +// return +// } - entry.UserID = userID +// entry.UserID = userID - if err := CreateTimeEntry(app.DB, &entry); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +// if err := CreateTimeEntry(app.DB, &entry); err != nil { +// http.Error(w, err.Error(), http.StatusInternalServerError) +// return +// } - w.WriteHeader(http.StatusCreated) -} +// w.WriteHeader(http.StatusCreated) +// } func (app *App) GetMyTimeEntriesHandler(w http.ResponseWriter, r *http.Request) { userIDStr := r.Header.Get("X-User-ID") @@ -170,3 +171,52 @@ func (app *App) GetAllTimeEntriesHandler(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } + +func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Header.Get("X-User-ID") + userID, _ := strconv.Atoi(userIDStr) + + var entry TimeEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + log.Print("Error on Decoding occured") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + entry.UserID = userID + + if err := CreateTimeEntry(app.DB, &entry); err != nil { + log.Print("Error on creating time entry in Database occured") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (app *App) DeleteUserHandler(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := DeleteUser(app.DB, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (app *App) GetWeeklyHoursHandler(w http.ResponseWriter, r *http.Request) { + hours, err := GetWeeklyHours(app.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hours) +} diff --git a/backend/main.go b/backend/main.go index 5ceb1bc..6b73d66 100644 --- a/backend/main.go +++ b/backend/main.go @@ -30,7 +30,9 @@ func main() { http.HandleFunc("/api/admin/schedules/delete", CORS(AdminMiddleware(app.DeleteScheduleHandler))) http.HandleFunc("/api/admin/users", CORS(AdminMiddleware(app.CreateUserHandler))) http.HandleFunc("/api/admin/users/list", CORS(AdminMiddleware(app.GetUsersHandler))) + http.HandleFunc("/api/admin/users/delete", CORS(AdminMiddleware(app.DeleteUserHandler))) // Neu http.HandleFunc("/api/admin/time-entries", CORS(AdminMiddleware(app.GetAllTimeEntriesHandler))) + http.HandleFunc("/api/admin/weekly-hours", CORS(AdminMiddleware(app.GetWeeklyHoursHandler))) // Neu // Serve frontend fs := http.FileServer(http.Dir("./static")) diff --git a/backend/middleware.go b/backend/middleware.go index 82d96bf..569adc3 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,125 +1,180 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" ) var jwtSecret = []byte("your-secret-key-change-in-production") type Claims struct { - UserID int `json:"user_id"` - Username string `json:"username"` - IsAdmin bool `json:"is_admin"` - Exp int64 `json:"exp"` + UserID int `json:"user_id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + Exp int64 `json:"exp"` +} + +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool + written int64 +} + +func wrapResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.status = code + rw.ResponseWriter.WriteHeader(code) + rw.wroteHeader = true +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusOK) + } + n, err := rw.ResponseWriter.Write(b) + rw.written += int64(n) + return n, err } func createToken(userID int, username string, isAdmin bool) (string, error) { - claims := Claims{ - UserID: userID, - Username: username, - IsAdmin: isAdmin, - Exp: time.Now().Add(24 * time.Hour).Unix(), - } + claims := Claims{ + UserID: userID, + Username: username, + IsAdmin: isAdmin, + Exp: time.Now().Add(24 * time.Hour).Unix(), + } - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - - payload, _ := json.Marshal(claims) - payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - message := header + "." + payloadEncoded - - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + payload, _ := json.Marshal(claims) + payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) - return message + "." + signature, nil + 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") - } + 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)) + 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") - } + if parts[2] != expectedSignature { + return nil, fmt.Errorf("invalid signature") + } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } - var claims Claims - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, err - } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } - if time.Now().Unix() > claims.Exp { - return nil, fmt.Errorf("token expired") - } + if time.Now().Unix() > claims.Exp { + return nil, fmt.Errorf("token expired") + } - return &claims, nil + return &claims, nil } func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + return func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := verifyToken(tokenString) - if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := verifyToken(tokenString) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } - r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID)) - r.Header.Set("X-Username", claims.Username) - r.Header.Set("X-Is-Admin", fmt.Sprintf("%t", claims.IsAdmin)) + r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID)) + r.Header.Set("X-Username", claims.Username) + r.Header.Set("X-Is-Admin", fmt.Sprintf("%t", claims.IsAdmin)) - next(w, r) - } + next(w, r) + } } func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { - return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - isAdmin := r.Header.Get("X-Is-Admin") == "true" - if !isAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - next(w, r) - }) + return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + isAdmin := r.Header.Get("X-Is-Admin") == "true" + if !isAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next(w, r) + }) +} + +func LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := wrapResponseWriter(w) + + defer func() { + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "query", r.URL.RawQuery, + "status", wrapped.status, + "duration_ms", time.Since(start).Milliseconds(), + "client_ip", r.RemoteAddr, + "user_agent", r.UserAgent(), + "bytes_written", wrapped.written, + ) + }() + + next(wrapped, r) + } } func CORS(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + return LoggingMiddleware(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } - next(w, r) - } + next(w, r) + }) } - diff --git a/backend/models.go b/backend/models.go index 2ef8dd5..fa69693 100644 --- a/backend/models.go +++ b/backend/models.go @@ -4,6 +4,26 @@ import ( "time" ) +type TimeEntry struct { + ID int `json:"id"` + UserID int `json:"user_id"` + ScheduleID int `json:"schedule_id"` + Date string `json:"date"` + Type string `json:"type"` + StartTime string `json:"start_time"` // Neu + EndTime string `json:"end_time"` // Neu + CreatedAt time.Time `json:"created_at"` + Username string `json:"username"` // Neu - für Join +} + +type WeeklyHours struct { + UserID int `json:"user_id"` + Username string `json:"username"` + Week int `json:"week"` + Year int `json:"year"` + TotalHours float64 `json:"total_hours"` +} + type User struct { ID int `json:"id"` Username string `json:"username"` @@ -20,14 +40,14 @@ type Schedule struct { Title string `json:"title"` } -type TimeEntry struct { - ID int `json:"id"` - UserID int `json:"user_id"` - ScheduleID int `json:"schedule_id"` - Date string `json:"date"` - Type string `json:"type"` // "lesson" or "break" - CreatedAt time.Time `json:"created_at"` -} +// type TimeEntry struct { +// ID int `json:"id"` +// UserID int `json:"user_id"` +// ScheduleID int `json:"schedule_id"` +// Date string `json:"date"` +// Type string `json:"type"` // "lesson" or "break" +// CreatedAt time.Time `json:"created_at"` +// } type LoginRequest struct { Username string `json:"username"` diff --git a/backend/static/index.html b/backend/static/index.html index 825d8bc..426d625 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -15,8 +15,21 @@