Files
team-presence/internal/app/server.go
T
nfel b0f486f026
Test and publish / verify (push) Successful in 2m41s
Remove blocked column from kanban board
2026-08-02 12:52:08 +03:30

1374 lines
46 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
Count int
}
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/tasks/{id}/todos", s.requireAuth(s.csrf(s.createBoardTaskTodo)))
mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/toggle", s.requireAuth(s.csrf(s.toggleBoardTaskTodo)))
mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/delete", s.requireAuth(s.csrf(s.deleteBoardTaskTodo)))
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 = ""
}
if filter.TagID > 0 {
filter.Count++
}
if filter.AssigneeID > 0 {
filter.Count++
}
if filter.Priority != "" {
filter.Count++
}
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: "Untriaged work"},
{Status: "todo", Title: "To do", Hint: "Ready to start"},
{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"},
{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) createBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := s.store.CreateBoardTaskTodo(taskID, r.FormValue("body")); err != nil {
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/board?flash=To-do+item+added", http.StatusSeeOther)
}
func (s *Server) toggleBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64)
if err := s.store.SetBoardTaskTodoCompleted(taskID, todoID, r.FormValue("completed") == "on"); err != nil {
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/board?flash=To-do+item+updated", http.StatusSeeOther)
}
func (s *Server) deleteBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64)
if err := s.store.DeleteBoardTaskTodo(taskID, todoID); err != nil {
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/board?flash=To-do+item+removed", 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)
}