mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
356 lines
11 KiB
Go
356 lines
11 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package user
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
activities_model "github.com/hanzoai/git/models/activities"
|
|
"github.com/hanzoai/git/models/db"
|
|
"github.com/hanzoai/git/models/organization"
|
|
"github.com/hanzoai/git/models/renderhelper"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/git"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/markup/markdown"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/templates"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/routers/web/feed"
|
|
"github.com/hanzoai/git/routers/web/org"
|
|
shared_user "github.com/hanzoai/git/routers/web/shared/user"
|
|
"github.com/hanzoai/git/services/context"
|
|
feed_service "github.com/hanzoai/git/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)
|
|
// a public-only API token must not surface private activity, even for its own owner
|
|
if showPrivate && context.TokenIsPublicOnly(ctx) {
|
|
showPrivate = false
|
|
}
|
|
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" {
|
|
// FIXME: UNLIMITE-PAGING-ONE-MORE-ROW: see another comment
|
|
pager.WithUnlimitedPaging(curRows, curRows == pagingNum)
|
|
}
|
|
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")))
|
|
}
|