code.gitea.io/gitea@v1.22.3/routers/web/user/home.go (about)

     1  // Copyright 2014 The Gogs Authors. All rights reserved.
     2  // Copyright 2019 The Gitea Authors. All rights reserved.
     3  // SPDX-License-Identifier: MIT
     4  
     5  package user
     6  
     7  import (
     8  	"bytes"
     9  	"fmt"
    10  	"net/http"
    11  	"regexp"
    12  	"slices"
    13  	"sort"
    14  	"strconv"
    15  	"strings"
    16  
    17  	activities_model "code.gitea.io/gitea/models/activities"
    18  	asymkey_model "code.gitea.io/gitea/models/asymkey"
    19  	"code.gitea.io/gitea/models/db"
    20  	issues_model "code.gitea.io/gitea/models/issues"
    21  	"code.gitea.io/gitea/models/organization"
    22  	repo_model "code.gitea.io/gitea/models/repo"
    23  	"code.gitea.io/gitea/models/unit"
    24  	user_model "code.gitea.io/gitea/models/user"
    25  	"code.gitea.io/gitea/modules/base"
    26  	"code.gitea.io/gitea/modules/container"
    27  	issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
    28  	"code.gitea.io/gitea/modules/log"
    29  	"code.gitea.io/gitea/modules/markup"
    30  	"code.gitea.io/gitea/modules/markup/markdown"
    31  	"code.gitea.io/gitea/modules/optional"
    32  	"code.gitea.io/gitea/modules/setting"
    33  	"code.gitea.io/gitea/routers/web/feed"
    34  	"code.gitea.io/gitea/services/context"
    35  	issue_service "code.gitea.io/gitea/services/issue"
    36  	pull_service "code.gitea.io/gitea/services/pull"
    37  
    38  	"github.com/keybase/go-crypto/openpgp"
    39  	"github.com/keybase/go-crypto/openpgp/armor"
    40  	"xorm.io/builder"
    41  )
    42  
    43  const (
    44  	tplDashboard  base.TplName = "user/dashboard/dashboard"
    45  	tplIssues     base.TplName = "user/dashboard/issues"
    46  	tplMilestones base.TplName = "user/dashboard/milestones"
    47  	tplProfile    base.TplName = "user/profile"
    48  )
    49  
    50  // getDashboardContextUser finds out which context user dashboard is being viewed as .
    51  func getDashboardContextUser(ctx *context.Context) *user_model.User {
    52  	ctxUser := ctx.Doer
    53  	orgName := ctx.Params(":org")
    54  	if len(orgName) > 0 {
    55  		ctxUser = ctx.Org.Organization.AsUser()
    56  		ctx.Data["Teams"] = ctx.Org.Teams
    57  	}
    58  	ctx.Data["ContextUser"] = ctxUser
    59  
    60  	orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer)
    61  	if err != nil {
    62  		ctx.ServerError("GetUserOrgsList", err)
    63  		return nil
    64  	}
    65  	ctx.Data["Orgs"] = orgs
    66  
    67  	return ctxUser
    68  }
    69  
    70  // Dashboard render the dashboard page
    71  func Dashboard(ctx *context.Context) {
    72  	ctxUser := getDashboardContextUser(ctx)
    73  	if ctx.Written() {
    74  		return
    75  	}
    76  
    77  	var (
    78  		date = ctx.FormString("date")
    79  		page = ctx.FormInt("page")
    80  	)
    81  
    82  	// Make sure page number is at least 1. Will be posted to ctx.Data.
    83  	if page <= 1 {
    84  		page = 1
    85  	}
    86  
    87  	ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard")
    88  	ctx.Data["PageIsDashboard"] = true
    89  	ctx.Data["PageIsNews"] = true
    90  	cnt, _ := organization.GetOrganizationCount(ctx, ctxUser)
    91  	ctx.Data["UserOrgsCount"] = cnt
    92  	ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled
    93  	ctx.Data["Date"] = date
    94  
    95  	var uid int64
    96  	if ctxUser != nil {
    97  		uid = ctxUser.ID
    98  	}
    99  
   100  	ctx.PageData["dashboardRepoList"] = map[string]any{
   101  		"searchLimit": setting.UI.User.RepoPagingNum,
   102  		"uid":         uid,
   103  	}
   104  
   105  	if setting.Service.EnableUserHeatmap {
   106  		data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
   107  		if err != nil {
   108  			ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
   109  			return
   110  		}
   111  		ctx.Data["HeatmapData"] = data
   112  		ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
   113  	}
   114  
   115  	feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
   116  		RequestedUser:   ctxUser,
   117  		RequestedTeam:   ctx.Org.Team,
   118  		Actor:           ctx.Doer,
   119  		IncludePrivate:  true,
   120  		OnlyPerformedBy: false,
   121  		IncludeDeleted:  false,
   122  		Date:            ctx.FormString("date"),
   123  		ListOptions: db.ListOptions{
   124  			Page:     page,
   125  			PageSize: setting.UI.FeedPagingNum,
   126  		},
   127  	})
   128  	if err != nil {
   129  		ctx.ServerError("GetFeeds", err)
   130  		return
   131  	}
   132  
   133  	ctx.Data["Feeds"] = feeds
   134  
   135  	pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5)
   136  	pager.AddParamString("date", date)
   137  	ctx.Data["Page"] = pager
   138  
   139  	ctx.HTML(http.StatusOK, tplDashboard)
   140  }
   141  
   142  // Milestones render the user milestones page
   143  func Milestones(ctx *context.Context) {
   144  	if unit.TypeIssues.UnitGlobalDisabled() && unit.TypePullRequests.UnitGlobalDisabled() {
   145  		log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled")
   146  		ctx.Status(http.StatusNotFound)
   147  		return
   148  	}
   149  
   150  	ctx.Data["Title"] = ctx.Tr("milestones")
   151  	ctx.Data["PageIsMilestonesDashboard"] = true
   152  
   153  	ctxUser := getDashboardContextUser(ctx)
   154  	if ctx.Written() {
   155  		return
   156  	}
   157  
   158  	repoOpts := repo_model.SearchRepoOptions{
   159  		Actor:         ctx.Doer,
   160  		OwnerID:       ctxUser.ID,
   161  		Private:       true,
   162  		AllPublic:     false, // Include also all public repositories of users and public organisations
   163  		AllLimited:    false, // Include also all public repositories of limited organisations
   164  		Archived:      optional.Some(false),
   165  		HasMilestones: optional.Some(true), // Just needs display repos has milestones
   166  	}
   167  
   168  	if ctxUser.IsOrganization() && ctx.Org.Team != nil {
   169  		repoOpts.TeamID = ctx.Org.Team.ID
   170  	}
   171  
   172  	var (
   173  		userRepoCond = repo_model.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit
   174  		repoCond     = userRepoCond
   175  		repoIDs      []int64
   176  
   177  		reposQuery   = ctx.FormString("repos")
   178  		isShowClosed = ctx.FormString("state") == "closed"
   179  		sortType     = ctx.FormString("sort")
   180  		page         = ctx.FormInt("page")
   181  		keyword      = ctx.FormTrim("q")
   182  	)
   183  
   184  	if page <= 1 {
   185  		page = 1
   186  	}
   187  
   188  	if len(reposQuery) != 0 {
   189  		if issueReposQueryPattern.MatchString(reposQuery) {
   190  			// remove "[" and "]" from string
   191  			reposQuery = reposQuery[1 : len(reposQuery)-1]
   192  			// for each ID (delimiter ",") add to int to repoIDs
   193  
   194  			for _, rID := range strings.Split(reposQuery, ",") {
   195  				// Ensure nonempty string entries
   196  				if rID != "" && rID != "0" {
   197  					rIDint64, err := strconv.ParseInt(rID, 10, 64)
   198  					// If the repo id specified by query is not parseable or not accessible by user, just ignore it.
   199  					if err == nil {
   200  						repoIDs = append(repoIDs, rIDint64)
   201  					}
   202  				}
   203  			}
   204  			if len(repoIDs) > 0 {
   205  				// Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs
   206  				// But the original repoCond has a limitation
   207  				repoCond = repoCond.And(builder.In("id", repoIDs))
   208  			}
   209  		} else {
   210  			log.Warn("issueReposQueryPattern not match with query")
   211  		}
   212  	}
   213  
   214  	counts, err := issues_model.CountMilestonesMap(ctx, issues_model.FindMilestoneOptions{
   215  		RepoCond: userRepoCond,
   216  		Name:     keyword,
   217  		IsClosed: optional.Some(isShowClosed),
   218  	})
   219  	if err != nil {
   220  		ctx.ServerError("CountMilestonesByRepoIDs", err)
   221  		return
   222  	}
   223  
   224  	milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
   225  		ListOptions: db.ListOptions{
   226  			Page:     page,
   227  			PageSize: setting.UI.IssuePagingNum,
   228  		},
   229  		RepoCond: repoCond,
   230  		IsClosed: optional.Some(isShowClosed),
   231  		SortType: sortType,
   232  		Name:     keyword,
   233  	})
   234  	if err != nil {
   235  		ctx.ServerError("SearchMilestones", err)
   236  		return
   237  	}
   238  
   239  	showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, &repoOpts, userRepoCond, false)
   240  	if err != nil {
   241  		ctx.ServerError("SearchRepositoryByCondition", err)
   242  		return
   243  	}
   244  	sort.Sort(showRepos)
   245  
   246  	for i := 0; i < len(milestones); {
   247  		for _, repo := range showRepos {
   248  			if milestones[i].RepoID == repo.ID {
   249  				milestones[i].Repo = repo
   250  				break
   251  			}
   252  		}
   253  		if milestones[i].Repo == nil {
   254  			log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID)
   255  			milestones = append(milestones[:i], milestones[i+1:]...)
   256  			continue
   257  		}
   258  
   259  		milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{
   260  			Links: markup.Links{
   261  				Base: milestones[i].Repo.Link(),
   262  			},
   263  			Metas: milestones[i].Repo.ComposeMetas(ctx),
   264  			Ctx:   ctx,
   265  		}, milestones[i].Content)
   266  		if err != nil {
   267  			ctx.ServerError("RenderString", err)
   268  			return
   269  		}
   270  
   271  		if milestones[i].Repo.IsTimetrackerEnabled(ctx) {
   272  			err := milestones[i].LoadTotalTrackedTime(ctx)
   273  			if err != nil {
   274  				ctx.ServerError("LoadTotalTrackedTime", err)
   275  				return
   276  			}
   277  		}
   278  		i++
   279  	}
   280  
   281  	milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, repoCond, keyword)
   282  	if err != nil {
   283  		ctx.ServerError("GetMilestoneStats", err)
   284  		return
   285  	}
   286  
   287  	var totalMilestoneStats *issues_model.MilestonesStats
   288  	if len(repoIDs) == 0 {
   289  		totalMilestoneStats = milestoneStats
   290  	} else {
   291  		totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, userRepoCond, keyword)
   292  		if err != nil {
   293  			ctx.ServerError("GetMilestoneStats", err)
   294  			return
   295  		}
   296  	}
   297  
   298  	showRepoIDs := make(container.Set[int64], len(showRepos))
   299  	for _, repo := range showRepos {
   300  		if repo.ID > 0 {
   301  			showRepoIDs.Add(repo.ID)
   302  		}
   303  	}
   304  	if len(repoIDs) == 0 {
   305  		repoIDs = showRepoIDs.Values()
   306  	}
   307  	repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool {
   308  		return !showRepoIDs.Contains(v)
   309  	})
   310  
   311  	var pagerCount int
   312  	if isShowClosed {
   313  		ctx.Data["State"] = "closed"
   314  		ctx.Data["Total"] = totalMilestoneStats.ClosedCount
   315  		pagerCount = int(milestoneStats.ClosedCount)
   316  	} else {
   317  		ctx.Data["State"] = "open"
   318  		ctx.Data["Total"] = totalMilestoneStats.OpenCount
   319  		pagerCount = int(milestoneStats.OpenCount)
   320  	}
   321  
   322  	ctx.Data["Milestones"] = milestones
   323  	ctx.Data["Repos"] = showRepos
   324  	ctx.Data["Counts"] = counts
   325  	ctx.Data["MilestoneStats"] = milestoneStats
   326  	ctx.Data["SortType"] = sortType
   327  	ctx.Data["Keyword"] = keyword
   328  	ctx.Data["RepoIDs"] = repoIDs
   329  	ctx.Data["IsShowClosed"] = isShowClosed
   330  
   331  	pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5)
   332  	pager.AddParamString("q", keyword)
   333  	pager.AddParamString("repos", reposQuery)
   334  	pager.AddParamString("sort", sortType)
   335  	pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
   336  	ctx.Data["Page"] = pager
   337  
   338  	ctx.HTML(http.StatusOK, tplMilestones)
   339  }
   340  
   341  // Pulls renders the user's pull request overview page
   342  func Pulls(ctx *context.Context) {
   343  	if unit.TypePullRequests.UnitGlobalDisabled() {
   344  		log.Debug("Pull request overview page not available as it is globally disabled.")
   345  		ctx.Status(http.StatusNotFound)
   346  		return
   347  	}
   348  
   349  	ctx.Data["Title"] = ctx.Tr("pull_requests")
   350  	ctx.Data["PageIsPulls"] = true
   351  	buildIssueOverview(ctx, unit.TypePullRequests)
   352  }
   353  
   354  // Issues renders the user's issues overview page
   355  func Issues(ctx *context.Context) {
   356  	if unit.TypeIssues.UnitGlobalDisabled() {
   357  		log.Debug("Issues overview page not available as it is globally disabled.")
   358  		ctx.Status(http.StatusNotFound)
   359  		return
   360  	}
   361  
   362  	ctx.Data["Title"] = ctx.Tr("issues")
   363  	ctx.Data["PageIsIssues"] = true
   364  	buildIssueOverview(ctx, unit.TypeIssues)
   365  }
   366  
   367  // Regexp for repos query
   368  var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`)
   369  
   370  func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
   371  	// ----------------------------------------------------
   372  	// Determine user; can be either user or organization.
   373  	// Return with NotFound or ServerError if unsuccessful.
   374  	// ----------------------------------------------------
   375  
   376  	ctxUser := getDashboardContextUser(ctx)
   377  	if ctx.Written() {
   378  		return
   379  	}
   380  
   381  	var (
   382  		viewType   string
   383  		sortType   = ctx.FormString("sort")
   384  		filterMode int
   385  	)
   386  
   387  	// Default to recently updated, unlike repository issues list
   388  	if sortType == "" {
   389  		sortType = "recentupdate"
   390  	}
   391  
   392  	// --------------------------------------------------------------------------------
   393  	// Distinguish User from Organization.
   394  	// Org:
   395  	// - Remember pre-determined viewType string for later. Will be posted to ctx.Data.
   396  	//   Organization does not have view type and filter mode.
   397  	// User:
   398  	// - Use ctx.FormString("type") to determine filterMode.
   399  	//  The type is set when clicking for example "assigned to me" on the overview page.
   400  	// - Remember either this or a fallback. Will be posted to ctx.Data.
   401  	// --------------------------------------------------------------------------------
   402  
   403  	// TODO: distinguish during routing
   404  
   405  	viewType = ctx.FormString("type")
   406  	switch viewType {
   407  	case "assigned":
   408  		filterMode = issues_model.FilterModeAssign
   409  	case "created_by":
   410  		filterMode = issues_model.FilterModeCreate
   411  	case "mentioned":
   412  		filterMode = issues_model.FilterModeMention
   413  	case "review_requested":
   414  		filterMode = issues_model.FilterModeReviewRequested
   415  	case "reviewed_by":
   416  		filterMode = issues_model.FilterModeReviewed
   417  	case "your_repositories":
   418  		fallthrough
   419  	default:
   420  		filterMode = issues_model.FilterModeYourRepositories
   421  		viewType = "your_repositories"
   422  	}
   423  
   424  	// --------------------------------------------------------------------------
   425  	// Build opts (IssuesOptions), which contains filter information.
   426  	// Will eventually be used to retrieve issues relevant for the overview page.
   427  	// Note: Non-final states of opts are used in-between, namely for:
   428  	//       - Keyword search
   429  	//       - Count Issues by repo
   430  	// --------------------------------------------------------------------------
   431  
   432  	// Get repository IDs where User/Org/Team has access.
   433  	var team *organization.Team
   434  	var org *organization.Organization
   435  	if ctx.Org != nil {
   436  		org = ctx.Org.Organization
   437  		team = ctx.Org.Team
   438  	}
   439  
   440  	isPullList := unitType == unit.TypePullRequests
   441  	opts := &issues_model.IssuesOptions{
   442  		IsPull:     optional.Some(isPullList),
   443  		SortType:   sortType,
   444  		IsArchived: optional.Some(false),
   445  		Org:        org,
   446  		Team:       team,
   447  		User:       ctx.Doer,
   448  	}
   449  
   450  	isFuzzy := ctx.FormBool("fuzzy")
   451  
   452  	// Search all repositories which
   453  	//
   454  	// As user:
   455  	// - Owns the repository.
   456  	// - Have collaborator permissions in repository.
   457  	//
   458  	// As org:
   459  	// - Owns the repository.
   460  	//
   461  	// As team:
   462  	// - Team org's owns the repository.
   463  	// - Team has read permission to repository.
   464  	repoOpts := &repo_model.SearchRepoOptions{
   465  		Actor:       ctx.Doer,
   466  		OwnerID:     ctxUser.ID,
   467  		Private:     true,
   468  		AllPublic:   false,
   469  		AllLimited:  false,
   470  		Collaborate: optional.None[bool](),
   471  		UnitType:    unitType,
   472  		Archived:    optional.Some(false),
   473  	}
   474  	if team != nil {
   475  		repoOpts.TeamID = team.ID
   476  	}
   477  	accessibleRepos := container.Set[int64]{}
   478  	{
   479  		ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts)
   480  		if err != nil {
   481  			ctx.ServerError("SearchRepositoryIDs", err)
   482  			return
   483  		}
   484  		accessibleRepos.AddMultiple(ids...)
   485  		opts.RepoIDs = ids
   486  		if len(opts.RepoIDs) == 0 {
   487  			// no repos found, don't let the indexer return all repos
   488  			opts.RepoIDs = []int64{0}
   489  		}
   490  	}
   491  	if ctx.Doer.ID == ctxUser.ID && filterMode != issues_model.FilterModeYourRepositories {
   492  		// If the doer is the same as the context user, which means the doer is viewing his own dashboard,
   493  		// it's not enough to show the repos that the doer owns or has been explicitly granted access to,
   494  		// because the doer may create issues or be mentioned in any public repo.
   495  		// So we need search issues in all public repos.
   496  		opts.AllPublic = true
   497  	}
   498  
   499  	switch filterMode {
   500  	case issues_model.FilterModeAll:
   501  	case issues_model.FilterModeYourRepositories:
   502  	case issues_model.FilterModeAssign:
   503  		opts.AssigneeID = ctx.Doer.ID
   504  	case issues_model.FilterModeCreate:
   505  		opts.PosterID = ctx.Doer.ID
   506  	case issues_model.FilterModeMention:
   507  		opts.MentionedID = ctx.Doer.ID
   508  	case issues_model.FilterModeReviewRequested:
   509  		opts.ReviewRequestedID = ctx.Doer.ID
   510  	case issues_model.FilterModeReviewed:
   511  		opts.ReviewedID = ctx.Doer.ID
   512  	}
   513  
   514  	// keyword holds the search term entered into the search field.
   515  	keyword := strings.Trim(ctx.FormString("q"), " ")
   516  	ctx.Data["Keyword"] = keyword
   517  
   518  	// Educated guess: Do or don't show closed issues.
   519  	isShowClosed := ctx.FormString("state") == "closed"
   520  	opts.IsClosed = optional.Some(isShowClosed)
   521  
   522  	// Make sure page number is at least 1. Will be posted to ctx.Data.
   523  	page := ctx.FormInt("page")
   524  	if page <= 1 {
   525  		page = 1
   526  	}
   527  	opts.Paginator = &db.ListOptions{
   528  		Page:     page,
   529  		PageSize: setting.UI.IssuePagingNum,
   530  	}
   531  
   532  	// Get IDs for labels (a filter option for issues/pulls).
   533  	// Required for IssuesOptions.
   534  	selectedLabels := ctx.FormString("labels")
   535  	if len(selectedLabels) > 0 && selectedLabels != "0" {
   536  		var err error
   537  		opts.LabelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ","))
   538  		if err != nil {
   539  			ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true)
   540  		}
   541  	}
   542  
   543  	// ------------------------------
   544  	// Get issues as defined by opts.
   545  	// ------------------------------
   546  
   547  	// Slice of Issues that will be displayed on the overview page
   548  	// USING FINAL STATE OF opts FOR A QUERY.
   549  	var issues issues_model.IssueList
   550  	{
   551  		issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy(
   552  			func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy },
   553  		))
   554  		if err != nil {
   555  			ctx.ServerError("issueIDsFromSearch", err)
   556  			return
   557  		}
   558  		issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)
   559  		if err != nil {
   560  			ctx.ServerError("GetIssuesByIDs", err)
   561  			return
   562  		}
   563  	}
   564  
   565  	commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
   566  	if err != nil {
   567  		ctx.ServerError("GetIssuesLastCommitStatus", err)
   568  		return
   569  	}
   570  
   571  	// -------------------------------
   572  	// Fill stats to post to ctx.Data.
   573  	// -------------------------------
   574  	issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy(
   575  		func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy },
   576  	))
   577  	if err != nil {
   578  		ctx.ServerError("getUserIssueStats", err)
   579  		return
   580  	}
   581  
   582  	// Will be posted to ctx.Data.
   583  	var shownIssues int
   584  	if !isShowClosed {
   585  		shownIssues = int(issueStats.OpenCount)
   586  	} else {
   587  		shownIssues = int(issueStats.ClosedCount)
   588  	}
   589  
   590  	ctx.Data["IsShowClosed"] = isShowClosed
   591  
   592  	ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.FormString("RepoLink"))
   593  
   594  	if err := issues.LoadAttributes(ctx); err != nil {
   595  		ctx.ServerError("issues.LoadAttributes", err)
   596  		return
   597  	}
   598  	ctx.Data["Issues"] = issues
   599  
   600  	approvalCounts, err := issues.GetApprovalCounts(ctx)
   601  	if err != nil {
   602  		ctx.ServerError("ApprovalCounts", err)
   603  		return
   604  	}
   605  	ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
   606  		counts, ok := approvalCounts[issueID]
   607  		if !ok || len(counts) == 0 {
   608  			return 0
   609  		}
   610  		reviewTyp := issues_model.ReviewTypeApprove
   611  		if typ == "reject" {
   612  			reviewTyp = issues_model.ReviewTypeReject
   613  		} else if typ == "waiting" {
   614  			reviewTyp = issues_model.ReviewTypeRequest
   615  		}
   616  		for _, count := range counts {
   617  			if count.Type == reviewTyp {
   618  				return count.Count
   619  			}
   620  		}
   621  		return 0
   622  	}
   623  	ctx.Data["CommitLastStatus"] = lastStatus
   624  	ctx.Data["CommitStatuses"] = commitStatuses
   625  	ctx.Data["IssueStats"] = issueStats
   626  	ctx.Data["ViewType"] = viewType
   627  	ctx.Data["SortType"] = sortType
   628  	ctx.Data["IsShowClosed"] = isShowClosed
   629  	ctx.Data["SelectLabels"] = selectedLabels
   630  	ctx.Data["IsFuzzy"] = isFuzzy
   631  
   632  	if isShowClosed {
   633  		ctx.Data["State"] = "closed"
   634  	} else {
   635  		ctx.Data["State"] = "open"
   636  	}
   637  
   638  	pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
   639  	pager.AddParamString("q", keyword)
   640  	pager.AddParamString("type", viewType)
   641  	pager.AddParamString("sort", sortType)
   642  	pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
   643  	pager.AddParamString("labels", selectedLabels)
   644  	pager.AddParamString("fuzzy", fmt.Sprintf("%v", isFuzzy))
   645  	ctx.Data["Page"] = pager
   646  
   647  	ctx.HTML(http.StatusOK, tplIssues)
   648  }
   649  
   650  // ShowSSHKeys output all the ssh keys of user by uid
   651  func ShowSSHKeys(ctx *context.Context) {
   652  	keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
   653  		OwnerID: ctx.ContextUser.ID,
   654  	})
   655  	if err != nil {
   656  		ctx.ServerError("ListPublicKeys", err)
   657  		return
   658  	}
   659  
   660  	var buf bytes.Buffer
   661  	for i := range keys {
   662  		buf.WriteString(keys[i].OmitEmail())
   663  		buf.WriteString("\n")
   664  	}
   665  	ctx.PlainTextBytes(http.StatusOK, buf.Bytes())
   666  }
   667  
   668  // ShowGPGKeys output all the public GPG keys of user by uid
   669  func ShowGPGKeys(ctx *context.Context) {
   670  	keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
   671  		ListOptions: db.ListOptionsAll,
   672  		OwnerID:     ctx.ContextUser.ID,
   673  	})
   674  	if err != nil {
   675  		ctx.ServerError("ListGPGKeys", err)
   676  		return
   677  	}
   678  
   679  	entities := make([]*openpgp.Entity, 0)
   680  	failedEntitiesID := make([]string, 0)
   681  	for _, k := range keys {
   682  		e, err := asymkey_model.GPGKeyToEntity(ctx, k)
   683  		if err != nil {
   684  			if asymkey_model.IsErrGPGKeyImportNotExist(err) {
   685  				failedEntitiesID = append(failedEntitiesID, k.KeyID)
   686  				continue // Skip previous import without backup of imported armored key
   687  			}
   688  			ctx.ServerError("ShowGPGKeys", err)
   689  			return
   690  		}
   691  		entities = append(entities, e)
   692  	}
   693  	var buf bytes.Buffer
   694  
   695  	headers := make(map[string]string)
   696  	if len(failedEntitiesID) > 0 { // If some key need re-import to be exported
   697  		headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", "))
   698  	} else if len(entities) == 0 {
   699  		headers["Note"] = "This user hasn't uploaded any GPG keys."
   700  	}
   701  	writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers)
   702  	for _, e := range entities {
   703  		err = e.Serialize(writer) // TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange)
   704  		if err != nil {
   705  			ctx.ServerError("ShowGPGKeys", err)
   706  			return
   707  		}
   708  	}
   709  	writer.Close()
   710  	ctx.PlainTextBytes(http.StatusOK, buf.Bytes())
   711  }
   712  
   713  func UsernameSubRoute(ctx *context.Context) {
   714  	// WORKAROUND to support usernames with "." in it
   715  	// https://github.com/go-chi/chi/issues/781
   716  	username := ctx.Params("username")
   717  	reloadParam := func(suffix string) (success bool) {
   718  		ctx.SetParams("username", strings.TrimSuffix(username, suffix))
   719  		context.UserAssignmentWeb()(ctx)
   720  		if ctx.Written() {
   721  			return false
   722  		}
   723  
   724  		// check view permissions
   725  		if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
   726  			ctx.NotFound("user", fmt.Errorf(ctx.ContextUser.Name))
   727  			return false
   728  		}
   729  		return true
   730  	}
   731  	switch {
   732  	case strings.HasSuffix(username, ".png"):
   733  		if reloadParam(".png") {
   734  			AvatarByUserName(ctx)
   735  		}
   736  	case strings.HasSuffix(username, ".keys"):
   737  		if reloadParam(".keys") {
   738  			ShowSSHKeys(ctx)
   739  		}
   740  	case strings.HasSuffix(username, ".gpg"):
   741  		if reloadParam(".gpg") {
   742  			ShowGPGKeys(ctx)
   743  		}
   744  	case strings.HasSuffix(username, ".rss"):
   745  		if !setting.Other.EnableFeed {
   746  			ctx.Error(http.StatusNotFound)
   747  			return
   748  		}
   749  		if reloadParam(".rss") {
   750  			feed.ShowUserFeedRSS(ctx)
   751  		}
   752  	case strings.HasSuffix(username, ".atom"):
   753  		if !setting.Other.EnableFeed {
   754  			ctx.Error(http.StatusNotFound)
   755  			return
   756  		}
   757  		if reloadParam(".atom") {
   758  			feed.ShowUserFeedAtom(ctx)
   759  		}
   760  	default:
   761  		context.UserAssignmentWeb()(ctx)
   762  		if !ctx.Written() {
   763  			ctx.Data["EnableFeed"] = setting.Other.EnableFeed
   764  			OwnerProfile(ctx)
   765  		}
   766  	}
   767  }
   768  
   769  func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) {
   770  	doerID := ctx.Doer.ID
   771  
   772  	opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
   773  		// If the doer is the same as the context user, which means the doer is viewing his own dashboard,
   774  		// it's not enough to show the repos that the doer owns or has been explicitly granted access to,
   775  		// because the doer may create issues or be mentioned in any public repo.
   776  		// So we need search issues in all public repos.
   777  		o.AllPublic = doerID == ctxUser.ID
   778  		o.AssigneeID = nil
   779  		o.PosterID = nil
   780  		o.MentionID = nil
   781  		o.ReviewRequestedID = nil
   782  		o.ReviewedID = nil
   783  	})
   784  
   785  	var (
   786  		err error
   787  		ret = &issues_model.IssueStats{}
   788  	)
   789  
   790  	{
   791  		openClosedOpts := opts.Copy()
   792  		switch filterMode {
   793  		case issues_model.FilterModeAll:
   794  			// no-op
   795  		case issues_model.FilterModeYourRepositories:
   796  			openClosedOpts.AllPublic = false
   797  		case issues_model.FilterModeAssign:
   798  			openClosedOpts.AssigneeID = optional.Some(doerID)
   799  		case issues_model.FilterModeCreate:
   800  			openClosedOpts.PosterID = optional.Some(doerID)
   801  		case issues_model.FilterModeMention:
   802  			openClosedOpts.MentionID = optional.Some(doerID)
   803  		case issues_model.FilterModeReviewRequested:
   804  			openClosedOpts.ReviewRequestedID = optional.Some(doerID)
   805  		case issues_model.FilterModeReviewed:
   806  			openClosedOpts.ReviewedID = optional.Some(doerID)
   807  		}
   808  		openClosedOpts.IsClosed = optional.Some(false)
   809  		ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
   810  		if err != nil {
   811  			return nil, err
   812  		}
   813  		openClosedOpts.IsClosed = optional.Some(true)
   814  		ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
   815  		if err != nil {
   816  			return nil, err
   817  		}
   818  	}
   819  
   820  	ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false }))
   821  	if err != nil {
   822  		return nil, err
   823  	}
   824  	ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) }))
   825  	if err != nil {
   826  		return nil, err
   827  	}
   828  	ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) }))
   829  	if err != nil {
   830  		return nil, err
   831  	}
   832  	ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = optional.Some(doerID) }))
   833  	if err != nil {
   834  		return nil, err
   835  	}
   836  	ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = optional.Some(doerID) }))
   837  	if err != nil {
   838  		return nil, err
   839  	}
   840  	ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = optional.Some(doerID) }))
   841  	if err != nil {
   842  		return nil, err
   843  	}
   844  	return ret, nil
   845  }