初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/avatars"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/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(), &httpcache.CacheControlOptions{MaxAge: 5 * time.Minute})
|
||||
ctx.Redirect(location)
|
||||
}
|
||||
|
||||
// AvatarByUsernameSize redirect browser to user avatar of requested size
|
||||
func AvatarByUsernameSize(ctx *context.Context) {
|
||||
username := ctx.PathParam("username")
|
||||
user := user_model.GetSystemUserByName(username)
|
||||
if user == nil {
|
||||
var err error
|
||||
if user, err = user_model.GetUserByName(ctx, username); err != nil {
|
||||
ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, ctx.PathParamInt("size")))
|
||||
}
|
||||
|
||||
// AvatarByEmailHash redirects the browser to the email avatar link
|
||||
func AvatarByEmailHash(ctx *context.Context) {
|
||||
hash := ctx.PathParam("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))
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
code_indexer "gitea.dev/modules/indexer/code"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/routers/common"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplUserCode templates.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
|
||||
}
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
|
||||
ctx.Data["Title"] = ctx.Tr("explore.code")
|
||||
ctx.Data["IsCodePage"] = true
|
||||
|
||||
prepareSearch := common.PrepareCodeSearch(ctx)
|
||||
if prepareSearch.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 int64
|
||||
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: prepareSearch.Keyword,
|
||||
SearchMode: prepareSearch.SearchMode,
|
||||
Language: prepareSearch.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 {
|
||||
if !slices.Contains(loadRepoIDs, result.RepoID) {
|
||||
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.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplUserCode)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func prepareHeatmapURL(ctx *context.Context) {
|
||||
ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap
|
||||
if !setting.Service.EnableUserHeatmap {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Org.Organization == nil {
|
||||
// for individual user
|
||||
ctx.Data["HeatmapURL"] = ctx.Doer.HomeLink() + "/-/heatmap"
|
||||
return
|
||||
}
|
||||
|
||||
// for org or team
|
||||
heatmapURL := ctx.Org.Organization.OrganisationLink() + "/dashboard/-/heatmap"
|
||||
if ctx.Org.Team != nil {
|
||||
heatmapURL += "/" + url.PathEscape(ctx.Org.Team.LowerName)
|
||||
}
|
||||
ctx.Data["HeatmapURL"] = heatmapURL
|
||||
}
|
||||
|
||||
func writeHeatmapJSON(ctx *context.Context, hdata []*activities_model.UserHeatmapData) {
|
||||
data := make([][2]int64, len(hdata))
|
||||
var total int64
|
||||
for i, v := range hdata {
|
||||
data[i] = [2]int64{int64(v.Timestamp), v.Contributions}
|
||||
total += v.Contributions
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"heatmapData": data,
|
||||
"totalContributions": total,
|
||||
})
|
||||
}
|
||||
|
||||
// DashboardHeatmap returns heatmap data as JSON, for the individual user, organization or team dashboard.
|
||||
func DashboardHeatmap(ctx *context.Context) {
|
||||
if !setting.Service.EnableUserHeatmap {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
var data []*activities_model.UserHeatmapData
|
||||
var err error
|
||||
if ctx.Org.Organization == nil {
|
||||
data, err = activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer)
|
||||
} else {
|
||||
data, err = activities_model.GetUserHeatmapDataByOrgTeam(ctx, ctx.Org.Organization, ctx.Org.Team, ctx.Doer)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserHeatmapData", err)
|
||||
return
|
||||
}
|
||||
writeHeatmapJSON(ctx, data)
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
// 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 "gitea.dev/models/activities"
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/indexer"
|
||||
issue_indexer "gitea.dev/modules/indexer/issues"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/routers/web/shared/issue"
|
||||
"gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
feed_service "gitea.dev/services/feed"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
const (
|
||||
tplDashboard templates.TplName = "user/dashboard/dashboard"
|
||||
tplIssues templates.TplName = "user/dashboard/issues"
|
||||
tplMilestones templates.TplName = "user/dashboard/milestones"
|
||||
tplProfile templates.TplName = "user/profile"
|
||||
)
|
||||
|
||||
// prepareDashboardContextUserOrgTeams finds out which context user dashboard is being viewed as .
|
||||
func prepareDashboardContextUserOrgTeams(ctx *context.Context) *user_model.User {
|
||||
ctxUser := ctx.Doer
|
||||
orgName := ctx.PathParam("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 := prepareDashboardContextUserOrgTeams(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,
|
||||
}
|
||||
|
||||
prepareHeatmapURL(ctx)
|
||||
|
||||
feeds, count, err := feed_service.GetFeedsForDashboard(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
|
||||
}
|
||||
|
||||
pager := context.NewPagination(count, setting.UI.FeedPagingNum, page, 5).WithCurRows(len(feeds))
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
ctx.Data["Feeds"] = feeds
|
||||
|
||||
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 := prepareDashboardContextUserOrgTeams(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.SplitSeq(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
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, milestones[i].Repo)
|
||||
milestones[i].RenderedContent, err = markdown.RenderString(rctx, 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 int64
|
||||
if isShowClosed {
|
||||
ctx.Data["State"] = "closed"
|
||||
ctx.Data["Total"] = totalMilestoneStats.ClosedCount
|
||||
pagerCount = milestoneStats.ClosedCount
|
||||
} else {
|
||||
ctx.Data["State"] = "open"
|
||||
ctx.Data["Total"] = totalMilestoneStats.OpenCount
|
||||
pagerCount = 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.AddParamFromRequest(ctx.Req)
|
||||
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 := prepareDashboardContextUserOrgTeams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
// Default to recently updated, unlike repository issues list
|
||||
sortType := util.IfZero(ctx.FormString("sort"), "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")
|
||||
var filterMode int
|
||||
switch viewType {
|
||||
case "assigned":
|
||||
filterMode = issues_model.FilterModeAssign
|
||||
case "created_by":
|
||||
filterMode = issues_model.FilterModeCreate
|
||||
case "mentioned":
|
||||
filterMode = issues_model.FilterModeMention
|
||||
case "review_requested":
|
||||
filterMode = issues_model.FilterModeReviewRequested
|
||||
case "reviewed_by":
|
||||
filterMode = issues_model.FilterModeReviewed
|
||||
case "your_repositories":
|
||||
fallthrough
|
||||
default:
|
||||
filterMode = issues_model.FilterModeYourRepositories
|
||||
viewType = "your_repositories"
|
||||
}
|
||||
|
||||
isPullList := unitType == unit.TypePullRequests
|
||||
opts := &issues_model.IssuesOptions{
|
||||
IsPull: optional.Some(isPullList),
|
||||
SortType: sortType,
|
||||
IsArchived: optional.Some(false),
|
||||
Doer: ctx.Doer,
|
||||
}
|
||||
// --------------------------------------------------------------------------
|
||||
// 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.
|
||||
if ctx.Org != nil && ctx.Org.Organization != nil {
|
||||
opts.Owner = ctx.Org.Organization.AsUser()
|
||||
opts.Team = ctx.Org.Team
|
||||
|
||||
issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Get filter by author id & assignee id
|
||||
// the existing "/posters" handlers doesn't work for this case, it is unable to list the related users correctly.
|
||||
// In the future, we need something like github: "author:user1" to accept usernames directly.
|
||||
posterUsername := ctx.FormString("poster")
|
||||
ctx.Data["FilterPosterUsername"] = posterUsername
|
||||
opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername)
|
||||
assigneeUsername := ctx.FormString("assignee")
|
||||
ctx.Data["FilterAssigneeUsername"] = assigneeUsername
|
||||
opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
|
||||
|
||||
searchMode := ctx.FormString("search_mode")
|
||||
|
||||
// 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 opts.Team != nil {
|
||||
repoOpts.TeamID = opts.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 = strconv.FormatInt(ctx.Doer.ID, 10)
|
||||
case issues_model.FilterModeCreate:
|
||||
opts.PosterID = strconv.FormatInt(ctx.Doer.ID, 10)
|
||||
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 := max(ctx.FormInt("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)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 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.SearchMode = indexer.SearchModeType(searchMode)
|
||||
},
|
||||
))
|
||||
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.Permission.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.SearchMode = indexer.SearchModeType(searchMode)
|
||||
},
|
||||
))
|
||||
if err != nil {
|
||||
ctx.ServerError("getUserIssueStats", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Will be posted to ctx.Data.
|
||||
var shownIssues int64
|
||||
if !isShowClosed {
|
||||
shownIssues = issueStats.OpenCount
|
||||
} else {
|
||||
shownIssues = 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
|
||||
switch typ {
|
||||
case "reject":
|
||||
reviewTyp = issues_model.ReviewTypeReject
|
||||
case "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["SearchModes"] = issue_indexer.SupportedSearchModes()
|
||||
ctx.Data["SelectedSearchMode"] = ctx.FormTrim("search_mode")
|
||||
|
||||
if isShowClosed {
|
||||
ctx.Data["State"] = "closed"
|
||||
} else {
|
||||
ctx.Data["State"] = "open"
|
||||
}
|
||||
|
||||
pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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
|
||||
// "authorized_keys" file format: "#" followed by comment line per key
|
||||
buf.WriteString("# Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified.\n")
|
||||
for i := range keys {
|
||||
if keys[i].Type == asymkey_model.KeyTypePrincipal {
|
||||
continue // SSH principal keys are not for signing or authentication
|
||||
}
|
||||
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)
|
||||
// https://www.rfc-editor.org/rfc/rfc4880
|
||||
headers["Comment"] = "Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified."
|
||||
if len(failedEntitiesID) > 0 { // If some key need re-import to be exported
|
||||
headers["Note"] = "The keys with the following IDs couldn't be exported and need to be reuploaded " + 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.PathParam("username")
|
||||
reloadParam := func(suffix string) (success bool) {
|
||||
ctx.SetPathParam("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(fmt.Errorf("%s", ctx.ContextUser.Name))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(username, ".png"):
|
||||
if reloadParam(".png") {
|
||||
AvatarByUsernameSize(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.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if reloadParam(".rss") {
|
||||
feed.ShowUserFeedRSS(ctx)
|
||||
}
|
||||
case strings.HasSuffix(username, ".atom"):
|
||||
if !setting.Other.EnableFeed {
|
||||
ctx.HTTPError(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) (ret *issues_model.IssueStats, err error) {
|
||||
ret = &issues_model.IssueStats{}
|
||||
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
|
||||
})
|
||||
|
||||
// Open/Closed are for the tabs of the issue list
|
||||
{
|
||||
openClosedOpts := opts.Copy()
|
||||
switch filterMode {
|
||||
case issues_model.FilterModeAll:
|
||||
// no-op
|
||||
case issues_model.FilterModeYourRepositories:
|
||||
openClosedOpts.AllPublic = false
|
||||
case issues_model.FilterModeAssign:
|
||||
openClosedOpts.AssigneeID = strconv.FormatInt(doerID, 10)
|
||||
case issues_model.FilterModeCreate:
|
||||
openClosedOpts.PosterID = strconv.FormatInt(doerID, 10)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Below stats are for the left sidebar
|
||||
opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
|
||||
o.AssigneeID = ""
|
||||
o.PosterID = ""
|
||||
o.MentionID = nil
|
||||
o.ReviewRequestedID = nil
|
||||
o.ReviewedID = nil
|
||||
})
|
||||
|
||||
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 = strconv.FormatInt(doerID, 10) }))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = strconv.FormatInt(doerID, 10) }))
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestArchivedIssues(t *testing.T) {
|
||||
// Arrange
|
||||
setting.UI.IssuePagingNum = 1
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "issues")
|
||||
contexttest.LoadUser(t, ctx, 30)
|
||||
ctx.Req.Form.Set("state", "open")
|
||||
|
||||
// Assume: User 30 has access to two Repos with Issues, one of the Repos being archived.
|
||||
repos, _, _ := repo_model.GetUserRepositories(t.Context(), 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.Equal(t, 1, NumIssues[50])
|
||||
assert.True(t, IsArchived[51])
|
||||
assert.Equal(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.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
|
||||
assert.Len(t, ctx.Data["Issues"], 1)
|
||||
}
|
||||
|
||||
func TestIssues(t *testing.T) {
|
||||
setting.UI.IssuePagingNum = 1
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "issues")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.Req.Form.Set("state", "closed")
|
||||
Issues(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
|
||||
assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
|
||||
assert.Len(t, ctx.Data["Issues"], 1)
|
||||
}
|
||||
|
||||
func TestPulls(t *testing.T) {
|
||||
setting.UI.IssuePagingNum = 20
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "pulls")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.Req.Form.Set("state", "open")
|
||||
Pulls(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
|
||||
assert.Len(t, ctx.Data["Issues"], 5)
|
||||
}
|
||||
|
||||
func TestMilestones(t *testing.T) {
|
||||
setting.UI.IssuePagingNum = 1
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "milestones")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.SetPathParam("sort", "issues")
|
||||
ctx.Req.Form.Set("state", "closed")
|
||||
ctx.Req.Form.Set("sort", "furthestduedate")
|
||||
Milestones(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
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
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "milestones")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.SetPathParam("sort", "issues")
|
||||
ctx.SetPathParam("repo", "1")
|
||||
ctx.Req.Form.Set("state", "closed")
|
||||
ctx.Req.Form.Set("sort", "furthestduedate")
|
||||
Milestones(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
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.PageRenderer()})
|
||||
page := context.NewPagination(10, 3, 1, 3)
|
||||
|
||||
setting.AppSubURL = "/SubPath"
|
||||
out, err := ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page})
|
||||
assert.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})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, out, `<a class=" item navigation" href="/?page=2">`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
const (
|
||||
tplNotification templates.TplName = "user/notification/notification"
|
||||
tplNotificationDiv templates.TplName = "user/notification/notification_div"
|
||||
tplNotificationSubscriptions templates.TplName = "user/notification/notification_subscriptions"
|
||||
)
|
||||
|
||||
// Notifications is the notification list page
|
||||
func Notifications(ctx *context.Context) {
|
||||
prepareUserNotificationsData(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if ctx.FormBool("div-only") {
|
||||
ctx.HTML(http.StatusOK, tplNotificationDiv)
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplNotification)
|
||||
}
|
||||
|
||||
func prepareUserNotificationsData(ctx *context.Context) {
|
||||
pageType := ctx.FormString("type", ctx.FormString("q")) // "q" is the legacy query parameter for "page type"
|
||||
page := max(1, ctx.FormInt("page"))
|
||||
perPage := util.IfZero(ctx.FormInt("perPage"), 20) // this value is never used or exposed ....
|
||||
queryStatus := util.Iif(pageType == "read", activities_model.NotificationStatusRead, activities_model.NotificationStatusUnread)
|
||||
|
||||
total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
|
||||
UserID: ctx.Doer.ID,
|
||||
Status: []activities_model.NotificationStatus{queryStatus},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("ErrGetNotificationCount", err)
|
||||
return
|
||||
}
|
||||
|
||||
pager := context.NewPagination(total, perPage, page, 5)
|
||||
if pager.Paginater.Current() < page {
|
||||
// use the last page if the requested page is more than total pages
|
||||
page = pager.Paginater.Current()
|
||||
pager = context.NewPagination(total, perPage, page, 5)
|
||||
}
|
||||
|
||||
statuses := []activities_model.NotificationStatus{queryStatus, 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["PageType"] = pageType
|
||||
ctx.Data["Notifications"] = notifications
|
||||
ctx.Data["Link"] = setting.AppSubURL + "/notifications"
|
||||
ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number")
|
||||
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
pager.RemoveParam(container.SetOf("div-only", "sequence-number"))
|
||||
ctx.Data["Page"] = pager
|
||||
}
|
||||
|
||||
// NotificationStatusPost is a route for changing the status of a notification
|
||||
func NotificationStatusPost(ctx *context.Context) {
|
||||
notificationID := ctx.FormInt64("notification_id")
|
||||
var newStatus activities_model.NotificationStatus
|
||||
switch ctx.FormString("notification_action") {
|
||||
case "mark_as_read":
|
||||
newStatus = activities_model.NotificationStatusRead
|
||||
case "mark_as_unread":
|
||||
newStatus = activities_model.NotificationStatusUnread
|
||||
case "pin":
|
||||
newStatus = activities_model.NotificationStatusPinned
|
||||
default:
|
||||
return // ignore user's invalid input
|
||||
}
|
||||
if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, newStatus); err != nil {
|
||||
ctx.ServerError("SetNotificationStatus", err)
|
||||
return
|
||||
}
|
||||
|
||||
prepareUserNotificationsData(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
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 := max(ctx.FormInt("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.Permission.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, "")
|
||||
|
||||
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
|
||||
switch typ {
|
||||
case "reject":
|
||||
reviewTyp = issues_model.ReviewTypeReject
|
||||
case "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(count, setting.UI.IssuePagingNum, page, 5)
|
||||
if pager.Paginater.Current() < page {
|
||||
ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current()))
|
||||
return
|
||||
}
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplNotificationSubscriptions)
|
||||
}
|
||||
|
||||
// NotificationWatching returns the list of watching repos
|
||||
func NotificationWatching(ctx *context.Context) {
|
||||
page := max(ctx.FormInt("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
|
||||
}
|
||||
ctx.Data["Total"] = count
|
||||
ctx.Data["Repos"] = repos
|
||||
|
||||
// redirect to last page if request page is more than total pages
|
||||
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
org_model "gitea.dev/models/organization"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
container_model "gitea.dev/models/packages/container"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/optional"
|
||||
alpine_module "gitea.dev/modules/packages/alpine"
|
||||
arch_module "gitea.dev/modules/packages/arch"
|
||||
container_module "gitea.dev/modules/packages/container"
|
||||
debian_module "gitea.dev/modules/packages/debian"
|
||||
rpm_module "gitea.dev/modules/packages/rpm"
|
||||
terraform_module "gitea.dev/modules/packages/terraform"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
packages_helper "gitea.dev/routers/api/packages/helper"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
container_service "gitea.dev/services/packages/container"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
tplPackagesList templates.TplName = "user/overview/packages"
|
||||
tplPackagesView templates.TplName = "package/view"
|
||||
tplPackageVersionList templates.TplName = "user/overview/package_versions"
|
||||
tplPackagesSettings templates.TplName = "package/settings"
|
||||
)
|
||||
|
||||
// ListPackages displays a list of all packages of the context user
|
||||
func ListPackages(ctx *context.Context) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
page := max(ctx.FormInt("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.GetDoerRepoPermission(ctx, pd.Repository, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDoerRepoPermission", err)
|
||||
return
|
||||
}
|
||||
repositoryAccessMap[pd.Repository.ID] = permission.HasAnyUnitAccess()
|
||||
}
|
||||
|
||||
hasPackages, err := packages_model.HasOwnerPackages(ctx, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasOwnerPackages", err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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(total, setting.UI.PackagesPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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.PathParam("type")), ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
ctx.NotFound(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())
|
||||
}
|
||||
|
||||
func viewPackageContainerImage(ctx gocontext.Context, pd *packages_model.PackageDescriptor, digest string) (*container_module.Metadata, error) {
|
||||
manifestBlob, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: pd.Owner.ID,
|
||||
Image: pd.Package.LowerName,
|
||||
Digest: digest,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifestReader, err := packages_service.OpenBlobStream(manifestBlob.Blob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer manifestReader.Close()
|
||||
_, _, metadata, err := container_service.ParseManifestMetadata(ctx, manifestReader, pd.Owner.ID, pd.Package.LowerName)
|
||||
return metadata, err
|
||||
}
|
||||
|
||||
// ViewPackageVersion displays a single package version
|
||||
func ViewPackageVersion(ctx *context.Context) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
versionSub := ctx.PathParam("version_sub")
|
||||
pd := ctx.Package.Descriptor
|
||||
ctx.Data["Title"] = pd.Package.Name
|
||||
ctx.Data["IsPackagesPage"] = true
|
||||
ctx.Data["PackageDescriptor"] = pd
|
||||
|
||||
registryHostURL, err := url.Parse(httplib.GuessCurrentHostURL(ctx))
|
||||
if err != nil {
|
||||
registryHostURL, _ = url.Parse(setting.AppURL)
|
||||
}
|
||||
ctx.Data["PackageRegistryHost"] = registryHostURL.Host
|
||||
|
||||
switch pd.Package.Type {
|
||||
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:
|
||||
repositories := make(container.Set[string])
|
||||
architectures := make(container.Set[string])
|
||||
|
||||
for _, f := range pd.Files {
|
||||
for _, pp := range f.Properties {
|
||||
switch pp.Name {
|
||||
case arch_module.PropertyRepository:
|
||||
repositories.Add(pp.Value)
|
||||
case arch_module.PropertyArchitecture:
|
||||
architectures.Add(pp.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Repositories"] = util.Sorted(repositories.Values())
|
||||
ctx.Data["Architectures"] = util.Sorted(architectures.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())
|
||||
case packages_model.TypeContainer:
|
||||
imageMetadata := pd.Metadata
|
||||
if versionSub != "" {
|
||||
imageMetadata, err = viewPackageContainerImage(ctx, pd, versionSub)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.ServerError("viewPackageContainerImage", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Data["ContainerImageMetadata"] = imageMetadata
|
||||
}
|
||||
var pvs []*packages_model.PackageVersion
|
||||
var pvsTotal int64
|
||||
if pd.Package.Type == packages_model.TypeContainer {
|
||||
pvs, pvsTotal, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
|
||||
Paginator: db.NewAbsoluteListOptions(0, 5),
|
||||
PackageID: pd.Package.ID,
|
||||
IsTagged: true,
|
||||
})
|
||||
} else {
|
||||
pvs, pvsTotal, 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"] = pvsTotal
|
||||
ctx.Data["PackageVersionViewData"], err = packages_service.GetSpecManager().Get(pd.Package.Type).GetViewPackageVersionData(ctx, pd)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetViewPackageVersionData", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
|
||||
|
||||
hasRepositoryAccess := false
|
||||
if pd.Repository != nil {
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, pd.Repository, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDoerRepoPermission", err)
|
||||
return
|
||||
}
|
||||
hasRepositoryAccess = permission.HasAnyUnitAccess()
|
||||
}
|
||||
ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess
|
||||
ctx.HTML(http.StatusOK, tplPackagesView)
|
||||
}
|
||||
|
||||
// ListPackageVersions lists all versions of a package
|
||||
func ListPackageVersions(ctx *context.Context) {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetPackageByName", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
pagination := &db.ListOptions{
|
||||
PageSize: setting.UI.PackagesPagingNum,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
query := ctx.FormTrim("q")
|
||||
sort := ctx.FormTrim("sort")
|
||||
|
||||
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
|
||||
|
||||
var (
|
||||
total int64
|
||||
pvs []*packages_model.PackageVersion
|
||||
)
|
||||
switch p.Type {
|
||||
case packages_model.TypeContainer:
|
||||
tagged := ctx.FormTrim("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
|
||||
|
||||
pager := context.NewPagination(total, setting.UI.PackagesPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplPackageVersionList)
|
||||
}
|
||||
|
||||
// PackageSettings displays the package settings page
|
||||
func PackageSettings(ctx *context.Context) {
|
||||
pd := ctx.Package.Descriptor
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = pd.Package.Name
|
||||
ctx.Data["IsPackagesPage"] = true
|
||||
ctx.Data["PackageDescriptor"] = pd
|
||||
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
|
||||
|
||||
if pd.Package.RepoID > 0 {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, pd.Package.RepoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepositoryByID", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["LinkedRepoName"] = repo.Name
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplPackagesSettings)
|
||||
}
|
||||
|
||||
// PackageSettingsPost updates the package settings
|
||||
func PackageSettingsPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.PackageSettingForm)
|
||||
switch form.Action {
|
||||
case "link":
|
||||
packageSettingsPostActionLink(ctx, form)
|
||||
case "delete":
|
||||
packageSettingsPostActionDelete(ctx)
|
||||
default:
|
||||
ctx.NotFound(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSettingForm) {
|
||||
pd := ctx.Package.Descriptor
|
||||
if form.RepoName == "" { // remove the link
|
||||
if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, 0); err != nil {
|
||||
ctx.JSONError(ctx.Tr("packages.settings.unlink.error"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.unlink.success"))
|
||||
ctx.JSONRedirect("")
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, pd.Owner.ID, form.RepoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.JSONError(ctx.Tr("packages.settings.link.repo_not_found", form.RepoName))
|
||||
} else {
|
||||
ctx.ServerError("GetRepositoryByOwnerAndName", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, repo.ID); err != nil {
|
||||
ctx.JSONError(ctx.Tr("packages.settings.link.error"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.link.success"))
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
|
||||
func packageSettingsPostActionDelete(ctx *context.Context) {
|
||||
pd := ctx.Package.Descriptor
|
||||
|
||||
if ctx.FormString("package_name") != pd.Package.Name {
|
||||
ctx.Flash.Error(ctx.Tr("packages.settings.delete.invalid_package_name"))
|
||||
ctx.Redirect(pd.PackageSettingsLink())
|
||||
return
|
||||
}
|
||||
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
|
||||
errTr := util.ErrorAsTranslatable(err)
|
||||
if errTr == nil {
|
||||
ctx.ServerError("RemovePackage", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Error(errTr.Translate(ctx.Locale))
|
||||
ctx.Redirect(pd.PackageSettingsLink())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
|
||||
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
|
||||
}
|
||||
|
||||
// PackageVersionDelete deletes a package version
|
||||
func PackageVersionDelete(ctx *context.Context) {
|
||||
pd := ctx.Package.Descriptor
|
||||
if pd.Version == nil {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
|
||||
errTr := util.ErrorAsTranslatable(err)
|
||||
if errTr == nil {
|
||||
ctx.ServerError("RemovePackageVersion", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Error(errTr.Translate(ctx.Locale))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
|
||||
}
|
||||
|
||||
// redirect to the package if there are still versions available
|
||||
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
|
||||
if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
|
||||
redirectURL = pd.PackageWebLink()
|
||||
}
|
||||
ctx.Redirect(redirectURL)
|
||||
}
|
||||
|
||||
// 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.PathParamInt64("fileid"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetFileForVersionByID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
ctx.ServerError("OpenFileForDownload", err)
|
||||
return
|
||||
}
|
||||
|
||||
packages_helper.ServePackageFile(ctx, s, u, pf, httplib.ServeHeaderOptions{
|
||||
Filename: pf.Name,
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||
})
|
||||
}
|
||||
|
||||
// ActionPackageTerraformLock locks a terraform state
|
||||
func ActionPackageTerraformLock(ctx *context.Context) {
|
||||
pd := ctx.Package.Descriptor
|
||||
if pd.Package.Type != packages_model.TypeTerraformState {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
existingLock, err := terraform_module.GetLock(ctx, pd.Package.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLock", err)
|
||||
return
|
||||
}
|
||||
if existingLock.IsLocked() {
|
||||
ctx.Flash.Error(ctx.Tr("packages.terraform.lock.error.already_locked"))
|
||||
ctx.Redirect(pd.VersionWebLink())
|
||||
return
|
||||
}
|
||||
|
||||
lockID := uuid.New().String()
|
||||
lockInfo := &terraform_module.LockInfo{
|
||||
ID: lockID,
|
||||
Operation: "Manual UI Lock",
|
||||
Who: ctx.Doer.Name,
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
if err := terraform_module.SetLock(ctx, pd.Package.ID, lockInfo); err != nil {
|
||||
ctx.ServerError("SetLock", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("packages.terraform.lock.success"))
|
||||
ctx.Redirect(pd.VersionWebLink())
|
||||
}
|
||||
|
||||
// ActionPackageTerraformUnlock unlocks a terraform state
|
||||
func ActionPackageTerraformUnlock(ctx *context.Context) {
|
||||
pd := ctx.Package.Descriptor
|
||||
if pd.Package.Type != packages_model.TypeTerraformState {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := terraform_module.RemoveLock(ctx, pd.Package.ID); err != nil {
|
||||
ctx.ServerError("RemoveLock", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("packages.terraform.unlock.success"))
|
||||
ctx.Redirect(pd.VersionWebLink())
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/routers/web/org"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
feed_service "gitea.dev/services/feed"
|
||||
)
|
||||
|
||||
const (
|
||||
tplProfileBigAvatar templates.TplName = "shared/user/profile_big_avatar"
|
||||
tplFollowUnfollow templates.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(fmt.Errorf("%s", ctx.ContextUser.Name))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.ContextUser.DisplayName()
|
||||
ctx.Data["PageIsUserProfile"] = true
|
||||
|
||||
profileDbRepo, profileReadmeBlob := shared_user.FindOwnerProfileReadme(ctx, ctx.Doer)
|
||||
|
||||
prepareUserProfileTabData(ctx, profileDbRepo, profileReadmeBlob)
|
||||
|
||||
// prepare the user nav header data after "prepareUserProfileTabData" to avoid re-querying the NumFollowers & NumFollowing
|
||||
// because ctx.Data["NumFollowers"] and "NumFollowing" logic duplicates in both of them
|
||||
// and the "profile readme" related logic also duplicates in both of FindOwnerProfileReadme and RenderUserOrgHeader
|
||||
// TODO: it is a bad design and should be refactored later,
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplProfile)
|
||||
}
|
||||
|
||||
func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.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["HasUserProfileReadme"] = 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 int64
|
||||
curRows 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 = numFollowers
|
||||
case "following":
|
||||
ctx.Data["Cards"] = following
|
||||
total = numFollowing
|
||||
case "activity":
|
||||
if setting.Service.EnableUserHeatmap && activities_model.ActivityReadable(ctx.ContextUser, ctx.Doer) {
|
||||
ctx.Data["EnableHeatmap"] = true
|
||||
ctx.Data["HeatmapURL"] = ctx.ContextUser.HomeLink() + "/-/heatmap"
|
||||
}
|
||||
|
||||
date := ctx.FormString("date")
|
||||
pagingNum = setting.UI.FeedPagingNum
|
||||
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
|
||||
items, feedCount, err := feed_service.GetFeedsForDashboard(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
|
||||
curRows = len(items)
|
||||
total = feedCount
|
||||
case "stars":
|
||||
ctx.Data["PageIsProfileStarList"] = true
|
||||
ctx.Data["ShowRepoOwnerOnList"] = 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 = 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 = count
|
||||
case "overview":
|
||||
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
|
||||
log.Error("failed to GetBlobContent: %v", err)
|
||||
} else {
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
|
||||
})
|
||||
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||
log.Error("failed to RenderString: %v", err)
|
||||
} else {
|
||||
ctx.Data["ProfileReadmeContent"] = profileContent
|
||||
}
|
||||
}
|
||||
case "organizations":
|
||||
orgs, count, err := db.FindAndCount[organization.Organization](ctx, organization.FindOrgOptions{
|
||||
UserID: ctx.ContextUser.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.ContextUser),
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pagingNum,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserOrganizations", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Cards"] = orgs
|
||||
total = count
|
||||
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 = count
|
||||
}
|
||||
ctx.Data["Repos"] = repos
|
||||
ctx.Data["Total"] = total
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
pager := context.NewPagination(total, pagingNum, page, 5)
|
||||
if tab == "activity" {
|
||||
pager.WithCurRows(curRows)
|
||||
}
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
}
|
||||
|
||||
// ActionUserFollow is for follow/unfollow user request
|
||||
func ActionUserFollow(ctx *context.Context) {
|
||||
var err error
|
||||
switch ctx.FormString("action") {
|
||||
case "follow":
|
||||
err = user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser)
|
||||
case "unfollow":
|
||||
err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
|
||||
ctx.HTTPError(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
|
||||
return
|
||||
}
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
if ctx.ContextUser.IsIndividual() {
|
||||
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.HTTPError(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
)
|
||||
|
||||
// SearchCandidates searches candidate users for dropdown list
|
||||
func SearchCandidates(ctx *context.Context) {
|
||||
searchUserTypes := []user_model.UserType{user_model.UserTypeIndividual}
|
||||
if ctx.FormBool("orgs") {
|
||||
searchUserTypes = append(searchUserTypes, user_model.UserTypeOrganization)
|
||||
}
|
||||
users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
Types: searchUserTypes,
|
||||
IsActive: optional.Some(true),
|
||||
ListOptions: db.ListOptions{PageSize: setting.UI.MembersPagingNum},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("Unable to search users", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{"data": convert.ToUsers(ctx, ctx.Doer, users)})
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// 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"
|
||||
|
||||
org_model "gitea.dev/models/organization"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/db"
|
||||
"gitea.dev/services/auth/source/smtp"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
"gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsAccount templates.TplName = "user/settings/account"
|
||||
)
|
||||
|
||||
// Account renders change user's password, user's email and user suicide page
|
||||
func Account(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials, setting.UserFeatureDeletion) {
|
||||
ctx.NotFound(errors.New("account setting are not allowed to be changed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("settings.account")
|
||||
ctx.Data["PageIsSettingsAccount"] = true
|
||||
ctx.Data["Email"] = ctx.Doer.Email
|
||||
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsAccount)
|
||||
}
|
||||
|
||||
// AccountPost response for change user's password
|
||||
func AccountPost(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.NotFound(errors.New("password setting is not allowed to be changed"))
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.ChangePasswordForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsAccount"] = true
|
||||
ctx.Data["Email"] = ctx.Doer.Email
|
||||
|
||||
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 {
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.NotFound(errors.New("emails are not allowed to be changed"))
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AddEmailForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsAccount"] = true
|
||||
ctx.Data["Email"] = ctx.Doer.Email
|
||||
|
||||
// Make email address primary.
|
||||
if ctx.FormString("_method") == "PRIMARY" {
|
||||
if err := user_model.MakeActiveEmailPrimary(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil {
|
||||
if user_model.IsErrEmailAddressNotExist(err) {
|
||||
ctx.Flash.Error(ctx.Tr("settings.email_primary_not_found"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
ctx.ServerError("MakeEmailPrimary", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Email made primary: %s", ctx.Doer.Name)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
// Send activation Email
|
||||
if ctx.FormString("_method") == "SENDACTIVATION" {
|
||||
var address string
|
||||
if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) {
|
||||
log.Error("Send activation: activation still pending")
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
|
||||
id := ctx.FormInt64("id")
|
||||
email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
|
||||
if err != nil {
|
||||
log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
if email == nil {
|
||||
log.Warn("Send activation failed: EmailAddress[%d] not found for user: %-v", id, ctx.Doer)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
if email.IsActivated {
|
||||
log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
if email.IsPrimary {
|
||||
if ctx.Doer.IsActive && !setting.Service.RegisterEmailConfirm {
|
||||
log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
// Only fired when the primary email is inactive (Wrong state)
|
||||
mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
|
||||
} else {
|
||||
mailer.SendActivateEmailMail(ctx.Doer, email.Email)
|
||||
}
|
||||
address = email.Email
|
||||
|
||||
if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
|
||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||
}
|
||||
|
||||
ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
return
|
||||
}
|
||||
|
||||
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.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form)
|
||||
} else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) {
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form)
|
||||
} else {
|
||||
ctx.ServerError("AddEmailAddresses", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send confirmation email
|
||||
if setting.Service.RegisterEmailConfirm {
|
||||
mailer.SendActivateEmailMail(ctx.Doer, form.Email)
|
||||
if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
|
||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||
}
|
||||
|
||||
ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
|
||||
}
|
||||
|
||||
log.Trace("Email address added: %s", form.Email)
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
}
|
||||
|
||||
// DeleteEmail response for delete user's email
|
||||
func DeleteEmail(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.NotFound(errors.New("emails are not allowed to be changed"))
|
||||
return
|
||||
}
|
||||
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.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsAccount"] = true
|
||||
ctx.Data["Email"] = ctx.Doer.Email
|
||||
|
||||
if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrUserNotExist(err):
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil)
|
||||
case errors.Is(err, smtp.ErrUnsupportedLoginType):
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil)
|
||||
case errors.As(err, &db.ErrUserPasswordNotSet{}):
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.unset_password"), tplSettingsAccount, nil)
|
||||
case errors.As(err, &db.ErrUserPasswordInvalid{}):
|
||||
loadAccountData(ctx)
|
||||
|
||||
ctx.RenderWithErrDeprecated(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 repo_model.IsErrUserOwnRepos(err):
|
||||
ctx.Flash.Error(ctx.Tr("form.still_own_repo"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
case org_model.IsErrUserHasOrgs(err):
|
||||
ctx.Flash.Error(ctx.Tr("form.still_has_org"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
case packages_model.IsErrUserOwnPackages(err):
|
||||
ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
case user_model.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["ActivationsPending"] = pendingActivation
|
||||
ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/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.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
repo_service "gitea.dev/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
|
||||
|
||||
// check not a repo
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, dir)
|
||||
if err != nil {
|
||||
ctx.ServerError("IsRepositoryExist", err)
|
||||
return
|
||||
}
|
||||
|
||||
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, dir)))
|
||||
if err != nil {
|
||||
ctx.ServerError("IsDir", err)
|
||||
return
|
||||
}
|
||||
if has || !exist {
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsApplications templates.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_title")
|
||||
ctx.Data["PageIsSettingsApplications"] = true
|
||||
|
||||
_ = ctx.Req.ParseForm()
|
||||
var scopeNames []string
|
||||
const accessTokenScopePrefix = "scope-"
|
||||
for k, v := range ctx.Req.Form {
|
||||
if strings.HasPrefix(k, accessTokenScopePrefix) {
|
||||
scopeNames = append(scopeNames, v...)
|
||||
}
|
||||
}
|
||||
|
||||
scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize()
|
||||
if err != nil {
|
||||
ctx.ServerError("GetScope", err)
|
||||
return
|
||||
}
|
||||
if !scope.HasPermissionScope() {
|
||||
ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true)
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
loadApplicationsData(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSettingsApplications)
|
||||
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
|
||||
|
||||
// Handle specific ordered token categories for admin or non-admin users
|
||||
tokenCategoryNames := auth_model.GetAccessTokenCategories()
|
||||
if !ctx.Doer.IsAdmin {
|
||||
tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin")
|
||||
}
|
||||
ctx.Data["TokenCategories"] = tokenCategoryNames
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsBlockedUsers templates.TplName = "user/settings/blocked_users"
|
||||
)
|
||||
|
||||
func BlockedUsers(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("user.block.list")
|
||||
ctx.Data["PageIsSettingsBlockedUsers"] = true
|
||||
|
||||
shared_user.BlockedUsers(ctx, ctx.Doer)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
|
||||
}
|
||||
|
||||
func BlockedUsersPost(ctx *context.Context) {
|
||||
shared_user.BlockedUsersPost(ctx, ctx.Doer, setting.AppSubURL+"/user/settings/blocked_users")
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// 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"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsKeys templates.TplName = "user/settings/keys"
|
||||
)
|
||||
|
||||
// Keys render user's SSH/GPG public keys page
|
||||
func Keys(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) {
|
||||
ctx.NotFound(errors.New("keys setting is not allowed to be changed"))
|
||||
return
|
||||
}
|
||||
|
||||
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_title")
|
||||
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_service.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.RenderWithErrDeprecated(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(errors.New("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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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(errors.New("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, false); err != nil {
|
||||
ctx.Data["HasSSHError"] = true
|
||||
switch {
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
loadKeysData(ctx)
|
||||
|
||||
ctx.Data["Err_Content"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form)
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
loadKeysData(ctx)
|
||||
|
||||
ctx.Data["Err_Title"] = true
|
||||
ctx.RenderWithErrDeprecated(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(errors.New("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.RenderWithErrDeprecated(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(errors.New("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(errors.New("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 token
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/user"
|
||||
)
|
||||
|
||||
const tplSettingsNotifications templates.TplName = "user/settings/notifications"
|
||||
|
||||
// Notifications render user's notifications settings
|
||||
func Notifications(ctx *context.Context) {
|
||||
if !setting.Service.EnableNotifyMail {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("notifications")
|
||||
ctx.Data["PageIsSettingsNotifications"] = true
|
||||
ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference
|
||||
|
||||
actionsEmailPref, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserSetting", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["ActionsEmailNotificationsPreference"] = actionsEmailPref
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsNotifications)
|
||||
}
|
||||
|
||||
// NotificationsEmailPost set user's email notification preference
|
||||
func NotificationsEmailPost(ctx *context.Context) {
|
||||
if !setting.Service.EnableNotifyMail {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
preference := ctx.FormString("preference")
|
||||
if !(preference == user_model.EmailNotificationsEnabled ||
|
||||
preference == user_model.EmailNotificationsOnMention ||
|
||||
preference == user_model.EmailNotificationsDisabled ||
|
||||
preference == user_model.EmailNotificationsAndYourOwn) {
|
||||
ctx.Flash.Error(ctx.Tr("invalid_data", preference))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
|
||||
return
|
||||
}
|
||||
opts := &user.UpdateOptions{
|
||||
EmailNotificationsPreference: optional.Some(preference),
|
||||
}
|
||||
if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
|
||||
}
|
||||
|
||||
// NotificationsActionsEmailPost set user's email notification preference on Gitea Actions
|
||||
func NotificationsActionsEmailPost(ctx *context.Context) {
|
||||
if !setting.Actions.Enabled || unit.TypeActions.UnitGlobalDisabled() {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
preference := ctx.FormString("preference")
|
||||
if !(preference == user_model.SettingEmailNotificationGiteaActionsAll ||
|
||||
preference == user_model.SettingEmailNotificationGiteaActionsDisabled ||
|
||||
preference == user_model.SettingEmailNotificationGiteaActionsFailureOnly) {
|
||||
ctx.Flash.Error(ctx.Tr("invalid_data", preference))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
|
||||
return
|
||||
}
|
||||
if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyEmailNotificationGiteaActions, preference); err != nil {
|
||||
ctx.ServerError("SetUserSetting", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsOAuthApplicationEdit templates.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_title")
|
||||
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_title")
|
||||
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_title")
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/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 templates.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.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", 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
|
||||
}
|
||||
|
||||
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
|
||||
Name: form.Name,
|
||||
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
||||
UserID: oa.OwnerID,
|
||||
ConfidentialClient: form.ConfidentialClient,
|
||||
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
||||
})
|
||||
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.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if auth.IsErrOAuthApplicationNotFound(err) {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||
return
|
||||
}
|
||||
if app.UID != oa.OwnerID {
|
||||
ctx.NotFound(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() {
|
||||
app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if auth.IsErrOAuthApplicationNotFound(err) {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||
return
|
||||
}
|
||||
if app.UID != oa.OwnerID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
ctx.Data["App"] = app
|
||||
|
||||
oa.renderEditPage(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
|
||||
ID: ctx.PathParamInt64("id"),
|
||||
Name: form.Name,
|
||||
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
||||
UserID: oa.OwnerID,
|
||||
ConfidentialClient: form.ConfidentialClient,
|
||||
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
||||
}); 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.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if auth.IsErrOAuthApplicationNotFound(err) {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||
return
|
||||
}
|
||||
if app.UID != oa.OwnerID {
|
||||
ctx.NotFound(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.PathParamInt64("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.PathParamInt64("grantId"), oa.OwnerID); err != nil {
|
||||
ctx.ServerError("RevokeOAuth2Grant", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
|
||||
ctx.JSONRedirect(oa.BasePathList)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
chef_module "gitea.dev/modules/packages/chef"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared "gitea.dev/routers/web/shared/packages"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsPackages templates.TplName = "user/settings/packages"
|
||||
tplSettingsPackagesRuleEdit templates.TplName = "user/settings/packages_cleanup_rules_edit"
|
||||
tplSettingsPackagesRulePreview templates.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_title")
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/avatars"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
user_service "gitea.dev/services/user"
|
||||
"gitea.dev/services/webtheme"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsProfile templates.TplName = "user/settings/profile"
|
||||
tplSettingsAppearance templates.TplName = "user/settings/appearance"
|
||||
tplSettingsOrganization templates.TplName = "user/settings/organization"
|
||||
tplSettingsRepositories templates.TplName = "user/settings/repos"
|
||||
)
|
||||
|
||||
// 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.HTML(http.StatusOK, tplSettingsProfile)
|
||||
}
|
||||
|
||||
// ProfilePost response for change user's profile
|
||||
func ProfilePost(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsProfile"] = true
|
||||
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
|
||||
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplSettingsProfile)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.UpdateProfileForm)
|
||||
|
||||
if form.Name != "" {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeUsername) {
|
||||
ctx.Flash.Error(ctx.Tr("user.form.change_username_disabled"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings")
|
||||
return
|
||||
}
|
||||
if err := user_service.RenameUser(ctx, ctx.Doer, form.Name, ctx.Doer); 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{
|
||||
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
|
||||
}
|
||||
|
||||
if form.FullName != "" {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) {
|
||||
ctx.Flash.Error(ctx.Tr("user.form.change_full_name_disabled"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings")
|
||||
return
|
||||
}
|
||||
opts.FullName = optional.Some(form.FullName)
|
||||
}
|
||||
|
||||
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,
|
||||
IncludeVisibility: structs.VisibleTypePrivate,
|
||||
}
|
||||
|
||||
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(total, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
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 := int64((opts.Page - 1) * opts.PageSize)
|
||||
end := start + int64(opts.PageSize)
|
||||
|
||||
adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories)
|
||||
|
||||
ctxUser := ctx.Doer
|
||||
var count int64
|
||||
|
||||
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, reposCount, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserRepositories", err)
|
||||
return
|
||||
}
|
||||
count = reposCount
|
||||
|
||||
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.AddParamFromRequest(ctx.Req)
|
||||
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
|
||||
ctx.Data["AllThemes"] = webtheme.GetAvailableThemes()
|
||||
|
||||
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_title")
|
||||
ctx.Data["PageIsSettingsAppearance"] = true
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Flash.Error(ctx.GetErrMsg())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
|
||||
return
|
||||
}
|
||||
|
||||
if webtheme.GetThemeMetaInfo(form.Theme) == nil {
|
||||
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_title")
|
||||
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")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code.
|
||||
func RegenerateScratchTwoFactor(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsSecurity"] = true
|
||||
ctx.Data["ShowTwoFactorRequiredMessage"] = false
|
||||
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsSecurity"] = true
|
||||
ctx.Data["ShowTwoFactorRequiredMessage"] = false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
newTwoFactorErr := auth.NewTwoFactor(ctx, t)
|
||||
if newTwoFactorErr == nil {
|
||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
}
|
||||
// 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 newTwoFactorErr != 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", newTwoFactorErr)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/openid"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
// OpenIDPost response for change user's openid
|
||||
func OpenIDPost(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AddOpenIDForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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.RenderWithErrDeprecated(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 user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
} else {
|
||||
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 user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id"), ctx.Doer); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
} else {
|
||||
ctx.ServerError("ToggleUserOpenIDVisibility", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.SetFormString("id", "1")
|
||||
|
||||
DeleteOpenID(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
|
||||
}
|
||||
|
||||
func TestToggleOpenIDVisibilityReturnsNotFoundForOtherUsersAddress(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
ctx.SetFormString("id", "1")
|
||||
|
||||
ToggleOpenIDVisibility(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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 "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsSecurity templates.TplName = "user/settings/security/security"
|
||||
tplSettingsTwofaEnroll templates.TplName = "user/settings/security/twofa_enroll"
|
||||
)
|
||||
|
||||
// Security render change user's password page and 2FA
|
||||
func Security(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer,
|
||||
setting.UserFeatureManageMFA, setting.UserFeatureManageCredentials) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
wa "gitea.dev/modules/auth/webauthn"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// WebAuthnRegister initializes the webauthn registration procedure
|
||||
func WebAuthnRegister(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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.HTTPError(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
|
||||
}
|
||||
|
||||
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
|
||||
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
}))
|
||||
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) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
|
||||
cred, err := wa.WebAuthn.FinishRegistration(webAuthnUser, *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.HTTPError(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.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
ctx.JSON(http.StatusCreated, cred)
|
||||
}
|
||||
|
||||
// WebauthnDelete deletes an security key by id
|
||||
func WebauthnDelete(ctx *context.Context) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.WebauthnDeleteForm)
|
||||
if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("GetWebAuthnCredentialByID", err)
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func SettingsCtxData(ctx *context.Context) {
|
||||
ctx.Data["PageIsUserSettings"] = true
|
||||
ctx.Data["EnablePackages"] = setting.Packages.Enabled
|
||||
ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail
|
||||
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
|
||||
}
|
||||
|
||||
func UpdatePreferences(ctx *context.Context) {
|
||||
type preferencesForm struct {
|
||||
CodeViewShowFileTree bool `json:"codeViewShowFileTree"`
|
||||
}
|
||||
form := &preferencesForm{}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
|
||||
ctx.HTTPError(http.StatusBadRequest, "json decode failed")
|
||||
return
|
||||
}
|
||||
_ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, strconv.FormatBool(form.CodeViewShowFileTree))
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/webhook"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsHooks templates.TplName = "user/settings/hooks"
|
||||
)
|
||||
|
||||
// Webhooks render webhook list page
|
||||
func Webhooks(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsHooks"] = true
|
||||
ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks"
|
||||
ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks"
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/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.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiSWs, err := convert.ToStopWatches(ctx, ctx.Doer, sws)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiSWs)
|
||||
}
|
||||
Reference in New Issue
Block a user