package forge import ( "slices" "strconv" "strings" ) type accountBrief struct { Handle string `json:"handle"` Kind string `json:"kind"` Display string `json:"display"` Avatar string `json:"avatar"` URL string `json:"url"` API string `json:"api"` } type accountFull struct { accountBrief Bio string `json:"bio"` Location string `json:"location"` Workplace string `json:"workplace"` Websites []string `json:"websites"` Joined *string `json:"joined"` Repos int `json:"repos"` Gists int `json:"gists"` Stars int `json:"stars"` Followers int `json:"followers"` Following int `json:"following"` Members []string `json:"members,omitempty"` Orgs []string `json:"orgs,omitempty"` Followed bool `json:"followed"` } type selfView struct { accountFull Email string `json:"email"` Verified bool `json:"verified"` Admin bool `json:"admin"` TwoStep bool `json:"two_factor"` Scope string `json:"scope"` Via string `json:"via"` } type keyView struct { ID int64 `json:"id"` Title string `json:"title"` Fingerprint string `json:"fingerprint"` Key string `json:"key"` Added *string `json:"added"` } type tokenView struct { ID int64 `json:"id"` Title string `json:"title"` Scope string `json:"scope"` Created *string `json:"created"` Used *string `json:"used"` Token string `json:"token,omitempty"` } type noticeView struct { ID int64 `json:"id"` Repo string `json:"repo"` Kind string `json:"kind"` Number int64 `json:"number"` Title string `json:"title"` Actor string `json:"actor"` Verb string `json:"verb"` At *string `json:"at"` Unread bool `json:"unread"` URL string `json:"url"` API string `json:"api"` } func (s *Server) brief(u User) accountBrief { kind := "user" if s.store.IsOrg(u.ID) { kind = "org" } p := s.store.Profile(u.ID) return accountBrief{Handle: handle(u.Name), Kind: kind, Display: p.Display, Avatar: "https://" + s.host + "/~" + u.Name + "/avatar?v=" + strconv.FormatInt(p.Avatar, 10), URL: "https://" + s.host + "/~" + u.Name, API: "https://" + s.host + "/api/~" + u.Name} } func (s *Server) full(u User, viewer *User) accountFull { b := s.brief(u) p := s.store.Profile(u.ID) member := viewer != nil && (viewer.ID == u.ID || s.store.IsMember(u.ID, viewer.ID)) repos, _ := s.store.ReposByOwner(u.ID, member) followers, following := s.store.FollowCounts(u.ID) out := accountFull{accountBrief: b, Bio: p.Bio, Location: p.Location, Workplace: p.Workplace, Websites: []string{}, Joined: iso(u.Created), Repos: len(repos), Stars: s.store.StarredCount(u.ID), Followers: followers, Following: following, Followed: viewer != nil && s.store.Follows(viewer.ID, u.ID)} if p.Website != "" { out.Websites = strings.Split(p.Website, "\n") } if b.Kind == "org" { out.Members = handles(s.store.Members(u.ID)) } else { out.Gists = s.store.GistCount(u.ID, viewer != nil && viewer.ID == u.ID) out.Orgs = handles(s.store.Orgs(u.ID)) } return out } func (s *Server) notice(n Notification) noticeView { return noticeView{ID: n.ID, Repo: n.Repo.Path(), Kind: n.Kind, Number: n.Number, Title: n.Title, Actor: handle(n.Actor), Verb: n.Verb, At: iso(n.Created), Unread: n.Unread, URL: "https://" + s.host + n.Link(), API: "https://" + s.host + "/api" + n.Link()} } func (s *Server) peopleRoutes() []apiRoute { return []apiRoute{ {"GET", "/me", needUser, "the authenticated account, with email, admin flag and the scope of the credential", s.apiMe, nil}, {"PATCH", "/me", needWrite, "change display, bio, location, workplace or websites", s.apiPatchMe, nil}, {"GET", "/me/repos", needUser, "every repository you can push to: your own, your organizations' and those you collaborate on", s.apiMyRepos, nil}, {"GET", "/me/orgs", needUser, "organizations you belong to", s.apiMyOrgs, nil}, {"GET", "/me/keys", needUser, "your SSH keys", s.apiKeys, nil}, {"POST", "/me/keys", needWrite, "add an SSH key from {title, key}", s.apiAddKey, nil}, {"DELETE", "/me/keys/:id", needWrite, "remove an SSH key", s.apiDeleteKey, nil}, {"GET", "/me/tokens", needUser, "your tokens, never their secrets", s.apiTokens, nil}, {"POST", "/me/tokens", needPassword, "mint a token from {title, scope}, the secret appears once in the reply", s.apiAddToken, nil}, {"DELETE", "/me/tokens/:id", needWrite, "revoke a token", s.apiDeleteToken, nil}, {"GET", "/me/notifications", needUser, "your inbox, ?unread=1 for unread only", s.apiNotifications, nil}, {"PUT", "/me/notifications/:id", needWrite, "mark one notification read", s.apiReadNotification, nil}, {"DELETE", "/me/notifications", needWrite, "mark every notification read", s.apiReadAll, nil}, {"GET", "/me/blocks", needUser, "accounts you have blocked", s.apiBlocks, nil}, {"PUT", "/me/blocks/~:name", needWrite, "block an account from your repositories", s.apiBlock, nil}, {"DELETE", "/me/blocks/~:name", needWrite, "unblock an account", s.apiBlock, nil}, {"POST", "/orgs", needWrite, "create an organization from {name} with you as its first member", s.apiCreateOrg, nil}, {"GET", "/search", needNone, "search with the site syntax, ?q= with lang:, user: and tag: qualifiers, ?kind=repos|people|gists", s.apiSearch, nil}, {"GET", "/~:owner", needNone, "an account or organization", s.apiAccount, nil}, {"GET", "/~:owner/repos", needNone, "repositories owned by the account that you can see", s.apiAccountRepos, nil}, {"GET", "/~:owner/stars", needNone, "repositories the account starred", s.apiAccountStars, nil}, {"GET", "/~:owner/followers", needNone, "followers", s.apiFollowers, nil}, {"GET", "/~:owner/following", needNone, "accounts this one follows", s.apiFollowing, nil}, {"PUT", "/~:owner/follow", needWrite, "follow the account", s.apiFollow, nil}, {"DELETE", "/~:owner/follow", needWrite, "stop following the account", s.apiFollow, nil}, {"GET", "/~:owner/keys", needNone, "public SSH keys and PGP fingerprints, the same keys commit seals are checked against", s.apiPublicKeys, nil}, {"GET", "/~:owner/members", needNone, "members of an organization", s.apiMembers, nil}, {"PUT", "/~:owner/members/~:name", needWrite, "add a member, members only", s.apiMember, nil}, {"DELETE", "/~:owner/members/~:name", needWrite, "remove a member, members only", s.apiMember, nil}, {"GET", "/admin/stats", needAdmin, "account and repository counts", s.apiStats, nil}, {"GET", "/admin/accounts", needAdmin, "every account with its flags", s.apiAccounts, nil}, {"PUT", "/admin/accounts/~:name/ban", needAdmin, "ban an account", s.apiBan, nil}, {"DELETE", "/admin/accounts/~:name/ban", needAdmin, "lift a ban", s.apiBan, nil}, {"GET", "/admin/reports", needAdmin, "reports, ?state=open|resolved", s.apiReports, nil}, {"POST", "/admin/reports/:id", needAdmin, "act on a report with {do: dismiss|resolve|ban|pages|delete, confirm}", s.apiModerate, nil}, {"POST", "/admin/invites", needAdmin, "mint a single use registration code", s.apiInvite, nil}, {"GET", "/admin/gists", needAdmin, "every gist, secret ones included, with size and open reports", s.apiAdminGists, nil}, } } func (s *Server) apiMe(c *call) { a := s.store.Account(c.user.ID) c.reply(200, selfView{accountFull: s.full(*c.user, c.user), Email: a.Email, Verified: a.Verified, Admin: s.store.IsAdmin(c.user.ID), TwoStep: a.TOTP != "", Scope: c.scope, Via: c.via}) } func (s *Server) apiPatchMe(c *call) { var in struct { Display *string `json:"display"` Bio *string `json:"bio"` Location *string `json:"location"` Workplace *string `json:"workplace"` Websites *[]string `json:"websites"` } if !c.decode(&in) { return } p := s.store.Profile(c.user.ID) pick := func(v *string, old string) string { if v != nil { return *v } return old } sites := strings.Split(p.Website, "\n") if in.Websites != nil { sites = *in.Websites } next := cleanProfile(pick(in.Display, p.Display), pick(in.Bio, p.Bio), pick(in.Location, p.Location), pick(in.Workplace, p.Workplace), sites) next.Avatar = p.Avatar if err := s.store.UpdateProfile(c.user.ID, next); err != nil { c.err(err) return } s.apiMe(c) } func (s *Server) repoViews(repos []Repo) []repoView { return mapped(repos, s.repoView) } func (s *Server) apiMyRepos(c *call) { own, _ := s.store.ReposByOwner(c.user.ID, true) for _, o := range s.store.Orgs(c.user.ID) { r, _ := s.store.ReposByOwner(o.ID, true) own = append(own, r...) } shared, _ := s.store.SharedRepos(c.user.ID) paged(c, s.repoViews(append(own, shared...))) } func (s *Server) apiMyOrgs(c *call) { paged(c, mapped(s.store.Orgs(c.user.ID), s.brief)) } func (s *Server) apiKeys(c *call) { keys, err := s.store.Keys(c.user.ID) if err != nil { c.err(err) return } paged(c, mapped(keys, func(k Key) keyView { return keyView{ID: k.ID, Title: k.Title, Fingerprint: k.Fingerprint, Key: k.Public, Added: iso(k.Created)} })) } func (s *Server) apiAddKey(c *call) { var in struct { Title string `json:"title"` Key string `json:"key"` } if !c.decode(&in) { return } if err := s.store.AddKey(c.user.ID, strings.TrimSpace(in.Title), strings.TrimSpace(in.Key)); err != nil { c.err(err) return } s.writeSigners() keys, _ := s.store.Keys(c.user.ID) k := keys[len(keys)-1] c.reply(201, keyView{ID: k.ID, Title: k.Title, Fingerprint: k.Fingerprint, Key: k.Public, Added: iso(k.Created)}) } func (s *Server) apiDeleteKey(c *call) { id, ok := c.id("id") if !ok { return } keys, _ := s.store.Keys(c.user.ID) if !slices.ContainsFunc(keys, func(k Key) bool { return k.ID == id }) { c.fail(404, "No such key") return } if err := s.store.DeleteKey(c.user.ID, id); err != nil { c.err(err) return } s.writeSigners() c.done() } func (s *Server) apiTokens(c *call) { tokens, err := s.store.Tokens(c.user.ID) if err != nil { c.err(err) return } paged(c, mapped(tokens, func(t Token) tokenView { return tokenView{ID: t.ID, Title: t.Title, Scope: t.Scope, Created: iso(t.Created), Used: iso(t.Used)} })) } func (s *Server) apiAddToken(c *call) { var in struct { Title string `json:"title"` Scope string `json:"scope"` } if !c.decode(&in) { return } if in.Scope == "" { in.Scope = scopeAll } secret, err := s.store.CreateToken(c.user.ID, strings.TrimSpace(in.Title), in.Scope) if err != nil { c.err(err) return } tokens, _ := s.store.Tokens(c.user.ID) t := tokens[len(tokens)-1] c.reply(201, tokenView{ID: t.ID, Title: t.Title, Scope: t.Scope, Created: iso(t.Created), Token: secret}) } func (s *Server) apiDeleteToken(c *call) { id, ok := c.id("id") if !ok { return } if err := s.store.DeleteToken(c.user.ID, id); err != nil { c.err(err) return } c.done() } func (s *Server) apiNotifications(c *call) { all := s.store.Notifications(c.user.ID, 500) if c.query("unread") == "1" || c.query("unread") == "true" { all = slices.DeleteFunc(all, func(n Notification) bool { return !n.Unread }) } paged(c, mapped(all, s.notice)) } func (s *Server) apiReadNotification(c *call) { id, ok := c.id("id") if !ok { return } if !s.store.ReadNotification(c.user.ID, id) { c.fail(404, "No such notification") return } c.done() } func (s *Server) apiReadAll(c *call) { s.store.ReadAll(c.user.ID) c.done() } func (s *Server) apiBlocks(c *call) { paged(c, mapped(s.store.BlockedUsers(c.user.ID), s.brief)) } func (s *Server) apiBlock(c *call) { target, err := s.store.UserByName(c.arg("name")) if err != nil || target.ID == c.user.ID || s.store.IsOrg(target.ID) { c.fail(400, "You cannot block that account") return } if err := s.store.SetBlock(c.user.ID, target.ID, c.r.Method == "PUT"); err != nil { c.err(err) return } c.done() } func (s *Server) apiCreateOrg(c *call) { var in struct { Name string `json:"name"` } if !c.decode(&in) { return } org, err := s.createOrg(*c.user, in.Name) if err != nil { c.err(err) return } c.reply(201, s.full(org, c.user)) } func (s *Server) apiSearch(c *call) { res := s.runSearch(strings.TrimSpace(c.query("q")), c.query("lang"), c.query("sort")) switch c.query("kind") { case "people", "users": paged(c, mapped(res.Users, func(h userHit) accountBrief { return s.brief(h.User) })) case "gists": paged(c, mapped(res.Gists, func(h gistHit) gistDoc { return s.gistDoc(h.Gist, false) })) default: paged(c, mapped(res.Repos, func(h repoHit) repoView { v := s.repoView(h.Repo) if h.Language != nil { v.Language = h.Language.Name } return v })) } } func (s *Server) apiAccount(c *call) { u, ok := c.account() if !ok { return } c.reply(200, s.full(u, c.user)) } func (s *Server) apiAccountRepos(c *call) { u, ok := c.account() if !ok { return } member := c.user != nil && (c.user.ID == u.ID || s.store.IsMember(u.ID, c.user.ID)) repos, err := s.store.ReposByOwner(u.ID, member) if err != nil { c.err(err) return } paged(c, s.repoViews(s.byUpdate(repos))) } func (s *Server) apiAccountStars(c *call) { u, ok := c.account() if !ok { return } repos, err := s.store.StarredRepos(u.ID) if err != nil { c.err(err) return } repos = slices.DeleteFunc(repos, func(r Repo) bool { return !s.store.CanRead(r, c.user) }) paged(c, s.repoViews(repos)) } func (s *Server) apiFollowers(c *call) { if u, ok := c.account(); ok { paged(c, mapped(s.store.Followers(u.ID), s.brief)) } } func (s *Server) apiFollowing(c *call) { if u, ok := c.account(); ok { paged(c, mapped(s.store.Following(u.ID), s.brief)) } } func (s *Server) apiFollow(c *call) { u, ok := c.account() if !ok { return } if s.store.IsOrg(c.user.ID) { c.fail(403, "Organizations cannot follow") return } if err := s.followUser(*c.user, u, c.r.Method == "PUT"); err != nil { c.err(err) return } c.done() } func (s *Server) apiPublicKeys(c *call) { u, ok := c.account() if !ok { return } ssh, pgp := s.store.PublicKeys(u.ID) c.reply(200, map[string][]string{"ssh": nonNil(ssh), "pgp": nonNil(pgp)}) } func (s *Server) apiMembers(c *call) { u, ok := c.account() if !ok { return } if !s.store.IsOrg(u.ID) { c.fail(404, "~"+u.Name+" is not an organization") return } paged(c, mapped(s.store.Members(u.ID), s.brief)) } func (s *Server) apiMember(c *call) { org, ok := c.account() if !ok { return } if !s.store.IsOrg(org.ID) { c.fail(404, "~"+org.Name+" is not an organization") return } if err := s.setMember(*c.user, org, c.arg("name"), c.r.Method == "PUT"); err != nil { c.err(err) return } c.done() } func (s *Server) apiStats(c *call) { st := s.store.Stats() c.reply(200, map[string]int{"users": st.Users, "orgs": st.Orgs, "admins": st.Admins, "banned": st.Banned, "repos": st.Repos, "private": st.Private, "unlisted": st.Unlisted, "gists": s.store.GistCount(0, true), "open_reports": s.store.OpenReports()}) } func (s *Server) apiAccounts(c *call) { type row struct { accountBrief Joined *string `json:"joined"` Admin bool `json:"admin"` Banned bool `json:"banned"` Pages bool `json:"pages"` Repos int `json:"repos"` } paged(c, mapped(s.store.Accounts(), func(a account) row { return row{accountBrief: s.brief(a.User), Joined: iso(a.Created), Admin: a.Admin, Banned: a.Banned, Pages: a.Pages, Repos: a.Repos} })) } func (s *Server) apiBan(c *call) { name := c.arg("name") if name == c.user.Name { c.fail(409, "You cannot ban yourself") return } if err := s.store.SetBanned(name, c.r.Method == "PUT"); err != nil { c.fail(404, "No such account") return } c.done() } func (s *Server) apiReports(c *call) { state := c.query("state") if state == "" { state = "open" } type row struct { ID int64 `json:"id"` Reporter string `json:"reporter"` Kind string `json:"kind"` Target string `json:"target"` Reason string `json:"reason"` State string `json:"state"` At *string `json:"at"` URL string `json:"url"` } paged(c, mapped(s.store.Reports(state), func(r Report) row { return row{ID: r.ID, Reporter: handle(r.Reporter.Name), Kind: r.Kind, Target: r.Target, Reason: r.Reason, State: r.State, At: iso(r.Created), URL: "https://" + s.host + r.Link()} })) } func (s *Server) apiModerate(c *call) { id, ok := c.id("id") if !ok { return } var in struct { Do string `json:"do"` Confirm string `json:"confirm"` } if !c.decode(&in) { return } if err := s.moderateReport(*c.user, id, in.Do, in.Confirm); err != nil { c.err(err) return } c.done() } func (s *Server) apiInvite(c *call) { code, err := s.store.CreateInvite() if err != nil { c.err(err) return } c.reply(201, map[string]string{"code": code, "register": "https://" + s.host + "/register?invite=" + code}) }