mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
fix(team,repo,org,wiki,auth): detect a kept admin permission, validate topics up front, page public members, name disabled wikis, treat auth switch's account as --user
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
12 files changed
+289
-64
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
@@ -210,40 +211,84 @@ type MemberListOptions struct {
|
||||
Limit, Page int
|
||||
}
|
||||
|
||||
// publicScanPages bounds the server pages Members reads to find public memberships.
|
||||
const publicScanPages = 20
|
||||
|
||||
// Members lists the members of an organization.
|
||||
func (s Organizations) Members(ctx context.Context, name string, opts MemberListOptions) (Page[OrganizationMember], error) {
|
||||
users, err := callPage(ctx, s.Client, opts.Limit, opts.Page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.User, *gitea.Response, error) {
|
||||
if opts.Public {
|
||||
return s.publicMembers(ctx, name, opts)
|
||||
}
|
||||
members, err := s.memberPage(ctx, name, opts.Limit, opts.Page, false)
|
||||
if err != nil || !opts.Visibility {
|
||||
return members, err
|
||||
}
|
||||
members.Items, _, err = s.withVisibility(ctx, name, members.Items)
|
||||
return members, err
|
||||
}
|
||||
|
||||
// memberPage lists one server page of members, or of the memberships Gitea reports as public.
|
||||
func (s Organizations) memberPage(ctx context.Context, name string, limit, page int, public bool) (Page[OrganizationMember], error) {
|
||||
users, err := callPage(ctx, s.Client, limit, page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.User, *gitea.Response, error) {
|
||||
membership := gitea.ListOrgMembershipOption{ListOptions: list}
|
||||
if opts.Public {
|
||||
if public {
|
||||
return c.Organizations.ListPublicOrgMembership(ctx, name, membership)
|
||||
}
|
||||
return c.Organizations.ListOrgMembership(ctx, name, membership)
|
||||
})
|
||||
members := convertPage(users, memberFrom)
|
||||
if err != nil || !(opts.Public || opts.Visibility) {
|
||||
return members, err
|
||||
}
|
||||
public := make([]bool, len(members.Items))
|
||||
err = concurrently(len(members.Items), func(i int) (err error) {
|
||||
public[i], err = s.IsPublicMember(ctx, name, members.Items[i].Username)
|
||||
return convertPage(users, memberFrom), err
|
||||
}
|
||||
|
||||
// withVisibility checks the membership of each member and reports which ones are public.
|
||||
func (s Organizations) withVisibility(ctx context.Context, name string, members []OrganizationMember) ([]OrganizationMember, []bool, error) {
|
||||
public := make([]bool, len(members))
|
||||
err := concurrently(len(members), func(i int) (err error) {
|
||||
public[i], err = s.IsPublicMember(ctx, name, members[i].Username)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Page[OrganizationMember]{}, err
|
||||
return nil, nil, err
|
||||
}
|
||||
shown := members.Items[:0]
|
||||
for i, member := range members.Items {
|
||||
member.Visibility = MembershipConcealed
|
||||
members = slices.Clone(members)
|
||||
for i := range members {
|
||||
members[i].Visibility = MembershipConcealed
|
||||
if public[i] {
|
||||
member.Visibility = MembershipPublic
|
||||
}
|
||||
if !opts.Public || public[i] {
|
||||
shown = append(shown, member)
|
||||
members[i].Visibility = MembershipPublic
|
||||
}
|
||||
}
|
||||
members.Items = shown
|
||||
members.Counted = members.Counted && !opts.Public
|
||||
return members, nil
|
||||
return members, public, nil
|
||||
}
|
||||
|
||||
// publicMembers lists the public memberships. Gitea lists concealed members as public to
|
||||
// administrators, so it reads server pages until the requested page of confirmed ones is full.
|
||||
func (s Organizations) publicMembers(ctx context.Context, name string, opts MemberListOptions) (Page[OrganizationMember], error) {
|
||||
const size = 50
|
||||
skip := (opts.Page - 1) * opts.Limit
|
||||
var matches []OrganizationMember
|
||||
more := false
|
||||
for page := 1; page <= publicScanPages && len(matches) <= skip+opts.Limit; page++ {
|
||||
batch, err := s.memberPage(ctx, name, size, page, true)
|
||||
if err != nil {
|
||||
return Page[OrganizationMember]{}, err
|
||||
}
|
||||
checked, public, err := s.withVisibility(ctx, name, batch.Items)
|
||||
if err != nil {
|
||||
return Page[OrganizationMember]{}, err
|
||||
}
|
||||
for i, member := range checked {
|
||||
if public[i] {
|
||||
matches = append(matches, member)
|
||||
}
|
||||
}
|
||||
more = batch.More
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Running out of the scan bound leaves later pages unread, which counts as more results.
|
||||
more = more || len(matches) > skip+opts.Limit
|
||||
matches = matches[min(len(matches), skip):]
|
||||
return Page[OrganizationMember]{Items: matches[:min(len(matches), opts.Limit)], More: more}, nil
|
||||
}
|
||||
|
||||
// concurrentRequests bounds the requests that concurrently keeps in flight.
|
||||
|
||||
@@ -6,7 +6,9 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/internal/transport"
|
||||
@@ -194,3 +196,30 @@ func TestOrganizationMembersCheckEachMembership(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationPublicMembersReadLaterPages(t *testing.T) {
|
||||
// The first server page holds only concealed members; a public one follows on page 2.
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/orgs/acme/public_members" && r.URL.Query().Get("page") == "1":
|
||||
var users []string
|
||||
for i := range 50 {
|
||||
users = append(users, fmt.Sprintf(`{"id":%d,"login":"hidden%d"}`, i+10, i))
|
||||
}
|
||||
w.Header().Set("Link", `<http://x/api/v1/orgs/acme/public_members?page=2>; rel="next"`)
|
||||
_, _ = w.Write([]byte("[" + strings.Join(users, ",") + "]"))
|
||||
case r.URL.Path == "/api/v1/orgs/acme/public_members":
|
||||
_, _ = w.Write([]byte(`[{"id":1,"login":"alice"}]`))
|
||||
case r.URL.Path == "/api/v1/orgs/acme/public_members/alice":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/acme/public_members/hidden"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
members, err := (Organizations{Client: client}).Members(context.Background(), "acme", MemberListOptions{Public: true, Visibility: true, Limit: 1, Page: 1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []OrganizationMember{{ID: 1, Username: "alice", Visibility: MembershipPublic}}, members.Items)
|
||||
require.False(t, members.More)
|
||||
}
|
||||
@@ -273,17 +273,17 @@ func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit
|
||||
return item, Classify(err, "repository", repo, "")
|
||||
}
|
||||
|
||||
// AddTopic adds a topic to the repository.
|
||||
func (s Repositories) AddTopic(ctx context.Context, repo, topic string) error {
|
||||
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||
return c.Repositories.AddRepoTopic(ctx, owner, name, topic)
|
||||
// Topics lists the topics of the repository. Gitea allows at most 25, so one page holds them all.
|
||||
func (s Repositories) Topics(ctx context.Context, repo string) ([]string, error) {
|
||||
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]string, *gitea.Response, error) {
|
||||
return c.Repositories.ListRepoTopics(ctx, owner, name, gitea.ListRepoTopicsOptions{ListOptions: gitea.ListOptions{Page: 1, PageSize: 50}})
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveTopic removes a topic from the repository.
|
||||
func (s Repositories) RemoveTopic(ctx context.Context, repo, topic string) error {
|
||||
// SetTopics replaces the topics of the repository.
|
||||
func (s Repositories) SetTopics(ctx context.Context, repo string, topics []string) error {
|
||||
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||
return c.Repositories.DeleteRepoTopic(ctx, owner, name, topic)
|
||||
return c.Repositories.SetRepoTopics(ctx, owner, name, topics)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+32
-11
@@ -133,8 +133,34 @@ func (s Wiki) ListPage(ctx context.Context, limit, page int) (Page[WikiPage], er
|
||||
return Page[WikiPage]{Items: items, More: page*limit < len(all), Total: len(all), Counted: true}, err
|
||||
}
|
||||
|
||||
// disabled explains a 404 from the wiki API. Gitea answers a bare 404 when the wiki is disabled or
|
||||
// the repository is missing, which says nothing about the page itself. It returns nil for any other
|
||||
// error, and the repository lookup's error when the repository cannot be read.
|
||||
func (s Wiki) disabled(ctx context.Context, err error) error {
|
||||
if !IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
repository, checkErr := (Repositories{Client: s.Client}).get(ctx, s.Repository)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if !repository.HasWiki {
|
||||
return fmt.Errorf("the wiki of %s is disabled; turn it on with: tea repo edit --enable-wiki -R %s", s.Repository, s.Repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// View retrieves and decodes a wiki page.
|
||||
func (s Wiki) View(ctx context.Context, name string) (WikiPage, error) {
|
||||
item, err := s.lookup(ctx, name)
|
||||
if err := s.disabled(ctx, err); err != nil {
|
||||
return WikiPage{}, err
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// lookup is View without the check for a disabled wiki.
|
||||
func (s Wiki) lookup(ctx context.Context, name string) (WikiPage, error) {
|
||||
item, err := s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
|
||||
return c.Wiki.GetPage(ctx, owner, repo, name)
|
||||
})
|
||||
@@ -150,16 +176,8 @@ func (s Wiki) Create(ctx context.Context, opts WikiOptions) (WikiPage, error) {
|
||||
item, err := s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
|
||||
return c.Wiki.CreatePage(ctx, owner, repo, wikiOptions(opts))
|
||||
})
|
||||
if IsNotFound(err) {
|
||||
// Gitea answers a bare 404 when the wiki is disabled or the repository is missing, which
|
||||
// says nothing about the page being created.
|
||||
repository, checkErr := (Repositories{Client: s.Client}).get(ctx, s.Repository)
|
||||
if checkErr != nil {
|
||||
return WikiPage{}, checkErr
|
||||
}
|
||||
if !repository.HasWiki {
|
||||
return WikiPage{}, fmt.Errorf("the wiki of %s is disabled; turn it on with: tea repo edit --enable-wiki -R %s", s.Repository, s.Repository)
|
||||
}
|
||||
if err := s.disabled(ctx, err); err != nil {
|
||||
return WikiPage{}, err
|
||||
}
|
||||
return item, Classify(err, "wiki page", strconv.Quote(opts.Title), s.Repository)
|
||||
}
|
||||
@@ -177,7 +195,7 @@ func (s Wiki) Edit(ctx context.Context, name string, opts WikiOptions) (WikiPage
|
||||
opts.Title = current.Title
|
||||
}
|
||||
if renamed {
|
||||
existing, err := s.View(ctx, opts.Title)
|
||||
existing, err := s.lookup(ctx, opts.Title)
|
||||
if err == nil && existing.Title != current.Title {
|
||||
return WikiPage{}, false, &ConflictError{Reason: ReasonExists, Kind: "wiki page", Key: strconv.Quote(opts.Title), Scope: s.Repository}
|
||||
}
|
||||
@@ -196,6 +214,9 @@ func (s Wiki) Delete(ctx context.Context, name string) error {
|
||||
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
return c.Wiki.DeletePage(ctx, owner, repo, name)
|
||||
})
|
||||
if err := s.disabled(ctx, err); err != nil {
|
||||
return err
|
||||
}
|
||||
return Classify(err, "wiki page", strconv.Quote(name), s.Repository)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,3 +134,22 @@ func TestWikiCreateExplainsADisabledWiki(t *testing.T) {
|
||||
require.ErrorContains(t, err, "the wiki of alice/project is disabled")
|
||||
require.NotErrorAs(t, err, new(*NotFoundError))
|
||||
}
|
||||
|
||||
func TestWikiViewEditAndDeleteExplainADisabledWiki(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/repos/alice/project" {
|
||||
_, _ = w.Write([]byte(`{"full_name":"alice/project","has_wiki":false}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
service := Wiki{Client: client, Repository: "alice/project"}
|
||||
_, viewErr := service.View(context.Background(), "Home")
|
||||
_, _, editErr := service.Edit(context.Background(), "Home", WikiOptions{Body: "body"})
|
||||
deleteErr := service.Delete(context.Background(), "Home")
|
||||
for _, err := range []error{viewErr, editErr, deleteErr} {
|
||||
require.ErrorContains(t, err, "the wiki of alice/project is disabled")
|
||||
require.ErrorContains(t, err, "tea repo edit --enable-wiki")
|
||||
require.NotErrorAs(t, err, new(*NotFoundError))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user