2
0
Files
gitcaddy-server/routers/web/explore/blog.go
logikonline a10dbda7ac
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
feat(blog): add view count tracking to blog posts
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.
2026-02-04 17:11:52 -05:00

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
}