2
0
Files
gitcaddy-server/models/blog/blog_post.go
logikonline c274086c46
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m20s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m23s
Build and Release / Lint (push) Successful in 5m40s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m50s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m58s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m36s
Build and Release / Build Binary (linux/arm64) (push) Successful in 10m51s
feat(blog): add filtering, sorting, and tags to explore page
Implements search by keyword (title/subtitle), tag filtering, and sort by newest/popular on explore blogs page. Adds GetExploreTopTags to show popular tags with usage counts. Enforces repository access permissions using AccessibleRepositoryCondition. Fixes secret lookup to skip scope conditions when querying by ID. Updates UI with tag cloud, search box, and sort dropdown.
2026-02-01 23:21:05 -05:00

362 lines
10 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package blog
import (
"context"
"fmt"
"sort"
"strings"
"code.gitcaddy.com/server/v3/models/db"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
// BlogPostStatus represents the publication state of a blog post.
type BlogPostStatus int //revive:disable-line:exported
const (
BlogPostDraft BlogPostStatus = 0
BlogPostPublic BlogPostStatus = 1
BlogPostPublished BlogPostStatus = 2
)
// String returns a human-readable label for the blog post status.
func (s BlogPostStatus) String() string {
switch s {
case BlogPostDraft:
return "draft"
case BlogPostPublic:
return "public"
case BlogPostPublished:
return "published"
default:
return "unknown"
}
}
// BlogPost represents a blog article belonging to a repository.
type BlogPost struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
AuthorID int64 `xorm:"INDEX NOT NULL"`
Title string `xorm:"VARCHAR(255) NOT NULL"`
Subtitle string `xorm:"VARCHAR(500)"`
Content string `xorm:"LONGTEXT NOT NULL"`
RenderedContent string `xorm:"-"`
Tags string `xorm:"TEXT"`
FeaturedImageID int64 `xorm:"DEFAULT 0"`
Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"`
AllowComments bool `xorm:"NOT NULL DEFAULT true"`
PublishedUnix timeutil.TimeStamp `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
Author *user_model.User `xorm:"-"`
Repo *repo_model.Repository `xorm:"-"`
FeaturedImage *repo_model.Attachment `xorm:"-"`
}
func init() {
db.RegisterModel(new(BlogPost))
}
// LoadAuthor loads the author user for a blog post.
func (p *BlogPost) LoadAuthor(ctx context.Context) error {
if p.Author != nil {
return nil
}
u, err := user_model.GetUserByID(ctx, p.AuthorID)
if err != nil {
return err
}
p.Author = u
return nil
}
// LoadRepo loads the repository for a blog post.
func (p *BlogPost) LoadRepo(ctx context.Context) error {
if p.Repo != nil {
return nil
}
r, err := repo_model.GetRepositoryByID(ctx, p.RepoID)
if err != nil {
return err
}
p.Repo = r
return nil
}
// LoadFeaturedImage loads the featured image attachment.
func (p *BlogPost) LoadFeaturedImage(ctx context.Context) error {
if p.FeaturedImage != nil || p.FeaturedImageID == 0 {
return nil
}
a, err := repo_model.GetAttachmentByID(ctx, p.FeaturedImageID)
if err != nil {
return err
}
p.FeaturedImage = a
return nil
}
// BlogPostSearchOptions contains filters for querying blog posts.
type BlogPostSearchOptions struct { //revive:disable-line:exported
RepoID int64
AuthorID int64
Status BlogPostStatus
AnyPublicStatus bool // if true, matches Public OR Published
Page int
PageSize int
}
// GetBlogPostByID returns a single blog post by ID.
func GetBlogPostByID(ctx context.Context, id int64) (*BlogPost, error) {
p := &BlogPost{}
has, err := db.GetEngine(ctx).ID(id).Get(p)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("blog post %d not found", id)
}
return p, nil
}
// GetBlogPostsByRepoID returns blog posts for a repo with filters and pagination.
func GetBlogPostsByRepoID(ctx context.Context, opts *BlogPostSearchOptions) ([]*BlogPost, int64, error) {
sess := db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID)
if opts.AnyPublicStatus {
sess = sess.And("status >= ?", BlogPostPublic)
} else if opts.Status >= 0 {
sess = sess.And("status = ?", opts.Status)
}
count, err := sess.Count(new(BlogPost))
if err != nil {
return nil, 0, err
}
// Re-create session for find (xorm reuses conditions)
sess = db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID)
if opts.AnyPublicStatus {
sess = sess.And("status >= ?", BlogPostPublic)
} else if opts.Status >= 0 {
sess = sess.And("status = ?", opts.Status)
}
pageSize := opts.PageSize
if pageSize <= 0 {
pageSize = 20
}
page := opts.Page
if page <= 0 {
page = 1
}
posts := make([]*BlogPost, 0, pageSize)
err = sess.OrderBy("created_unix DESC").
Limit(pageSize, (page-1)*pageSize).
Find(&posts)
return posts, count, err
}
// GetPublishedBlogPosts returns published blog posts across all repos.
func GetPublishedBlogPosts(ctx context.Context, page, pageSize int) ([]*BlogPost, int64, error) {
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
count, err := db.GetEngine(ctx).Where("status = ?", BlogPostPublished).Count(new(BlogPost))
if err != nil {
return nil, 0, err
}
posts := make([]*BlogPost, 0, pageSize)
err = db.GetEngine(ctx).Where("status = ?", BlogPostPublished).
OrderBy("published_unix DESC").
Limit(pageSize, (page-1)*pageSize).
Find(&posts)
return posts, count, err
}
// ExploreBlogPostsOptions configures the explore blogs query.
type ExploreBlogPostsOptions struct {
Actor *user_model.User
Page int
PageSize int
SortType string // "newest" (default) or "popular"
Keyword string // search in title/subtitle
Tag string // filter by tag (exact match within comma-separated tags)
}
// exploreBlogBaseSess creates a base session with blog status and repo access conditions.
func exploreBlogBaseSess(ctx context.Context, actor *user_model.User) *xorm.Session {
repoCond := repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid)
return db.GetEngine(ctx).
Table("blog_post").
Join("INNER", "`repository`", "`blog_post`.repo_id = `repository`.id").
Where("blog_post.status >= ?", BlogPostPublic).
And(repoCond)
}
// GetExploreBlogPosts returns published/public blog posts visible to the actor,
// filtered by repository access permissions.
func GetExploreBlogPosts(ctx context.Context, opts *ExploreBlogPostsOptions) ([]*BlogPost, int64, error) {
if opts.PageSize <= 0 {
opts.PageSize = 20
}
if opts.Page <= 0 {
opts.Page = 1
}
countSess := exploreBlogBaseSess(ctx, opts.Actor)
findSess := exploreBlogBaseSess(ctx, opts.Actor)
if opts.Keyword != "" {
kw := "%" + opts.Keyword + "%"
countSess = countSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
findSess = findSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
}
if opts.Tag != "" {
// Match tag within comma-separated list: exact match, or starts with, ends with, or in middle
tagPattern := "%" + opts.Tag + "%"
countSess = countSess.And("blog_post.tags LIKE ?", tagPattern)
findSess = findSess.And("blog_post.tags LIKE ?", tagPattern)
}
count, err := countSess.Count(new(BlogPost))
if err != nil {
return nil, 0, err
}
switch opts.SortType {
case "popular":
findSess = findSess.OrderBy(
"(SELECT COUNT(*) FROM blog_reaction WHERE blog_reaction.blog_post_id = blog_post.id AND blog_reaction.is_like = ?) DESC, blog_post.published_unix DESC",
true,
)
default: // "newest"
findSess = findSess.OrderBy("blog_post.published_unix DESC")
}
posts := make([]*BlogPost, 0, opts.PageSize)
err = findSess.
Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).
Find(&posts)
return posts, count, err
}
// TagCount represents a tag name and its usage count.
type TagCount struct {
Tag string
Count int
}
// GetExploreTopTags returns the top N tags across all accessible published blog posts.
func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ([]*TagCount, error) {
// Fetch all tags from accessible posts
type tagRow struct {
Tags string `xorm:"tags"`
}
var rows []tagRow
err := exploreBlogBaseSess(ctx, actor).
Cols("blog_post.tags").
Where("blog_post.tags != ''").
Find(&rows)
if err != nil {
return nil, err
}
// Aggregate tag counts
counts := make(map[string]int)
for _, r := range rows {
for t := range strings.SplitSeq(r.Tags, ",") {
t = strings.TrimSpace(t)
if t != "" {
counts[t]++
}
}
}
// Sort by count descending
result := make([]*TagCount, 0, len(counts))
for tag, c := range counts {
result = append(result, &TagCount{Tag: tag, Count: c})
}
sort.Slice(result, func(i, j int) bool {
if result[i].Count != result[j].Count {
return result[i].Count > result[j].Count
}
return result[i].Tag < result[j].Tag
})
if limit > 0 && len(result) > limit {
result = result[:limit]
}
return result, nil
}
// CountPublishedBlogsByRepoID returns the count of published/public blog posts for a repo.
func CountPublishedBlogsByRepoID(ctx context.Context, repoID int64) (int64, error) {
return db.GetEngine(ctx).Where("repo_id = ? AND status >= ?", repoID, BlogPostPublic).Count(new(BlogPost))
}
// CreateBlogPost inserts a new blog post.
func CreateBlogPost(ctx context.Context, p *BlogPost) error {
_, err := db.GetEngine(ctx).Insert(p)
return err
}
// UpdateBlogPost updates an existing blog post.
func UpdateBlogPost(ctx context.Context, p *BlogPost) error {
_, err := db.GetEngine(ctx).ID(p.ID).AllCols().Update(p)
return err
}
// DeleteBlogPost removes a blog post by ID.
func DeleteBlogPost(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(BlogPost))
return err
}
// CountPublishedBlogPostsByAuthorID returns the number of published/public blog posts by a user.
func CountPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64) (int64, error) {
return db.GetEngine(ctx).Where("author_id = ? AND status >= ?", authorID, BlogPostPublic).Count(new(BlogPost))
}
// GetPublishedBlogPostsByAuthorID returns published/public blog posts by a user, ordered by published date descending.
func GetPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64, page, pageSize int) ([]*BlogPost, int64, error) {
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
cond := "author_id = ? AND status >= ?"
count, err := db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).Count(new(BlogPost))
if err != nil {
return nil, 0, err
}
posts := make([]*BlogPost, 0, pageSize)
err = db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).
OrderBy("published_unix DESC").
Limit(pageSize, (page-1)*pageSize).
Find(&posts)
return posts, count, err
}