7e8b7c4343
Test and publish / verify (push) Successful in 3m16s
Switch Vazirmatn font-face from TTF-only to WOFF2 to halve the font payload (~50 KB vs ~105 KB per weight). Add a staticHandler middleware that injects font/ttf and font/woff2 Content-Type headers so browsers don't reject them as application/octet-stream.
1642 lines
57 KiB
Go
1642 lines
57 KiB
Go
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
|
||
Workspaces []Workspace
|
||
Workspace Workspace
|
||
WorkspaceMembers map[int64]bool
|
||
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() }
|
||
|
||
// staticHandler wraps a file server so font files always get the correct
|
||
// Content-Type — Go's http.DetectContentType can return application/octet-stream
|
||
// for .ttf files on older Go versions.
|
||
func staticHandler(fs http.FileSystem) http.Handler {
|
||
h := http.FileServer(fs)
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
path := strings.TrimPrefix(r.URL.Path, "/static/")
|
||
switch {
|
||
case strings.HasSuffix(path, ".ttf"):
|
||
w.Header().Set("Content-Type", "font/ttf")
|
||
case strings.HasSuffix(path, ".woff"), strings.HasSuffix(path, ".woff2"):
|
||
w.Header().Set("Content-Type", "font/woff2")
|
||
}
|
||
h.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func (s *Server) routes(mux *http.ServeMux) {
|
||
fs := http.FS(assets)
|
||
mux.Handle("GET /static/", staticHandler(fs))
|
||
mux.Handle("GET /uploads/avatars/", http.StripPrefix("/uploads/avatars/", http.FileServer(http.Dir(filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")))))
|
||
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 /profile", s.requireAuth(s.profilePage))
|
||
mux.HandleFunc("POST /profile/avatar", s.requireAuth(s.csrf(s.uploadAvatar)))
|
||
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("GET /board/archive", s.requireAuth(s.archivedBoardPage))
|
||
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("POST /admin/users/{id}/password", s.requireAdmin(s.csrf(s.changeUserPassword)))
|
||
mux.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.csrf(s.deleteUser)))
|
||
mux.HandleFunc("GET /admin/workspaces", s.requireAdmin(s.workspacesPage))
|
||
mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace)))
|
||
mux.HandleFunc("POST /admin/workspaces/{id}/members", s.requireAdmin(s.csrf(s.setWorkspaceMembers)))
|
||
mux.HandleFunc("POST /workspace/switch", s.requireAuth(s.csrf(s.switchWorkspace)))
|
||
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.Year, 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) {
|
||
if data.User != nil {
|
||
if workspaces, err := s.store.Workspaces(data.User.ID); err == nil {
|
||
data.Workspaces = workspaces
|
||
if data.Workspace.ID == 0 && data.User.WorkspaceID > 0 {
|
||
for _, ws := range workspaces {
|
||
if ws.ID == data.User.WorkspaceID {
|
||
data.Workspace = ws
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if data.Workspace.ID == 0 && len(workspaces) > 0 {
|
||
data.Workspace = workspaces[0]
|
||
}
|
||
}
|
||
}
|
||
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(year, 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(year, month, day int) string {
|
||
// Official public holidays for Solar Hijri 1405. Lunar holidays are
|
||
// recorded using the dates published for this Persian calendar year.
|
||
if year != 1405 {
|
||
return ""
|
||
}
|
||
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",
|
||
"2-6": "Eid al-Adha", "2-14": "Eid al-Ghadir",
|
||
"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
|
||
"4-3": "Tasua", "4-4": "Ashura",
|
||
"5-13": "Arbaeen", "5-21": "Demise of Prophet Muhammad", "5-23": "Martyrdom of Imam Hassan", "5-30": "Martyrdom of Imam Reza",
|
||
"6-8": "Prophet Muhammad's Birthday",
|
||
"9-3": "Martyrdom of Fatima",
|
||
"10-2": "Imam Ali's Birthday",
|
||
"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) {
|
||
workspaceID := requestWorkspaceID(r)
|
||
tasks, err := s.store.BoardTasksInWorkspace(workspaceID)
|
||
if err != nil {
|
||
http.Error(w, "could not load team board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
archivedTasks, err := s.store.ArchivedBoardTasksInWorkspace(workspaceID)
|
||
if err != nil {
|
||
http.Error(w, "could not load archived cards", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
users, err := s.store.ActiveUsersInWorkspace(workspaceID)
|
||
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 (s *Server) archivedBoardPage(w http.ResponseWriter, r *http.Request) {
|
||
tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r))
|
||
if err != nil {
|
||
http.Error(w, "could not load archived cards", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
s.render(w, "archived.html", PageData{Title: "Archived cards", User: currentUser(r), CSRF: csrfToken(r), ArchivedTasks: tasks, 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) {
|
||
workspaceID := requestWorkspaceID(r)
|
||
if raw := r.FormValue("workspace_id"); raw != "" {
|
||
if selected, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||
if _, err := s.store.WorkspaceMember(selected, currentUser(r).ID); err != nil {
|
||
http.Redirect(w, r, "/board?error=You+do+not+have+access+to+that+workspace", http.StatusSeeOther)
|
||
return
|
||
}
|
||
workspaceID = selected
|
||
}
|
||
}
|
||
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.CreateBoardTaskInWorkspace(
|
||
workspaceID, 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) profilePage(w http.ResponseWriter, r *http.Request) {
|
||
u := currentUser(r)
|
||
s.render(w, "profile.html", PageData{Title: "Profile", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"), Error: r.URL.Query().Get("error"), SelectedSection: "profile"})
|
||
}
|
||
|
||
func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseMultipartForm(3 << 20); err != nil {
|
||
http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("avatar")
|
||
if err != nil || header.Size > 2<<20 {
|
||
http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
|
||
return
|
||
}
|
||
defer file.Close()
|
||
buf := make([]byte, 512)
|
||
n, _ := file.Read(buf)
|
||
kind := http.DetectContentType(buf[:n])
|
||
ext := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[kind]
|
||
if ext == "" {
|
||
http.Redirect(w, r, "/profile?error=Only+JPG,+PNG,+or+WebP+images+are+supported", http.StatusSeeOther)
|
||
return
|
||
}
|
||
if _, err := file.Seek(0, 0); err != nil {
|
||
http.Redirect(w, r, "/profile?error=Could+not+read+image", http.StatusSeeOther)
|
||
return
|
||
}
|
||
dir := filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
http.Error(w, "could not save avatar", 500)
|
||
return
|
||
}
|
||
name := fmt.Sprintf("%d%s", currentUser(r).ID, ext)
|
||
dst, err := os.Create(filepath.Join(dir, name))
|
||
if err != nil {
|
||
http.Error(w, "could not save avatar", 500)
|
||
return
|
||
}
|
||
defer dst.Close()
|
||
if _, err = io.Copy(dst, file); err != nil {
|
||
http.Error(w, "could not save avatar", 500)
|
||
return
|
||
}
|
||
if err := s.store.SetUserAvatar(currentUser(r).ID, "/uploads/avatars/"+name); err != nil {
|
||
http.Error(w, "could not save avatar", 500)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/profile?flash=Profile+photo+updated", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) {
|
||
u := currentUser(r)
|
||
workspaces, err := s.store.AllWorkspaces()
|
||
if err != nil {
|
||
http.Error(w, "could not load workspaces", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
users, err := s.store.Users()
|
||
if err != nil {
|
||
http.Error(w, "could not load users", 500)
|
||
return
|
||
}
|
||
selected := int64(0)
|
||
if len(workspaces) > 0 {
|
||
selected = workspaces[0].ID
|
||
}
|
||
if raw := r.URL.Query().Get("workspace"); raw != "" {
|
||
selected, _ = strconv.ParseInt(raw, 10, 64)
|
||
}
|
||
members, _ := s.store.WorkspaceMemberIDs(selected)
|
||
selectedWorkspace := Workspace{ID: selected}
|
||
for _, ws := range workspaces {
|
||
if ws.ID == selected {
|
||
selectedWorkspace = ws
|
||
break
|
||
}
|
||
}
|
||
s.render(w, "workspaces.html", PageData{Title: "Workspaces", User: u, CSRF: csrfToken(r), Workspaces: workspaces, Users: users, Workspace: selectedWorkspace, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "workspaces", WorkspaceMembers: members})
|
||
}
|
||
|
||
func (s *Server) setWorkspaceMembers(w http.ResponseWriter, r *http.Request) {
|
||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
var ids []int64
|
||
seen := map[int64]bool{}
|
||
for _, raw := range r.Form["user_ids"] {
|
||
if userID, err := strconv.ParseInt(raw, 10, 64); err == nil && !seen[userID] {
|
||
ids = append(ids, userID)
|
||
seen[userID] = true
|
||
}
|
||
}
|
||
// The admin editing access must retain access to the workspace being managed.
|
||
adminID := currentUser(r).ID
|
||
foundAdmin := false
|
||
for _, id := range ids {
|
||
if id == adminID {
|
||
foundAdmin = true
|
||
break
|
||
}
|
||
}
|
||
if !foundAdmin {
|
||
ids = append(ids, adminID)
|
||
}
|
||
if err := s.store.SetWorkspaceMembers(id, ids); err != nil {
|
||
http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/workspaces?workspace="+strconv.FormatInt(id, 10)+"&flash=Workspace+members+updated", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) {
|
||
u := currentUser(r)
|
||
if _, err := s.store.CreateWorkspace(r.FormValue("name"), r.FormValue("slug"), u.ID); err != nil {
|
||
http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/workspaces?flash=Workspace+created", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
|
||
id, err := strconv.ParseInt(r.FormValue("workspace_id"), 10, 64)
|
||
if err != nil {
|
||
http.Error(w, "invalid workspace", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if _, err := s.store.WorkspaceMember(id, currentUser(r).ID); err != nil {
|
||
http.Error(w, "workspace access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
http.SetCookie(w, &http.Cookie{Name: "teammate_workspace", Value: strconv.FormatInt(id, 10), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 31536000})
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
returnTo := r.FormValue("return_to")
|
||
if returnTo == "" || !strings.HasPrefix(returnTo, "/") {
|
||
returnTo = "/admin/workspaces?workspace=" + strconv.FormatInt(id, 10)
|
||
}
|
||
if r.FormValue("return_to") == "" {
|
||
if ref, err := url.Parse(r.Referer()); err == nil && ref.Path != "" && ref.Path != "/workspace/switch" {
|
||
returnTo = ref.Path
|
||
if ref.RawQuery != "" {
|
||
returnTo += "?" + ref.RawQuery
|
||
}
|
||
}
|
||
}
|
||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||
}
|
||
|
||
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) changeUserPassword(w http.ResponseWriter, r *http.Request) {
|
||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
password := r.FormValue("password")
|
||
if password == "" || password != r.FormValue("password_confirm") {
|
||
http.Redirect(w, r, "/admin/users?error=Passwords+must+match", http.StatusSeeOther)
|
||
return
|
||
}
|
||
if err := s.store.SetUserPassword(id, password); err != nil {
|
||
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/users?flash=Password+updated", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
if err := s.store.DeleteUser(id, currentUser(r).ID); err != nil {
|
||
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/users?flash=Account+deleted", 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 requestWorkspaceID(r *http.Request) int64 {
|
||
if c, err := r.Cookie("teammate_workspace"); err == nil {
|
||
if id, err := strconv.ParseInt(c.Value, 10, 64); err == nil && id > 0 {
|
||
return id
|
||
}
|
||
}
|
||
return 1
|
||
}
|
||
|
||
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 {
|
||
u.WorkspaceID = requestWorkspaceID(r)
|
||
if _, err := s.store.WorkspaceMember(u.WorkspaceID, u.ID); err != nil {
|
||
if available, lookupErr := s.store.Workspaces(u.ID); lookupErr == nil && len(available) > 0 {
|
||
u.WorkspaceID = available[0].ID
|
||
}
|
||
}
|
||
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) {
|
||
var err error
|
||
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
|
||
err = r.ParseMultipartForm(8 << 20)
|
||
} else {
|
||
err = r.ParseForm()
|
||
}
|
||
if 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'; style-src 'self'; font-src 'self' 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)
|
||
}
|