package httpserver import ( "database/sql" "encoding/json" "errors" "net/http" "strings" "time" "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/docs" ) type permissionPageData struct { ResourceType string ResourceID string Title string Users []permissionUserData } type permissionUserData struct { ID string Email string DisplayName string Role string CreatedAt string LastSeenAt string Permission string PermissionText string GrantCreatedAt string GrantedBy string GrantSummary string } func (s *Server) handlePermissionsPage(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) resourceType, resourceID := permissionResourceFromRequest(r) if resourceType == "" { resourceType = auth.ResourceDocument } if resourceType == auth.ResourceDocument { resourceID = strings.TrimSpace(resourceID) if resourceID == "" { resourceID = strings.TrimSpace(r.URL.Query().Get("path")) } } if resourceID == "" && resourceType != auth.ResourceGlobal && resourceType != auth.ResourceFolder { s.renderError(w, r, http.StatusBadRequest, "resource is required") return } if !s.canAdminResource(r, resourceType, resourceID, nil) { s.renderError(w, r, http.StatusNotFound, "document not found") return } data, err := s.buildPermissionPageData(r, principal, resourceType, resourceID) if err != nil { s.renderError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderTemplate(w, r, http.StatusOK, "permissions.gohtml", layoutData{ Title: "Permissions: " + data.Title, BodyClass: "page-permissions", BodyTemplate: "permissions_content", Data: data, }) } func (s *Server) handlePermissionsForm(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if err := r.ParseForm(); err != nil { s.renderError(w, r, http.StatusBadRequest, "invalid permissions form") return } resourceType := auth.ResourceType(r.FormValue("resourceType")) resourceID := r.FormValue("resourceId") updates := permissionUpdatesFromForm(r) if _, err := s.auth.UpdateResourcePermissions(r.Context(), principal, resourceType, resourceID, updates); err != nil { s.renderError(w, r, http.StatusBadRequest, err.Error()) return } http.Redirect(w, r, "/permissions?resourceType="+queryEscape(string(resourceType))+"&resourceId="+queryEscape(resourceID), http.StatusSeeOther) } func (s *Server) handlePermissionsAPI(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handlePermissionsGetAPI(w, r) case http.MethodPatch: s.handlePermissionsPatchAPI(w, r) default: writeJSONWithStatus(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) } } func (s *Server) handlePermissionsGetAPI(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) resourceType, resourceID := permissionResourceFromRequest(r) if !s.canAdminResource(r, resourceType, resourceID, nil) { writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"}) return } permissions, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID) if err != nil { writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } users, err := s.auth.ListUsers(r.Context()) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{ "resourceType": resourceType, "resourceId": resourceID, "permissions": permissions, "users": users, }) } func (s *Server) handlePermissionsPatchAPI(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) var req struct { ResourceType auth.ResourceType `json:"resourceType"` ResourceID string `json:"resourceId"` Users []auth.ResourcePermissionUpdate `json:"users"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if !s.canAdminResource(r, req.ResourceType, req.ResourceID, nil) { writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"}) return } permissions, err := s.auth.UpdateResourcePermissions(r.Context(), principal, req.ResourceType, req.ResourceID, req.Users) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"permissions": permissions}) } func (s *Server) buildPermissionPageData(r *http.Request, principal auth.Principal, resourceType auth.ResourceType, resourceID string) (permissionPageData, error) { users, err := s.auth.ListUsers(r.Context()) if err != nil { return permissionPageData{}, err } grants, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID) if err != nil { return permissionPageData{}, err } grantsByUser := make(map[string]auth.Permission, len(grants)) grantMetaByUser := make(map[string]auth.ResourcePermission, len(grants)) for _, grant := range grants { grantsByUser[grant.UserID] = grant.Permission grantMetaByUser[grant.UserID] = grant } usersByID := make(map[string]auth.User, len(users)) for _, user := range users { usersByID[user.ID] = user } result := permissionPageData{ ResourceType: string(resourceType), ResourceID: resourceID, Title: permissionResourceTitle(resourceType, resourceID), Users: make([]permissionUserData, 0, len(users)), } for _, user := range users { if user.Disabled { continue } permission := grantsByUser[user.ID] grant := grantMetaByUser[user.ID] result.Users = append(result.Users, permissionUserData{ ID: user.ID, Email: user.Email, DisplayName: user.DisplayName, Role: string(user.Role), CreatedAt: formatPermissionTime(user.CreatedAt), LastSeenAt: formatPermissionTime(user.LastSeenAt), Permission: string(permission), PermissionText: permissionLabel(permission), GrantCreatedAt: formatPermissionTime(grant.CreatedAt), GrantedBy: grantActorLabel(grant.GrantedBy, usersByID), GrantSummary: grantSummary(grant, usersByID), }) } return result, nil } func permissionResourceTitle(resourceType auth.ResourceType, resourceID string) string { switch resourceType { case auth.ResourceDocument: return "Page " + resourceID case auth.ResourceFolder: if resourceID == "" { return "Root folder" } return "Folder " + resourceID case auth.ResourceCollection: return "Collection " + resourceID default: return string(resourceType) } } func permissionResourceFromRequest(r *http.Request) (auth.ResourceType, string) { return auth.ResourceType(r.URL.Query().Get("resourceType")), r.URL.Query().Get("resourceId") } func permissionUpdatesFromForm(r *http.Request) []auth.ResourcePermissionUpdate { var updates []auth.ResourcePermissionUpdate for key, values := range r.PostForm { if !strings.HasPrefix(key, "permission:") || len(values) == 0 { continue } userID := strings.TrimPrefix(key, "permission:") permission := auth.Permission(values[0]) updates = append(updates, auth.ResourcePermissionUpdate{UserID: userID, Permission: permission}) } return updates } func permissionLabel(permission auth.Permission) string { switch permission { case auth.PermissionRead: return "View" case auth.PermissionWrite: return "Edit" case auth.PermissionAdmin: return "Admin" default: return "No access" } } func grantSummary(grant auth.ResourcePermission, usersByID map[string]auth.User) string { if grant.ID == "" { return "No explicit grant" } parts := []string{"Explicit " + permissionLabel(grant.Permission)} if grant.CreatedAt.IsZero() { return strings.Join(parts, " · ") } parts = append(parts, "granted "+formatPermissionTime(grant.CreatedAt)) if actor := grantActorLabel(grant.GrantedBy, usersByID); actor != "" { parts = append(parts, "by "+actor) } return strings.Join(parts, " · ") } func grantActorLabel(userID string, usersByID map[string]auth.User) string { if userID == "" { return "" } if user, ok := usersByID[userID]; ok { if user.DisplayName != "" { return user.DisplayName } return user.Email } return userID } func formatPermissionTime(value time.Time) string { if value.IsZero() { return "Never" } return value.Local().Format("Jan 2, 2006 3:04 PM") } func (s *Server) filterReadableDocuments(r *http.Request, records []docs.DocumentRecord) ([]docs.DocumentRecord, error) { filtered := make([]docs.DocumentRecord, 0, len(records)) for _, record := range records { if ok, err := s.canReadDocumentRecord(r, record); err != nil { return nil, err } else if ok { filtered = append(filtered, record) } } return filtered, nil } func (s *Server) filterReadableSearchResults(r *http.Request, results []docs.SearchResult) ([]docs.SearchResult, error) { filtered := make([]docs.SearchResult, 0, len(results)) for _, result := range results { record, err := s.repository.GetDocumentByPath(r.Context(), result.Path) if err != nil { if errors.Is(err, sql.ErrNoRows) { continue } return nil, err } if ok, err := s.canReadDocumentRecord(r, *record); err != nil { return nil, err } else if ok { filtered = append(filtered, result) } } return filtered, nil } func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath string) (*docs.DocumentRecord, error) { clean := strings.Trim(strings.TrimSpace(requestPath), "/") if clean == "" { return nil, sql.ErrNoRows } if !strings.HasSuffix(strings.ToLower(clean), ".md") { clean += ".md" } return s.repository.GetDocumentByPath(r.Context(), clean) } func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) { return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionRead) } func (s *Server) canWriteDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) { return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionWrite) } func (s *Server) canAdminDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) { return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionAdmin) } func (s *Server) documentPermissionAllows(r *http.Request, path string, tags []string, required auth.Permission) (bool, error) { principal, ok := principalFromContext(r.Context()) if !ok { return false, nil } if principal.Role == auth.RoleAdmin { return true, nil } permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceDocument, path, tags) if err != nil { return false, err } return auth.PermissionAllows(permission, required), nil } func (s *Server) canAdminResource(r *http.Request, resourceType auth.ResourceType, resourceID string, tags []string) bool { principal, ok := principalFromContext(r.Context()) if !ok { return false } if principal.Role == auth.RoleAdmin { return true } permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, resourceType, resourceID, tags) return err == nil && auth.PermissionAllows(permission, auth.PermissionAdmin) } func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool { principal, ok := principalFromContext(r.Context()) if !ok { return false } if principal.Role == auth.RoleAdmin { return true } folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath)) return s.canWriteFolder(r, folder) } func (s *Server) canWriteFolder(r *http.Request, folder string) bool { principal, ok := principalFromContext(r.Context()) if !ok { return false } if principal.Role == auth.RoleAdmin { return true } permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil) return err == nil && auth.PermissionAllows(permission, auth.PermissionWrite) } func (s *Server) grantDocumentCreatorAdmin(r *http.Request, path string) error { principal, ok := principalFromContext(r.Context()) if !ok || principal.UserID == "" { return nil } return s.auth.EnsureResourcePermission(r.Context(), principal, auth.ResourceDocument, path, auth.ResourcePermissionUpdate{ UserID: principal.UserID, Permission: auth.PermissionAdmin, }) } func lastPathSegment(path string) string { trimmed := strings.Trim(path, "/") if trimmed == "" { return "" } parts := strings.Split(trimmed, "/") return parts[len(parts)-1] } func queryEscape(value string) string { return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value) }