All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m10s
Build and Release / Lint (push) Successful in 4m54s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m0s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m43s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 6m39s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m46s
Build and Release / Build Binary (linux/arm64) (push) Successful in 9m6s
Adds view_count field to blog_post table with database migration. Implements atomic increment on post views in both standalone and repo blog routes. Displays view count with eye icon in post templates.
334 lines
9.2 KiB
Go
334 lines
9.2 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package explore
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
blog_model "code.gitcaddy.com/server/v3/models/blog"
|
|
monetize_model "code.gitcaddy.com/server/v3/models/monetize"
|
|
access_model "code.gitcaddy.com/server/v3/models/perm/access"
|
|
"code.gitcaddy.com/server/v3/models/renderhelper"
|
|
"code.gitcaddy.com/server/v3/models/unit"
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
"code.gitcaddy.com/server/v3/modules/markup/markdown"
|
|
"code.gitcaddy.com/server/v3/modules/setting"
|
|
"code.gitcaddy.com/server/v3/modules/sitemap"
|
|
"code.gitcaddy.com/server/v3/modules/templates"
|
|
"code.gitcaddy.com/server/v3/services/context"
|
|
|
|
perm_model "code.gitcaddy.com/server/v3/models/perm"
|
|
)
|
|
|
|
const (
|
|
tplExploreBlogs templates.TplName = "explore/blogs"
|
|
tplStandaloneBlogView templates.TplName = "blog/standalone_view"
|
|
)
|
|
|
|
// Blogs renders the explore blogs page with published posts across all repos.
|
|
func Blogs(ctx *context.Context) {
|
|
if !setting.Config().Theme.EnableBlogs.Value(ctx) {
|
|
ctx.Redirect(setting.AppSubURL + "/explore")
|
|
return
|
|
}
|
|
|
|
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx)
|
|
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
|
|
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
|
|
ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx)
|
|
ctx.Data["BlogsPageIsEnabled"] = true
|
|
ctx.Data["Title"] = ctx.Tr("explore.blogs")
|
|
ctx.Data["PageIsExploreBlogs"] = true
|
|
|
|
blogsInTopNav := setting.Config().Theme.BlogsInTopNav.Value(ctx)
|
|
ctx.Data["BlogsInTopNav"] = blogsInTopNav
|
|
if !blogsInTopNav {
|
|
ctx.Data["PageIsExplore"] = true
|
|
}
|
|
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
|
|
|
|
page := max(ctx.FormInt("page"), 1)
|
|
pageSize := setting.UI.IssuePagingNum
|
|
|
|
sortType := ctx.FormString("sort")
|
|
if sortType != "popular" {
|
|
sortType = "newest"
|
|
}
|
|
ctx.Data["SortType"] = sortType
|
|
|
|
keyword := ctx.FormTrim("q")
|
|
ctx.Data["Keyword"] = keyword
|
|
|
|
tag := ctx.FormTrim("tag")
|
|
ctx.Data["Tag"] = tag
|
|
|
|
series := ctx.FormTrim("series")
|
|
ctx.Data["Series"] = series
|
|
|
|
posts, total, err := blog_model.GetExploreBlogPosts(ctx, &blog_model.ExploreBlogPostsOptions{
|
|
Actor: ctx.Doer,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
SortType: sortType,
|
|
Keyword: keyword,
|
|
Tag: tag,
|
|
Series: series,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetExploreBlogPosts", err)
|
|
return
|
|
}
|
|
|
|
// Load authors, repos (with owners for avatar fallback), and featured images
|
|
for _, post := range posts {
|
|
_ = post.LoadAuthor(ctx)
|
|
_ = post.LoadRepo(ctx)
|
|
if post.Repo != nil {
|
|
_ = post.Repo.LoadOwner(ctx)
|
|
}
|
|
_ = post.LoadFeaturedImage(ctx)
|
|
}
|
|
|
|
// Only show featured post on page 1 with no search/tag/series filter
|
|
showFeatured := page == 1 && keyword == "" && tag == "" && series == ""
|
|
if showFeatured && len(posts) > 0 {
|
|
if counts, err := blog_model.GetBlogReactionCounts(ctx, posts[0].ID); err == nil {
|
|
ctx.Data["FeaturedLikes"] = counts.Likes
|
|
}
|
|
ctx.Data["FeaturedPost"] = posts[0]
|
|
if len(posts) > 1 {
|
|
ctx.Data["Posts"] = posts[1:]
|
|
}
|
|
} else {
|
|
ctx.Data["Posts"] = posts
|
|
}
|
|
|
|
// Load top tags for sidebar
|
|
topTags, err := blog_model.GetExploreTopTags(ctx, ctx.Doer, 10)
|
|
if err != nil {
|
|
ctx.ServerError("GetExploreTopTags", err)
|
|
return
|
|
}
|
|
ctx.Data["TopTags"] = topTags
|
|
|
|
ctx.Data["Total"] = total
|
|
|
|
pager := context.NewPagination(int(total), pageSize, page, 5)
|
|
pager.AddParamFromRequest(ctx.Req)
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.HTML(http.StatusOK, tplExploreBlogs)
|
|
}
|
|
|
|
// StandaloneBlogView renders a blog post without the repo header/tabs.
|
|
func StandaloneBlogView(ctx *context.Context) {
|
|
if !setting.Config().Theme.EnableBlogs.Value(ctx) {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
|
|
post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id"))
|
|
if err != nil {
|
|
ctx.NotFound(err)
|
|
return
|
|
}
|
|
|
|
// Only public/published posts visible in standalone view
|
|
if post.Status < blog_model.BlogPostPublic {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
|
|
if err := post.LoadRepo(ctx); err != nil {
|
|
ctx.ServerError("LoadRepo", err)
|
|
return
|
|
}
|
|
|
|
// Check repo access for the current user
|
|
if post.Repo == nil {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
|
|
// Verify the user can read this repo
|
|
hasAccess, err := access_model.HasAccessUnit(ctx, ctx.Doer, post.Repo, unit.TypeCode, perm_model.AccessModeRead)
|
|
if err != nil {
|
|
ctx.ServerError("HasAccessUnit", err)
|
|
return
|
|
}
|
|
if !hasAccess {
|
|
// Also check if repo is public/limited
|
|
if post.Repo.IsPrivate {
|
|
ctx.NotFound(nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Increment view count
|
|
_ = blog_model.IncrementBlogPostViewCount(ctx, post.ID)
|
|
post.ViewCount++
|
|
|
|
if err := post.LoadAuthor(ctx); err != nil {
|
|
ctx.ServerError("LoadAuthor", err)
|
|
return
|
|
}
|
|
if err := post.LoadFeaturedImage(ctx); err != nil {
|
|
ctx.ServerError("LoadFeaturedImage", err)
|
|
return
|
|
}
|
|
if err := post.Repo.LoadOwner(ctx); err != nil {
|
|
ctx.ServerError("LoadOwner", err)
|
|
return
|
|
}
|
|
|
|
// Render markdown content
|
|
rctx := renderhelper.NewRenderContextRepoComment(ctx, post.Repo)
|
|
rendered, err := markdown.RenderString(rctx, post.Content)
|
|
if err != nil {
|
|
ctx.ServerError("RenderString", err)
|
|
return
|
|
}
|
|
post.RenderedContent = string(rendered)
|
|
|
|
// Parse tags
|
|
if post.Tags != "" {
|
|
ctx.Data["BlogTags"] = strings.Split(post.Tags, ",")
|
|
}
|
|
|
|
// Load reaction counts and user's current reaction
|
|
reactionCounts, err := blog_model.GetBlogReactionCounts(ctx, post.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetBlogReactionCounts", err)
|
|
return
|
|
}
|
|
ctx.Data["ReactionCounts"] = reactionCounts
|
|
|
|
var userID int64
|
|
guestIP := ctx.Req.RemoteAddr
|
|
if ctx.Doer != nil {
|
|
userID = ctx.Doer.ID
|
|
guestIP = ""
|
|
}
|
|
userReaction, _ := blog_model.GetUserBlogReaction(ctx, post.ID, userID, guestIP)
|
|
ctx.Data["UserReaction"] = userReaction
|
|
|
|
// Always load comments (even when disabled, to show existing ones as read-only)
|
|
comments, err := blog_model.GetBlogCommentsByPostID(ctx, post.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetBlogCommentsByPostID", err)
|
|
return
|
|
}
|
|
ctx.Data["BlogComments"] = comments
|
|
|
|
commentCount, _ := blog_model.CountBlogComments(ctx, post.ID)
|
|
ctx.Data["CommentCount"] = commentCount
|
|
|
|
// Load comment reaction counts and user reactions
|
|
commentIDs := collectCommentIDs(comments)
|
|
if len(commentIDs) > 0 {
|
|
commentReactionCounts, err := blog_model.GetBlogCommentReactionCountsBatch(ctx, commentIDs)
|
|
if err == nil {
|
|
ctx.Data["CommentReactionCounts"] = commentReactionCounts
|
|
}
|
|
userCommentReactions, err := blog_model.GetUserBlogCommentReactionsBatch(ctx, commentIDs, userID, guestIP)
|
|
if err == nil {
|
|
ctx.Data["UserCommentReactions"] = userCommentReactions
|
|
}
|
|
}
|
|
|
|
// Check guest token cookie
|
|
if ctx.Doer == nil {
|
|
if tokenStr, err := ctx.Req.Cookie("blog_guest_token"); err == nil {
|
|
guestToken, _ := blog_model.GetGuestTokenByToken(ctx, tokenStr.Value)
|
|
if guestToken != nil && guestToken.Verified {
|
|
ctx.Data["GuestToken"] = guestToken
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if user is a writer on this repo
|
|
isWriter := false
|
|
if ctx.Doer != nil {
|
|
perm, permErr := access_model.GetUserRepoPermission(ctx, post.Repo, ctx.Doer)
|
|
if permErr == nil {
|
|
isWriter = perm.CanWrite(unit.TypeCode)
|
|
}
|
|
}
|
|
|
|
// Subscription-only gate
|
|
subscriptionGated := false
|
|
if post.SubscriptionOnly && !isWriter && setting.Monetize.Enabled && post.Repo.SubscriptionsEnabled {
|
|
if ctx.Doer == nil || !ctx.Doer.IsAdmin {
|
|
hasAccess := false
|
|
if ctx.Doer != nil {
|
|
hasAccess, _ = monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, post.Repo.ID)
|
|
}
|
|
if !hasAccess {
|
|
subscriptionGated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// RepoLink is needed for form actions (reactions, comments)
|
|
repoLink := post.Repo.Link()
|
|
|
|
ctx.Data["Title"] = post.Title
|
|
ctx.Data["BlogPost"] = post
|
|
ctx.Data["IsWriter"] = isWriter
|
|
ctx.Data["IsSigned"] = ctx.Doer != nil
|
|
ctx.Data["SubscriptionGated"] = subscriptionGated
|
|
ctx.Data["RepoLink"] = repoLink
|
|
if ctx.Doer != nil {
|
|
ctx.Data["SignedUserID"] = ctx.Doer.ID
|
|
}
|
|
|
|
// SEO: ISO 8601 published time for OpenGraph article:published_time
|
|
if post.PublishedUnix > 0 {
|
|
ctx.Data["BlogPublishedISO"] = post.PublishedUnix.AsTime().Format(time.RFC3339)
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, tplStandaloneBlogView)
|
|
}
|
|
|
|
// BlogSitemap renders a sitemap page for blog posts.
|
|
func BlogSitemap(ctx *context.Context) {
|
|
page := ctx.PathParamInt("idx")
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
posts, err := blog_model.GetPublicBlogPostsPage(ctx, page, setting.UI.SitemapPagingNum)
|
|
if err != nil {
|
|
ctx.ServerError("GetPublicBlogPostsPage", err)
|
|
return
|
|
}
|
|
|
|
m := sitemap.NewSitemap()
|
|
for _, post := range posts {
|
|
m.Add(sitemap.URL{
|
|
URL: setting.AppURL + "blog/" + strconv.FormatInt(post.ID, 10),
|
|
LastMod: post.UpdatedUnix.AsTimePtr(),
|
|
})
|
|
}
|
|
ctx.Resp.Header().Set("Content-Type", "text/xml")
|
|
if _, err := m.WriteTo(ctx.Resp); err != nil {
|
|
log.Error("Failed writing blog sitemap: %v", err)
|
|
}
|
|
}
|
|
|
|
// collectCommentIDs returns all comment IDs from top-level comments and their replies.
|
|
func collectCommentIDs(comments []*blog_model.BlogComment) []int64 {
|
|
var ids []int64
|
|
for _, c := range comments {
|
|
ids = append(ids, c.ID)
|
|
for _, r := range c.Replies {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|