This commit is contained in:
2026-07-28 02:04:36 +03:30
commit a5aed05b98
31 changed files with 3500 additions and 0 deletions
+811
View File
@@ -0,0 +1,811 @@
package app
import (
"context"
"crypto/subtle"
"database/sql"
"embed"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"teammate/internal/jalali"
)
//go:embed templates/*.html static/*
var assets embed.FS
type Config struct {
Addr string
BaseURL string
DatabasePath string
SessionSecure bool
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",
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
}
type Stats struct{ Present, Remote, Leave int }
type DayDetail struct {
Gregorian string
Jalali string
Weekday string
DayNote string
Prev string
Next string
Present int
Remote int
Absent int
Members []RosterMember
}
type RosterMember struct {
User User
Status string
Label string
Detail string
CheckIn string
CheckOut string
}
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 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": func(t sql.NullTime) string {
if !t.Valid {
return "—"
}
return t.Time.Local().Format("15:04")
},
"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)
},
}
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 /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("GET /reports", s.requireAdmin(s.reportPage))
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
}
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)
rows, err := s.store.DayRoster(day)
if err != nil {
http.Error(w, "could not load the team day view", http.StatusInternalServerError)
return
}
jalaliDate := jalali.FromTime(selected)
detail := DayDetail{
Gregorian: day,
Jalali: jalaliDate.String(),
Weekday: selected.Format("Monday"),
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)
}
s.render(w, "day.html", PageData{
Title: "Team day", User: currentUser(r), CSRF: csrfToken(r), Day: detail,
Error: errorMessage, SelectedSection: "day",
})
}
func attendanceDetail(checkIn, checkOut, location string) string {
if checkOut != "" {
return fmt.Sprintf("%s · %s%s", location, checkIn, checkOut)
}
if checkIn != "" {
return fmt.Sprintf("%s · checked in %s", location, checkIn)
}
return location
}
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) 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) reportPage(w http.ResponseWriter, r *http.Request) {
now := time.Now()
start := now.AddDate(0, -1, 0).Format("2006-01-02")
s.render(w, "reports.html", PageData{
Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start,
ReportEnd: now.Format("2006-01-02"), 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) 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)
}