diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /routers/web/user | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
28 files changed, 5322 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..34d2377 --- /dev/null +++ b/routers/web/user/setting/account.go @@ -0,0 +1,353 @@ +// 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) + if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil { + ctx.ServerError("SendActivateAccountMail", err) + return + } + } else { + if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, email.Email); err != nil { + ctx.ServerError("SendActivateEmailMail", err) + return + } + } + 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 { + if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, form.Email); err != nil { + ctx.ServerError("SendActivateEmailMail", err) + return + } + 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..7c85c0e --- /dev/null +++ b/routers/web/user/setting/security/2fa.go @@ -0,0 +1,263 @@ +// 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") + } else { + 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") + } else { + 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") + } else { + 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, + }) +} |