Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
112 lines
2.8 KiB
Go
112 lines
2.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"task/internal/sqlite"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
userKey ctxKey = 1
|
|
tokenAuthKey ctxKey = 2
|
|
)
|
|
|
|
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
|
|
u, ok := r.Context().Value(userKey).(sqlite.User)
|
|
return u, ok
|
|
}
|
|
|
|
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
|
|
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
|
|
return t, ok
|
|
}
|
|
|
|
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, ok := s.authenticate(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
|
raw := strings.TrimSpace(auth[len("Bearer "):])
|
|
ta, err := s.DB.ResolveBearerToken(raw)
|
|
if err != nil {
|
|
return r.Context(), false
|
|
}
|
|
ctx := context.WithValue(r.Context(), userKey, ta.User)
|
|
ctx = context.WithValue(ctx, tokenAuthKey, ta)
|
|
return ctx, true
|
|
}
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
return r.Context(), false
|
|
}
|
|
u, err := s.DB.UserFromSession(c.Value)
|
|
if err != nil {
|
|
return r.Context(), false
|
|
}
|
|
u.PasswordHash = ""
|
|
return context.WithValue(r.Context(), userKey, u), true
|
|
}
|
|
|
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, tok := s.currentTokenAuth(r); tok {
|
|
writeError(w, http.StatusForbidden, "admin required")
|
|
return
|
|
}
|
|
u, _ := s.currentUser(r)
|
|
if !u.IsAdmin {
|
|
writeError(w, http.StatusForbidden, "admin required")
|
|
return
|
|
}
|
|
next(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
|
|
u, ok := s.currentUser(r)
|
|
if !ok {
|
|
return sqlite.User{}, false, http.StatusUnauthorized
|
|
}
|
|
if ta, ok := s.currentTokenAuth(r); ok {
|
|
if ta.ProjectID != projectID {
|
|
return u, false, http.StatusForbidden
|
|
}
|
|
need := sqlite.TokenScopeRead
|
|
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
|
|
need = sqlite.TokenScopeWrite
|
|
}
|
|
// viewer → read; member+ → write
|
|
if minRole == sqlite.RoleViewer {
|
|
need = sqlite.TokenScopeRead
|
|
}
|
|
if !sqlite.TokenAllows(ta.Scope, need) {
|
|
return u, false, http.StatusForbidden
|
|
}
|
|
return u, true, 0
|
|
}
|
|
if u.IsAdmin {
|
|
return u, true, 0
|
|
}
|
|
role, err := s.DB.MemberRole(projectID, u.ID)
|
|
if err != nil || role == "" {
|
|
return u, false, http.StatusForbidden
|
|
}
|
|
if !sqlite.RoleAtLeast(role, minRole) {
|
|
return u, false, http.StatusForbidden
|
|
}
|
|
return u, true, 0
|
|
}
|