summaryrefslogtreecommitdiffstats
path: root/routers/web/user
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /routers/web/user
parentInitial commit. (diff)
downloadforgejo-upstream/9.0.0.tar.xz
forgejo-upstream/9.0.0.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'routers/web/user')
-rw-r--r--routers/web/user/avatar.go57
-rw-r--r--routers/web/user/code.go129
-rw-r--r--routers/web/user/home.go883
-rw-r--r--routers/web/user/home_test.go169
-rw-r--r--routers/web/user/main_test.go14
-rw-r--r--routers/web/user/notification.go485
-rw-r--r--routers/web/user/package.go513
-rw-r--r--routers/web/user/profile.go385
-rw-r--r--routers/web/user/search.go44
-rw-r--r--routers/web/user/setting/account.go344
-rw-r--r--routers/web/user/setting/account_test.go101
-rw-r--r--routers/web/user/setting/adopt.go64
-rw-r--r--routers/web/user/setting/applications.go115
-rw-r--r--routers/web/user/setting/blocked_users.go46
-rw-r--r--routers/web/user/setting/keys.go338
-rw-r--r--routers/web/user/setting/main_test.go14
-rw-r--r--routers/web/user/setting/oauth2.go68
-rw-r--r--routers/web/user/setting/oauth2_common.go163
-rw-r--r--routers/web/user/setting/packages.go119
-rw-r--r--routers/web/user/setting/profile.go433
-rw-r--r--routers/web/user/setting/runner.go13
-rw-r--r--routers/web/user/setting/security/2fa.go260
-rw-r--r--routers/web/user/setting/security/openid.go126
-rw-r--r--routers/web/user/setting/security/security.go148
-rw-r--r--routers/web/user/setting/security/webauthn.go137
-rw-r--r--routers/web/user/setting/webhooks.go49
-rw-r--r--routers/web/user/stop_watch.go40
-rw-r--r--routers/web/user/task.go53
28 files changed, 5310 insertions, 0 deletions
diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go
new file mode 100644
index 0000000..04f5101
--- /dev/null
+++ b/routers/web/user/avatar.go
@@ -0,0 +1,57 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/avatars"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/httpcache"
+ "code.gitea.io/gitea/services/context"
+)
+
+func cacheableRedirect(ctx *context.Context, location string) {
+ // here we should not use `setting.StaticCacheTime`, it is pretty long (default: 6 hours)
+ // we must make sure the redirection cache time is short enough, otherwise a user won't see the updated avatar in 6 hours
+ // it's OK to make the cache time short, it is only a redirection, and doesn't cost much to make a new request
+ httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 5*time.Minute)
+ ctx.Redirect(location)
+}
+
+// AvatarByUserName redirect browser to user avatar of requested size
+func AvatarByUserName(ctx *context.Context) {
+ userName := ctx.Params(":username")
+ size := int(ctx.ParamsInt64(":size"))
+
+ var user *user_model.User
+ if strings.ToLower(userName) != user_model.GhostUserLowerName {
+ var err error
+ if user, err = user_model.GetUserByName(ctx, userName); err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.NotFound("GetUserByName", err)
+ return
+ }
+ ctx.ServerError("Invalid user: "+userName, err)
+ return
+ }
+ } else {
+ user = user_model.NewGhostUser()
+ }
+
+ cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, size))
+}
+
+// AvatarByEmailHash redirects the browser to the email avatar link
+func AvatarByEmailHash(ctx *context.Context) {
+ hash := ctx.Params(":hash")
+ email, err := avatars.GetEmailForHash(ctx, hash)
+ if err != nil {
+ ctx.ServerError("invalid avatar hash: "+hash, err)
+ return
+ }
+ size := ctx.FormInt("size")
+ cacheableRedirect(ctx, avatars.GenerateEmailAvatarFinalLink(ctx, email, size))
+}
diff --git a/routers/web/user/code.go b/routers/web/user/code.go
new file mode 100644
index 0000000..e2e8f25
--- /dev/null
+++ b/routers/web/user/code.go
@@ -0,0 +1,129 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/base"
+ code_indexer "code.gitea.io/gitea/modules/indexer/code"
+ "code.gitea.io/gitea/modules/setting"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplUserCode base.TplName = "user/code"
+)
+
+// CodeSearch render user/organization code search page
+func CodeSearch(ctx *context.Context) {
+ if !setting.Indexer.RepoIndexerEnabled {
+ ctx.Redirect(ctx.ContextUser.HomeLink())
+ return
+ }
+ shared_user.PrepareContextForProfileBigAvatar(ctx)
+ shared_user.RenderUserHeader(ctx)
+
+ if err := shared_user.LoadHeaderCount(ctx); err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
+ ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+ ctx.Data["Title"] = ctx.Tr("explore.code")
+
+ language := ctx.FormTrim("l")
+ keyword := ctx.FormTrim("q")
+
+ isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true)
+
+ ctx.Data["Keyword"] = keyword
+ ctx.Data["Language"] = language
+ ctx.Data["IsFuzzy"] = isFuzzy
+ ctx.Data["IsCodePage"] = true
+
+ if keyword == "" {
+ ctx.HTML(http.StatusOK, tplUserCode)
+ return
+ }
+
+ var (
+ repoIDs []int64
+ err error
+ )
+
+ page := ctx.FormInt("page")
+ if page <= 0 {
+ page = 1
+ }
+
+ repoIDs, err = repo_model.FindUserCodeAccessibleOwnerRepoIDs(ctx, ctx.ContextUser.ID, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("FindUserCodeAccessibleOwnerRepoIDs", err)
+ return
+ }
+
+ var (
+ total int
+ searchResults []*code_indexer.Result
+ searchResultLanguages []*code_indexer.SearchResultLanguages
+ )
+
+ if len(repoIDs) > 0 {
+ total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
+ RepoIDs: repoIDs,
+ Keyword: keyword,
+ IsKeywordFuzzy: isFuzzy,
+ Language: language,
+ Paginator: &db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.RepoSearchPagingNum,
+ },
+ })
+ if err != nil {
+ if code_indexer.IsAvailable(ctx) {
+ ctx.ServerError("SearchResults", err)
+ return
+ }
+ ctx.Data["CodeIndexerUnavailable"] = true
+ } else {
+ ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx)
+ }
+
+ loadRepoIDs := make([]int64, 0, len(searchResults))
+ for _, result := range searchResults {
+ var find bool
+ for _, id := range loadRepoIDs {
+ if id == result.RepoID {
+ find = true
+ break
+ }
+ }
+ if !find {
+ loadRepoIDs = append(loadRepoIDs, result.RepoID)
+ }
+ }
+
+ repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs)
+ if err != nil {
+ ctx.ServerError("GetRepositoriesMapByIDs", err)
+ return
+ }
+
+ ctx.Data["RepoMaps"] = repoMaps
+ }
+ ctx.Data["SearchResults"] = searchResults
+ ctx.Data["SearchResultLanguages"] = searchResultLanguages
+
+ pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5)
+ pager.SetDefaultParams(ctx)
+ pager.AddParam(ctx, "l", "Language")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplUserCode)
+}
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
new file mode 100644
index 0000000..4b249e9
--- /dev/null
+++ b/routers/web/user/home.go
@@ -0,0 +1,883 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "regexp"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/container"
+ issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/web/feed"
+ "code.gitea.io/gitea/services/context"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "xorm.io/builder"
+)
+
+const (
+ tplDashboard base.TplName = "user/dashboard/dashboard"
+ tplIssues base.TplName = "user/dashboard/issues"
+ tplMilestones base.TplName = "user/dashboard/milestones"
+ tplProfile base.TplName = "user/profile"
+)
+
+// getDashboardContextUser finds out which context user dashboard is being viewed as .
+func getDashboardContextUser(ctx *context.Context) *user_model.User {
+ ctxUser := ctx.Doer
+ orgName := ctx.Params(":org")
+ if len(orgName) > 0 {
+ ctxUser = ctx.Org.Organization.AsUser()
+ ctx.Data["Teams"] = ctx.Org.Teams
+ }
+ ctx.Data["ContextUser"] = ctxUser
+
+ orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserOrgsList", err)
+ return nil
+ }
+ ctx.Data["Orgs"] = orgs
+
+ return ctxUser
+}
+
+// Dashboard render the dashboard page
+func Dashboard(ctx *context.Context) {
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ var (
+ date = ctx.FormString("date")
+ page = ctx.FormInt("page")
+ )
+
+ // Make sure page number is at least 1. Will be posted to ctx.Data.
+ if page <= 1 {
+ page = 1
+ }
+
+ ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard")
+ ctx.Data["PageIsDashboard"] = true
+ ctx.Data["PageIsNews"] = true
+ cnt, _ := organization.GetOrganizationCount(ctx, ctxUser)
+ ctx.Data["UserOrgsCount"] = cnt
+ ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled
+ ctx.Data["Date"] = date
+
+ var uid int64
+ if ctxUser != nil {
+ uid = ctxUser.ID
+ }
+
+ ctx.PageData["dashboardRepoList"] = map[string]any{
+ "searchLimit": setting.UI.User.RepoPagingNum,
+ "uid": uid,
+ }
+
+ if setting.Service.EnableUserHeatmap {
+ data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
+ return
+ }
+ ctx.Data["HeatmapData"] = data
+ ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
+ }
+
+ feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+ RequestedUser: ctxUser,
+ RequestedTeam: ctx.Org.Team,
+ Actor: ctx.Doer,
+ IncludePrivate: true,
+ OnlyPerformedBy: false,
+ IncludeDeleted: false,
+ Date: ctx.FormString("date"),
+ ListOptions: db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.FeedPagingNum,
+ },
+ })
+ if err != nil {
+ ctx.ServerError("GetFeeds", err)
+ return
+ }
+
+ ctx.Data["Feeds"] = feeds
+
+ pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5)
+ pager.AddParam(ctx, "date", "Date")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplDashboard)
+}
+
+// Milestones render the user milestones page
+func Milestones(ctx *context.Context) {
+ if unit.TypeIssues.UnitGlobalDisabled() && unit.TypePullRequests.UnitGlobalDisabled() {
+ log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled")
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("milestones")
+ ctx.Data["PageIsMilestonesDashboard"] = true
+
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ repoOpts := repo_model.SearchRepoOptions{
+ Actor: ctx.Doer,
+ OwnerID: ctxUser.ID,
+ Private: true,
+ AllPublic: false, // Include also all public repositories of users and public organisations
+ AllLimited: false, // Include also all public repositories of limited organisations
+ Archived: optional.Some(false),
+ HasMilestones: optional.Some(true), // Just needs display repos has milestones
+ }
+
+ if ctxUser.IsOrganization() && ctx.Org.Team != nil {
+ repoOpts.TeamID = ctx.Org.Team.ID
+ }
+
+ var (
+ userRepoCond = repo_model.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit
+ repoCond = userRepoCond
+ repoIDs []int64
+
+ reposQuery = ctx.FormString("repos")
+ isShowClosed = ctx.FormString("state") == "closed"
+ sortType = ctx.FormString("sort")
+ page = ctx.FormInt("page")
+ keyword = ctx.FormTrim("q")
+ )
+
+ if page <= 1 {
+ page = 1
+ }
+
+ if len(reposQuery) != 0 {
+ if issueReposQueryPattern.MatchString(reposQuery) {
+ // remove "[" and "]" from string
+ reposQuery = reposQuery[1 : len(reposQuery)-1]
+ // for each ID (delimiter ",") add to int to repoIDs
+
+ for _, rID := range strings.Split(reposQuery, ",") {
+ // Ensure nonempty string entries
+ if rID != "" && rID != "0" {
+ rIDint64, err := strconv.ParseInt(rID, 10, 64)
+ // If the repo id specified by query is not parseable or not accessible by user, just ignore it.
+ if err == nil {
+ repoIDs = append(repoIDs, rIDint64)
+ }
+ }
+ }
+ if len(repoIDs) > 0 {
+ // Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs
+ // But the original repoCond has a limitation
+ repoCond = repoCond.And(builder.In("id", repoIDs))
+ }
+ } else {
+ log.Warn("issueReposQueryPattern not match with query")
+ }
+ }
+
+ counts, err := issues_model.CountMilestonesMap(ctx, issues_model.FindMilestoneOptions{
+ RepoCond: userRepoCond,
+ Name: keyword,
+ IsClosed: optional.Some(isShowClosed),
+ })
+ if err != nil {
+ ctx.ServerError("CountMilestonesByRepoIDs", err)
+ return
+ }
+
+ milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
+ ListOptions: db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.IssuePagingNum,
+ },
+ RepoCond: repoCond,
+ IsClosed: optional.Some(isShowClosed),
+ SortType: sortType,
+ Name: keyword,
+ })
+ if err != nil {
+ ctx.ServerError("SearchMilestones", err)
+ return
+ }
+
+ showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, &repoOpts, userRepoCond, false)
+ if err != nil {
+ ctx.ServerError("SearchRepositoryByCondition", err)
+ return
+ }
+ sort.Sort(showRepos)
+
+ for i := 0; i < len(milestones); {
+ for _, repo := range showRepos {
+ if milestones[i].RepoID == repo.ID {
+ milestones[i].Repo = repo
+ break
+ }
+ }
+ if milestones[i].Repo == nil {
+ log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID)
+ milestones = append(milestones[:i], milestones[i+1:]...)
+ continue
+ }
+
+ milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{
+ Links: markup.Links{
+ Base: milestones[i].Repo.Link(),
+ },
+ Metas: milestones[i].Repo.ComposeMetas(ctx),
+ Ctx: ctx,
+ }, milestones[i].Content)
+ if err != nil {
+ ctx.ServerError("RenderString", err)
+ return
+ }
+
+ if milestones[i].Repo.IsTimetrackerEnabled(ctx) {
+ err := milestones[i].LoadTotalTrackedTime(ctx)
+ if err != nil {
+ ctx.ServerError("LoadTotalTrackedTime", err)
+ return
+ }
+ }
+ i++
+ }
+
+ milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, repoCond, keyword)
+ if err != nil {
+ ctx.ServerError("GetMilestoneStats", err)
+ return
+ }
+
+ var totalMilestoneStats *issues_model.MilestonesStats
+ if len(repoIDs) == 0 {
+ totalMilestoneStats = milestoneStats
+ } else {
+ totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, userRepoCond, keyword)
+ if err != nil {
+ ctx.ServerError("GetMilestoneStats", err)
+ return
+ }
+ }
+
+ showRepoIDs := make(container.Set[int64], len(showRepos))
+ for _, repo := range showRepos {
+ if repo.ID > 0 {
+ showRepoIDs.Add(repo.ID)
+ }
+ }
+ if len(repoIDs) == 0 {
+ repoIDs = showRepoIDs.Values()
+ }
+ repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool {
+ return !showRepoIDs.Contains(v)
+ })
+
+ var pagerCount int
+ if isShowClosed {
+ ctx.Data["State"] = "closed"
+ ctx.Data["Total"] = totalMilestoneStats.ClosedCount
+ pagerCount = int(milestoneStats.ClosedCount)
+ } else {
+ ctx.Data["State"] = "open"
+ ctx.Data["Total"] = totalMilestoneStats.OpenCount
+ pagerCount = int(milestoneStats.OpenCount)
+ }
+
+ ctx.Data["Milestones"] = milestones
+ ctx.Data["Repos"] = showRepos
+ ctx.Data["Counts"] = counts
+ ctx.Data["MilestoneStats"] = milestoneStats
+ ctx.Data["SortType"] = sortType
+ ctx.Data["Keyword"] = keyword
+ ctx.Data["RepoIDs"] = repoIDs
+ ctx.Data["IsShowClosed"] = isShowClosed
+
+ pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5)
+ pager.AddParam(ctx, "q", "Keyword")
+ pager.AddParam(ctx, "repos", "RepoIDs")
+ pager.AddParam(ctx, "sort", "SortType")
+ pager.AddParam(ctx, "state", "State")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplMilestones)
+}
+
+// Pulls renders the user's pull request overview page
+func Pulls(ctx *context.Context) {
+ if unit.TypePullRequests.UnitGlobalDisabled() {
+ log.Debug("Pull request overview page not available as it is globally disabled.")
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("pull_requests")
+ ctx.Data["PageIsPulls"] = true
+ buildIssueOverview(ctx, unit.TypePullRequests)
+}
+
+// Issues renders the user's issues overview page
+func Issues(ctx *context.Context) {
+ if unit.TypeIssues.UnitGlobalDisabled() {
+ log.Debug("Issues overview page not available as it is globally disabled.")
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("issues")
+ ctx.Data["PageIsIssues"] = true
+ buildIssueOverview(ctx, unit.TypeIssues)
+}
+
+// Regexp for repos query
+var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`)
+
+func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
+ // ----------------------------------------------------
+ // Determine user; can be either user or organization.
+ // Return with NotFound or ServerError if unsuccessful.
+ // ----------------------------------------------------
+
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ var (
+ viewType string
+ sortType = ctx.FormString("sort")
+ filterMode int
+ )
+
+ // Default to recently updated, unlike repository issues list
+ if sortType == "" {
+ sortType = "recentupdate"
+ }
+
+ // --------------------------------------------------------------------------------
+ // Distinguish User from Organization.
+ // Org:
+ // - Remember pre-determined viewType string for later. Will be posted to ctx.Data.
+ // Organization does not have view type and filter mode.
+ // User:
+ // - Use ctx.FormString("type") to determine filterMode.
+ // The type is set when clicking for example "assigned to me" on the overview page.
+ // - Remember either this or a fallback. Will be posted to ctx.Data.
+ // --------------------------------------------------------------------------------
+
+ // TODO: distinguish during routing
+
+ viewType = ctx.FormString("type")
+ switch viewType {
+ case "assigned":
+ filterMode = issues_model.FilterModeAssign
+ case "mentioned":
+ filterMode = issues_model.FilterModeMention
+ case "review_requested":
+ filterMode = issues_model.FilterModeReviewRequested
+ case "reviewed_by":
+ filterMode = issues_model.FilterModeReviewed
+ case "your_repositories":
+ filterMode = issues_model.FilterModeYourRepositories
+ case "created_by":
+ fallthrough
+ default:
+ filterMode = issues_model.FilterModeCreate
+ viewType = "created_by"
+ }
+
+ // --------------------------------------------------------------------------
+ // Build opts (IssuesOptions), which contains filter information.
+ // Will eventually be used to retrieve issues relevant for the overview page.
+ // Note: Non-final states of opts are used in-between, namely for:
+ // - Keyword search
+ // - Count Issues by repo
+ // --------------------------------------------------------------------------
+
+ // Get repository IDs where User/Org/Team has access.
+ var team *organization.Team
+ var org *organization.Organization
+ if ctx.Org != nil {
+ org = ctx.Org.Organization
+ team = ctx.Org.Team
+ }
+
+ isPullList := unitType == unit.TypePullRequests
+ opts := &issues_model.IssuesOptions{
+ IsPull: optional.Some(isPullList),
+ SortType: sortType,
+ IsArchived: optional.Some(false),
+ Org: org,
+ Team: team,
+ User: ctx.Doer,
+ }
+
+ isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true)
+
+ // Search all repositories which
+ //
+ // As user:
+ // - Owns the repository.
+ // - Have collaborator permissions in repository.
+ //
+ // As org:
+ // - Owns the repository.
+ //
+ // As team:
+ // - Team org's owns the repository.
+ // - Team has read permission to repository.
+ repoOpts := &repo_model.SearchRepoOptions{
+ Actor: ctx.Doer,
+ OwnerID: ctxUser.ID,
+ Private: true,
+ AllPublic: false,
+ AllLimited: false,
+ Collaborate: optional.None[bool](),
+ UnitType: unitType,
+ Archived: optional.Some(false),
+ }
+ if team != nil {
+ repoOpts.TeamID = team.ID
+ }
+ accessibleRepos := container.Set[int64]{}
+ {
+ ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts)
+ if err != nil {
+ ctx.ServerError("SearchRepositoryIDs", err)
+ return
+ }
+ accessibleRepos.AddMultiple(ids...)
+ opts.RepoIDs = ids
+ if len(opts.RepoIDs) == 0 {
+ // no repos found, don't let the indexer return all repos
+ opts.RepoIDs = []int64{0}
+ }
+ }
+ if ctx.Doer.ID == ctxUser.ID && filterMode != issues_model.FilterModeYourRepositories {
+ // If the doer is the same as the context user, which means the doer is viewing his own dashboard,
+ // it's not enough to show the repos that the doer owns or has been explicitly granted access to,
+ // because the doer may create issues or be mentioned in any public repo.
+ // So we need search issues in all public repos.
+ opts.AllPublic = true
+ }
+
+ switch filterMode {
+ case issues_model.FilterModeAll:
+ case issues_model.FilterModeYourRepositories:
+ case issues_model.FilterModeAssign:
+ opts.AssigneeID = ctx.Doer.ID
+ case issues_model.FilterModeCreate:
+ opts.PosterID = ctx.Doer.ID
+ case issues_model.FilterModeMention:
+ opts.MentionedID = ctx.Doer.ID
+ case issues_model.FilterModeReviewRequested:
+ opts.ReviewRequestedID = ctx.Doer.ID
+ case issues_model.FilterModeReviewed:
+ opts.ReviewedID = ctx.Doer.ID
+ }
+
+ // keyword holds the search term entered into the search field.
+ keyword := strings.Trim(ctx.FormString("q"), " ")
+ ctx.Data["Keyword"] = keyword
+
+ // Educated guess: Do or don't show closed issues.
+ isShowClosed := ctx.FormString("state") == "closed"
+ opts.IsClosed = optional.Some(isShowClosed)
+
+ // Make sure page number is at least 1. Will be posted to ctx.Data.
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ opts.Paginator = &db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.IssuePagingNum,
+ }
+
+ // Get IDs for labels (a filter option for issues/pulls).
+ // Required for IssuesOptions.
+ selectedLabels := ctx.FormString("labels")
+ if len(selectedLabels) > 0 && selectedLabels != "0" {
+ var err error
+ opts.LabelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ","))
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true)
+ }
+ }
+
+ if org != nil {
+ // Get Org Labels
+ labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("GetLabelsByOrgID", err)
+ return
+ }
+
+ // Get the exclusive scope for every label ID
+ labelExclusiveScopes := make([]string, 0, len(opts.LabelIDs))
+ for _, labelID := range opts.LabelIDs {
+ foundExclusiveScope := false
+ for _, label := range labels {
+ if label.ID == labelID || label.ID == -labelID {
+ labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
+ foundExclusiveScope = true
+ break
+ }
+ }
+ if !foundExclusiveScope {
+ labelExclusiveScopes = append(labelExclusiveScopes, "")
+ }
+ }
+
+ for _, l := range labels {
+ l.LoadSelectedLabelsAfterClick(opts.LabelIDs, labelExclusiveScopes)
+ }
+ ctx.Data["Labels"] = labels
+ }
+
+ // ------------------------------
+ // Get issues as defined by opts.
+ // ------------------------------
+
+ // Slice of Issues that will be displayed on the overview page
+ // USING FINAL STATE OF opts FOR A QUERY.
+ var issues issues_model.IssueList
+ {
+ issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy(
+ func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy },
+ ))
+ if err != nil {
+ ctx.ServerError("issueIDsFromSearch", err)
+ return
+ }
+ issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true)
+ if err != nil {
+ ctx.ServerError("GetIssuesByIDs", err)
+ return
+ }
+ }
+
+ commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
+ if err != nil {
+ ctx.ServerError("GetIssuesLastCommitStatus", err)
+ return
+ }
+ if !ctx.Repo.CanRead(unit.TypeActions) {
+ for key := range commitStatuses {
+ git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
+ }
+ }
+
+ // -------------------------------
+ // Fill stats to post to ctx.Data.
+ // -------------------------------
+ issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy(
+ func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy },
+ ))
+ if err != nil {
+ ctx.ServerError("getUserIssueStats", err)
+ return
+ }
+
+ // Will be posted to ctx.Data.
+ var shownIssues int
+ if !isShowClosed {
+ shownIssues = int(issueStats.OpenCount)
+ } else {
+ shownIssues = int(issueStats.ClosedCount)
+ }
+
+ ctx.Data["IsShowClosed"] = isShowClosed
+
+ ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.FormString("RepoLink"))
+
+ if err := issues.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("issues.LoadAttributes", err)
+ return
+ }
+ ctx.Data["Issues"] = issues
+
+ approvalCounts, err := issues.GetApprovalCounts(ctx)
+ if err != nil {
+ ctx.ServerError("ApprovalCounts", err)
+ return
+ }
+ ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
+ counts, ok := approvalCounts[issueID]
+ if !ok || len(counts) == 0 {
+ return 0
+ }
+ reviewTyp := issues_model.ReviewTypeApprove
+ if typ == "reject" {
+ reviewTyp = issues_model.ReviewTypeReject
+ } else if typ == "waiting" {
+ reviewTyp = issues_model.ReviewTypeRequest
+ }
+ for _, count := range counts {
+ if count.Type == reviewTyp {
+ return count.Count
+ }
+ }
+ return 0
+ }
+ ctx.Data["CommitLastStatus"] = lastStatus
+ ctx.Data["CommitStatuses"] = commitStatuses
+ ctx.Data["IssueStats"] = issueStats
+ ctx.Data["ViewType"] = viewType
+ ctx.Data["SortType"] = sortType
+ ctx.Data["IsShowClosed"] = isShowClosed
+ ctx.Data["SelectLabels"] = selectedLabels
+ ctx.Data["PageIsOrgIssues"] = org != nil
+ ctx.Data["IsFuzzy"] = isFuzzy
+
+ if isShowClosed {
+ ctx.Data["State"] = "closed"
+ } else {
+ ctx.Data["State"] = "open"
+ }
+
+ pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
+ pager.AddParam(ctx, "q", "Keyword")
+ pager.AddParam(ctx, "type", "ViewType")
+ pager.AddParam(ctx, "sort", "SortType")
+ pager.AddParam(ctx, "state", "State")
+ pager.AddParam(ctx, "labels", "SelectLabels")
+ pager.AddParam(ctx, "milestone", "MilestoneID")
+ pager.AddParam(ctx, "assignee", "AssigneeID")
+ pager.AddParam(ctx, "fuzzy", "IsFuzzy")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplIssues)
+}
+
+// ShowSSHKeys output all the ssh keys of user by uid
+func ShowSSHKeys(ctx *context.Context) {
+ keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ OwnerID: ctx.ContextUser.ID,
+ })
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+
+ var buf bytes.Buffer
+ for i := range keys {
+ buf.WriteString(keys[i].OmitEmail())
+ buf.WriteString("\n")
+ }
+ ctx.PlainTextBytes(http.StatusOK, buf.Bytes())
+}
+
+// ShowGPGKeys output all the public GPG keys of user by uid
+func ShowGPGKeys(ctx *context.Context) {
+ keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: ctx.ContextUser.ID,
+ })
+ if err != nil {
+ ctx.ServerError("ListGPGKeys", err)
+ return
+ }
+
+ entities := make([]*openpgp.Entity, 0)
+ failedEntitiesID := make([]string, 0)
+ for _, k := range keys {
+ e, err := asymkey_model.GPGKeyToEntity(ctx, k)
+ if err != nil {
+ if asymkey_model.IsErrGPGKeyImportNotExist(err) {
+ failedEntitiesID = append(failedEntitiesID, k.KeyID)
+ continue // Skip previous import without backup of imported armored key
+ }
+ ctx.ServerError("ShowGPGKeys", err)
+ return
+ }
+ entities = append(entities, e)
+ }
+ var buf bytes.Buffer
+
+ headers := make(map[string]string)
+ if len(failedEntitiesID) > 0 { // If some key need re-import to be exported
+ headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", "))
+ } else if len(entities) == 0 {
+ headers["Note"] = "This user hasn't uploaded any GPG keys."
+ }
+ writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers)
+ for _, e := range entities {
+ err = e.Serialize(writer) // TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange)
+ if err != nil {
+ ctx.ServerError("ShowGPGKeys", err)
+ return
+ }
+ }
+ writer.Close()
+ ctx.PlainTextBytes(http.StatusOK, buf.Bytes())
+}
+
+func UsernameSubRoute(ctx *context.Context) {
+ // WORKAROUND to support usernames with "." in it
+ // https://github.com/go-chi/chi/issues/781
+ username := ctx.Params("username")
+ reloadParam := func(suffix string) (success bool) {
+ ctx.SetParams("username", strings.TrimSuffix(username, suffix))
+ context.UserAssignmentWeb()(ctx)
+ if ctx.Written() {
+ return false
+ }
+ // check view permissions
+ if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
+ ctx.NotFound("User not visible", nil)
+ return false
+ }
+ return true
+ }
+ switch {
+ case strings.HasSuffix(username, ".png"):
+ if reloadParam(".png") {
+ AvatarByUserName(ctx)
+ }
+ case strings.HasSuffix(username, ".keys"):
+ if reloadParam(".keys") {
+ ShowSSHKeys(ctx)
+ }
+ case strings.HasSuffix(username, ".gpg"):
+ if reloadParam(".gpg") {
+ ShowGPGKeys(ctx)
+ }
+ case strings.HasSuffix(username, ".rss"):
+ if !setting.Other.EnableFeed {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if reloadParam(".rss") {
+ feed.ShowUserFeedRSS(ctx)
+ }
+ case strings.HasSuffix(username, ".atom"):
+ if !setting.Other.EnableFeed {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if reloadParam(".atom") {
+ feed.ShowUserFeedAtom(ctx)
+ }
+ default:
+ context.UserAssignmentWeb()(ctx)
+ if !ctx.Written() {
+ ctx.Data["EnableFeed"] = setting.Other.EnableFeed
+ OwnerProfile(ctx)
+ }
+ }
+}
+
+func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) {
+ doerID := ctx.Doer.ID
+
+ opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
+ // If the doer is the same as the context user, which means the doer is viewing his own dashboard,
+ // it's not enough to show the repos that the doer owns or has been explicitly granted access to,
+ // because the doer may create issues or be mentioned in any public repo.
+ // So we need search issues in all public repos.
+ o.AllPublic = doerID == ctxUser.ID
+ o.AssigneeID = nil
+ o.PosterID = nil
+ o.MentionID = nil
+ o.ReviewRequestedID = nil
+ o.ReviewedID = nil
+ })
+
+ var (
+ err error
+ ret = &issues_model.IssueStats{}
+ )
+
+ {
+ openClosedOpts := opts.Copy()
+ switch filterMode {
+ case issues_model.FilterModeAll:
+ // no-op
+ case issues_model.FilterModeYourRepositories:
+ openClosedOpts.AllPublic = false
+ case issues_model.FilterModeAssign:
+ openClosedOpts.AssigneeID = optional.Some(doerID)
+ case issues_model.FilterModeCreate:
+ openClosedOpts.PosterID = optional.Some(doerID)
+ case issues_model.FilterModeMention:
+ openClosedOpts.MentionID = optional.Some(doerID)
+ case issues_model.FilterModeReviewRequested:
+ openClosedOpts.ReviewRequestedID = optional.Some(doerID)
+ case issues_model.FilterModeReviewed:
+ openClosedOpts.ReviewedID = optional.Some(doerID)
+ }
+ openClosedOpts.IsClosed = optional.Some(false)
+ ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
+ if err != nil {
+ return nil, err
+ }
+ openClosedOpts.IsClosed = optional.Some(true)
+ ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false }))
+ if err != nil {
+ return nil, err
+ }
+ ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) }))
+ if err != nil {
+ return nil, err
+ }
+ ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) }))
+ if err != nil {
+ return nil, err
+ }
+ ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = optional.Some(doerID) }))
+ if err != nil {
+ return nil, err
+ }
+ ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = optional.Some(doerID) }))
+ if err != nil {
+ return nil, err
+ }
+ ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = optional.Some(doerID) }))
+ if err != nil {
+ return nil, err
+ }
+ return ret, nil
+}
diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go
new file mode 100644
index 0000000..e1c8ca9
--- /dev/null
+++ b/routers/web/user/home_test.go
@@ -0,0 +1,169 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestArchivedIssues(t *testing.T) {
+ // Arrange
+ setting.UI.IssuePagingNum = 1
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "issues")
+ contexttest.LoadUser(t, ctx, 30)
+ ctx.Req.Form.Set("state", "open")
+ ctx.Req.Form.Set("type", "your_repositories")
+
+ // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived.
+ repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, &repo_model.SearchRepoOptions{Actor: ctx.Doer})
+ assert.Len(t, repos, 3)
+ IsArchived := make(map[int64]bool)
+ NumIssues := make(map[int64]int)
+ for _, repo := range repos {
+ IsArchived[repo.ID] = repo.IsArchived
+ NumIssues[repo.ID] = repo.NumIssues
+ }
+ assert.False(t, IsArchived[50])
+ assert.EqualValues(t, 1, NumIssues[50])
+ assert.True(t, IsArchived[51])
+ assert.EqualValues(t, 1, NumIssues[51])
+
+ // Act
+ Issues(ctx)
+
+ // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.Len(t, ctx.Data["Issues"], 1)
+}
+
+func TestIssues(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "issues")
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.Req.Form.Set("state", "closed")
+ Issues(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.Len(t, ctx.Data["Issues"], 1)
+}
+
+func TestPulls(t *testing.T) {
+ setting.UI.IssuePagingNum = 20
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "pulls")
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.Req.Form.Set("state", "open")
+ ctx.Req.Form.Set("type", "your_repositories")
+ Pulls(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.Len(t, ctx.Data["Issues"], 5)
+}
+
+func TestMilestones(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "milestones")
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.SetParams("sort", "issues")
+ ctx.Req.Form.Set("state", "closed")
+ ctx.Req.Form.Set("sort", "furthestduedate")
+ Milestones(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+ assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"])
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"])
+ assert.EqualValues(t, 1, ctx.Data["Total"])
+ assert.Len(t, ctx.Data["Milestones"], 1)
+ assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2
+}
+
+func TestMilestonesForSpecificRepo(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "milestones")
+ contexttest.LoadUser(t, ctx, 2)
+ ctx.SetParams("sort", "issues")
+ ctx.SetParams("repo", "1")
+ ctx.Req.Form.Set("state", "closed")
+ ctx.Req.Form.Set("sort", "furthestduedate")
+ Milestones(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+ assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"])
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"])
+ assert.EqualValues(t, 1, ctx.Data["Total"])
+ assert.Len(t, ctx.Data["Milestones"], 1)
+ assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2
+}
+
+func TestDashboardPagination(t *testing.T) {
+ ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+ page := context.NewPagination(10, 3, 1, 3)
+
+ setting.AppSubURL = "/SubPath"
+ out, err := ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page})
+ require.NoError(t, err)
+ assert.Contains(t, out, `<a class=" item navigation" href="/SubPath/?page=2">`)
+
+ setting.AppSubURL = ""
+ out, err = ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page})
+ require.NoError(t, err)
+ assert.Contains(t, out, `<a class=" item navigation" href="/?page=2">`)
+}
+
+func TestOrgLabels(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ ctx, _ := contexttest.MockContext(t, "org/org3/issues")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadOrganization(t, ctx, 3)
+ Issues(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.True(t, ctx.Data["PageIsOrgIssues"].(bool))
+
+ orgLabels := []struct {
+ ID int64
+ OrgID int64
+ Name string
+ }{
+ {3, 3, "orglabel3"},
+ {4, 3, "orglabel4"},
+ }
+
+ labels, ok := ctx.Data["Labels"].([]*issues_model.Label)
+
+ assert.True(t, ok)
+
+ if assert.Len(t, labels, len(orgLabels)) {
+ for i, label := range labels {
+ assert.EqualValues(t, orgLabels[i].OrgID, label.OrgID)
+ assert.EqualValues(t, orgLabels[i].ID, label.ID)
+ assert.EqualValues(t, orgLabels[i].Name, label.Name)
+ }
+ }
+}
diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go
new file mode 100644
index 0000000..8b6ae69
--- /dev/null
+++ b/routers/web/user/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go
new file mode 100644
index 0000000..dfcaf58
--- /dev/null
+++ b/routers/web/user/notification.go
@@ -0,0 +1,485 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ goctx "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+)
+
+const (
+ tplNotification base.TplName = "user/notification/notification"
+ tplNotificationDiv base.TplName = "user/notification/notification_div"
+ tplNotificationSubscriptions base.TplName = "user/notification/notification_subscriptions"
+)
+
+// GetNotificationCount is the middleware that sets the notification count in the context
+func GetNotificationCount(ctx *context.Context) {
+ if strings.HasPrefix(ctx.Req.URL.Path, "/api") {
+ return
+ }
+
+ if !ctx.IsSigned {
+ return
+ }
+
+ ctx.Data["NotificationUnreadCount"] = func() int64 {
+ count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
+ UserID: ctx.Doer.ID,
+ Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
+ })
+ if err != nil {
+ if err != goctx.Canceled {
+ log.Error("Unable to GetNotificationCount for user:%-v: %v", ctx.Doer, err)
+ }
+ return -1
+ }
+
+ return count
+ }
+}
+
+// Notifications is the notifications page
+func Notifications(ctx *context.Context) {
+ getNotifications(ctx)
+ if ctx.Written() {
+ return
+ }
+ if ctx.FormBool("div-only") {
+ ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number")
+ ctx.HTML(http.StatusOK, tplNotificationDiv)
+ return
+ }
+ ctx.HTML(http.StatusOK, tplNotification)
+}
+
+func getNotifications(ctx *context.Context) {
+ var (
+ keyword = ctx.FormTrim("q")
+ status activities_model.NotificationStatus
+ page = ctx.FormInt("page")
+ perPage = ctx.FormInt("perPage")
+ )
+ if page < 1 {
+ page = 1
+ }
+ if perPage < 1 {
+ perPage = 20
+ }
+
+ switch keyword {
+ case "read":
+ status = activities_model.NotificationStatusRead
+ default:
+ status = activities_model.NotificationStatusUnread
+ }
+
+ total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
+ UserID: ctx.Doer.ID,
+ Status: []activities_model.NotificationStatus{status},
+ })
+ if err != nil {
+ ctx.ServerError("ErrGetNotificationCount", err)
+ return
+ }
+
+ // redirect to last page if request page is more than total pages
+ pager := context.NewPagination(int(total), perPage, page, 5)
+ if pager.Paginater.Current() < page {
+ ctx.Redirect(fmt.Sprintf("%s/notifications?q=%s&page=%d", setting.AppSubURL, url.QueryEscape(ctx.FormString("q")), pager.Paginater.Current()))
+ return
+ }
+
+ statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned}
+ nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
+ ListOptions: db.ListOptions{
+ PageSize: perPage,
+ Page: page,
+ },
+ UserID: ctx.Doer.ID,
+ Status: statuses,
+ })
+ if err != nil {
+ ctx.ServerError("db.Find[activities_model.Notification]", err)
+ return
+ }
+
+ notifications := activities_model.NotificationList(nls)
+
+ failCount := 0
+
+ repos, failures, err := notifications.LoadRepos(ctx)
+ if err != nil {
+ ctx.ServerError("LoadRepos", err)
+ return
+ }
+ notifications = notifications.Without(failures)
+ if err := repos.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return
+ }
+ failCount += len(failures)
+
+ failures, err = notifications.LoadIssues(ctx)
+ if err != nil {
+ ctx.ServerError("LoadIssues", err)
+ return
+ }
+
+ if err = notifications.LoadIssuePullRequests(ctx); err != nil {
+ ctx.ServerError("LoadIssuePullRequests", err)
+ return
+ }
+
+ notifications = notifications.Without(failures)
+ failCount += len(failures)
+
+ failures, err = notifications.LoadComments(ctx)
+ if err != nil {
+ ctx.ServerError("LoadComments", err)
+ return
+ }
+ notifications = notifications.Without(failures)
+ failCount += len(failures)
+
+ if failCount > 0 {
+ ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount))
+ }
+
+ ctx.Data["Title"] = ctx.Tr("notifications")
+ ctx.Data["Keyword"] = keyword
+ ctx.Data["Status"] = status
+ ctx.Data["Notifications"] = notifications
+
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+}
+
+// NotificationStatusPost is a route for changing the status of a notification
+func NotificationStatusPost(ctx *context.Context) {
+ var (
+ notificationID = ctx.FormInt64("notification_id")
+ statusStr = ctx.FormString("status")
+ status activities_model.NotificationStatus
+ )
+
+ switch statusStr {
+ case "read":
+ status = activities_model.NotificationStatusRead
+ case "unread":
+ status = activities_model.NotificationStatusUnread
+ case "pinned":
+ status = activities_model.NotificationStatusPinned
+ default:
+ ctx.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status"))
+ return
+ }
+
+ if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, status); err != nil {
+ ctx.ServerError("SetNotificationStatus", err)
+ return
+ }
+
+ if !ctx.FormBool("noredirect") {
+ url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, url.QueryEscape(ctx.FormString("page")))
+ ctx.Redirect(url, http.StatusSeeOther)
+ }
+
+ getNotifications(ctx)
+ if ctx.Written() {
+ return
+ }
+ ctx.Data["Link"] = setting.AppSubURL + "/notifications"
+ ctx.Data["SequenceNumber"] = ctx.Req.PostFormValue("sequence-number")
+
+ ctx.HTML(http.StatusOK, tplNotificationDiv)
+}
+
+// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read
+func NotificationPurgePost(ctx *context.Context) {
+ err := activities_model.UpdateNotificationStatuses(ctx, ctx.Doer, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead)
+ if err != nil {
+ ctx.ServerError("UpdateNotificationStatuses", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther)
+}
+
+// NotificationSubscriptions returns the list of subscribed issues
+func NotificationSubscriptions(ctx *context.Context) {
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ sortType := ctx.FormString("sort")
+ ctx.Data["SortType"] = sortType
+
+ state := ctx.FormString("state")
+ if !util.SliceContainsString([]string{"all", "open", "closed"}, state, true) {
+ state = "all"
+ }
+
+ ctx.Data["State"] = state
+ // default state filter is "all"
+ showClosed := optional.None[bool]()
+ switch state {
+ case "closed":
+ showClosed = optional.Some(true)
+ case "open":
+ showClosed = optional.Some(false)
+ }
+
+ issueType := ctx.FormString("issueType")
+ // default issue type is no filter
+ issueTypeBool := optional.None[bool]()
+ switch issueType {
+ case "issues":
+ issueTypeBool = optional.Some(false)
+ case "pulls":
+ issueTypeBool = optional.Some(true)
+ }
+ ctx.Data["IssueType"] = issueType
+
+ var labelIDs []int64
+ selectedLabels := ctx.FormString("labels")
+ ctx.Data["Labels"] = selectedLabels
+ if len(selectedLabels) > 0 && selectedLabels != "0" {
+ var err error
+ labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ","))
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true)
+ }
+ }
+
+ count, err := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{
+ SubscriberID: ctx.Doer.ID,
+ IsClosed: showClosed,
+ IsPull: issueTypeBool,
+ LabelIDs: labelIDs,
+ })
+ if err != nil {
+ ctx.ServerError("CountIssues", err)
+ return
+ }
+ issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
+ Paginator: &db.ListOptions{
+ PageSize: setting.UI.IssuePagingNum,
+ Page: page,
+ },
+ SubscriberID: ctx.Doer.ID,
+ SortType: sortType,
+ IsClosed: showClosed,
+ IsPull: issueTypeBool,
+ LabelIDs: labelIDs,
+ })
+ if err != nil {
+ ctx.ServerError("Issues", err)
+ return
+ }
+
+ commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
+ if err != nil {
+ ctx.ServerError("GetIssuesAllCommitStatus", err)
+ return
+ }
+ if !ctx.Repo.CanRead(unit.TypeActions) {
+ for key := range commitStatuses {
+ git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
+ }
+ }
+ ctx.Data["CommitLastStatus"] = lastStatus
+ ctx.Data["CommitStatuses"] = commitStatuses
+ ctx.Data["Issues"] = issues
+
+ ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "")
+
+ commitStatus, err := pull_service.GetIssuesLastCommitStatus(ctx, issues)
+ if err != nil {
+ ctx.ServerError("GetIssuesLastCommitStatus", err)
+ return
+ }
+ ctx.Data["CommitStatus"] = commitStatus
+
+ approvalCounts, err := issues.GetApprovalCounts(ctx)
+ if err != nil {
+ ctx.ServerError("ApprovalCounts", err)
+ return
+ }
+ ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
+ counts, ok := approvalCounts[issueID]
+ if !ok || len(counts) == 0 {
+ return 0
+ }
+ reviewTyp := issues_model.ReviewTypeApprove
+ if typ == "reject" {
+ reviewTyp = issues_model.ReviewTypeReject
+ } else if typ == "waiting" {
+ reviewTyp = issues_model.ReviewTypeRequest
+ }
+ for _, count := range counts {
+ if count.Type == reviewTyp {
+ return count.Count
+ }
+ }
+ return 0
+ }
+
+ ctx.Data["Status"] = 1
+ ctx.Data["Title"] = ctx.Tr("notification.subscriptions")
+
+ // redirect to last page if request page is more than total pages
+ pager := context.NewPagination(int(count), setting.UI.IssuePagingNum, page, 5)
+ if pager.Paginater.Current() < page {
+ ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current()))
+ return
+ }
+ pager.AddParam(ctx, "sort", "SortType")
+ pager.AddParam(ctx, "state", "State")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplNotificationSubscriptions)
+}
+
+// NotificationWatching returns the list of watching repos
+func NotificationWatching(ctx *context.Context) {
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ keyword := ctx.FormTrim("q")
+ ctx.Data["Keyword"] = keyword
+
+ var orderBy db.SearchOrderBy
+ ctx.Data["SortType"] = ctx.FormString("sort")
+ switch ctx.FormString("sort") {
+ case "newest":
+ orderBy = db.SearchOrderByNewest
+ case "oldest":
+ orderBy = db.SearchOrderByOldest
+ case "recentupdate":
+ orderBy = db.SearchOrderByRecentUpdated
+ case "leastupdate":
+ orderBy = db.SearchOrderByLeastUpdated
+ case "reversealphabetically":
+ orderBy = db.SearchOrderByAlphabeticallyReverse
+ case "alphabetically":
+ orderBy = db.SearchOrderByAlphabetically
+ case "moststars":
+ orderBy = db.SearchOrderByStarsReverse
+ case "feweststars":
+ orderBy = db.SearchOrderByStars
+ case "mostforks":
+ orderBy = db.SearchOrderByForksReverse
+ case "fewestforks":
+ orderBy = db.SearchOrderByForks
+ default:
+ ctx.Data["SortType"] = "recentupdate"
+ orderBy = db.SearchOrderByRecentUpdated
+ }
+
+ archived := ctx.FormOptionalBool("archived")
+ ctx.Data["IsArchived"] = archived
+
+ fork := ctx.FormOptionalBool("fork")
+ ctx.Data["IsFork"] = fork
+
+ mirror := ctx.FormOptionalBool("mirror")
+ ctx.Data["IsMirror"] = mirror
+
+ template := ctx.FormOptionalBool("template")
+ ctx.Data["IsTemplate"] = template
+
+ private := ctx.FormOptionalBool("private")
+ ctx.Data["IsPrivate"] = private
+
+ repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
+ ListOptions: db.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ },
+ Actor: ctx.Doer,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ WatchedByID: ctx.Doer.ID,
+ Collaborate: optional.Some(false),
+ TopicOnly: ctx.FormBool("topic"),
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ Archived: archived,
+ Fork: fork,
+ Mirror: mirror,
+ Template: template,
+ IsPrivate: private,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+ total := int(count)
+ ctx.Data["Total"] = total
+ ctx.Data["Repos"] = repos
+
+ // redirect to last page if request page is more than total pages
+ pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5)
+ pager.SetDefaultParams(ctx)
+ if archived.Has() {
+ pager.AddParamString("archived", fmt.Sprint(archived.Value()))
+ }
+ if fork.Has() {
+ pager.AddParamString("fork", fmt.Sprint(fork.Value()))
+ }
+ if mirror.Has() {
+ pager.AddParamString("mirror", fmt.Sprint(mirror.Value()))
+ }
+ if template.Has() {
+ pager.AddParamString("template", fmt.Sprint(template.Value()))
+ }
+ if private.Has() {
+ pager.AddParamString("private", fmt.Sprint(private.Value()))
+ }
+ ctx.Data["Page"] = pager
+
+ ctx.Data["Status"] = 2
+ ctx.Data["Title"] = ctx.Tr("notification.watching")
+
+ ctx.HTML(http.StatusOK, tplNotificationSubscriptions)
+}
+
+// NewAvailable returns the notification counts
+func NewAvailable(ctx *context.Context) {
+ total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
+ UserID: ctx.Doer.ID,
+ Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
+ })
+ if err != nil {
+ log.Error("db.Count[activities_model.Notification]", err)
+ ctx.JSON(http.StatusOK, structs.NotificationCount{New: 0})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, structs.NotificationCount{New: total})
+}
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
new file mode 100644
index 0000000..d47a36e
--- /dev/null
+++ b/routers/web/user/package.go
@@ -0,0 +1,513 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ org_model "code.gitea.io/gitea/models/organization"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ alpine_module "code.gitea.io/gitea/modules/packages/alpine"
+ arch_model "code.gitea.io/gitea/modules/packages/arch"
+ debian_module "code.gitea.io/gitea/modules/packages/debian"
+ rpm_module "code.gitea.io/gitea/modules/packages/rpm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ packages_helper "code.gitea.io/gitea/routers/api/packages/helper"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+const (
+ tplPackagesList base.TplName = "user/overview/packages"
+ tplPackagesView base.TplName = "package/view"
+ tplPackageVersionList base.TplName = "user/overview/package_versions"
+ tplPackagesSettings base.TplName = "package/settings"
+)
+
+// ListPackages displays a list of all packages of the context user
+func ListPackages(ctx *context.Context) {
+ shared_user.PrepareContextForProfileBigAvatar(ctx)
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ query := ctx.FormTrim("q")
+ packageType := ctx.FormTrim("type")
+
+ pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ Paginator: &db.ListOptions{
+ PageSize: setting.UI.PackagesPagingNum,
+ Page: page,
+ },
+ OwnerID: ctx.ContextUser.ID,
+ Type: packages_model.Type(packageType),
+ Name: packages_model.SearchValue{Value: query},
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ ctx.ServerError("SearchLatestVersions", err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ ctx.ServerError("GetPackageDescriptors", err)
+ return
+ }
+
+ repositoryAccessMap := make(map[int64]bool)
+ for _, pd := range pds {
+ if pd.Repository == nil {
+ continue
+ }
+ if _, has := repositoryAccessMap[pd.Repository.ID]; has {
+ continue
+ }
+
+ permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+ repositoryAccessMap[pd.Repository.ID] = permission.HasAccess()
+ }
+
+ hasPackages, err := packages_model.HasOwnerPackages(ctx, ctx.ContextUser.ID)
+ if err != nil {
+ ctx.ServerError("HasOwnerPackages", err)
+ return
+ }
+
+ shared_user.RenderUserHeader(ctx)
+
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["IsPackagesPage"] = true
+ ctx.Data["Query"] = query
+ ctx.Data["PackageType"] = packageType
+ ctx.Data["AvailableTypes"] = packages_model.TypeList
+ ctx.Data["HasPackages"] = hasPackages
+ ctx.Data["PackageDescriptors"] = pds
+ ctx.Data["Total"] = total
+ ctx.Data["RepositoryAccessMap"] = repositoryAccessMap
+
+ err = shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ // TODO: context/org -> HandleOrgAssignment() can not be used
+ if ctx.ContextUser.IsOrganization() {
+ org := org_model.OrgFromUser(ctx.ContextUser)
+ ctx.Data["Org"] = org
+ ctx.Data["OrgLink"] = ctx.ContextUser.OrganisationLink()
+
+ if ctx.Doer != nil {
+ ctx.Data["IsOrganizationMember"], _ = org_model.IsOrganizationMember(ctx, org.ID, ctx.Doer.ID)
+ ctx.Data["IsOrganizationOwner"], _ = org_model.IsOrganizationOwner(ctx, org.ID, ctx.Doer.ID)
+ } else {
+ ctx.Data["IsOrganizationMember"] = false
+ ctx.Data["IsOrganizationOwner"] = false
+ }
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
+ pager.AddParam(ctx, "q", "Query")
+ pager.AddParam(ctx, "type", "PackageType")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplPackagesList)
+}
+
+// RedirectToLastVersion redirects to the latest package version
+func RedirectToLastVersion(ctx *context.Context) {
+ p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name"))
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ ctx.NotFound("GetPackageByName", err)
+ } else {
+ ctx.ServerError("GetPackageByName", err)
+ }
+ return
+ }
+
+ pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ PackageID: p.ID,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ ctx.ServerError("GetPackageByName", err)
+ return
+ }
+ if len(pvs) == 0 {
+ ctx.NotFound("", err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0])
+ if err != nil {
+ ctx.ServerError("GetPackageDescriptor", err)
+ return
+ }
+
+ ctx.Redirect(pd.VersionWebLink())
+}
+
+// ViewPackageVersion displays a single package version
+func ViewPackageVersion(ctx *context.Context) {
+ pd := ctx.Package.Descriptor
+
+ shared_user.RenderUserHeader(ctx)
+
+ ctx.Data["Title"] = pd.Package.Name
+ ctx.Data["IsPackagesPage"] = true
+ ctx.Data["PackageDescriptor"] = pd
+
+ switch pd.Package.Type {
+ case packages_model.TypeContainer:
+ ctx.Data["RegistryHost"] = setting.Packages.RegistryHost
+ case packages_model.TypeAlpine:
+ branches := make(container.Set[string])
+ repositories := make(container.Set[string])
+ architectures := make(container.Set[string])
+
+ for _, f := range pd.Files {
+ for _, pp := range f.Properties {
+ switch pp.Name {
+ case alpine_module.PropertyBranch:
+ branches.Add(pp.Value)
+ case alpine_module.PropertyRepository:
+ repositories.Add(pp.Value)
+ case alpine_module.PropertyArchitecture:
+ architectures.Add(pp.Value)
+ }
+ }
+ }
+
+ ctx.Data["Branches"] = util.Sorted(branches.Values())
+ ctx.Data["Repositories"] = util.Sorted(repositories.Values())
+ ctx.Data["Architectures"] = util.Sorted(architectures.Values())
+ case packages_model.TypeArch:
+ ctx.Data["RegistryHost"] = setting.Packages.RegistryHost
+ ctx.Data["SignMail"] = fmt.Sprintf("%s@noreply.%s", ctx.Package.Owner.Name, setting.Packages.RegistryHost)
+ groups := make(container.Set[string])
+ for _, f := range pd.Files {
+ for _, pp := range f.Properties {
+ if pp.Name == arch_model.PropertyDistribution {
+ groups.Add(pp.Value)
+ }
+ }
+ }
+ ctx.Data["Groups"] = util.Sorted(groups.Values())
+ case packages_model.TypeDebian:
+ distributions := make(container.Set[string])
+ components := make(container.Set[string])
+ architectures := make(container.Set[string])
+
+ for _, f := range pd.Files {
+ for _, pp := range f.Properties {
+ switch pp.Name {
+ case debian_module.PropertyDistribution:
+ distributions.Add(pp.Value)
+ case debian_module.PropertyComponent:
+ components.Add(pp.Value)
+ case debian_module.PropertyArchitecture:
+ architectures.Add(pp.Value)
+ }
+ }
+ }
+
+ ctx.Data["Distributions"] = util.Sorted(distributions.Values())
+ ctx.Data["Components"] = util.Sorted(components.Values())
+ ctx.Data["Architectures"] = util.Sorted(architectures.Values())
+ case packages_model.TypeRpm:
+ groups := make(container.Set[string])
+ architectures := make(container.Set[string])
+
+ for _, f := range pd.Files {
+ for _, pp := range f.Properties {
+ switch pp.Name {
+ case rpm_module.PropertyGroup:
+ groups.Add(pp.Value)
+ case rpm_module.PropertyArchitecture:
+ architectures.Add(pp.Value)
+ }
+ }
+ }
+
+ ctx.Data["Groups"] = util.Sorted(groups.Values())
+ ctx.Data["Architectures"] = util.Sorted(architectures.Values())
+ }
+
+ var (
+ total int64
+ pvs []*packages_model.PackageVersion
+ err error
+ )
+ switch pd.Package.Type {
+ case packages_model.TypeContainer:
+ pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
+ Paginator: db.NewAbsoluteListOptions(0, 5),
+ PackageID: pd.Package.ID,
+ IsTagged: true,
+ })
+ default:
+ pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ Paginator: db.NewAbsoluteListOptions(0, 5),
+ PackageID: pd.Package.ID,
+ IsInternal: optional.Some(false),
+ })
+ }
+ if err != nil {
+ ctx.ServerError("", err)
+ return
+ }
+
+ ctx.Data["LatestVersions"] = pvs
+ ctx.Data["TotalVersionCount"] = total
+
+ ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
+
+ hasRepositoryAccess := false
+ if pd.Repository != nil {
+ permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+ hasRepositoryAccess = permission.HasAccess()
+ }
+ ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess
+
+ err = shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplPackagesView)
+}
+
+// ListPackageVersions lists all versions of a package
+func ListPackageVersions(ctx *context.Context) {
+ shared_user.PrepareContextForProfileBigAvatar(ctx)
+ p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.Params("type")), ctx.Params("name"))
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ ctx.NotFound("GetPackageByName", err)
+ } else {
+ ctx.ServerError("GetPackageByName", err)
+ }
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ pagination := &db.ListOptions{
+ PageSize: setting.UI.PackagesPagingNum,
+ Page: page,
+ }
+
+ query := ctx.FormTrim("q")
+ sort := ctx.FormTrim("sort")
+
+ shared_user.RenderUserHeader(ctx)
+
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["IsPackagesPage"] = true
+ ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{
+ Package: p,
+ Owner: ctx.Package.Owner,
+ }
+ ctx.Data["Query"] = query
+ ctx.Data["Sort"] = sort
+
+ pagerParams := map[string]string{
+ "q": query,
+ "sort": sort,
+ }
+
+ var (
+ total int64
+ pvs []*packages_model.PackageVersion
+ )
+ switch p.Type {
+ case packages_model.TypeContainer:
+ tagged := ctx.FormTrim("tagged")
+
+ pagerParams["tagged"] = tagged
+ ctx.Data["Tagged"] = tagged
+
+ pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
+ Paginator: pagination,
+ PackageID: p.ID,
+ Query: query,
+ IsTagged: tagged == "" || tagged == "tagged",
+ Sort: sort,
+ })
+ if err != nil {
+ ctx.ServerError("SearchImageTags", err)
+ return
+ }
+ default:
+ pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ Paginator: pagination,
+ PackageID: p.ID,
+ Version: packages_model.SearchValue{
+ ExactMatch: false,
+ Value: query,
+ },
+ IsInternal: optional.Some(false),
+ Sort: sort,
+ })
+ if err != nil {
+ ctx.ServerError("SearchVersions", err)
+ return
+ }
+ }
+
+ ctx.Data["PackageDescriptors"], err = packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ ctx.ServerError("GetPackageDescriptors", err)
+ return
+ }
+
+ ctx.Data["Total"] = total
+
+ err = shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
+ for k, v := range pagerParams {
+ pager.AddParamString(k, v)
+ }
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplPackageVersionList)
+}
+
+// PackageSettings displays the package settings page
+func PackageSettings(ctx *context.Context) {
+ pd := ctx.Package.Descriptor
+
+ shared_user.RenderUserHeader(ctx)
+
+ ctx.Data["Title"] = pd.Package.Name
+ ctx.Data["IsPackagesPage"] = true
+ ctx.Data["PackageDescriptor"] = pd
+
+ repos, _, _ := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{
+ Actor: pd.Owner,
+ Private: true,
+ })
+ ctx.Data["Repos"] = repos
+ ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
+
+ err := shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplPackagesSettings)
+}
+
+// PackageSettingsPost updates the package settings
+func PackageSettingsPost(ctx *context.Context) {
+ pd := ctx.Package.Descriptor
+
+ form := web.GetForm(ctx).(*forms.PackageSettingForm)
+ switch form.Action {
+ case "link":
+ success := func() bool {
+ repoID := int64(0)
+ if form.RepoID != 0 {
+ repo, err := repo_model.GetRepositoryByID(ctx, form.RepoID)
+ if err != nil {
+ log.Error("Error getting repository: %v", err)
+ return false
+ }
+
+ if repo.OwnerID != pd.Owner.ID {
+ return false
+ }
+
+ repoID = repo.ID
+ }
+
+ if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, repoID); err != nil {
+ log.Error("Error updating package: %v", err)
+ return false
+ }
+
+ return true
+ }()
+
+ if success {
+ ctx.Flash.Success(ctx.Tr("packages.settings.link.success"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("packages.settings.link.error"))
+ }
+
+ ctx.Redirect(ctx.Link)
+ return
+ case "delete":
+ err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version)
+ if err != nil {
+ log.Error("Error deleting package: %v", err)
+ ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
+ } else {
+ ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
+ }
+
+ redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
+ // redirect to the package if there are still versions available
+ if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has {
+ redirectURL = ctx.Package.Descriptor.PackageWebLink()
+ }
+
+ ctx.Redirect(redirectURL)
+ return
+ }
+}
+
+// DownloadPackageFile serves the content of a package file
+func DownloadPackageFile(ctx *context.Context) {
+ pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.ParamsInt64(":fileid"))
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ ctx.NotFound("", err)
+ } else {
+ ctx.ServerError("GetFileForVersionByID", err)
+ }
+ return
+ }
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ ctx.ServerError("GetPackageFileStream", err)
+ return
+ }
+
+ packages_helper.ServePackageFile(ctx, s, u, pf)
+}
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
new file mode 100644
index 0000000..9cb392d
--- /dev/null
+++ b/routers/web/user/profile.go
@@ -0,0 +1,385 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "path"
+ "strings"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/web/feed"
+ "code.gitea.io/gitea/routers/web/org"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+const (
+ tplProfileBigAvatar base.TplName = "shared/user/profile_big_avatar"
+ tplFollowUnfollow base.TplName = "org/follow_unfollow"
+)
+
+// OwnerProfile render profile page for a user or a organization (aka, repo owner)
+func OwnerProfile(ctx *context.Context) {
+ if strings.Contains(ctx.Req.Header.Get("Accept"), "application/rss+xml") {
+ feed.ShowUserFeedRSS(ctx)
+ return
+ }
+ if strings.Contains(ctx.Req.Header.Get("Accept"), "application/atom+xml") {
+ feed.ShowUserFeedAtom(ctx)
+ return
+ }
+
+ if ctx.ContextUser.IsOrganization() {
+ org.Home(ctx)
+ } else {
+ userProfile(ctx)
+ }
+}
+
+func userProfile(ctx *context.Context) {
+ // check view permissions
+ if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
+ ctx.NotFound("User not visible", nil)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.ContextUser.DisplayName()
+ ctx.Data["PageIsUserProfile"] = true
+
+ // prepare heatmap data
+ if setting.Service.EnableUserHeatmap {
+ data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserHeatmapDataByUser", err)
+ return
+ }
+ ctx.Data["HeatmapData"] = data
+ ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data)
+ }
+
+ profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer)
+ defer profileClose()
+
+ showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
+ prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileGitRepo, profileReadmeBlob)
+ // call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing
+ shared_user.PrepareContextForProfileBigAvatar(ctx)
+ ctx.HTML(http.StatusOK, tplProfile)
+}
+
+func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadme *git.Blob) {
+ // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page
+ // if there is not a profile readme, the overview tab should be treated as the repositories tab
+ tab := ctx.FormString("tab")
+ if tab == "" || tab == "overview" {
+ if profileReadme != nil {
+ tab = "overview"
+ } else {
+ tab = "repositories"
+ }
+ }
+ ctx.Data["TabName"] = tab
+ ctx.Data["HasProfileReadme"] = profileReadme != nil
+
+ page := ctx.FormInt("page")
+ if page <= 0 {
+ page = 1
+ }
+
+ pagingNum := setting.UI.User.RepoPagingNum
+ topicOnly := ctx.FormBool("topic")
+ var (
+ repos []*repo_model.Repository
+ count int64
+ total int
+ orderBy db.SearchOrderBy
+ )
+
+ sortOrder := ctx.FormString("sort")
+ if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok {
+ sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for user home?
+ }
+ ctx.Data["SortType"] = sortOrder
+ orderBy = repo_model.OrderByFlatMap[sortOrder]
+
+ keyword := ctx.FormTrim("q")
+ ctx.Data["Keyword"] = keyword
+
+ language := ctx.FormTrim("language")
+ ctx.Data["Language"] = language
+
+ followers, numFollowers, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ })
+ if err != nil {
+ ctx.ServerError("GetUserFollowers", err)
+ return
+ }
+ ctx.Data["NumFollowers"] = numFollowers
+ following, numFollowing, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ })
+ if err != nil {
+ ctx.ServerError("GetUserFollowing", err)
+ return
+ }
+ ctx.Data["NumFollowing"] = numFollowing
+
+ archived := ctx.FormOptionalBool("archived")
+ ctx.Data["IsArchived"] = archived
+
+ fork := ctx.FormOptionalBool("fork")
+ ctx.Data["IsFork"] = fork
+
+ mirror := ctx.FormOptionalBool("mirror")
+ ctx.Data["IsMirror"] = mirror
+
+ template := ctx.FormOptionalBool("template")
+ ctx.Data["IsTemplate"] = template
+
+ private := ctx.FormOptionalBool("private")
+ ctx.Data["IsPrivate"] = private
+
+ switch tab {
+ case "followers":
+ ctx.Data["Cards"] = followers
+ total = int(numFollowers)
+ ctx.Data["CardsTitle"] = ctx.TrN(total, "user.followers.title.one", "user.followers.title.few")
+ case "following":
+ ctx.Data["Cards"] = following
+ total = int(numFollowing)
+ ctx.Data["CardsTitle"] = ctx.TrN(total, "user.following.title.one", "user.following.title.few")
+ case "activity":
+ date := ctx.FormString("date")
+ pagingNum = setting.UI.FeedPagingNum
+ items, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+ RequestedUser: ctx.ContextUser,
+ Actor: ctx.Doer,
+ IncludePrivate: showPrivate,
+ OnlyPerformedBy: true,
+ IncludeDeleted: false,
+ Date: date,
+ ListOptions: db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ },
+ })
+ if err != nil {
+ ctx.ServerError("GetFeeds", err)
+ return
+ }
+ ctx.Data["Feeds"] = items
+ ctx.Data["Date"] = date
+
+ total = int(count)
+ case "stars":
+ ctx.Data["PageIsProfileStarList"] = true
+ repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
+ ListOptions: db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ },
+ Actor: ctx.Doer,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ StarredByID: ctx.ContextUser.ID,
+ Collaborate: optional.Some(false),
+ TopicOnly: topicOnly,
+ Language: language,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ Archived: archived,
+ Fork: fork,
+ Mirror: mirror,
+ Template: template,
+ IsPrivate: private,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ case "watching":
+ repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
+ ListOptions: db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ },
+ Actor: ctx.Doer,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ WatchedByID: ctx.ContextUser.ID,
+ Collaborate: optional.Some(false),
+ TopicOnly: topicOnly,
+ Language: language,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ Archived: archived,
+ Fork: fork,
+ Mirror: mirror,
+ Template: template,
+ IsPrivate: private,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ case "overview":
+ if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
+ log.Error("failed to GetBlobContent: %v", err)
+ } else {
+ if profileContent, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: ctx,
+ GitRepo: profileGitRepo,
+ Links: markup.Links{
+ // Give the repo link to the markdown render for the full link of media element.
+ // the media link usually be like /[user]/[repoName]/media/branch/[branchName],
+ // Eg. /Tom/.profile/media/branch/main
+ // The branch shown on the profile page is the default branch, this need to be in sync with doc, see:
+ // https://docs.gitea.com/usage/profile-readme
+ Base: profileDbRepo.Link(),
+ BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
+ },
+ Metas: map[string]string{"mode": "document"},
+ }, bytes); err != nil {
+ log.Error("failed to RenderString: %v", err)
+ } else {
+ ctx.Data["ProfileReadme"] = profileContent
+ }
+ }
+ default: // default to "repositories"
+ repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
+ ListOptions: db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ },
+ Actor: ctx.Doer,
+ Keyword: keyword,
+ OwnerID: ctx.ContextUser.ID,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ Collaborate: optional.Some(false),
+ TopicOnly: topicOnly,
+ Language: language,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ Archived: archived,
+ Fork: fork,
+ Mirror: mirror,
+ Template: template,
+ IsPrivate: private,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ }
+ ctx.Data["Repos"] = repos
+ ctx.Data["Total"] = total
+
+ err = shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+
+ pager := context.NewPagination(total, pagingNum, page, 5)
+ pager.SetDefaultParams(ctx)
+ pager.AddParam(ctx, "tab", "TabName")
+ if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" {
+ pager.AddParam(ctx, "language", "Language")
+ }
+ if tab == "activity" {
+ pager.AddParam(ctx, "date", "Date")
+ }
+ if archived.Has() {
+ pager.AddParamString("archived", fmt.Sprint(archived.Value()))
+ }
+ if fork.Has() {
+ pager.AddParamString("fork", fmt.Sprint(fork.Value()))
+ }
+ if mirror.Has() {
+ pager.AddParamString("mirror", fmt.Sprint(mirror.Value()))
+ }
+ if template.Has() {
+ pager.AddParamString("template", fmt.Sprint(template.Value()))
+ }
+ if private.Has() {
+ pager.AddParamString("private", fmt.Sprint(private.Value()))
+ }
+ ctx.Data["Page"] = pager
+}
+
+// Action response for follow/unfollow user request
+func Action(ctx *context.Context) {
+ var err error
+ action := ctx.FormString("action")
+
+ if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") {
+ log.Error("Cannot perform this action on an organization %q", ctx.FormString("action"))
+ ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+ return
+ }
+
+ switch action {
+ case "follow":
+ err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+ case "unfollow":
+ err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+ case "block":
+ err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+ case "unblock":
+ err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+ }
+
+ if err != nil {
+ if !errors.Is(err, user_model.ErrBlockedByUser) {
+ log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
+ ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+ return
+ }
+
+ if ctx.ContextUser.IsOrganization() {
+ ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true)
+ } else {
+ ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"), true)
+ }
+ }
+
+ if ctx.ContextUser.IsIndividual() {
+ shared_user.PrepareContextForProfileBigAvatar(ctx)
+ ctx.Data["IsHTMX"] = true
+ ctx.HTML(http.StatusOK, tplProfileBigAvatar)
+ return
+ } else if ctx.ContextUser.IsOrganization() {
+ ctx.Data["Org"] = ctx.ContextUser
+ ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+ ctx.HTML(http.StatusOK, tplFollowUnfollow)
+ return
+ }
+ log.Error("Failed to apply action %q: unsupported context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type)
+ ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+}
diff --git a/routers/web/user/search.go b/routers/web/user/search.go
new file mode 100644
index 0000000..fb7729b
--- /dev/null
+++ b/routers/web/user/search.go
@@ -0,0 +1,44 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// Search search users
+func Search(ctx *context.Context) {
+ listOptions := db.ListOptions{
+ Page: ctx.FormInt("page"),
+ PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
+ }
+
+ users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{
+ Actor: ctx.Doer,
+ Keyword: ctx.FormTrim("q"),
+ UID: ctx.FormInt64("uid"),
+ Type: user_model.UserTypeIndividual,
+ IsActive: ctx.FormOptionalBool("active"),
+ ListOptions: listOptions,
+ })
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, map[string]any{
+ "ok": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ ctx.SetTotalCountHeader(maxResults)
+
+ ctx.JSON(http.StatusOK, map[string]any{
+ "ok": true,
+ "data": convert.ToUsers(ctx, ctx.Doer, users),
+ })
+}
diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go
new file mode 100644
index 0000000..3a2527c
--- /dev/null
+++ b/routers/web/user/setting/account.go
@@ -0,0 +1,344 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/db"
+ "code.gitea.io/gitea/services/auth/source/smtp"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+ "code.gitea.io/gitea/services/user"
+)
+
+const (
+ tplSettingsAccount base.TplName = "user/settings/account"
+)
+
+// Account renders change user's password, user's email and user suicide page
+func Account(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.account")
+ ctx.Data["PageIsSettingsAccount"] = true
+ ctx.Data["Email"] = ctx.Doer.Email
+ ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail
+
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsAccount)
+}
+
+// AccountPost response for change user's password
+func AccountPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.ChangePasswordForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ if ctx.HasError() {
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsAccount)
+ return
+ }
+
+ if ctx.Doer.IsPasswordSet() && !ctx.Doer.ValidatePassword(form.OldPassword) {
+ ctx.Flash.Error(ctx.Tr("settings.password_incorrect"))
+ } else if form.Password != form.Retype {
+ ctx.Flash.Error(ctx.Tr("form.password_not_match"))
+ } else {
+ opts := &user.UpdateAuthOptions{
+ Password: optional.Some(form.Password),
+ MustChangePassword: optional.Some(false),
+ }
+ if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil {
+ switch {
+ case errors.Is(err, password.ErrMinLength):
+ ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength))
+ case errors.Is(err, password.ErrComplexity):
+ ctx.Flash.Error(password.BuildComplexityError(ctx.Locale))
+ case errors.Is(err, password.ErrIsPwned):
+ ctx.Flash.Error(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"))
+ case password.IsErrIsPwnedRequest(err):
+ ctx.Flash.Error(ctx.Tr("auth.password_pwned_err"))
+ default:
+ ctx.ServerError("UpdateAuth", err)
+ return
+ }
+ } else {
+ // Re-generate LTA cookie.
+ if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 {
+ if err := ctx.SetLTACookie(ctx.Doer); err != nil {
+ ctx.ServerError("SetLTACookie", err)
+ return
+ }
+ }
+
+ log.Trace("User password updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
+ }
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// EmailPost response for change user's email
+func EmailPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddEmailForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ // Make emailaddress primary.
+ if ctx.FormString("_method") == "PRIMARY" {
+ id := ctx.FormInt64("id")
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
+ if err != nil {
+ log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil {
+ ctx.ServerError("MakeEmailPrimary", err)
+ return
+ }
+
+ log.Trace("Email made primary: %s", ctx.Doer.Name)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Send activation Email
+ if ctx.FormString("_method") == "SENDACTIVATION" {
+ var address string
+ if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) {
+ log.Error("Send activation: activation still pending")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ id := ctx.FormInt64("id")
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
+ if err != nil {
+ log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email == nil {
+ log.Warn("Send activation failed: EmailAddress[%d] not found for user: %-v", id, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email.IsActivated {
+ log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email.IsPrimary {
+ if ctx.Doer.IsActive && !setting.Service.RegisterEmailConfirm {
+ log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Only fired when the primary email is inactive (Wrong state)
+ mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
+ } else {
+ mailer.SendActivateEmailMail(ctx.Doer, email.Email)
+ }
+ address = email.Email
+
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+
+ ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Set Email Notification Preference
+ if ctx.FormString("_method") == "NOTIFICATION" {
+ preference := ctx.FormString("preference")
+ if !(preference == user_model.EmailNotificationsEnabled ||
+ preference == user_model.EmailNotificationsOnMention ||
+ preference == user_model.EmailNotificationsDisabled ||
+ preference == user_model.EmailNotificationsAndYourOwn) {
+ log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name)
+ ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
+ return
+ }
+ opts := &user.UpdateOptions{
+ EmailNotificationsPreference: optional.Some(preference),
+ }
+ if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ log.Error("Set Email Notifications failed: %v", err)
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if ctx.HasError() {
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsAccount)
+ return
+ }
+
+ if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil {
+ if user_model.IsErrEmailAlreadyUsed(err) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form)
+ } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form)
+ } else {
+ ctx.ServerError("AddEmailAddresses", err)
+ }
+ return
+ }
+
+ // Send confirmation email
+ if setting.Service.RegisterEmailConfirm {
+ mailer.SendActivateEmailMail(ctx.Doer, form.Email)
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+
+ ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)))
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
+ }
+
+ log.Trace("Email address added: %s", form.Email)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// DeleteEmail response for delete user's email
+func DeleteEmail(ctx *context.Context) {
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id"))
+ if err != nil || email == nil {
+ ctx.ServerError("GetEmailAddressByID", err)
+ return
+ }
+
+ if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil {
+ ctx.ServerError("DeleteEmailAddresses", err)
+ return
+ }
+ log.Trace("Email address deleted: %s", ctx.Doer.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.email_deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// DeleteAccount render user suicide page and response for delete user himself
+func DeleteAccount(ctx *context.Context) {
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil {
+ switch {
+ case user_model.IsErrUserNotExist(err):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil)
+ case errors.Is(err, smtp.ErrUnsupportedLoginType):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil)
+ case errors.As(err, &db.ErrUserPasswordNotSet{}):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.unset_password"), tplSettingsAccount, nil)
+ case errors.As(err, &db.ErrUserPasswordInvalid{}):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil)
+ default:
+ ctx.ServerError("UserSignIn", err)
+ }
+ return
+ }
+
+ // admin should not delete themself
+ if ctx.Doer.IsAdmin {
+ ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil {
+ switch {
+ case models.IsErrUserOwnRepos(err):
+ ctx.Flash.Error(ctx.Tr("form.still_own_repo"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ case models.IsErrUserHasOrgs(err):
+ ctx.Flash.Error(ctx.Tr("form.still_has_org"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ case models.IsErrUserOwnPackages(err):
+ ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ case models.IsErrDeleteLastAdminUser(err):
+ ctx.Flash.Error(ctx.Tr("auth.last_admin"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ default:
+ ctx.ServerError("DeleteUser", err)
+ }
+ } else {
+ log.Trace("Account deleted: %s", ctx.Doer.Name)
+ ctx.Redirect(setting.AppSubURL + "/")
+ }
+}
+
+func loadAccountData(ctx *context.Context) {
+ emlist, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetEmailAddresses", err)
+ return
+ }
+ type UserEmail struct {
+ user_model.EmailAddress
+ CanBePrimary bool
+ }
+ pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName)
+ emails := make([]*UserEmail, len(emlist))
+ for i, em := range emlist {
+ var email UserEmail
+ email.EmailAddress = *em
+ email.CanBePrimary = em.IsActivated
+ emails[i] = &email
+ }
+ ctx.Data["Emails"] = emails
+ ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference
+ ctx.Data["ActivationsPending"] = pendingActivation
+ ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm
+ ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
+
+ if setting.Service.UserDeleteWithCommentsMaxTime != 0 {
+ ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String()
+ ctx.Data["UserDeleteWithComments"] = ctx.Doer.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now())
+ }
+}
diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go
new file mode 100644
index 0000000..9fdc5e4
--- /dev/null
+++ b/routers/web/user/setting/account_test.go
@@ -0,0 +1,101 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/contexttest"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChangePassword(t *testing.T) {
+ oldPassword := "password"
+ setting.MinPasswordLength = 6
+ pcALL := []string{"lower", "upper", "digit", "spec"}
+ pcLUN := []string{"lower", "upper", "digit"}
+ pcLU := []string{"lower", "upper"}
+
+ for _, req := range []struct {
+ OldPassword string
+ NewPassword string
+ Retype string
+ Message string
+ PasswordComplexity []string
+ }{
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty123456-",
+ Retype: "Qwerty123456-",
+ Message: "",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "12345",
+ Retype: "12345",
+ Message: "auth.password_too_short",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: "12334",
+ NewPassword: "123456",
+ Retype: "123456",
+ Message: "settings.password_incorrect",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "123456",
+ Retype: "12345",
+ Message: "form.password_not_match",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty",
+ Retype: "Qwerty",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty",
+ Retype: "Qwerty",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcLUN,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "QWERTY",
+ Retype: "QWERTY",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcLU,
+ },
+ } {
+ t.Run(req.OldPassword+"__"+req.NewPassword, func(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ setting.PasswordComplexity = req.PasswordComplexity
+ ctx, _ := contexttest.MockContext(t, "user/settings/security")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadRepo(t, ctx, 1)
+
+ web.SetForm(ctx, &forms.ChangePasswordForm{
+ OldPassword: req.OldPassword,
+ Password: req.NewPassword,
+ Retype: req.Retype,
+ })
+ AccountPost(ctx)
+
+ assert.Contains(t, ctx.Flash.ErrorMsg, req.Message)
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ })
+ }
+}
diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go
new file mode 100644
index 0000000..171c193
--- /dev/null
+++ b/routers/web/user/setting/adopt.go
@@ -0,0 +1,64 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// AdoptOrDeleteRepository adopts or deletes a repository
+func AdoptOrDeleteRepository(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.adopt")
+ ctx.Data["PageIsSettingsRepos"] = true
+ allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
+ ctx.Data["allowAdopt"] = allowAdopt
+ allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
+ ctx.Data["allowDelete"] = allowDelete
+
+ dir := ctx.FormString("id")
+ action := ctx.FormString("action")
+
+ ctxUser := ctx.Doer
+ root := user_model.UserPath(ctxUser.LowerName)
+
+ // check not a repo
+ has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir)
+ if err != nil {
+ ctx.ServerError("IsRepositoryExist", err)
+ return
+ }
+
+ isDir, err := util.IsDir(filepath.Join(root, dir+".git"))
+ if err != nil {
+ ctx.ServerError("IsDir", err)
+ return
+ }
+ if has || !isDir {
+ // Fallthrough to failure mode
+ } else if action == "adopt" && allowAdopt {
+ if _, err := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_service.CreateRepoOptions{
+ Name: dir,
+ IsPrivate: true,
+ }); err != nil {
+ ctx.ServerError("repository.AdoptRepository", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
+ } else if action == "delete" && allowDelete {
+ if err := repo_service.DeleteUnadoptedRepository(ctx, ctxUser, ctxUser, dir); err != nil {
+ ctx.ServerError("repository.AdoptRepository", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir))
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/repos")
+}
diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go
new file mode 100644
index 0000000..24ebf9b
--- /dev/null
+++ b/routers/web/user/setting/applications.go
@@ -0,0 +1,115 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplSettingsApplications base.TplName = "user/settings/applications"
+)
+
+// Applications render manage access token page
+func Applications(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.applications")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+}
+
+// ApplicationsPost response for add user's access token
+func ApplicationsPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.NewAccessTokenForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ if ctx.HasError() {
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+ return
+ }
+
+ scope, err := form.GetScope()
+ if err != nil {
+ ctx.ServerError("GetScope", err)
+ return
+ }
+ t := &auth_model.AccessToken{
+ UID: ctx.Doer.ID,
+ Name: form.Name,
+ Scope: scope,
+ }
+
+ exist, err := auth_model.AccessTokenByNameExists(ctx, t)
+ if err != nil {
+ ctx.ServerError("AccessTokenByNameExists", err)
+ return
+ }
+ if exist {
+ ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
+ return
+ }
+
+ if err := auth_model.NewAccessToken(ctx, t); err != nil {
+ ctx.ServerError("NewAccessToken", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
+ ctx.Flash.Info(t.Token)
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
+}
+
+// DeleteApplication response for delete user access token
+func DeleteApplication(ctx *context.Context) {
+ if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
+ ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
+}
+
+func loadApplicationsData(ctx *context.Context) {
+ ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
+ tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListAccessTokens", err)
+ return
+ }
+ ctx.Data["Tokens"] = tokens
+ ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled
+ ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin
+ if setting.OAuth2.Enabled {
+ ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{
+ OwnerID: ctx.Doer.ID,
+ })
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationsByUserID", err)
+ return
+ }
+ ctx.Data["Grants"], err = auth_model.GetOAuth2GrantsByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2GrantsByUserID", err)
+ return
+ }
+ ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
+ }
+}
diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go
new file mode 100644
index 0000000..3f35b2e
--- /dev/null
+++ b/routers/web/user/setting/blocked_users.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users"
+)
+
+// BlockedUsers render the blocked users list page.
+func BlockedUsers(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+ ctx.Data["PageIsBlockedUsers"] = true
+ ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users"
+ ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users"
+
+ blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListBlockedUsers", err)
+ return
+ }
+
+ ctx.Data["BlockedUsers"] = blockedUsers
+ ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
+}
+
+// UnblockUser unblocks a particular user for the doer.
+func UnblockUser(ctx *context.Context) {
+ if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
+ ctx.ServerError("UnblockUser", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
+}
diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go
new file mode 100644
index 0000000..9462be7
--- /dev/null
+++ b/routers/web/user/setting/keys.go
@@ -0,0 +1,338 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/http"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplSettingsKeys base.TplName = "user/settings/keys"
+)
+
+// Keys render user's SSH/GPG public keys page
+func Keys(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.ssh_gpg_keys")
+ ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
+
+ loadKeysData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsKeys)
+}
+
+// KeysPost response for change user's SSH/GPG keys
+func KeysPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddKeyForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
+
+ if ctx.HasError() {
+ loadKeysData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsKeys)
+ return
+ }
+ switch form.Type {
+ case "principal":
+ content, err := asymkey_model.CheckPrincipalKeyString(ctx, ctx.Doer, form.Content)
+ if err != nil {
+ if db.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error()))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if _, err = asymkey_model.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil {
+ ctx.Data["HasPrincipalError"] = true
+ switch {
+ case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("AddPrincipalKey", err)
+ }
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "gpg":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
+ return
+ }
+
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ keys, err := asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, token, form.Signature)
+ if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) {
+ keys, err = asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasGPGError"] = true
+ switch {
+ case asymkey_model.IsErrGPGKeyParsing(err):
+ ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error()))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case asymkey_model.IsErrGPGKeyIDAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrGPGInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["Err_Content"] = true
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrGPGNoEmailFound(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGNoEmailFound).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("AddPublicKey", err)
+ }
+ return
+ }
+ keyIDs := ""
+ for _, key := range keys {
+ keyIDs += key.KeyID
+ keyIDs += ", "
+ }
+ if len(keyIDs) > 0 {
+ keyIDs = keyIDs[:len(keyIDs)-2]
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", keyIDs))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "verify_gpg":
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ keyID, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature)
+ if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) {
+ keyID, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasGPGVerifyError"] = true
+ switch {
+ case asymkey_model.IsErrGPGInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["VerifyingID"] = form.KeyID
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("VerifyGPG", err)
+ }
+ }
+ ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ content, err := asymkey_model.CheckPublicKeyString(form.Content)
+ if err != nil {
+ if db.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else if asymkey_model.IsErrKeyUnableVerify(err) {
+ ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key"))
+ } else if err == asymkey_model.ErrKeyIsPrivate {
+ ctx.Flash.Error(ctx.Tr("form.must_use_public_key"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error()))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+
+ if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil {
+ ctx.Data["HasSSHError"] = true
+ switch {
+ case asymkey_model.IsErrKeyAlreadyExist(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Title"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrKeyUnableVerify(err):
+ ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ default:
+ ctx.ServerError("AddPublicKey", err)
+ }
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "verify_ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ fingerprint, err := asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, token, form.Signature)
+ if err != nil && asymkey_model.IsErrSSHInvalidTokenSignature(err) {
+ fingerprint, err = asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasSSHVerifyError"] = true
+ switch {
+ case asymkey_model.IsErrSSHInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["Err_Signature"] = true
+ ctx.Data["Fingerprint"] = err.(asymkey_model.ErrSSHInvalidTokenSignature).Fingerprint
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_invalid_token_signature"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("VerifySSH", err)
+ }
+ }
+ ctx.Flash.Success(ctx.Tr("settings.verify_ssh_key_success", fingerprint))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+
+ default:
+ ctx.Flash.Warning("Function not implemented")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ }
+}
+
+// DeleteKey response for delete user's SSH/GPG key
+func DeleteKey(ctx *context.Context) {
+ switch ctx.FormString("type") {
+ case "gpg":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
+ return
+ }
+ if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteGPGKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
+ }
+ case "ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ keyID := ctx.FormInt64("id")
+ external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID)
+ if err != nil {
+ ctx.ServerError("sshKeysExternalManaged", err)
+ return
+ }
+ if external {
+ ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil {
+ ctx.Flash.Error("DeletePublicKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
+ }
+ case "principal":
+ if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeletePublicKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
+ }
+ default:
+ ctx.Flash.Warning("Function not implemented")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ }
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/keys")
+}
+
+func loadKeysData(ctx *context.Context) {
+ keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ OwnerID: ctx.Doer.ID,
+ NotKeytype: asymkey_model.KeyTypePrincipal,
+ })
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["Keys"] = keys
+
+ externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(ctx, keys)
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["ExternalKeys"] = externalKeys
+
+ gpgkeys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: ctx.Doer.ID,
+ })
+ if err != nil {
+ ctx.ServerError("ListGPGKeys", err)
+ return
+ }
+ if err := asymkey_model.GPGKeyList(gpgkeys).LoadSubKeys(ctx); err != nil {
+ ctx.ServerError("LoadSubKeys", err)
+ return
+ }
+ ctx.Data["GPGKeys"] = gpgkeys
+ tokenToSign := asymkey_model.VerificationToken(ctx.Doer, 1)
+
+ // generate a new aes cipher using the csrfToken
+ ctx.Data["TokenToSign"] = tokenToSign
+
+ principals, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: ctx.Doer.ID,
+ KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypePrincipal},
+ })
+ if err != nil {
+ ctx.ServerError("ListPrincipalKeys", err)
+ return
+ }
+ ctx.Data["Principals"] = principals
+
+ ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg")
+ ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh")
+ ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
+}
diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go
new file mode 100644
index 0000000..e398208
--- /dev/null
+++ b/routers/web/user/setting/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go
new file mode 100644
index 0000000..1f485e0
--- /dev/null
+++ b/routers/web/user/setting/oauth2.go
@@ -0,0 +1,68 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsOAuthApplicationEdit base.TplName = "user/settings/applications_oauth2_edit"
+)
+
+func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers {
+ return &OAuth2CommonHandlers{
+ OwnerID: userID,
+ BasePathList: setting.AppSubURL + "/user/settings/applications",
+ BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2",
+ TplAppEdit: tplSettingsOAuthApplicationEdit,
+ }
+}
+
+// OAuthApplicationsPost response for adding a oauth2 application
+func OAuthApplicationsPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.AddApp(ctx)
+}
+
+// OAuthApplicationsEdit response for editing oauth2 application
+func OAuthApplicationsEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.EditSave(ctx)
+}
+
+// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret
+func OAuthApplicationsRegenerateSecret(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.RegenerateSecret(ctx)
+}
+
+// OAuth2ApplicationShow displays the given application
+func OAuth2ApplicationShow(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.EditShow(ctx)
+}
+
+// DeleteOAuth2Application deletes the given oauth2 application
+func DeleteOAuth2Application(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.DeleteApp(ctx)
+}
+
+// RevokeOAuth2Grant revokes the grant with the given id
+func RevokeOAuth2Grant(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.RevokeGrant(ctx)
+}
diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go
new file mode 100644
index 0000000..85d1e82
--- /dev/null
+++ b/routers/web/user/setting/oauth2_common.go
@@ -0,0 +1,163 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+type OAuth2CommonHandlers struct {
+ OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID
+ BasePathList string // the base URL for the application list page, eg: "/user/setting/applications"
+ BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2"
+ TplAppEdit base.TplName // the template for the application edit page
+}
+
+func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) {
+ app := ctx.Data["App"].(*auth.OAuth2Application)
+ ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID)
+
+ if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() {
+ if err := shared_user.LoadHeaderCount(ctx); err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+ }
+
+ ctx.HTML(http.StatusOK, oa.TplAppEdit)
+}
+
+// AddApp adds an oauth2 application
+func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+ if ctx.HasError() {
+ ctx.Flash.Error(ctx.GetErrMsg())
+ // go to the application list page
+ ctx.Redirect(oa.BasePathList)
+ return
+ }
+
+ // TODO validate redirect URI
+ app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
+ Name: form.Name,
+ RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
+ UserID: oa.OwnerID,
+ ConfidentialClient: form.ConfidentialClient,
+ })
+ if err != nil {
+ ctx.ServerError("CreateOAuth2Application", err)
+ return
+ }
+
+ // render the edit page with secret
+ ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true)
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+
+ oa.renderEditPage(ctx)
+}
+
+// EditShow displays the given application
+func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id"))
+ if err != nil {
+ if auth.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != oa.OwnerID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ oa.renderEditPage(ctx)
+}
+
+// EditSave saves the oauth2 application
+func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+
+ if ctx.HasError() {
+ oa.renderEditPage(ctx)
+ return
+ }
+
+ // TODO validate redirect URI
+ var err error
+ if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
+ ID: ctx.ParamsInt64("id"),
+ Name: form.Name,
+ RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
+ UserID: oa.OwnerID,
+ ConfidentialClient: form.ConfidentialClient,
+ }); err != nil {
+ ctx.ServerError("UpdateOAuth2Application", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
+ ctx.Redirect(oa.BasePathList)
+}
+
+// RegenerateSecret regenerates the secret
+func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id"))
+ if err != nil {
+ if auth.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != oa.OwnerID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true)
+ oa.renderEditPage(ctx)
+}
+
+// DeleteApp deletes the given oauth2 application
+func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) {
+ if err := auth.DeleteOAuth2Application(ctx, ctx.ParamsInt64("id"), oa.OwnerID); err != nil {
+ ctx.ServerError("DeleteOAuth2Application", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success"))
+ ctx.JSONRedirect(oa.BasePathList)
+}
+
+// RevokeGrant revokes the grant
+func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) {
+ if err := auth.RevokeOAuth2Grant(ctx, ctx.ParamsInt64("grantId"), oa.OwnerID); err != nil {
+ ctx.ServerError("RevokeOAuth2Grant", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
+ ctx.JSONRedirect(oa.BasePathList)
+}
diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go
new file mode 100644
index 0000000..4132659
--- /dev/null
+++ b/routers/web/user/setting/packages.go
@@ -0,0 +1,119 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ chef_module "code.gitea.io/gitea/modules/packages/chef"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ shared "code.gitea.io/gitea/routers/web/shared/packages"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsPackages base.TplName = "user/settings/packages"
+ tplSettingsPackagesRuleEdit base.TplName = "user/settings/packages_cleanup_rules_edit"
+ tplSettingsPackagesRulePreview base.TplName = "user/settings/packages_cleanup_rules_preview"
+)
+
+func Packages(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetPackagesContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackages)
+}
+
+func PackagesRuleAdd(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRuleAddContext(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
+}
+
+func PackagesRuleEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRuleEditContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
+}
+
+func PackagesRuleAddPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.PerformRuleAddPost(
+ ctx,
+ ctx.Doer,
+ setting.AppSubURL+"/user/settings/packages",
+ tplSettingsPackagesRuleEdit,
+ )
+}
+
+func PackagesRuleEditPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.PerformRuleEditPost(
+ ctx,
+ ctx.Doer,
+ setting.AppSubURL+"/user/settings/packages",
+ tplSettingsPackagesRuleEdit,
+ )
+}
+
+func PackagesRulePreview(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRulePreviewContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview)
+}
+
+func InitializeCargoIndex(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.InitializeCargoIndex(ctx, ctx.Doer)
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/packages")
+}
+
+func RebuildCargoIndex(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.RebuildCargoIndex(ctx, ctx.Doer)
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/packages")
+}
+
+func RegenerateChefKeyPair(ctx *context.Context) {
+ priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits)
+ if err != nil {
+ ctx.ServerError("GenerateKeyPair", err)
+ return
+ }
+
+ if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil {
+ ctx.ServerError("SetUserSetting", err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{
+ ContentType: "application/x-pem-file",
+ Filename: ctx.Doer.Name + ".priv",
+ })
+}
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
new file mode 100644
index 0000000..907f0f5
--- /dev/null
+++ b/routers/web/user/setting/profile.go
@@ -0,0 +1,433 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/models/avatars"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+const (
+ tplSettingsProfile base.TplName = "user/settings/profile"
+ tplSettingsAppearance base.TplName = "user/settings/appearance"
+ tplSettingsOrganization base.TplName = "user/settings/organization"
+ tplSettingsRepositories base.TplName = "user/settings/repos"
+)
+
+// must be kept in sync with `web_src/js/features/user-settings.js`
+var recognisedPronouns = []string{"", "he/him", "she/her", "they/them", "it/its", "any pronouns"}
+
+// Profile render user's profile page
+func Profile(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.profile")
+ ctx.Data["PageIsSettingsProfile"] = true
+ ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
+ ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+ ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
+
+ ctx.HTML(http.StatusOK, tplSettingsProfile)
+}
+
+// ProfilePost response for change user's profile
+func ProfilePost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsProfile"] = true
+ ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
+ ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+ ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSettingsProfile)
+ return
+ }
+
+ form := web.GetForm(ctx).(*forms.UpdateProfileForm)
+
+ if form.Name != "" {
+ if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil {
+ switch {
+ case user_model.IsErrUserIsNotLocal(err):
+ ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user"))
+ case user_model.IsErrUserAlreadyExist(err):
+ ctx.Flash.Error(ctx.Tr("form.username_been_taken"))
+ case db.IsErrNameReserved(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name))
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name))
+ case db.IsErrNameCharsNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name))
+ default:
+ ctx.ServerError("RenameUser", err)
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+ return
+ }
+ }
+
+ opts := &user_service.UpdateOptions{
+ FullName: optional.Some(form.FullName),
+ KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
+ Description: optional.Some(form.Biography),
+ Pronouns: optional.Some(form.Pronouns),
+ Website: optional.Some(form.Website),
+ Location: optional.Some(form.Location),
+ Visibility: optional.Some(form.Visibility),
+ KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.update_profile_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+}
+
+// UpdateAvatarSetting update user's avatar
+// FIXME: limit size.
+func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *user_model.User) error {
+ ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal
+ if len(form.Gravatar) > 0 {
+ if form.Avatar != nil {
+ ctxUser.Avatar = avatars.HashEmail(form.Gravatar)
+ } else {
+ ctxUser.Avatar = ""
+ }
+ ctxUser.AvatarEmail = form.Gravatar
+ }
+
+ if form.Avatar != nil && form.Avatar.Filename != "" {
+ fr, err := form.Avatar.Open()
+ if err != nil {
+ return fmt.Errorf("Avatar.Open: %w", err)
+ }
+ defer fr.Close()
+
+ if form.Avatar.Size > setting.Avatar.MaxFileSize {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
+ }
+
+ data, err := io.ReadAll(fr)
+ if err != nil {
+ return fmt.Errorf("io.ReadAll: %w", err)
+ }
+
+ st := typesniffer.DetectContentType(data)
+ if !(st.IsImage() && !st.IsSvgImage()) {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
+ }
+ if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil {
+ return fmt.Errorf("UploadAvatar: %w", err)
+ }
+ } else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" {
+ // No avatar is uploaded but setting has been changed to enable,
+ // generate a random one when needed.
+ if err := user_model.GenerateRandomAvatar(ctx, ctxUser); err != nil {
+ log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err)
+ }
+ }
+
+ if err := user_model.UpdateUserCols(ctx, ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil {
+ return fmt.Errorf("UpdateUserCols: %w", err)
+ }
+
+ return nil
+}
+
+// AvatarPost response for change user's avatar request
+func AvatarPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AvatarForm)
+ if err := UpdateAvatarSetting(ctx, form, ctx.Doer); err != nil {
+ ctx.Flash.Error(err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.update_avatar_success"))
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+}
+
+// DeleteAvatar render delete avatar page
+func DeleteAvatar(ctx *context.Context) {
+ if err := user_service.DeleteAvatar(ctx, ctx.Doer); err != nil {
+ ctx.Flash.Error(err.Error())
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
+}
+
+// Organization render all the organization of the user
+func Organization(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.organization")
+ ctx.Data["PageIsSettingsOrganization"] = true
+
+ opts := organization.FindOrgOptions{
+ ListOptions: db.ListOptions{
+ PageSize: setting.UI.Admin.UserPagingNum,
+ Page: ctx.FormInt("page"),
+ },
+ UserID: ctx.Doer.ID,
+ IncludePrivate: ctx.IsSigned,
+ }
+
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+
+ orgs, total, err := db.FindAndCount[organization.Organization](ctx, opts)
+ if err != nil {
+ ctx.ServerError("FindOrgs", err)
+ return
+ }
+
+ ctx.Data["Orgs"] = orgs
+ pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsOrganization)
+}
+
+// Repos display a list of all repositories of the user
+func Repos(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.repos")
+ ctx.Data["PageIsSettingsRepos"] = true
+ ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
+ ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
+
+ opts := db.ListOptions{
+ PageSize: setting.UI.Admin.UserPagingNum,
+ Page: ctx.FormInt("page"),
+ }
+
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+ start := (opts.Page - 1) * opts.PageSize
+ end := start + opts.PageSize
+
+ adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories)
+
+ ctxUser := ctx.Doer
+ count := 0
+
+ if adoptOrDelete {
+ repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum)
+ repos := map[string]*repo_model.Repository{}
+ // We're going to iterate by pagesize.
+ root := user_model.UserPath(ctxUser.Name)
+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if !d.IsDir() || path == root {
+ return nil
+ }
+ name := d.Name()
+ if !strings.HasSuffix(name, ".git") {
+ return filepath.SkipDir
+ }
+ name = name[:len(name)-4]
+ if repo_model.IsUsableRepoName(name) != nil || strings.ToLower(name) != name {
+ return filepath.SkipDir
+ }
+ if count >= start && count < end {
+ repoNames = append(repoNames, name)
+ }
+ count++
+ return filepath.SkipDir
+ }); err != nil {
+ ctx.ServerError("filepath.WalkDir", err)
+ return
+ }
+
+ userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{
+ Actor: ctxUser,
+ Private: true,
+ ListOptions: db.ListOptions{
+ Page: 1,
+ PageSize: setting.UI.Admin.UserPagingNum,
+ },
+ LowerNames: repoNames,
+ })
+ if err != nil {
+ ctx.ServerError("GetUserRepositories", err)
+ return
+ }
+ for _, repo := range userRepos {
+ if repo.IsFork {
+ if err := repo.GetBaseRepo(ctx); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ repos[repo.LowerName] = repo
+ }
+ ctx.Data["Dirs"] = repoNames
+ ctx.Data["ReposMap"] = repos
+ } else {
+ repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts})
+ if err != nil {
+ ctx.ServerError("GetUserRepositories", err)
+ return
+ }
+ count = int(count64)
+
+ for i := range repos {
+ if repos[i].IsFork {
+ if err := repos[i].GetBaseRepo(ctx); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ }
+
+ ctx.Data["Repos"] = repos
+ }
+ ctx.Data["ContextUser"] = ctxUser
+ pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsRepositories)
+}
+
+// Appearance render user's appearance settings
+func Appearance(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.appearance")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ var hiddenCommentTypes *big.Int
+ val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
+ if err != nil {
+ ctx.ServerError("GetUserSetting", err)
+ return
+ }
+ hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
+
+ ctx.Data["IsCommentTypeGroupChecked"] = func(commentTypeGroup string) bool {
+ return forms.IsUserHiddenCommentTypeGroupChecked(commentTypeGroup, hiddenCommentTypes)
+ }
+
+ ctx.HTML(http.StatusOK, tplSettingsAppearance)
+}
+
+// UpdateUIThemePost is used to update users' specific theme
+func UpdateUIThemePost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateThemeForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ if ctx.HasError() {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+
+ if !form.IsThemeExists() {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+
+ opts := &user_service.UpdateOptions{
+ Theme: optional.Some(form.Theme),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.theme_update_success"))
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserLang update a user's language
+func UpdateUserLang(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateLanguageForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ if form.Language != "" {
+ if !util.SliceContainsString(setting.Langs, form.Language) {
+ ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+ }
+
+ opts := &user_service.UpdateOptions{
+ Language: optional.Some(form.Language),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ // Update the language to the one we just set
+ middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0)
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserHints updates a user's hints settings
+func UpdateUserHints(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateHintsForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ opts := &user_service.UpdateOptions{
+ EnableRepoUnitHints: optional.Some(form.EnableRepoUnitHints),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_hints_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserHiddenComments update a user's shown comment types
+func UpdateUserHiddenComments(ctx *context.Context) {
+ err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String())
+ if err != nil {
+ ctx.ServerError("SetUserSetting", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go
new file mode 100644
index 0000000..2bb10cc
--- /dev/null
+++ b/routers/web/user/setting/runner.go
@@ -0,0 +1,13 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+func RedirectToDefaultSetting(ctx *context.Context) {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners")
+}
diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go
new file mode 100644
index 0000000..a145867
--- /dev/null
+++ b/routers/web/user/setting/security/2fa.go
@@ -0,0 +1,260 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "bytes"
+ "encoding/base64"
+ "html/template"
+ "image/png"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+
+ "github.com/pquerna/otp"
+ "github.com/pquerna/otp/totp"
+)
+
+// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code.
+func RegenerateScratchTwoFactor(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ if auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err)
+ return
+ }
+
+ token, err := t.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err)
+ return
+ }
+
+ if err = auth.UpdateTwoFactor(ctx, t); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
+
+// DisableTwoFactor deletes the user's 2FA settings.
+func DisableTwoFactor(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ if auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err)
+ return
+ }
+
+ if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil {
+ if auth.IsErrTwoFactorNotEnrolled(err) {
+ // There is a potential DB race here - we must have been disabled by another request in the intervening period
+ ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err)
+ return
+ }
+
+ if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil {
+ ctx.ServerError("SendDisabledTOTP", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
+
+func twofaGenerateSecretAndQr(ctx *context.Context) bool {
+ var otpKey *otp.Key
+ var err error
+ uri := ctx.Session.Get("twofaUri")
+ if uri != nil {
+ otpKey, err = otp.NewKeyFromURL(uri.(string))
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err)
+ return false
+ }
+ }
+ // Filter unsafe character ':' in issuer
+ issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "")
+ if otpKey == nil {
+ otpKey, err = totp.Generate(totp.GenerateOpts{
+ SecretSize: 40,
+ Issuer: issuer,
+ AccountName: ctx.Doer.Name,
+ })
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err)
+ return false
+ }
+ }
+
+ ctx.Data["TwofaSecret"] = otpKey.Secret()
+ img, err := otpKey.Image(320, 240)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err)
+ return false
+ }
+
+ var imgBytes bytes.Buffer
+ if err = png.Encode(&imgBytes, img); err != nil {
+ ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err)
+ return false
+ }
+
+ ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
+
+ if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err)
+ return false
+ }
+
+ if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err)
+ return false
+ }
+
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ return true
+}
+
+// EnrollTwoFactor shows the page where the user can enroll into 2FA.
+func EnrollTwoFactor(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if t != nil {
+ // already enrolled - we should redirect back!
+ log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer)
+ ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err)
+ return
+ }
+
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll)
+}
+
+// EnrollTwoFactorPost handles enrolling the user into 2FA.
+func EnrollTwoFactorPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if t != nil {
+ // already enrolled
+ ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err)
+ return
+ }
+
+ if ctx.HasError() {
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+ ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll)
+ return
+ }
+
+ secretRaw := ctx.Session.Get("twofaSecret")
+ if secretRaw == nil {
+ ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll")
+ return
+ }
+
+ secret := secretRaw.(string)
+ if !totp.Validate(form.Passcode, secret) {
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+ ctx.Flash.Error(ctx.Tr("settings.passcode_invalid"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll")
+ return
+ }
+
+ t = &auth.TwoFactor{
+ UID: ctx.Doer.ID,
+ }
+ err = t.SetSecret(secret)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set secret", err)
+ return
+ }
+ token, err := t.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err)
+ return
+ }
+
+ // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used
+ // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor
+ if err := ctx.Session.Delete("twofaSecret"); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to delete twofaSecret from the session: Error: %v", err)
+ }
+ if err := ctx.Session.Delete("twofaUri"); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to delete twofaUri from the session: Error: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+
+ if err := mailer.SendTOTPEnrolled(ctx, ctx.Doer); err != nil {
+ ctx.ServerError("SendTOTPEnrolled", err)
+ return
+ }
+
+ if err = auth.NewTwoFactor(ctx, t); err != nil {
+ // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us.
+ // If there is a unique constraint fail we should just tolerate the error
+ ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go
new file mode 100644
index 0000000..8f788e1
--- /dev/null
+++ b/routers/web/user/setting/security/openid.go
@@ -0,0 +1,126 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/openid"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+// OpenIDPost response for change user's openid
+func OpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddOpenIDForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ if ctx.HasError() {
+ loadSecurityData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsSecurity)
+ return
+ }
+
+ // WARNING: specifying a wrong OpenID here could lock
+ // a user out of her account, would be better to
+ // verify/confirm the new OpenID before storing it
+
+ // Also, consider allowing for multiple OpenID URIs
+
+ id, err := openid.Normalize(form.Openid)
+ if err != nil {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form)
+ return
+ }
+ form.Openid = id
+ log.Trace("Normalized id: " + id)
+
+ oids, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+ ctx.Data["OpenIDs"] = oids
+
+ // Check that the OpenID is not already used
+ for _, obj := range oids {
+ if obj.URI == id {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form)
+ return
+ }
+ }
+
+ redirectTo := setting.AppURL + "user/settings/security"
+ url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
+ if err != nil {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form)
+ return
+ }
+ ctx.Redirect(url)
+}
+
+func settingsOpenIDVerify(ctx *context.Context) {
+ log.Trace("Incoming call to: " + ctx.Req.URL.String())
+
+ fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
+ log.Trace("Full URL: " + fullURL)
+
+ id, err := openid.Verify(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.AddOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+
+ log.Trace("Verified ID: " + id)
+
+ oid := &user_model.UserOpenID{UID: ctx.Doer.ID, URI: id}
+ if err = user_model.AddUserOpenID(ctx, oid); err != nil {
+ if user_model.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id})
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+ log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
+
+// DeleteOpenID response for delete user's openid
+func DeleteOpenID(ctx *context.Context) {
+ if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil {
+ ctx.ServerError("DeleteUserOpenID", err)
+ return
+ }
+ log.Trace("OpenID address deleted: %s", ctx.Doer.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
+
+// ToggleOpenIDVisibility response for toggle visibility of user's openid
+func ToggleOpenIDVisibility(ctx *context.Context) {
+ if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil {
+ ctx.ServerError("ToggleUserOpenIDVisibility", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go
new file mode 100644
index 0000000..8d6859a
--- /dev/null
+++ b/routers/web/user/setting/security/security.go
@@ -0,0 +1,148 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "net/http"
+ "sort"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsSecurity base.TplName = "user/settings/security/security"
+ tplSettingsTwofaEnroll base.TplName = "user/settings/security/twofa_enroll"
+)
+
+// Security render change user's password page and 2FA
+func Security(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.security")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ if ctx.FormString("openid.return_to") != "" {
+ settingsOpenIDVerify(ctx)
+ return
+ }
+
+ loadSecurityData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsSecurity)
+}
+
+// DeleteAccountLink delete a single account link
+func DeleteAccountLink(ctx *context.Context) {
+ id := ctx.FormInt64("id")
+ if id <= 0 {
+ ctx.Flash.Error("Account link id is not given")
+ } else {
+ if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil {
+ ctx.Flash.Error("RemoveAccountLink: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success"))
+ }
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
+
+func loadSecurityData(ctx *context.Context) {
+ enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor", err)
+ return
+ }
+ ctx.Data["TOTPEnrolled"] = enrolled
+
+ credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ ctx.Data["WebAuthnCredentials"] = credentials
+
+ tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListAccessTokens", err)
+ return
+ }
+ ctx.Data["Tokens"] = tokens
+
+ accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{
+ UserID: ctx.Doer.ID,
+ OrderBy: "login_source_id DESC",
+ })
+ if err != nil {
+ ctx.ServerError("ListAccountLinks", err)
+ return
+ }
+
+ // map the provider display name with the AuthSource
+ sources := make(map[*auth_model.Source]string)
+ for _, externalAccount := range accountLinks {
+ if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil {
+ var providerDisplayName string
+
+ type DisplayNamed interface {
+ DisplayName() string
+ }
+
+ type Named interface {
+ Name() string
+ }
+
+ if displayNamed, ok := authSource.Cfg.(DisplayNamed); ok {
+ providerDisplayName = displayNamed.DisplayName()
+ } else if named, ok := authSource.Cfg.(Named); ok {
+ providerDisplayName = named.Name()
+ } else {
+ providerDisplayName = authSource.Name
+ }
+ sources[authSource] = providerDisplayName
+ }
+ }
+ ctx.Data["AccountLinks"] = sources
+
+ authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{
+ IsActive: optional.None[bool](),
+ LoginType: auth_model.OAuth2,
+ })
+ if err != nil {
+ ctx.ServerError("FindSources", err)
+ return
+ }
+
+ var orderedOAuth2Names []string
+ oauth2Providers := make(map[string]oauth2.Provider)
+ for _, source := range authSources {
+ provider, err := oauth2.CreateProviderFromSource(source)
+ if err != nil {
+ ctx.ServerError("CreateProviderFromSource", err)
+ return
+ }
+ oauth2Providers[source.Name] = provider
+ if source.IsActive {
+ orderedOAuth2Names = append(orderedOAuth2Names, source.Name)
+ }
+ }
+
+ sort.Strings(orderedOAuth2Names)
+
+ ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+
+ openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+ ctx.Data["OpenIDs"] = openid
+}
diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go
new file mode 100644
index 0000000..bfbc06c
--- /dev/null
+++ b/routers/web/user/setting/security/webauthn.go
@@ -0,0 +1,137 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ wa "code.gitea.io/gitea/modules/auth/webauthn"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// WebAuthnRegister initializes the webauthn registration procedure
+func WebAuthnRegister(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm)
+ if form.Name == "" {
+ // Set name to the hexadecimal of the current time
+ form.Name = strconv.FormatInt(time.Now().UnixNano(), 16)
+ }
+
+ cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name)
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ if cred != nil {
+ ctx.Error(http.StatusConflict, "Name already taken")
+ return
+ }
+
+ _ = ctx.Session.Delete("webauthnRegistration")
+ if err := ctx.Session.Set("webauthnName", form.Name); err != nil {
+ ctx.ServerError("Unable to set session key for webauthnName", err)
+ return
+ }
+
+ credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer))
+ if err != nil {
+ ctx.ServerError("Unable to BeginRegistration", err)
+ return
+ }
+
+ // Save the session data as marshaled JSON
+ if err = ctx.Session.Set("webauthnRegistration", sessionData); err != nil {
+ ctx.ServerError("Unable to set session", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, credentialOptions)
+}
+
+// WebauthnRegisterPost receives the response of the security key
+func WebauthnRegisterPost(ctx *context.Context) {
+ name, ok := ctx.Session.Get("webauthnName").(string)
+ if !ok || name == "" {
+ ctx.ServerError("Get webauthnName", errors.New("no webauthnName"))
+ return
+ }
+
+ // Load the session data
+ sessionData, ok := ctx.Session.Get("webauthnRegistration").(*webauthn.SessionData)
+ if !ok || sessionData == nil {
+ ctx.ServerError("Get registration", errors.New("no registration"))
+ return
+ }
+ defer func() {
+ _ = ctx.Session.Delete("webauthnRegistration")
+ }()
+
+ // Verify that the challenge succeeded
+ cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req)
+ if err != nil {
+ if pErr, ok := err.(*protocol.Error); ok {
+ log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo)
+ }
+ ctx.ServerError("CreateCredential", err)
+ return
+ }
+
+ dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name)
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ if dbCred != nil {
+ ctx.Error(http.StatusConflict, "Name already taken")
+ return
+ }
+
+ // Create the credential
+ _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred)
+ if err != nil {
+ ctx.ServerError("CreateCredential", err)
+ return
+ }
+ _ = ctx.Session.Delete("webauthnName")
+
+ ctx.JSON(http.StatusCreated, cred)
+}
+
+// WebauthnDelete deletes an security key by id
+func WebauthnDelete(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.WebauthnDeleteForm)
+ cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID)
+ if err != nil || cred.UserID != ctx.Doer.ID {
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ log.Error("GetWebAuthnCredentialByID: %v", err)
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
+ ctx.ServerError("GetWebAuthnCredentialByID", err)
+ return
+ }
+
+ if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil {
+ ctx.ServerError("SendRemovedSecurityKey", err)
+ return
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go
new file mode 100644
index 0000000..3cc67d9
--- /dev/null
+++ b/routers/web/user/setting/webhooks.go
@@ -0,0 +1,49 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+)
+
+const (
+ tplSettingsHooks base.TplName = "user/settings/hooks"
+)
+
+// Webhooks render webhook list page
+func Webhooks(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks"
+ ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks"
+ ctx.Data["WebhookList"] = webhook_service.List()
+ ctx.Data["Description"] = ctx.Tr("settings.hooks.desc")
+
+ ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListWebhooksByOpts", err)
+ return
+ }
+
+ ctx.Data["Webhooks"] = ws
+ ctx.HTML(http.StatusOK, tplSettingsHooks)
+}
+
+// DeleteWebhook response for delete webhook
+func DeleteWebhook(ctx *context.Context) {
+ if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/hooks")
+}
diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go
new file mode 100644
index 0000000..38f74ea
--- /dev/null
+++ b/routers/web/user/stop_watch.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+)
+
+// GetStopwatches get all stopwatches
+func GetStopwatches(ctx *context.Context) {
+ sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, db.ListOptions{
+ Page: ctx.FormInt("page"),
+ PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ apiSWs, err := convert.ToStopWatches(ctx, sws)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ ctx.SetTotalCountHeader(count)
+ ctx.JSON(http.StatusOK, apiSWs)
+}
diff --git a/routers/web/user/task.go b/routers/web/user/task.go
new file mode 100644
index 0000000..8476767
--- /dev/null
+++ b/routers/web/user/task.go
@@ -0,0 +1,53 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "net/http"
+ "strconv"
+
+ admin_model "code.gitea.io/gitea/models/admin"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/services/context"
+)
+
+// TaskStatus returns task's status
+func TaskStatus(ctx *context.Context) {
+ task, opts, err := admin_model.GetMigratingTaskByID(ctx, ctx.ParamsInt64("task"), ctx.Doer.ID)
+ if err != nil {
+ if admin_model.IsErrTaskDoesNotExist(err) {
+ ctx.JSON(http.StatusNotFound, map[string]any{
+ "error": "task `" + strconv.FormatInt(ctx.ParamsInt64("task"), 10) + "` does not exist",
+ })
+ return
+ }
+ ctx.JSON(http.StatusInternalServerError, map[string]any{
+ "err": err,
+ })
+ return
+ }
+
+ message := task.Message
+
+ if task.Message != "" && task.Message[0] == '{' {
+ // assume message is actually a translatable string
+ var translatableMessage admin_model.TranslatableMessage
+ if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil {
+ translatableMessage = admin_model.TranslatableMessage{
+ Format: "migrate.migrating_failed.error",
+ Args: []any{task.Message},
+ }
+ }
+ message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...)
+ }
+
+ ctx.JSON(http.StatusOK, map[string]any{
+ "status": task.Status,
+ "message": message,
+ "repo-id": task.RepoID,
+ "repo-name": opts.RepoName,
+ "start": task.StartTime,
+ "end": task.EndTime,
+ })
+}