hmn/src/website/routes.go

329 lines
14 KiB
Go
Raw Normal View History

2021-03-14 20:49:58 +00:00
package website
import (
"errors"
2021-04-26 06:56:49 +00:00
"fmt"
2021-03-14 20:49:58 +00:00
"net/http"
"strconv"
2021-04-26 06:56:49 +00:00
"time"
2021-03-14 20:49:58 +00:00
"git.handmade.network/hmn/hmn/src/db"
2021-08-17 18:48:54 +00:00
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmndata"
2021-05-04 14:40:40 +00:00
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
2021-08-17 06:08:33 +00:00
"git.handmade.network/hmn/hmn/src/utils"
2023-01-02 21:52:41 +00:00
"github.com/jackc/pgx/v5/pgxpool"
2021-03-14 20:49:58 +00:00
)
func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
2021-04-29 03:07:14 +00:00
router := &Router{}
routes := RouteBuilder{
Router: router,
Middlewares: []Middleware{
setDBConn(conn),
trackRequestPerf,
logContextErrorsMiddleware,
panicCatcherMiddleware,
2021-04-26 06:56:49 +00:00
},
2021-03-14 20:49:58 +00:00
}
anyProject := routes.WithMiddleware(
storeNoticesInCookieMiddleware,
loadCommonData,
)
hmnOnly := anyProject.WithMiddleware(
redirectToHMN,
)
2021-08-17 06:08:33 +00:00
2021-07-08 07:40:30 +00:00
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
var res ResponseData
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
addCORSHeaders(c, &res)
2021-07-08 07:40:30 +00:00
return res
})
routes.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
2021-06-22 09:50:40 +00:00
// NOTE(asaf): HMN-only routes:
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
hmnOnly.GET(hmnurl.RegexAbout, About)
// hmnOnly.GET(hmnurl.RegexFoundation, Foundation)
hmnOnly.GET(hmnurl.RegexCommunicationGuidelines, CommunicationGuidelines)
hmnOnly.GET(hmnurl.RegexContactPage, ContactPage)
hmnOnly.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
hmnOnly.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
hmnOnly.GET(hmnurl.RegexConferences, Conferences)
hmnOnly.GET(hmnurl.RegexWhenIsIt, WhenIsIt)
2023-06-02 14:46:26 +00:00
hmnOnly.GET(hmnurl.RegexNewsletterSignup, func(c *RequestContext) ResponseData {
return c.Redirect("https://cdn.forms-content.sg-form.com/9c83182a-f04a-11ed-a42d-f6f307313b7c", http.StatusFound)
})
2023-04-22 16:26:07 +00:00
hmnOnly.GET(hmnurl.RegexJamsIndex, JamsIndex)
hmnOnly.GET(hmnurl.RegexJamIndex, func(c *RequestContext) ResponseData {
2024-02-09 21:02:58 +00:00
return c.Redirect(hmnurl.BuildJamIndex2024_Learning(), http.StatusFound)
})
2022-06-17 22:30:18 +00:00
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
2022-06-19 22:26:33 +00:00
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
hmnOnly.GET(hmnurl.RegexJamFeed2022, JamFeed2022)
2023-03-07 17:37:01 +00:00
hmnOnly.GET(hmnurl.RegexJamIndex2023_Visibility, JamIndex2023_Visibility)
2023-04-06 18:54:14 +00:00
hmnOnly.GET(hmnurl.RegexJamFeed2023_Visibility, JamFeed2023_Visibility)
hmnOnly.GET(hmnurl.RegexJamRecap2023_Visibility, JamRecap2023_Visibility)
2023-09-01 14:35:40 +00:00
hmnOnly.GET(hmnurl.RegexJamIndex2023, JamIndex2023)
hmnOnly.GET(hmnurl.RegexJamFeed2023, JamFeed2023)
2024-02-08 20:21:01 +00:00
hmnOnly.GET(hmnurl.RegexJamIndex2024_Learning, JamIndex2024_Learning)
hmnOnly.GET(hmnurl.RegexJamFeed2024_Learning, JamFeed2024_Learning)
2024-03-13 04:20:35 +00:00
hmnOnly.GET(hmnurl.RegexJamGuidelines2024_Learning, JamGuidelines2024_Learning)
2023-05-28 05:16:12 +00:00
hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine)
2023-06-06 18:23:54 +00:00
hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions)
2023-06-09 20:01:51 +00:00
hmnOnly.GET(hmnurl.RegexTimeMachineAtomFeed, TimeMachineAtomFeed)
2023-06-01 21:42:46 +00:00
hmnOnly.GET(hmnurl.RegexTimeMachineForm, needsAuth(TimeMachineForm))
hmnOnly.GET(hmnurl.RegexTimeMachineFormDone, needsAuth(TimeMachineFormDone))
hmnOnly.POST(hmnurl.RegexTimeMachineForm, needsAuth(csrfMiddleware(TimeMachineFormSubmit)))
2023-05-28 05:16:12 +00:00
2024-01-28 17:12:59 +00:00
hmnOnly.GET(hmnurl.RegexCalendarIndex, CalendarIndex)
hmnOnly.GET(hmnurl.RegexCalendarICal, CalendarICal)
hmnOnly.GET(hmnurl.RegexStaffRolesIndex, StaffRolesIndex)
hmnOnly.GET(hmnurl.RegexStaffRole, StaffRole)
hmnOnly.GET(hmnurl.RegexOldHome, Index)
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
hmnOnly.GET(hmnurl.RegexLoginWithDiscord, LoginWithDiscord)
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
hmnOnly.GET(hmnurl.RegexRequestPasswordReset, RequestPasswordReset)
hmnOnly.POST(hmnurl.RegexRequestPasswordReset, securityTimerMiddleware(email.ExpectedEmailSendDuration, RequestPasswordResetSubmit))
hmnOnly.GET(hmnurl.RegexPasswordResetSent, PasswordResetSent)
hmnOnly.GET(hmnurl.RegexOldDoPasswordReset, DoPasswordReset)
hmnOnly.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset)
hmnOnly.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
hmnOnly.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
hmnOnly.GET(hmnurl.RegexAdminApprovalQueue, adminsOnly(AdminApprovalQueue))
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminsOnly(csrfMiddleware(AdminApprovalQueueSubmit)))
hmnOnly.POST(hmnurl.RegexAdminSetUserOptions, adminsOnly(csrfMiddleware(UserProfileAdminSetOptions)))
hmnOnly.POST(hmnurl.RegexAdminNukeUser, adminsOnly(csrfMiddleware(UserProfileAdminNuke)))
hmnOnly.GET(hmnurl.RegexFeed, Feed)
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew))
hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit)))
2021-11-25 03:59:51 +00:00
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, DiscordOAuthCallback)
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))
2024-03-28 19:24:46 +00:00
hmnOnly.GET(hmnurl.RegexDiscordBotDebugPage, adminsOnly(DiscordBotDebugPage))
2022-03-22 18:07:43 +00:00
hmnOnly.POST(hmnurl.RegexTwitchEventSubCallback, TwitchEventSubCallback)
2024-03-28 19:24:46 +00:00
hmnOnly.GET(hmnurl.RegexTwitchDebugPage, adminsOnly(TwitchDebugPage))
2022-03-22 18:07:43 +00:00
hmnOnly.GET(hmnurl.RegexUserProfile, UserProfile)
hmnOnly.GET(hmnurl.RegexUserSettings, needsAuth(UserSettings))
hmnOnly.POST(hmnurl.RegexUserSettings, needsAuth(csrfMiddleware(UserSettingsSave)))
hmnOnly.GET(hmnurl.RegexPodcast, PodcastIndex)
hmnOnly.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
hmnOnly.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)
hmnOnly.GET(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeNew)
hmnOnly.POST(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeSubmit)
hmnOnly.GET(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeEdit)
hmnOnly.POST(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeSubmit)
hmnOnly.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
hmnOnly.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
hmnOnly.GET(hmnurl.RegexFishbowlIndex, FishbowlIndex)
hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
2022-11-05 21:18:39 +00:00
hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex)
2022-11-05 21:27:55 +00:00
hmnOnly.GET(hmnurl.RegexEducationGlossary, educationAuthorsOnly(EducationGlossary)) // TODO: Remove this gate
2022-11-05 21:18:39 +00:00
hmnOnly.GET(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNew))
hmnOnly.POST(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNewSubmit))
hmnOnly.GET(hmnurl.RegexEducationRerender, educationAuthorsOnly(EducationRerender))
hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle) // Article stuff must be last so `/glossary` and others do not match as an article slug
hmnOnly.GET(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEdit))
hmnOnly.POST(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEditSubmit))
hmnOnly.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete))
hmnOnly.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit)))
2021-11-25 03:59:51 +00:00
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
2022-11-05 21:23:12 +00:00
hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildEducationIndex(), http.StatusFound)
})
// Project routes can appear either at the root (e.g. hero.handmade.network/edit)
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we
// have pulled all those routes into this function.
attachProjectRoutes := func(rb *RouteBuilder) {
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
if c.CurrentProject.IsHMN() {
return Index(c)
} else {
return ProjectHomepage(c)
}
})
rb.GET(hmnurl.RegexProjectEdit, needsAuth(ProjectEdit))
rb.POST(hmnurl.RegexProjectEdit, needsAuth(csrfMiddleware(ProjectEditSubmit)))
2021-11-25 03:59:51 +00:00
// Middleware used for forum action routes - anything related to actually creating or editing forum content
needsForums := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has forums disabled
if !c.CurrentProject.HasForums() {
return FourOhFour(c)
}
// Require auth if forums are enabled
return needsAuth(h)(c)
}
}
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
rb.GET(hmnurl.RegexForumThread, ForumThread)
rb.GET(hmnurl.RegexForum, Forum)
rb.POST(hmnurl.RegexForumMarkRead, needsAuth(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
rb.GET(hmnurl.RegexForumPostReply, needsForums(ForumPostReply))
rb.POST(hmnurl.RegexForumPostReply, needsForums(csrfMiddleware(ForumPostReplySubmit)))
rb.GET(hmnurl.RegexForumPostEdit, needsForums(ForumPostEdit))
rb.POST(hmnurl.RegexForumPostEdit, needsForums(csrfMiddleware(ForumPostEditSubmit)))
rb.GET(hmnurl.RegexForumPostDelete, needsForums(ForumPostDelete))
rb.POST(hmnurl.RegexForumPostDelete, needsForums(csrfMiddleware(ForumPostDeleteSubmit)))
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
// Middleware used for blog action routes - anything related to actually creating or editing blog content
needsBlogs := func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// 404 if the project has blogs disabled
if !c.CurrentProject.HasBlog() {
return FourOhFour(c)
}
// Require auth if blogs are enabled
return needsAuth(h)(c)
}
}
rb.GET(hmnurl.RegexBlog, BlogIndex)
rb.GET(hmnurl.RegexBlogNewThread, needsBlogs(BlogNewThread))
rb.POST(hmnurl.RegexBlogNewThread, needsBlogs(csrfMiddleware(BlogNewThreadSubmit)))
rb.GET(hmnurl.RegexBlogThread, BlogThread)
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
rb.GET(hmnurl.RegexBlogPostReply, needsBlogs(BlogPostReply))
rb.POST(hmnurl.RegexBlogPostReply, needsBlogs(csrfMiddleware(BlogPostReplySubmit)))
rb.GET(hmnurl.RegexBlogPostEdit, needsBlogs(BlogPostEdit))
rb.POST(hmnurl.RegexBlogPostEdit, needsBlogs(csrfMiddleware(BlogPostEditSubmit)))
rb.GET(hmnurl.RegexBlogPostDelete, needsBlogs(BlogPostDelete))
rb.POST(hmnurl.RegexBlogPostDelete, needsBlogs(csrfMiddleware(BlogPostDeleteSubmit)))
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
return c.Redirect(c.UrlContext.Url(
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
), http.StatusMovedPermanently)
})
2021-07-30 19:59:48 +00:00
rb.POST(hmnurl.RegexAssetUpload, AssetUpload)
}
officialProjectRoutes := anyProject.WithMiddleware(officialProjectMiddleware)
personalProjectRoutes := hmnOnly.Group(hmnurl.RegexPersonalProject, personalProjectMiddleware)
attachProjectRoutes(&officialProjectRoutes)
attachProjectRoutes(&personalProjectRoutes)
2021-07-23 03:09:46 +00:00
2022-08-05 04:03:45 +00:00
anyProject.POST(hmnurl.RegexSnippetSubmit, needsAuth(csrfMiddleware(SnippetEditSubmit)))
anyProject.GET(hmnurl.RegexEpisodeList, EpisodeList)
anyProject.GET(hmnurl.RegexEpisode, Episode)
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
2021-08-16 04:40:56 +00:00
anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS)
anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) ResponseData {
2021-07-30 22:32:19 +00:00
var res ResponseData
res.MustWriteTemplate("markdown_worker.js", nil, c.Perf)
2021-07-30 22:32:19 +00:00
res.Header().Add("Content-Type", "application/javascript")
return res
})
2021-03-14 20:49:58 +00:00
2021-06-22 09:50:40 +00:00
// Other
anyProject.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
2021-03-31 04:20:50 +00:00
return router
2021-03-14 20:49:58 +00:00
}
func setDBConn(conn *pgxpool.Pool) Middleware {
return func(h Handler) Handler {
return func(c *RequestContext) ResponseData {
c.Conn = conn
return h(c)
}
2021-11-08 19:16:54 +00:00
}
}
2021-03-27 21:10:11 +00:00
func redirectToHMN(h Handler) Handler {
return func(c *RequestContext) ResponseData {
if !c.CurrentProject.IsHMN() {
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
2021-03-27 21:10:11 +00:00
}
return h(c)
2021-03-27 21:10:11 +00:00
}
}
2021-04-29 03:07:14 +00:00
func officialProjectMiddleware(h Handler) Handler {
return func(c *RequestContext) ResponseData {
// Check if the current project (matched by subdomain) is actually no longer official
// and therefore needs to be redirected to the personal project version of the route.
if c.CurrentProject.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
2021-04-29 03:07:14 +00:00
}
return h(c)
2021-04-29 03:07:14 +00:00
}
}
2021-08-17 05:18:04 +00:00
func personalProjectMiddleware(h Handler) Handler {
return func(c *RequestContext) ResponseData {
hmnProject := c.CurrentProject
id := utils.Must1(strconv.Atoi(c.PathParams["projectid"]))
p, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
if err != nil {
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
}
2021-08-28 12:21:03 +00:00
}
c.CurrentProject = &p.Project
c.CurrentProject.Color1 = hmnProject.Color1
c.CurrentProject.Color2 = hmnProject.Color2
2021-08-17 05:18:04 +00:00
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, p.Owners)
2021-08-17 05:18:04 +00:00
if !c.CurrentProject.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
2021-08-17 05:18:04 +00:00
}
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(c.CurrentProject.Name) {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
2021-08-17 05:18:04 +00:00
}
return h(c)
2021-08-17 05:18:04 +00:00
}
}