refactor: username sub route (#39967)

Remove the workaround for chi router, do as much as possible in
middlewares

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
authored and GitHub committed 2026-10-24 16:27:56 +08:00
1 parent fc49368845
commit 1d8474ee1f
13 files changed
+103 -117

No files matched your search

+9 -6
View File
@@ -260,18 +260,21 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
return items, err
}
const (
ExtNameRss = ".rss"
ExtNameAtom = ".atom"
ContentTypeRssXml = "application/rss+xml"
ContentTypeAtomXml = "application/atom+xml"
)
// GetFeedType return if it is a feed request and altered name and feed type.
func GetFeedType(name string, req *http.Request) (showFeed bool, feedType string) {
if strings.HasSuffix(name, ".rss") ||
strings.Contains(req.Header.Get("Accept"), "application/rss+xml") {
if strings.HasSuffix(name, ExtNameRss) || strings.Contains(req.Header.Get("Accept"), ContentTypeRssXml) {
return true, "rss"
}
if strings.HasSuffix(name, ".atom") ||
strings.Contains(req.Header.Get("Accept"), "application/atom+xml") {
if strings.HasSuffix(name, ExtNameAtom) || strings.Contains(req.Header.Get("Accept"), ContentTypeAtomXml) {
return true, "atom"
}
return false, ""
}
+5 -9
View File
@@ -9,7 +9,6 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
@@ -30,16 +29,13 @@ func ShowUserFeedAtom(ctx *context.Context) {
// showUserFeed show user activity as RSS / Atom feed
func showUserFeed(ctx *context.Context, formatType string) {
isOrganisation := ctx.ContextUser.IsOrganization()
if !setting.Other.EnableFeed ||
isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
if !setting.Other.EnableFeed {
ctx.NotFound(nil)
return
}
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if ctx.IsSigned && isOrganisation && !includePrivate {
if ctx.IsSigned && ctx.ContextUser.IsOrganization() && !includePrivate {
// When feed is requested by a member of the organization,
// include the private repo's the member has access to.
isOrgMember, err := organization.IsOrganizationMember(ctx, ctx.ContextUser.ID, ctx.Doer.ID)
@@ -54,7 +50,7 @@ func showUserFeed(ctx *context.Context, formatType string) {
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
IncludePrivate: includePrivate,
OnlyPerformedBy: !isOrganisation,
OnlyPerformedBy: !ctx.ContextUser.IsOrganization(),
IncludeDeleted: false,
Date: ctx.FormString("date"),
})
@@ -90,12 +86,12 @@ func showUserFeed(ctx *context.Context, formatType string) {
// writeFeed write a feeds.Feed as atom or rss to ctx.Resp
func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) {
if formatType == "atom" {
ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8")
ctx.Resp.Header().Set("Content-Type", ContentTypeAtomXml+"; charset=utf-8")
if err := feed.WriteAtom(ctx.Resp); err != nil {
ctx.ServerError("Render Atom failed", err)
}
} else {
ctx.Resp.Header().Set("Content-Type", "application/rss+xml;charset=utf-8")
ctx.Resp.Header().Set("Content-Type", ContentTypeRssXml+"; charset=utf-8")
if err := feed.WriteRss(ctx.Resp); err != nil {
ctx.ServerError("Render RSS failed", err)
}
+2 -6
View File
@@ -96,18 +96,14 @@ func goGetDefaultBranch(ctx *context.Context, repo *repo_model.Repository) strin
if len(repo.DefaultBranch) == 0 {
return def
}
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
return def
}
// a token that was not granted repository read scope must not learn repository details, even when the
// account behind it could read the repo through the web UI
if !goGetTokenCanReadRepo(ctx) {
return def
}
// the caller must be able to read the code and see the owner: a limited/private owner hides its repos
// from anonymous/non-member callers even when the repo itself is public
// the caller must be able to read the code
perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil || !perm.CanRead(unit.TypeCode) || !user_model.IsUserVisibleToViewer(ctx, repo.Owner, ctx.Doer) {
if err != nil || !perm.CanRead(unit.TypeCode) {
return def
}
return repo.DefaultBranch
+5
View File
@@ -17,6 +17,7 @@ import (
"gitea.dev/modules/sitemap"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/routers/web/feed"
"gitea.dev/routers/web/user"
"gitea.dev/services/context"
)
@@ -24,6 +25,10 @@ import (
const tplHome templates.TplName = "home"
func Home(ctx *context.Context) {
if _, feedType := feed.GetFeedType(ctx.Req.URL.Path, ctx.Req); feedType != "" {
ctx.Resp.WriteHeader(http.StatusUnsupportedMediaType)
return
}
if ctx.IsSigned {
user.Dashboard(ctx)
return
+1 -9
View File
@@ -5,7 +5,6 @@ package org
import (
"net/http"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/organization"
@@ -25,14 +24,7 @@ const tplOrgHome templates.TplName = "org/home"
// Home show organization home page
func Home(ctx *context.Context) {
uname := ctx.PathParam("username")
if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") {
ctx.NotFound(nil)
return
}
ctx.SetPathParam("org", uname)
ctx.SetPathParam("org", ctx.PathParam("username"))
context.OrgAssignment(context.OrgAssignmentOptions{})(ctx)
if ctx.Written() {
return
-7
View File
@@ -8,7 +8,6 @@ import (
"net/url"
activities_model "gitea.dev/models/activities"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
@@ -52,12 +51,6 @@ func DashboardHeatmap(ctx *context.Context) {
ctx.NotFound(nil)
return
}
if ctx.Org.Organization == nil && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
// this end point is mounted on different routes, so we need to
// check if the user is visible to the viewer when there is no organization context (no visibility check)
ctx.NotFound(nil)
return
}
var data []*activities_model.UserHeatmapData
var err error
if ctx.Org.Organization == nil {
+41 -42
View File
@@ -6,8 +6,8 @@ package user
import (
"bytes"
"fmt"
"net/http"
"path"
"regexp"
"slices"
"sort"
@@ -43,6 +43,7 @@ import (
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/go-chi/chi/v5"
"xorm.io/builder"
)
@@ -702,52 +703,50 @@ func ShowGPGKeys(ctx *context.Context) {
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
// UsernamePreRoute rewrites the route path if the username has a suffix, so that the correct handler can be called.
func UsernamePreRoute(next http.Handler) http.Handler {
// HINT: OWNER-NAME-RESERVED-EXT: use the reserved extensions to check and rewrite requests
suffixes := container.SetOf(".png", ".keys", ".gpg", feed.ExtNameRss, feed.ExtNameAtom)
rewriteRoutePath := func(rctx *chi.Context, r *http.Request) (ownerPath, rewrittenExt string) {
if rctx.RouteMethod != "HEAD" && rctx.RouteMethod != "GET" {
return "", ""
}
// if there is no slash in the path, then it is possible to be a username or a username with a suffix
possibleUsernameWithSuffix := rctx.RoutePath != "" && strings.IndexByte(rctx.RoutePath[1:], '/') == -1
if !possibleUsernameWithSuffix {
return "", ""
}
// check view permissions
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name))
return false
// HINT: USERNAME-AVATAR-APPLE-TOUCH-ICON: skip rewriting the path if it is an apple touch icon request
isAppleTouchIcon := rctx.RoutePath == "/apple-touch-icon.png" || rctx.RoutePath == "/apple-touch-icon-precomposed.png"
if isAppleTouchIcon {
return "", ""
}
return true
// try to extract the suffix from the path, if it is a valid suffix, then rewrite the path to "/-/ownername.png/{username}"
ext := path.Ext(rctx.RoutePath)
if suffixes.Contains(ext) {
return rctx.RoutePath[:len(rctx.RoutePath)-len(ext)], ext
}
// also try to rewrite "/username" to "/-/ownername.rss/{username}" if the Accept header is for RSS or Atom
if strings.Contains(r.Header.Get("Accept"), feed.ContentTypeRssXml) {
return rctx.RoutePath, feed.ExtNameRss
} else if strings.Contains(r.Header.Get("Accept"), feed.ContentTypeAtomXml) {
return rctx.RoutePath, feed.ExtNameAtom
}
return "", ""
}
switch {
case strings.HasSuffix(username, ".png"):
if reloadParam(".png") {
AvatarByUsernameSize(ctx)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
if ownerPath, rewrittenExt := rewriteRoutePath(rctx, r); ownerPath != "/" && rewrittenExt != "" {
// Owner path can be something like "/" (site home) or "/explore" (endpoint of reserved username)
// It's safe to pass the owner path to the handler as is and just let the handler respond 4xx.
// Because the server doesn't respond avatar, keys or feeds for these endpoints.
rctx.RoutePath = "/-/ownername" + rewrittenExt + ownerPath
}
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 reloadParam(".rss") {
feed.ShowUserFeedRSS(ctx)
}
case strings.HasSuffix(username, ".atom"):
if reloadParam(".atom") {
feed.ShowUserFeedAtom(ctx)
}
default:
context.UserAssignmentWeb()(ctx)
if !ctx.Written() {
ctx.Data["EnableFeed"] = setting.Other.EnableFeed
OwnerProfile(ctx)
}
}
next.ServeHTTP(w, r)
})
}
func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (ret *issues_model.IssueStats, err error) {
+1 -24
View File
@@ -7,7 +7,6 @@ package user
import (
"fmt"
"net/http"
"strings"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
@@ -21,7 +20,6 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/routers/web/feed"
"gitea.dev/routers/web/org"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
@@ -35,15 +33,7 @@ const (
// 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
}
ctx.Data["EnableFeed"] = setting.Other.EnableFeed
if ctx.ContextUser.IsOrganization() {
org.Home(ctx)
} else {
@@ -52,12 +42,6 @@ func OwnerProfile(ctx *context.Context) {
}
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
@@ -322,13 +306,6 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
// ActionUserFollow is for follow/unfollow user request
func ActionUserFollow(ctx *context.Context) {
isOrg := ctx.ContextUser.IsOrganization()
if isOrg && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrg && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
var err error
switch ctx.FormString("action") {
case "follow":
+19 -10
View File
@@ -258,15 +258,18 @@ func Routes() *web.Router {
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
routes.BeforeRouting(chi_middleware.GetHead)
routes.BeforeRouting(user.UsernamePreRoute)
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD", "/assets/site-manifest.json", misc.SiteManifest)
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png"))
// HINT: USERNAME-AVATAR-APPLE-TOUCH-ICON: these two paths conflict with "/{username}.png"
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png"))
var mid []any
@@ -906,13 +909,19 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, adminReq, ctxDataSet(reqctx.ContextData{"EnableOAuth2": setting.OAuth2.Enabled, "EnablePackages": setting.Packages.Enabled}))
// ***** END: Admin *****
m.Group("", func() {
// it handles "username.rss" in the handler, so allow basic auth as other rss/atom routes
m.Get("/{username}", webAuth.AllowBasic, user.UsernameSubRoute)
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn)
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), webAuth.AllowBasic, webAuth.AllowOAuth2, optSignIn, repo.GetAttachment)
m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.ActionUserFollow)
m.Group("", func() {
// "UsernamePreRoute" is used to rewrite username with a suffix like "/username.png" to "/-/ownername.png/username"
m.Get("/-/ownername.png/{username}", user.AvatarByUsernameSize)
m.Get("/-/ownername.keys/{username}", user.ShowSSHKeys)
m.Get("/-/ownername.gpg/{username}", user.ShowGPGKeys)
m.Get("/-/ownername.rss/{username}", feed.ShowUserFeedRSS)
m.Get("/-/ownername.atom/{username}", feed.ShowUserFeedAtom)
m.Get("/{username}", user.OwnerProfile)
}, webAuth.AllowBasic, optSignIn, context.UserAssignmentWeb(), context.ReqContextUserVisibleToDoer())
m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), context.ReqContextUserVisibleToDoer(), user.ActionUserFollow)
reqRepoAdmin := context.RequireRepoAdmin()
reqRepoCodeWriter := context.RequireUnitWriter(unit.TypeCode)
@@ -981,8 +990,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/{org}", func() {
m.Get("/dashboard", user.Dashboard)
m.Get("/dashboard/{team}", user.Dashboard)
m.Get("/dashboard/-/heatmap", user.DashboardHeatmap)
m.Get("/dashboard/-/heatmap/{team}", user.DashboardHeatmap)
m.Get("/dashboard/-/heatmap", context.ReqContextUserVisibleToDoer(), user.DashboardHeatmap)
m.Get("/dashboard/-/heatmap/{team}", context.ReqContextUserVisibleToDoer(), user.DashboardHeatmap)
m.Get("/issues", user.Issues)
m.Get("/issues/{team}", user.Issues)
m.Get("/pulls", user.Pulls)
@@ -1132,7 +1141,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/mentions-in-owner", reqAnyRepoUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner)
m.Get("/repositories", org.Repositories)
m.Get("/heatmap", user.DashboardHeatmap)
m.Get("/heatmap", context.ReqContextUserVisibleToDoer(), user.DashboardHeatmap)
m.Group("/projects", func() {
m.Group("", func() {