fix workspace access and vendor correct fonts
Test and publish / verify (push) Successful in 2m22s

This commit is contained in:
2026-08-04 20:07:35 +03:30
parent 29d7f1636f
commit 9380f116d7
46 changed files with 320 additions and 122 deletions
+100 -17
View File
@@ -91,6 +91,7 @@ type PageData struct {
Users []User
Workspaces []Workspace
Workspace Workspace
WorkspaceMembers map[int64]bool
Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate
@@ -268,6 +269,7 @@ 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.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)
@@ -276,6 +278,8 @@ func (s *Server) routes(mux *http.ServeMux) {
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))
@@ -288,6 +292,7 @@ func (s *Server) routes(mux *http.ServeMux) {
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)))
@@ -307,6 +312,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus)))
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))
@@ -392,7 +398,7 @@ func (s *Server) buildDayDetail(day string) (DayDetail, error) {
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 != "" {
if holiday := persianHoliday(jalaliDate.Year, jalaliDate.Month, jalaliDate.Day); holiday != "" {
detail.DayNote = holiday
} else if selected.Weekday() == time.Friday {
detail.DayNote = "Friday weekend"
@@ -489,6 +495,7 @@ 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] }
}
}
@@ -626,7 +633,7 @@ func (s *Server) buildCalendar(userID int64, selected string) Calendar {
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)
cell.Holiday = persianHoliday(year, month, d)
if a, ok := attendance[raw]; ok {
cell.Status = a.Mode
}
@@ -650,12 +657,21 @@ func (s *Server) buildCalendar(userID int64, selected string) Calendar {
}
}
func persianHoliday(month, day int) string {
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",
"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
"11-22": "Revolution Day", "12-29": "Oil Nationalization Day",
"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)]
}
@@ -775,17 +791,18 @@ func (s *Server) deleteWorkUpdate(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.BoardTasks()
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.ArchivedBoardTasks()
archivedTasks, err := s.store.ArchivedBoardTasksInWorkspace(workspaceID)
if err != nil {
http.Error(w, "could not load archived cards", http.StatusInternalServerError)
return
}
users, err := s.store.ActiveUsers()
users, err := s.store.ActiveUsersInWorkspace(workspaceID)
if err != nil {
http.Error(w, "could not load teammates", http.StatusInternalServerError)
return
@@ -842,6 +859,11 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
})
}
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"
}
@@ -878,6 +900,13 @@ func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool {
}
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
@@ -900,8 +929,8 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
}
tagIDs = append(tagIDs, tag.ID)
}
taskID, err := s.store.CreateBoardTask(
currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
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 {
@@ -1064,11 +1093,44 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
})
}
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.Workspaces(u.ID)
workspaces, err := s.store.AllWorkspaces()
if err != nil { http.Error(w, "could not load workspaces", http.StatusInternalServerError); return }
s.render(w, "workspaces.html", PageData{Title:"Workspaces", User:u, CSRF:csrfToken(r), Workspaces:workspaces, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"workspaces"})
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) {
@@ -1082,7 +1144,11 @@ func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
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})
returnTo := r.FormValue("return_to"); if returnTo == "" || !strings.HasPrefix(returnTo,"/") { returnTo = "/" }
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)
}
@@ -1245,6 +1311,13 @@ func currentUser(r *http.Request) *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
@@ -1255,6 +1328,10 @@ func (s *Server) withUser(next http.Handler) http.Handler {
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)
@@ -1287,7 +1364,13 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
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 {
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
}
@@ -1300,7 +1383,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
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'")
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)
})
}