| package main |
| |
| import ( |
| "database/sql" |
| "embed" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "html/template" |
| "log" |
| "net/http" |
| "net/url" |
| "regexp" |
| "slices" |
| "strings" |
| "sync" |
| |
| "github.com/gorilla/mux" |
| _ "github.com/ncruces/go-sqlite3/driver" |
| _ "github.com/ncruces/go-sqlite3/embed" |
| ) |
| |
| var port = flag.Int("port", 8080, "Port to listen on") |
| var apiPort = flag.Int("api-port", 8081, "Port to listen on for API requests") |
| var dbPath = flag.String("db-path", "memberships.db", "Path to SQLite file") |
| |
| //go:embed memberships-tmpl/* |
| var tmpls embed.FS |
| |
| //go:embed static/* |
| var staticResources embed.FS |
| |
| type cachingHandler struct { |
| h http.Handler |
| } |
| |
| func (h cachingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Cache-Control", "max-age=604800") |
| h.h.ServeHTTP(w, r) |
| } |
| |
| type Server struct { |
| store Store |
| syncAddresses map[string]struct{} |
| mu sync.Mutex |
| } |
| |
| type Group struct { |
| Id string `json:"id"` |
| Title string `json:"title"` |
| Description string `json:"description"` |
| } |
| |
| type User struct { |
| Id string `json:"id"` |
| Username string `json:"username"` |
| Email string `json:"email"` |
| } |
| |
| func getLoggedInUser(r *http.Request) (string, error) { |
| if user := r.Header.Get("X-Forwarded-UserId"); user != "" { |
| return user, nil |
| } else { |
| return "", fmt.Errorf("unauthenticated") |
| } |
| // return "0063f4b6-29cb-4bd6-b8ce-1f6b203d490f", nil |
| } |
| |
| type Status int |
| |
| const ( |
| Owner Status = iota |
| Member |
| ) |
| |
| func (s *Server) Start() error { |
| e := make(chan error) |
| go func() { |
| r := mux.NewRouter() |
| r.PathPrefix("/static/").Handler(cachingHandler{http.FileServer(http.FS(staticResources))}) |
| r.HandleFunc("/group/{groupId:.*}/add-user/", s.addUserToGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}/add-child-group", s.addChildGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}/add-owner-group", s.addOwnerGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}/remove-child-group/{otherId:.*}", s.removeChildGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}/remove-owner/{username}", s.removeOwnerFromGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}/remove-member/{username}", s.removeMemberFromGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/group/{groupId:.*}", s.groupHandler) |
| r.HandleFunc("/user/{username}/ssh-key", s.addSSHKeyForUserHandler).Methods(http.MethodPost) |
| r.HandleFunc("/user/{username}/remove-ssh-key", s.removeSSHKeyForUserHandler).Methods(http.MethodPost) |
| r.HandleFunc("/user/{username}", s.userHandler) |
| r.HandleFunc("/create-group", s.createGroupHandler).Methods(http.MethodPost) |
| r.HandleFunc("/", s.homePageHandler) |
| e <- http.ListenAndServe(fmt.Sprintf(":%d", *port), r) |
| }() |
| go func() { |
| r := mux.NewRouter() |
| r.HandleFunc("/api/init", s.apiInitHandler) |
| // TODO(gio): change to /api/users/{username} |
| r.HandleFunc("/api/users/{username}/keys", s.apiAddUserKey).Methods(http.MethodPost) |
| r.HandleFunc("/api/user/{username}", s.apiMemberOfHandler) |
| r.HandleFunc("/api/users", s.apiGetAllUsers).Methods(http.MethodGet) |
| r.HandleFunc("/api/users", s.apiCreateUser).Methods(http.MethodPost) |
| r.HandleFunc("/api/group", s.apiCreateGroup).Methods(http.MethodPost) |
| r.HandleFunc("/api/add-group-user", s.apiAddUserToGroup).Methods(http.MethodPost) |
| r.HandleFunc("/api/remove-group-user", s.apiRemoveUserFromGroup).Methods(http.MethodPost) |
| r.HandleFunc("/api/add-group-group", s.apiAddGroupToGroup).Methods(http.MethodPost) |
| r.HandleFunc("/api/remove-group-group", s.apiRemoveGroupFromGroup).Methods(http.MethodPost) |
| r.HandleFunc("/api/get-group", s.apiGetGroup).Methods(http.MethodPost) |
| e <- http.ListenAndServe(fmt.Sprintf(":%d", *apiPort), r) |
| }() |
| return <-e |
| } |
| |
| type GroupData struct { |
| Group Group |
| Membership string |
| } |
| |
| func (s *Server) checkIsOwner(w http.ResponseWriter, userId, groupId string) error { |
| ownerUsers, err := s.store.GetOwnerUsers(nil, groupId) |
| if err != nil { |
| return err |
| } |
| if slices.ContainsFunc(ownerUsers, func(u User) bool { |
| return u.Id == userId |
| }) { |
| return nil |
| } |
| ownerIds, err := s.store.GetOwnerGroups(nil, groupId) |
| if err != nil { |
| return err |
| } |
| canActAs, err := s.store.GetGroupsUserCanActAs(nil, userId) |
| if err != nil { |
| return err |
| } |
| for _, g := range canActAs { |
| if slices.Index(ownerIds, g) != -1 { |
| return nil |
| } |
| } |
| return fmt.Errorf("not an owner") |
| } |
| |
| type templates struct { |
| group *template.Template |
| user *template.Template |
| } |
| |
| func parseTemplates(fs embed.FS) (templates, error) { |
| base, err := template.ParseFS(fs, "memberships-tmpl/base.html") |
| if err != nil { |
| return templates{}, err |
| } |
| parse := func(path string) (*template.Template, error) { |
| if b, err := base.Clone(); err != nil { |
| return nil, err |
| } else { |
| return b.ParseFS(fs, path) |
| } |
| } |
| user, err := parse("memberships-tmpl/user.html") |
| if err != nil { |
| return templates{}, err |
| } |
| group, err := parse("memberships-tmpl/group.html") |
| if err != nil { |
| return templates{}, err |
| } |
| return templates{group, user}, nil |
| } |
| |
| func (s *Server) homePageHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther) |
| } |
| |
| type UserPageData struct { |
| User User |
| OwnerGroups []Group |
| MembershipGroups []Group |
| TransitiveGroups []Group |
| LoggedInUserPage bool |
| SSHPublicKeys []string |
| ErrorMessage string |
| } |
| |
| func (s *Server) userHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| errorMsg := r.URL.Query().Get("errorMessage") |
| vars := mux.Vars(r) |
| user := strings.ToLower(vars["username"]) |
| // TODO(dtabidze): should check if username exists or not. |
| loggedInUserPage := loggedInUser == user |
| ownerGroups, err := s.store.GetGroupsUserOwns(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| membershipGroups, err := s.store.GetGroupsUserIsMemberOf(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| transitiveGroups, err := s.store.GetGroupsUserCanActAs(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| userInfo, err := s.store.GetUser(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| sshPublicKeys, err := s.store.GetUserPublicKeys(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| data := UserPageData{ |
| User: userInfo, |
| OwnerGroups: ownerGroups, |
| MembershipGroups: membershipGroups, |
| TransitiveGroups: transitiveGroups, |
| LoggedInUserPage: loggedInUserPage, |
| SSHPublicKeys: sshPublicKeys, |
| ErrorMessage: errorMsg, |
| } |
| templates, err := parseTemplates(tmpls) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| if err := templates.user.Execute(w, data); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) createGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| if err := r.ParseForm(); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| var group Group |
| group.Id = r.PostFormValue("id") |
| group.Title = r.PostFormValue("title") |
| group.Description = r.PostFormValue("description") |
| if err := isValidGroupId(group.Id); err != nil { |
| // http.Error(w, err.Error(), http.StatusBadRequest) |
| redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| if err := s.store.CreateGroup(nil, loggedInUser, group); err != nil { |
| // http.Error(w, err.Error(), http.StatusInternalServerError) |
| redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| } |
| |
| type GroupPageData struct { |
| GroupId string |
| Title string |
| Description string |
| Owners []User |
| Members []User |
| AllGroups []Group |
| AllUsers []User |
| TransitiveGroups []Group |
| ChildGroups []Group |
| OwnerGroups []Group |
| ErrorMessage string |
| } |
| |
| func (s *Server) groupHandler(w http.ResponseWriter, r *http.Request) { |
| _, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| errorMsg := r.URL.Query().Get("errorMessage") |
| vars := mux.Vars(r) |
| groupId := normalizeGroupId(vars["groupId"]) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| g, err := s.store.GetGroup(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| owners, err := s.store.GetOwnerUsers(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| members, err := s.store.GetMemberUsers(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| allUsers, err := s.store.GetAllUsers(nil) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| allGroups, err := s.store.GetAllGroups(nil) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| transitiveGroups, err := s.store.GetGroupsGroupCanActAs(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| childGroups, err := s.store.GetMemberGroups(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| ownerGroups, err := s.store.GetOwnerGroups(nil, groupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| data := GroupPageData{ |
| GroupId: groupId, |
| Title: g.Title, |
| Description: g.Description, |
| Owners: owners, |
| Members: members, |
| AllGroups: allGroups, |
| AllUsers: allUsers, |
| TransitiveGroups: transitiveGroups, |
| ChildGroups: childGroups, |
| OwnerGroups: ownerGroups, |
| ErrorMessage: errorMsg, |
| } |
| templates, err := parseTemplates(tmpls) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| if err := templates.group.Execute(w, data); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) removeChildGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| groupId := normalizeGroupId(vars["groupId"]) |
| otherId := vars["otherId"] |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := isValidGroupId(otherId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| err = s.store.RemoveMemberGroup(nil, groupId, otherId) |
| if err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) removeOwnerFromGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| username := vars["username"] |
| groupId := normalizeGroupId(vars["groupId"]) |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| err = s.store.RemoveOwnerUser(nil, groupId, username) |
| if err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) removeMemberFromGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| username := vars["username"] |
| groupId := normalizeGroupId(vars["groupId"]) |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| err = s.store.RemoveMemberUser(nil, groupId, username) |
| if err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) addUserToGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| groupId := normalizeGroupId(vars["groupId"]) |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| userId := strings.ToLower(r.FormValue("userId")) |
| if userId == "" { |
| http.Error(w, "Username parameter is required", http.StatusBadRequest) |
| return |
| } |
| status, err := convertStatus(r.FormValue("status")) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| switch status { |
| case Owner: |
| err = s.store.AddOwnerUser(nil, groupId, userId) |
| case Member: |
| err = s.store.AddMemberUser(nil, groupId, userId) |
| default: |
| http.Error(w, "Invalid status", http.StatusBadRequest) |
| return |
| } |
| if err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) addChildGroupHandler(w http.ResponseWriter, r *http.Request) { |
| // TODO(dtabidze): In future we might need to make one group OWNER of another and not just a member. |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| groupId := normalizeGroupId(vars["groupId"]) |
| otherId := r.FormValue("otherId") |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := isValidGroupId(otherId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| if err := s.store.AddMemberGroup(nil, groupId, otherId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) addOwnerGroupHandler(w http.ResponseWriter, r *http.Request) { |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| groupId := normalizeGroupId(vars["groupId"]) |
| otherId := r.FormValue("otherId") |
| if err := isValidGroupId(groupId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := isValidGroupId(otherId); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusSeeOther) |
| return |
| } |
| if err := s.store.AddOwnerGroup(nil, groupId, otherId); err != nil { |
| redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther) |
| } |
| |
| func (s *Server) addSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) { |
| defer s.pingAllSyncAddresses() |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| username := vars["username"] |
| if loggedInUser != username { |
| http.Error(w, "You are not allowed to add SSH key for someone else", http.StatusUnauthorized) |
| return |
| } |
| sshKey := r.FormValue("ssh-key") |
| if sshKey == "" { |
| http.Error(w, "SSH key not present", http.StatusBadRequest) |
| return |
| } |
| if err := s.store.AddUserPublicKey(nil, strings.ToLower(username), sshKey); err != nil { |
| redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther) |
| } |
| |
| func (s *Server) removeSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) { |
| defer s.pingAllSyncAddresses() |
| loggedInUser, err := getLoggedInUser(r) |
| if err != nil { |
| http.Error(w, "User Not Logged In", http.StatusUnauthorized) |
| return |
| } |
| vars := mux.Vars(r) |
| username := vars["username"] |
| if loggedInUser != username { |
| http.Error(w, "You are not allowed to remove SSH key for someone else", http.StatusUnauthorized) |
| return |
| } |
| if err := r.ParseForm(); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| sshKey := r.FormValue("ssh-key") |
| if sshKey == "" { |
| http.Error(w, "SSH key not present", http.StatusBadRequest) |
| return |
| } |
| if err := s.store.RemoveUserPublicKey(nil, username, sshKey); err != nil { |
| redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error())) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| return |
| } |
| http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther) |
| } |
| |
| type initRequest struct { |
| User User `json:"user"` |
| Groups []Group `json:"groups"` |
| } |
| |
| func (s *Server) apiInitHandler(w http.ResponseWriter, r *http.Request) { |
| var req initRequest |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| if err := s.store.Init(req.User, req.Groups); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type userInfo struct { |
| CanActAs []Group `json:"canActAs"` |
| OwnerOf []Group `json:"ownerOf"` |
| } |
| |
| func (s *Server) apiMemberOfHandler(w http.ResponseWriter, r *http.Request) { |
| vars := mux.Vars(r) |
| user, ok := vars["username"] |
| if !ok || user == "" { |
| http.Error(w, "Username parameter is required", http.StatusBadRequest) |
| return |
| } |
| user = strings.ToLower(user) |
| transitiveGroups, err := s.store.GetGroupsUserCanActAs(nil, user) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| owned, err := s.store.GetGroupsUserOwns(nil, user) |
| w.Header().Set("Content-Type", "application/json") |
| if err := json.NewEncoder(w).Encode(userInfo{transitiveGroups, owned}); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) apiGetAllUsers(w http.ResponseWriter, r *http.Request) { |
| s.addSyncAddress(r.FormValue("selfAddress")) |
| var users []User |
| var err error |
| groups := r.FormValue("groups") |
| if groups == "" { |
| users, err = s.store.GetAllUsers(nil) |
| } else { |
| uniqueUsers := make(map[string]struct{}) |
| g := strings.Split(groups, ",") |
| uniqueTG := make(map[string]struct{}) |
| for _, group := range g { |
| uniqueTG[group] = struct{}{} |
| trGroups, err := s.store.GetGroupsGroupCanActAs(nil, group) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| for _, tg := range trGroups { |
| uniqueTG[tg.Title] = struct{}{} |
| } |
| } |
| for group := range uniqueTG { |
| u, err := s.store.GetMemberUsers(nil, group) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| for _, user := range u { |
| uniqueUsers[user.Username] = struct{}{} |
| } |
| } |
| usernames := make([]string, 0, len(uniqueUsers)) |
| for username := range uniqueUsers { |
| usernames = append(usernames, username) |
| } |
| users, err = s.store.GetUsers(nil, usernames) |
| } |
| if err != nil { |
| http.Error(w, "Failed to retrieve user infos", http.StatusInternalServerError) |
| return |
| } |
| w.Header().Set("Content-Type", "application/json") |
| if err := json.NewEncoder(w).Encode(users); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) apiCreateUser(w http.ResponseWriter, r *http.Request) { |
| defer s.pingAllSyncAddresses() |
| var req User |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| if req.Id == "" { |
| http.Error(w, "Id cannot be empty", http.StatusBadRequest) |
| return |
| } |
| if req.Username == "" { |
| http.Error(w, "Username cannot be empty", http.StatusBadRequest) |
| return |
| } |
| if req.Email == "" { |
| http.Error(w, "Email cannot be empty", http.StatusBadRequest) |
| return |
| } |
| user := User{Id: req.Id, Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email)} |
| if err := s.store.CreateUser(nil, user); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type createGroupRequest struct { |
| UserId string `json:"userId"` |
| GroupId string `json:"groupId"` |
| Title string `json:"title"` |
| Description string `json:"description"` |
| } |
| |
| func (s *Server) apiCreateGroup(w http.ResponseWriter, r *http.Request) { |
| var req createGroupRequest |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| if err := s.store.CreateGroup(nil, req.UserId, Group{req.GroupId, req.Title, req.Description}); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type apiGetGroupReq struct { |
| GroupId string `json:"groupId"` |
| } |
| |
| // TODO(gio): update client |
| type apiGetGroupResp struct { |
| Self Group `json:"self"` |
| Members []User `json:"members"` |
| Owners []User `json:"owners"` |
| MemberGroups []Group `json:"memberGroups"` |
| OwnerGroups []Group `json:"ownerGroups"` |
| } |
| |
| func (s *Server) apiGetGroup(w http.ResponseWriter, r *http.Request) { |
| var req apiGetGroupReq |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "missing group name", http.StatusBadRequest) |
| return |
| } |
| group, err := s.store.GetGroup(nil, req.GroupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| members, err := s.store.GetMemberUsers(nil, req.GroupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| owners, err := s.store.GetOwnerUsers(nil, req.GroupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| memberGroups, err := s.store.GetMemberGroups(nil, req.GroupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| ownerGroups, err := s.store.GetOwnerGroups(nil, req.GroupId) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| if err := json.NewEncoder(w).Encode(apiGetGroupResp{group, members, owners, memberGroups, ownerGroups}); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type userGroupStatus struct { |
| GroupId string `json:"groupId"` |
| UserId string `json:"userId"` |
| Status string `json:"status"` |
| } |
| |
| func (s *Server) apiAddUserToGroup(w http.ResponseWriter, r *http.Request) { |
| var req userGroupStatus |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| status, err := convertStatus(req.Status) |
| if err != nil { |
| http.Error(w, "Invalid status", http.StatusBadRequest) |
| return |
| } |
| fmt.Printf("AAA %+v\n", req) |
| switch status { |
| case Member: |
| err = s.store.AddMemberUser(nil, req.GroupId, req.UserId) |
| case Owner: |
| err = s.store.AddOwnerUser(nil, req.GroupId, req.UserId) |
| } |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) apiRemoveUserFromGroup(w http.ResponseWriter, r *http.Request) { |
| var req userGroupStatus |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| status, err := convertStatus(req.Status) |
| if err != nil { |
| http.Error(w, "Invalid status", http.StatusBadRequest) |
| return |
| } |
| switch status { |
| case Member: |
| err = s.store.RemoveMemberUser(nil, req.GroupId, req.UserId) |
| case Owner: |
| err = s.store.RemoveOwnerUser(nil, req.GroupId, req.UserId) |
| } |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type groupGroupStatus struct { |
| GroupId string `json:"groupId"` |
| OtherId string `json:"otherId"` |
| Status string `json:"status"` |
| } |
| |
| func (s *Server) apiAddGroupToGroup(w http.ResponseWriter, r *http.Request) { |
| var req groupGroupStatus |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| status, err := convertStatus(req.Status) |
| if err != nil { |
| http.Error(w, "Invalid status", http.StatusBadRequest) |
| return |
| } |
| switch status { |
| case Member: |
| err = s.store.AddMemberGroup(nil, req.GroupId, req.OtherId) |
| case Owner: |
| err = s.store.AddOwnerGroup(nil, req.GroupId, req.OtherId) |
| } |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| func (s *Server) apiRemoveGroupFromGroup(w http.ResponseWriter, r *http.Request) { |
| var req groupGroupStatus |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| status, err := convertStatus(req.Status) |
| if err != nil { |
| http.Error(w, "Invalid status", http.StatusBadRequest) |
| return |
| } |
| switch status { |
| case Member: |
| err = s.store.RemoveMemberGroup(nil, req.GroupId, req.OtherId) |
| case Owner: |
| err = s.store.RemoveOwnerGroup(nil, req.GroupId, req.OtherId) |
| } |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type getGroupsOwnedByRequest struct { |
| User string `json:"user"` |
| } |
| |
| type getGroupsOwnedByResponse struct { |
| Groups []string `json:"groups"` |
| } |
| |
| func (s *Server) apiGetUserOwnedGroups(w http.ResponseWriter, r *http.Request) { |
| var req getGroupsOwnedByRequest |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
| groups, err := s.store.GetGroupsUserOwns(nil, req.User) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| var resp getGroupsOwnedByResponse |
| for _, g := range groups { |
| resp.Groups = append(resp.Groups, g.Title) |
| } |
| if err := json.NewEncoder(w).Encode(resp); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| type addUserKeyRequest struct { |
| User string `json:"user"` |
| PublicKey string `json:"publicKey"` |
| } |
| |
| func (s *Server) apiAddUserKey(w http.ResponseWriter, r *http.Request) { |
| var req addUserKeyRequest |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, "Invalid request body", http.StatusBadRequest) |
| return |
| } |
| if req.User == "" { |
| http.Error(w, "Username cannot be empty", http.StatusBadRequest) |
| return |
| } |
| if req.PublicKey == "" { |
| http.Error(w, "PublicKey cannot be empty", http.StatusBadRequest) |
| return |
| } |
| if err := s.store.AddUserPublicKey(nil, strings.ToLower(req.User), req.PublicKey); err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| } |
| |
| // TODO(gio): enque sync event instead of directly reaching out to clients. |
| // This will allow to deduplicate sync events and save resources. |
| func (s *Server) pingAllSyncAddresses() { |
| s.mu.Lock() |
| defer s.mu.Unlock() |
| for address := range s.syncAddresses { |
| go func(address string) { |
| log.Printf("Pinging %s", address) |
| resp, err := http.Get(address) |
| if err != nil { |
| // TODO(gio): remove sync address after N number of failures. |
| log.Printf("Failed to ping %s: %v", address, err) |
| return |
| } |
| defer resp.Body.Close() |
| if resp.StatusCode != http.StatusOK { |
| log.Printf("Ping to %s returned status %d", address, resp.StatusCode) |
| } |
| }(address) |
| } |
| } |
| |
| func (s *Server) addSyncAddress(address string) { |
| if address == "" { |
| return |
| } |
| fmt.Printf("Adding sync address: %s\n", address) |
| s.mu.Lock() |
| defer s.mu.Unlock() |
| s.syncAddresses[address] = struct{}{} |
| } |
| |
| func convertStatus(status string) (Status, error) { |
| switch strings.ToLower(status) { |
| case "owner": |
| return Owner, nil |
| case "member": |
| return Member, nil |
| default: |
| return Owner, fmt.Errorf("invalid status: %s", status) |
| } |
| } |
| |
| func isValidGroupId(group string) error { |
| if strings.TrimSpace(group) == "" { |
| return fmt.Errorf("Group name can't be empty or contain only whitespaces") |
| } |
| validGroupName := regexp.MustCompile(`^[a-z0-9\-_:.\/ ]+$`) |
| if !validGroupName.MatchString(group) { |
| return fmt.Errorf("Group name should contain only lowercase letters, digits, -, _, :, ., /") |
| } |
| return nil |
| } |
| |
| func main() { |
| flag.Parse() |
| db, err := sql.Open("sqlite3", *dbPath) |
| if err != nil { |
| panic(err) |
| } |
| store, err := NewSQLiteStore(db) |
| if err != nil { |
| panic(err) |
| } |
| s := Server{ |
| store: store, |
| syncAddresses: make(map[string]struct{}), |
| mu: sync.Mutex{}, |
| } |
| log.Fatal(s.Start()) |
| } |
| |
| func normalizeGroupId(groupId string) string { |
| if strings.Contains(groupId, "/") && !strings.HasPrefix(groupId, "/") { |
| return fmt.Sprintf("/%s", groupId) |
| } |
| return groupId |
| } |