package app import ( "context" "crypto/subtle" "embed" "encoding/csv" "encoding/json" "errors" "fmt" "html/template" "io" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" _ "time/tzdata" "unicode/utf8" "teammate/internal/jalali" ) // Embed the complete UI trees so the deployed binary never depends on // template or static files being present beside it. // //go:embed templates static var assets embed.FS type Config struct { Addr string BaseURL string DatabasePath string SessionSecure bool Timezone string GitHubClientID string GitHubClientSecret string GoogleClientID string GoogleClientSecret string } func ConfigFromEnv() Config { return Config{ Addr: env("APP_ADDR", ":8080"), BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"), DatabasePath: env("DATABASE_PATH", "./data/teammate.db"), SessionSecure: env("SESSION_SECURE", "false") == "true", Timezone: env("APP_TIMEZONE", "Asia/Tehran"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), } } func env(name, fallback string) string { if v := os.Getenv(name); v != "" { return v } return fallback } type Server struct { cfg Config store *Store templates *template.Template http *http.Server } type PageData struct { Title string User *User CSRF string Flash string Error string Today string TodayJalali string Attendance *Attendance Calendar Calendar Requests []Request PendingCount int Stats Stats OAuthGitHub bool OAuthGoogle bool ReportStart string ReportEnd string SelectedSection string Users []User Day DayDetail Week WeekDetail WorkUpdates []WorkUpdate ReportSummary []PersonReportSummary ReportTotals ReportTotals Board []BoardColumn BoardTags []BoardTag ArchivedTasks []BoardTask BoardFilter BoardFilter } type Stats struct{ Present, Remote, Leave int } type BoardFilter struct { TagID int64 AssigneeID int64 Priority string Active bool } type ReportTotals struct { Present int Remote int Leave int Done int Blocked int Pending int } type DayDetail struct { Gregorian string Jalali string Weekday string DayNote string IsToday bool Prev string Next string Present int Remote int Absent int Members []RosterMember } type WeekDetail struct { Enabled bool StartJalali string EndJalali string Prev string Next string Present int Remote int Absent int Days []DayDetail } type RosterMember struct { User User Status string Label string Detail string CheckIn string CheckOut string } type BoardColumn struct { Status string Title string Hint string Tasks []BoardTask } type Calendar struct { Year, Month int MonthName string MonthNameFA string Prev, Next string Cells []CalendarCell } type CalendarCell struct { Day, Weekday int Gregorian string InMonth bool IsToday bool IsFriday bool Holiday string Status string StartRequest bool } func New(cfg Config) (*Server, error) { if cfg.Timezone == "" { cfg.Timezone = "Asia/Tehran" } location, err := time.LoadLocation(cfg.Timezone) if err != nil { return nil, fmt.Errorf("load application timezone %q: %w", cfg.Timezone, err) } time.Local = location if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil { return nil, err } store, err := OpenStore(cfg.DatabasePath) if err != nil { return nil, err } funcs := template.FuncMap{ "timeHM": formatStoredClock, "dateFA": func(raw string) string { t, err := time.Parse("2006-01-02", raw) if err != nil { return raw } return jalali.FromTime(t).String() }, "kindLabel": func(v string) string { if v == "remote" { return "Remote day" } return "Time off" }, "statusLabel": func(v string) string { return strings.ToUpper(v[:1]) + v[1:] }, "initial": func(v string) string { r, _ := utf8.DecodeRuneInString(v) return string(r) }, "hasTag": func(tags []BoardTag, id int64) bool { for _, tag := range tags { if tag.ID == id { return true } } return false }, "hasUser": func(users []User, id int64) bool { for _, user := range users { if user.ID == id { return true } } return false }, } tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html") if err != nil { store.Close() return nil, err } s := &Server{cfg: cfg, store: store, templates: tmpl} mux := http.NewServeMux() s.routes(mux) s.http = &http.Server{ Addr: cfg.Addr, Handler: s.securityHeaders(s.withUser(mux)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } return s, nil } func (s *Server) ListenAndServe() error { err := s.http.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { return nil } return err } func (s *Server) Close() error { return s.store.Close() } func (s *Server) routes(mux *http.ServeMux) { mux.Handle("GET /static/", http.FileServer(http.FS(assets))) mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /login", s.loginPage) mux.HandleFunc("POST /login", s.login) mux.HandleFunc("GET /register", s.registerPage) mux.HandleFunc("POST /register", s.register) mux.HandleFunc("POST /logout", s.requireAuth(s.csrf(s.logout))) mux.HandleFunc("GET /auth/{provider}", s.oauthStart) mux.HandleFunc("GET /auth/{provider}/callback", s.oauthCallback) mux.HandleFunc("GET /", s.requireAuth(s.dashboard)) mux.HandleFunc("GET /day", s.requireAuth(s.dayPage)) mux.HandleFunc("GET /calendar", s.requireAuth(s.calendarPartial)) mux.HandleFunc("POST /attendance/check-in", s.requireAuth(s.csrf(s.checkIn))) mux.HandleFunc("POST /attendance/check-out", s.requireAuth(s.csrf(s.checkOut))) mux.HandleFunc("GET /requests", s.requireAuth(s.requestsPage)) mux.HandleFunc("POST /requests", s.requireAuth(s.csrf(s.createRequest))) mux.HandleFunc("POST /requests/{id}/cancel", s.requireAuth(s.csrf(s.cancelRequest))) mux.HandleFunc("GET /updates", s.requireAuth(s.updatesPage)) mux.HandleFunc("POST /updates", s.requireAuth(s.csrf(s.createWorkUpdate))) mux.HandleFunc("POST /updates/{id}/delete", s.requireAuth(s.csrf(s.deleteWorkUpdate))) mux.HandleFunc("GET /board", s.requireAuth(s.boardPage)) mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/archive", s.requireAuth(s.csrf(s.archiveBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags))) mux.HandleFunc("POST /board/tasks/{id}/details", s.requireAuth(s.csrf(s.setBoardTaskDetails))) mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag))) mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag))) mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage)) mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest))) mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser))) mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus))) mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage)) mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV)) mux.HandleFunc("GET /reports/work-updates.csv", s.requireAdmin(s.workUpdatesCSV)) mux.HandleFunc("GET /reports/summary.csv", s.requireAdmin(s.reportSummaryCSV)) } func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) { rawDate := r.URL.Query().Get("date") if rawDate == "" { rawDate = time.Now().Format("2006-01-02") } day, ok := parseUserDate(rawDate) errorMessage := r.URL.Query().Get("error") if !ok { day = time.Now().Format("2006-01-02") errorMessage = "Choose a valid Persian date." } selected, _ := time.Parse("2006-01-02", day) view := r.URL.Query().Get("view") if view != "day" { view = "week" } var detail DayDetail var week WeekDetail if view == "week" { offset := (int(selected.Weekday()) + 1) % 7 weekStart := selected.AddDate(0, 0, -offset) week = WeekDetail{ Enabled: true, StartJalali: jalali.FromTime(weekStart).String(), EndJalali: jalali.FromTime(weekStart.AddDate(0, 0, 6)).String(), Prev: jalali.FromTime(weekStart.AddDate(0, 0, -7)).String(), Next: jalali.FromTime(weekStart.AddDate(0, 0, 7)).String(), } for index := 0; index < 7; index++ { date := weekStart.AddDate(0, 0, index) dayDetail, err := s.buildDayDetail(date.Format("2006-01-02")) if err != nil { http.Error(w, "could not load the team week view", http.StatusInternalServerError) return } if dayDetail.Gregorian == day { detail = dayDetail } week.Present += dayDetail.Present week.Remote += dayDetail.Remote week.Absent += dayDetail.Absent week.Days = append(week.Days, dayDetail) } } else { var err error detail, err = s.buildDayDetail(day) if err != nil { http.Error(w, "could not load the team day view", http.StatusInternalServerError) return } } if detail.Gregorian == "" { detail, _ = s.buildDayDetail(day) } s.render(w, "day.html", PageData{ Title: "Team " + view, User: currentUser(r), CSRF: csrfToken(r), Day: detail, Week: week, Error: errorMessage, SelectedSection: "day", }) } func (s *Server) buildDayDetail(day string) (DayDetail, error) { selected, err := time.Parse("2006-01-02", day) if err != nil { return DayDetail{}, err } rows, err := s.store.DayRoster(day) if err != nil { return DayDetail{}, err } jalaliDate := jalali.FromTime(selected) detail := DayDetail{ Gregorian: day, Jalali: jalaliDate.String(), Weekday: selected.Format("Monday"), IsToday: day == time.Now().Format("2006-01-02"), Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(), Next: jalali.FromTime(selected.AddDate(0, 0, 1)).String(), } if holiday := persianHoliday(jalaliDate.Month, jalaliDate.Day); holiday != "" { detail.DayNote = holiday } else if selected.Weekday() == time.Friday { detail.DayNote = "Friday weekend" } today := time.Now().Format("2006-01-02") for _, row := range rows { member := RosterMember{User: row.User, CheckIn: row.CheckIn, CheckOut: row.CheckOut} switch { case row.Mode == "office": member.Status, member.Label = "present", "Present" member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Office") detail.Present++ case row.Mode == "remote": member.Status, member.Label = "remote", "Remote" member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Remote") detail.Remote++ case row.RequestKind == "leave": member.Status, member.Label, member.Detail = "absent", "Absent", "Approved time off" detail.Absent++ case row.RequestKind == "remote": member.Status, member.Label, member.Detail = "remote", "Remote", "Approved remote day" detail.Remote++ case detail.DayNote != "": member.Status, member.Label, member.Detail = "absent", "Absent", detail.DayNote detail.Absent++ case day >= today: member.Status, member.Label = "present", "Present" if day == today { member.Detail = "Expected at the office today" } else { member.Detail = "Expected at the office" } detail.Present++ default: member.Status, member.Label, member.Detail = "absent", "Absent", "No attendance recorded" detail.Absent++ } detail.Members = append(detail.Members, member) } return detail, nil } func attendanceDetail(checkIn, checkOut, location string) string { if checkOut != "" { return fmt.Sprintf("%s · %s–%s", location, formatStoredClock(checkIn), formatStoredClock(checkOut)) } if checkIn != "" { return fmt.Sprintf("%s · checked in %s", location, formatStoredClock(checkIn)) } return location } func formatStoredClock(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "—" } if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { return parsed.In(time.Local).Format("15:04") } // Older rows were written from time.Time directly. SQLite persisted Go's // String form, including a duplicate zone name and monotonic suffix: // "2026-07-28 11:48:51.004 +0330 +0330 m=+18.1". fields := strings.Fields(raw) if len(fields) >= 3 { legacy := strings.Join(fields[:3], " ") for _, layout := range []string{ "2006-01-02 15:04:05.999999999 -0700", "2006-01-02 15:04:05 -0700", } { if parsed, err := time.Parse(layout, legacy); err == nil { return parsed.In(time.Local).Format("15:04") } } } if len(raw) >= 16 { return raw[11:16] } return raw } func (s *Server) health(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Second) defer cancel() if err := s.store.Ping(ctx); err != nil { http.Error(w, `{"status":"unhealthy"}`, http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok"}`) } func (s *Server) render(w http.ResponseWriter, name string, data PageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, name, data); err != nil { log.Printf("render %s: %v", name, err) } } func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { if currentUser(r) != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, "login.html", PageData{ Title: "Sign in", Error: r.URL.Query().Get("error"), OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "", }) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } identifier := r.FormValue("identifier") if identifier == "" { identifier = r.FormValue("username") } u, err := s.store.Authenticate(identifier, r.FormValue("password")) if err != nil { http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } s.startSession(w, u.ID) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) { if currentUser(r) != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, "register.html", PageData{ Title: "Create account", Error: r.URL.Query().Get("error"), OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "", }) } func (s *Server) register(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if r.FormValue("password") != r.FormValue("password_confirm") { http.Redirect(w, r, "/register?error=Passwords+do+not+match", http.StatusSeeOther) return } userID, err := s.store.CreateUser( r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), "member", ) if err != nil { http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } s.startSession(w, userID) http.Redirect(w, r, "/?flash=Welcome+to+Hamkar", http.StatusSeeOther) } func (s *Server) startSession(w http.ResponseWriter, userID int64) { token, _, err := s.store.CreateSession(userID) if err != nil { http.Error(w, "could not create session", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "teammate_session", Value: token, Path: "/", HttpOnly: true, Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 60 * 60, }) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("teammate_session"); err == nil { s.store.DeleteSession(c.Value) } http.SetCookie(w, &http.Cookie{Name: "teammate_session", Path: "/", MaxAge: -1, HttpOnly: true}) http.Redirect(w, r, "/login", http.StatusSeeOther) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { u := currentUser(r) now := time.Now() today := now.Format("2006-01-02") a, _ := s.store.TodayAttendance(u.ID, today) cal := s.buildCalendar(u.ID, r.URL.Query().Get("month")) start := jalali.ToGregorian(cal.Year, cal.Month, 1).Format("2006-01-02") end := jalali.ToGregorian(cal.Year, cal.Month, jalali.DaysInMonth(cal.Year, cal.Month)).Format("2006-01-02") present, remote, leave, _ := s.store.Stats(u.ID, start, end) requests, _ := s.store.Requests(u.ID, false, "") if len(requests) > 4 { requests = requests[:4] } pending, _ := s.store.Requests(u.ID, u.Role == "admin", "pending") s.render(w, "dashboard.html", PageData{ Title: "Dashboard", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"), Error: r.URL.Query().Get("error"), Today: today, TodayJalali: jalali.FromTime(now).String(), Attendance: a, Calendar: cal, Requests: requests, PendingCount: len(pending), Stats: Stats{present, remote, leave}, SelectedSection: "dashboard", }) } func (s *Server) calendarPartial(w http.ResponseWriter, r *http.Request) { s.render(w, "calendar.html", PageData{User: currentUser(r), Calendar: s.buildCalendar(currentUser(r).ID, r.URL.Query().Get("month"))}) } func (s *Server) buildCalendar(userID int64, selected string) Calendar { today := jalali.FromTime(time.Now()) year, month := today.Year, today.Month if selected != "" { if _, err := fmt.Sscanf(selected, "%d-%d", &year, &month); err != nil || month < 1 || month > 12 { year, month = today.Year, today.Month } } first := jalali.ToGregorian(year, month, 1) last := jalali.ToGregorian(year, month, jalali.DaysInMonth(year, month)) attendance, _ := s.store.AttendanceBetween(userID, first.Format("2006-01-02"), last.Format("2006-01-02")) requests, _ := s.store.Requests(userID, false, "approved") // Persian weeks begin Saturday. Go's Sunday=0, so Saturday maps to zero. offset := (int(first.Weekday()) + 1) % 7 cells := make([]CalendarCell, offset, offset+jalali.DaysInMonth(year, month)) for d := 1; d <= jalali.DaysInMonth(year, month); d++ { g := jalali.ToGregorian(year, month, d) raw := g.Format("2006-01-02") cell := CalendarCell{Day: d, Weekday: (offset + d - 1) % 7, Gregorian: raw, InMonth: true, IsToday: raw == time.Now().Format("2006-01-02")} cell.IsFriday = g.Weekday() == time.Friday cell.Holiday = persianHoliday(month, d) if a, ok := attendance[raw]; ok { cell.Status = a.Mode } for _, req := range requests { if raw >= req.StartDate && raw <= req.EndDate { cell.Status = req.Kind } } cells = append(cells, cell) } prevY, prevM, nextY, nextM := year, month-1, year, month+1 if prevM == 0 { prevY, prevM = year-1, 12 } if nextM == 13 { nextY, nextM = year+1, 1 } return Calendar{ Year: year, Month: month, MonthName: jalali.MonthNames[month], MonthNameFA: jalali.MonthNamesFA[month], Prev: fmt.Sprintf("%04d-%02d", prevY, prevM), Next: fmt.Sprintf("%04d-%02d", nextY, nextM), Cells: cells, } } func persianHoliday(month, day int) string { holidays := map[string]string{ "1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz", "1-12": "Islamic Republic Day", "1-13": "Nature Day", "3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising", "11-22": "Revolution Day", "12-29": "Oil Nationalization Day", } return holidays[fmt.Sprintf("%d-%d", month, day)] } func (s *Server) checkIn(w http.ResponseWriter, r *http.Request) { u := currentUser(r) if err := s.store.CheckIn(u.ID, time.Now().Format("2006-01-02"), r.FormValue("mode")); err != nil { s.redirectError(w, r, err) return } http.Redirect(w, r, "/?flash=Checked+in+successfully", http.StatusSeeOther) } func (s *Server) checkOut(w http.ResponseWriter, r *http.Request) { if err := s.store.CheckOut(currentUser(r).ID, time.Now().Format("2006-01-02")); err != nil { s.redirectError(w, r, err) return } http.Redirect(w, r, "/?flash=Checked+out+successfully", http.StatusSeeOther) } func (s *Server) requestsPage(w http.ResponseWriter, r *http.Request) { requests, err := s.store.Requests(currentUser(r).ID, false, "") if err != nil { http.Error(w, "could not load requests", http.StatusInternalServerError) return } s.render(w, "requests.html", PageData{ Title: "My requests", User: currentUser(r), CSRF: csrfToken(r), Requests: requests, Today: time.Now().Format("2006-01-02"), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "requests", }) } func (s *Server) createRequest(w http.ResponseWriter, r *http.Request) { start, startOK := parseUserDate(r.FormValue("start_date")) end, endOK := parseUserDate(r.FormValue("end_date")) if !startOK || !endOK || end < start { s.redirectRequestError(w, r, errors.New("choose a valid date range")) return } if err := s.store.CreateRequest(currentUser(r).ID, r.FormValue("kind"), start, end, r.FormValue("reason")); err != nil { s.redirectRequestError(w, r, err) return } http.Redirect(w, r, "/requests?flash=Request+sent+for+approval", http.StatusSeeOther) } func parseUserDate(raw string) (string, bool) { var y, m, d int if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d-%d-%d", &y, &m, &d); err != nil { return "", false } if y >= 1700 { t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-%02d-%02d", y, m, d)) return t.Format("2006-01-02"), err == nil } if y < 1200 || m < 1 || m > 12 || d < 1 || d > jalali.DaysInMonth(y, m) { return "", false } return jalali.ToGregorian(y, m, d).Format("2006-01-02"), true } func validDate(raw string) bool { _, err := time.Parse("2006-01-02", raw) return err == nil } func (s *Server) cancelRequest(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) _ = s.store.CancelRequest(id, currentUser(r).ID) http.Redirect(w, r, "/requests?flash=Request+cancelled", http.StatusSeeOther) } func (s *Server) updatesPage(w http.ResponseWriter, r *http.Request) { updates, err := s.store.WorkUpdates("", "", 100) if err != nil { http.Error(w, "could not load work updates", http.StatusInternalServerError) return } s.render(w, "updates.html", PageData{ Title: "Work updates", User: currentUser(r), CSRF: csrfToken(r), WorkUpdates: updates, TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "updates", }) } func (s *Server) createWorkUpdate(w http.ResponseWriter, r *http.Request) { start, startOK := parseUserDate(r.FormValue("period_start")) end, endOK := parseUserDate(r.FormValue("period_end")) if !startOK || !endOK || end < start { http.Redirect(w, r, "/updates?error=Choose+a+valid+reporting+period", http.StatusSeeOther) return } startTime, _ := time.Parse("2006-01-02", start) endTime, _ := time.Parse("2006-01-02", end) if endTime.Sub(startTime) > 366*24*time.Hour { http.Redirect(w, r, "/updates?error=Reporting+period+cannot+exceed+one+year", http.StatusSeeOther) return } if err := s.store.CreateWorkUpdate( currentUser(r).ID, start, end, r.FormValue("status"), r.FormValue("note"), ); err != nil { http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/updates?flash=Work+update+shared", http.StatusSeeOther) } func (s *Server) deleteWorkUpdate(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.DeleteWorkUpdate(id, currentUser(r).ID); err != nil { http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/updates?flash=Work+update+removed", http.StatusSeeOther) } func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { tasks, err := s.store.BoardTasks() if err != nil { http.Error(w, "could not load team board", http.StatusInternalServerError) return } archivedTasks, err := s.store.ArchivedBoardTasks() if err != nil { http.Error(w, "could not load archived cards", http.StatusInternalServerError) return } users, err := s.store.ActiveUsers() if err != nil { http.Error(w, "could not load teammates", http.StatusInternalServerError) return } tags, err := s.store.BoardTags() if err != nil { http.Error(w, "could not load board tags", http.StatusInternalServerError) return } filter := BoardFilter{Priority: r.URL.Query().Get("priority")} filter.TagID, _ = strconv.ParseInt(r.URL.Query().Get("tag"), 10, 64) filter.AssigneeID, _ = strconv.ParseInt(r.URL.Query().Get("assignee"), 10, 64) if !validBoardPriority(filter.Priority) { filter.Priority = "" } filter.Active = filter.TagID > 0 || filter.AssigneeID > 0 || filter.Priority != "" if filter.Active { filtered := make([]BoardTask, 0, len(tasks)) for _, task := range tasks { if boardTaskMatchesFilter(task, filter) { filtered = append(filtered, task) } } tasks = filtered } columns := []BoardColumn{ {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, {Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, {Status: "blocked", Title: "Blocked", Hint: "Needs help"}, {Status: "done", Title: "Done", Hint: "Completed work"}, } for _, task := range tasks { for index := range columns { if columns[index].Status == task.Status { columns[index].Tasks = append(columns[index].Tasks, task) break } } } s.render(w, "board.html", PageData{ Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags, ArchivedTasks: archivedTasks, BoardFilter: filter, TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board", }) } func validBoardPriority(priority string) bool { return priority == "" || priority == "low" || priority == "normal" || priority == "high" || priority == "urgent" } func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool { if filter.Priority != "" && task.Importance != filter.Priority { return false } if filter.TagID > 0 { found := false for _, tag := range task.Tags { if tag.ID == filter.TagID { found = true break } } if !found { return false } } if filter.AssigneeID > 0 { found := false for _, assignee := range task.Assignees { if assignee.ID == filter.AssigneeID { found = true break } } if !found { return false } } return true } func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.FormValue("title")) == "" { http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther) return } dueDate := "" if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" { parsed, ok := parseUserDate(rawDue) if !ok { http.Redirect(w, r, "/board?error=Choose+a+valid+Jalali+due+date", http.StatusSeeOther) return } dueDate = parsed } tagIDs := parseIDList(r.Form["tag_ids"]) if newTagName := strings.TrimSpace(r.FormValue("new_tag_name")); newTagName != "" { tag, err := s.store.FindOrCreateBoardTag(newTagName, r.FormValue("new_tag_color")) if err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } tagIDs = append(tagIDs, tag.ID) } taskID, err := s.store.CreateBoardTask( currentUser(r).ID, r.FormValue("title"), r.FormValue("description"), r.FormValue("status"), r.FormValue("importance"), parseIDList(r.Form["assignee_ids"]), dueDate, ) if err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } if err := s.store.SetBoardTaskTags(taskID, tagIDs); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Task+added+to+the+team+board", http.StatusSeeOther) } func parseIDList(values []string) []int64 { ids := make([]int64, 0, len(values)) for _, raw := range values { if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 { ids = append(ids, id) } } return ids } func (s *Server) moveBoardTask(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.MoveBoardTask(id, r.FormValue("status")); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Task+moved", http.StatusSeeOther) } func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) user := currentUser(r) if err := s.store.DeleteBoardTask(id, user.ID, user.Role == "admin"); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Task+permanently+deleted", http.StatusSeeOther) } func (s *Server) archiveBoardTask(w http.ResponseWriter, r *http.Request) { s.setBoardTaskArchived(w, r, true) } func (s *Server) restoreBoardTask(w http.ResponseWriter, r *http.Request) { s.setBoardTaskArchived(w, r, false) } func (s *Server) setBoardTaskArchived(w http.ResponseWriter, r *http.Request, archived bool) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) user := currentUser(r) if err := s.store.SetBoardTaskArchived(id, user.ID, user.Role == "admin", archived); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } message := "Task+restored" if archived { message = "Task+archived" } http.Redirect(w, r, "/board?flash="+message, http.StatusSeeOther) } func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.SetBoardTaskTags(id, parseIDList(r.Form["tag_ids"])); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther) } func (s *Server) setBoardTaskDetails(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.SetBoardTaskDetails(id, parseIDList(r.Form["assignee_ids"]), r.FormValue("importance")); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Card+details+updated", http.StatusSeeOther) } func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) { if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Tag+created", http.StatusSeeOther) } func (s *Server) deleteBoardTag(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) if err := s.store.DeleteBoardTag(id); err != nil { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/board?flash=Tag+deleted", http.StatusSeeOther) } func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status")) if err != nil { http.Error(w, "could not load requests", http.StatusInternalServerError) return } s.render(w, "admin.html", PageData{ Title: "Approvals", User: currentUser(r), CSRF: csrfToken(r), Requests: requests, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "admin", }) } func (s *Server) reviewRequest(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) err := s.store.ReviewRequest(r.Context(), id, currentUser(r).ID, r.FormValue("decision"), r.FormValue("note")) if err != nil { http.Redirect(w, r, "/admin/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/admin/requests?flash=Request+reviewed", http.StatusSeeOther) } func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) { users, err := s.store.Users() if err != nil { http.Error(w, "could not load teammates", http.StatusInternalServerError) return } s.render(w, "users.html", PageData{ Title: "Teammates", User: currentUser(r), CSRF: csrfToken(r), Users: users, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "users", }) } func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { _, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role")) if err != nil { http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/admin/users?flash=Teammate+account+created", http.StatusSeeOther) } func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) action := r.FormValue("action") if action != "lock" && action != "unlock" { http.Redirect(w, r, "/admin/users?error=Choose+a+valid+account+action", http.StatusSeeOther) return } active := action == "unlock" if err := s.store.SetUserActive(id, currentUser(r).ID, active); err != nil { http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } message := "Account+locked" if active { message = "Account+unlocked" } http.Redirect(w, r, "/admin/users?flash="+message, http.StatusSeeOther) } func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) { now := time.Now() start := now.AddDate(0, -1, 0).Format("2006-01-02") end := now.Format("2006-01-02") if requestedStart, requestedEnd := r.URL.Query().Get("start"), r.URL.Query().Get("end"); validDate(requestedStart) && validDate(requestedEnd) && requestedEnd >= requestedStart { start, end = requestedStart, requestedEnd } summaries, err := s.store.ReportSummary(start, end) if err != nil { http.Error(w, "could not build report summary", http.StatusInternalServerError) return } var totals ReportTotals for _, summary := range summaries { totals.Present += summary.PresentDays totals.Remote += summary.RemoteDays totals.Leave += summary.LeaveDays totals.Done += summary.DoneUpdates totals.Blocked += summary.Blocked totals.Pending += summary.Pending } s.render(w, "reports.html", PageData{ Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start, ReportEnd: end, ReportSummary: summaries, ReportTotals: totals, SelectedSection: "reports", }) } func (s *Server) reportCSV(w http.ResponseWriter, r *http.Request) { start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end") if !validDate(start) || !validDate(end) || end < start { http.Error(w, "invalid report date range", http.StatusBadRequest) return } rows, err := s.store.ReportRows(start, end) if err != nil { http.Error(w, "could not generate report", http.StatusInternalServerError) return } defer rows.Close() w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="attendance-%s-to-%s.csv"`, start, end)) _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM. cw := csv.NewWriter(w) _ = cw.Write([]string{"Name", "Username", "Date", "Persian date", "Check in", "Check out", "Work mode", "Approved request"}) for rows.Next() { var name, username, day, in, out, mode, request string if err := rows.Scan(&name, &username, &day, &in, &out, &mode, &request); err != nil { continue } t, _ := time.Parse("2006-01-02", day) _ = cw.Write([]string{name, username, day, jalali.FromTime(t).String(), in, out, mode, request}) } cw.Flush() } func (s *Server) workUpdatesCSV(w http.ResponseWriter, r *http.Request) { start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end") if !validDate(start) || !validDate(end) || end < start { http.Error(w, "invalid report date range", http.StatusBadRequest) return } updates, err := s.store.WorkUpdates(start, end, 0) if err != nil { http.Error(w, "could not generate work-update report", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="work-updates-%s-to-%s.csv"`, start, end)) _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) cw := csv.NewWriter(w) _ = cw.Write([]string{"Name", "Username", "Period start", "Persian start", "Period end", "Persian end", "Status", "Update", "Submitted at"}) for _, update := range updates { startTime, _ := time.Parse("2006-01-02", update.PeriodStart) endTime, _ := time.Parse("2006-01-02", update.PeriodEnd) _ = cw.Write([]string{ update.UserName, update.Username, update.PeriodStart, jalali.FromTime(startTime).String(), update.PeriodEnd, jalali.FromTime(endTime).String(), update.Status, update.Note, update.CreatedAt, }) } cw.Flush() } func (s *Server) reportSummaryCSV(w http.ResponseWriter, r *http.Request) { start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end") if !validDate(start) || !validDate(end) || end < start { http.Error(w, "invalid report date range", http.StatusBadRequest) return } summaries, err := s.store.ReportSummary(start, end) if err != nil { http.Error(w, "could not generate accumulated report", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="team-summary-%s-to-%s.csv"`, start, end)) _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) cw := csv.NewWriter(w) _ = cw.Write([]string{ "Name", "Username", "Office presence days", "Remote days", "Approved time-off days", "Done updates", "Blocked updates", "Pending updates", "Total updates", }) for _, summary := range summaries { totalUpdates := summary.DoneUpdates + summary.Blocked + summary.Pending _ = cw.Write([]string{ summary.User.DisplayName, summary.User.Username, strconv.Itoa(summary.PresentDays), strconv.Itoa(summary.RemoteDays), strconv.Itoa(summary.LeaveDays), strconv.Itoa(summary.DoneUpdates), strconv.Itoa(summary.Blocked), strconv.Itoa(summary.Pending), strconv.Itoa(totalUpdates), }) } cw.Flush() } func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, err error) { http.Redirect(w, r, "/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) } func (s *Server) redirectRequestError(w http.ResponseWriter, r *http.Request, err error) { http.Redirect(w, r, "/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) } type contextKey string const userKey contextKey = "user" const csrfKey contextKey = "csrf" func currentUser(r *http.Request) *User { u, _ := r.Context().Value(userKey).(*User) return u } func csrfToken(r *http.Request) string { v, _ := r.Context().Value(csrfKey).(string) return v } func (s *Server) withUser(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie("teammate_session") if err == nil { if u, csrf, err := s.store.Session(c.Value); err == nil { ctx := r.Context() ctx = context.WithValue(ctx, userKey, u) ctx = context.WithValue(ctx, csrfKey, csrf) r = r.WithContext(ctx) } } next.ServeHTTP(w, r) }) } func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if currentUser(r) == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next(w, r) } } func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { if currentUser(r).Role != "admin" { http.Error(w, "admin access required", http.StatusForbidden) return } next(w, r) }) } func (s *Server) csrf(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 { http.Error(w, "invalid security token; reload the page and try again", http.StatusForbidden) return } next(w, r) } } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com; style-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: https:; connect-src 'self'") next.ServeHTTP(w, r) }) } type oauthProvider struct { authorize, token, profile, clientID, secret, scope string } func (s *Server) provider(name string) (oauthProvider, bool) { switch name { case "github": return oauthProvider{"https://github.com/login/oauth/authorize", "https://github.com/login/oauth/access_token", "https://api.github.com/user", s.cfg.GitHubClientID, s.cfg.GitHubClientSecret, "read:user user:email"}, s.cfg.GitHubClientID != "" case "google": return oauthProvider{"https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", "https://openidconnect.googleapis.com/v1/userinfo", s.cfg.GoogleClientID, s.cfg.GoogleClientSecret, "openid email profile"}, s.cfg.GoogleClientID != "" default: return oauthProvider{}, false } } func (s *Server) oauthStart(w http.ResponseWriter, r *http.Request) { name := r.PathValue("provider") p, ok := s.provider(name) if !ok { http.Redirect(w, r, "/login?error=OAuth+provider+is+not+configured", http.StatusSeeOther) return } state, _ := randomToken(24) http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, Path: "/auth/", HttpOnly: true, Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600}) q := url.Values{ "client_id": {p.clientID}, "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, "response_type": {"code"}, "scope": {p.scope}, "state": {state}, } http.Redirect(w, r, p.authorize+"?"+q.Encode(), http.StatusTemporaryRedirect) } func (s *Server) oauthCallback(w http.ResponseWriter, r *http.Request) { name := r.PathValue("provider") p, ok := s.provider(name) state, err := r.Cookie("oauth_state") if !ok || err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 { http.Redirect(w, r, "/login?error=Invalid+OAuth+state", http.StatusSeeOther) return } form := url.Values{ "client_id": {p.clientID}, "client_secret": {p.secret}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, "grant_type": {"authorization_code"}, } req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, p.token, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { s.oauthFail(w, r) return } defer resp.Body.Close() var token struct { AccessToken string `json:"access_token"` } if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.AccessToken == "" { s.oauthFail(w, r) return } profileReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, p.profile, nil) profileReq.Header.Set("Authorization", "Bearer "+token.AccessToken) profileReq.Header.Set("Accept", "application/json") profileResp, err := http.DefaultClient.Do(profileReq) if err != nil { s.oauthFail(w, r) return } defer profileResp.Body.Close() var profile map[string]any decoder := json.NewDecoder(io.LimitReader(profileResp.Body, 1<<20)) decoder.UseNumber() if decoder.Decode(&profile) != nil { s.oauthFail(w, r) return } id := fmt.Sprint(profile["id"]) if name == "google" { id = fmt.Sprint(profile["sub"]) } login, _ := profile["login"].(string) email, _ := profile["email"].(string) display, _ := profile["name"].(string) avatar, _ := profile["avatar_url"].(string) if name == "google" { avatar, _ = profile["picture"].(string) login = strings.Split(email, "@")[0] } userID, err := s.store.UpsertOAuthUser(name, id, login, email, display, avatar) if err != nil { s.oauthFail(w, r) return } s.startSession(w, userID) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) oauthFail(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login?error=Could+not+sign+in+with+OAuth", http.StatusSeeOther) }