2
0
Files
logikonline 573aa49a22
Some checks failed
Build and Release / Unit Tests (push) Successful in 3m32s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 8m1s
Build and Release / Lint (push) Failing after 8m13s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
fix(blog): allow public access to blog featured images
Allow public access to blog post featured images even when repository is private, if the post is published and blog is enabled. This supports public landing pages with blog sections that display featured images. Adds IsPublishedBlogFeaturedImage query and isBlogFeaturedImage check in attachment serving. Also removes redundant SafeHTML filter from blog content templates (already HTML-safe).
2026-03-15 23:05:58 -04:00

483 lines
14 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"`
Series string `xorm:"VARCHAR(255)"`
FeaturedImageID int64 `xorm:"DEFAULT 0"`
Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"`
AllowComments bool `xorm:"NOT NULL DEFAULT true"`
SubscriptionOnly bool `xorm:"NOT NULL DEFAULT false"`
ViewCount int64 `xorm:"NOT NULL DEFAULT 0"`
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
Keyword string
Tag string
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)
}
if opts.Keyword != "" {
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
"%"+strings.ToLower(opts.Keyword)+"%",
"%"+strings.ToLower(opts.Keyword)+"%")
}
if opts.Tag != "" {
// Match tag in comma-separated list
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
}
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)
}
if opts.Keyword != "" {
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
"%"+strings.ToLower(opts.Keyword)+"%",
"%"+strings.ToLower(opts.Keyword)+"%")
}
if opts.Tag != "" {
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
}
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)
Series string // filter by series name (exact match)
}
// 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)
}
if opts.Series != "" {
countSess = countSess.And("blog_post.series = ?", opts.Series)
findSess = findSess.And("blog_post.series = ?", opts.Series)
}
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 recent accessible published blog posts.
// Limits scan to most recent 500 posts for performance.
func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ([]*TagCount, error) {
// Fetch tags from recent accessible posts (limit scan for performance)
type tagRow struct {
Tags string `xorm:"tags"`
}
var rows []tagRow
err := exploreBlogBaseSess(ctx, actor).
Cols("blog_post.tags").
Where("blog_post.tags != ''").
OrderBy("blog_post.published_unix DESC").
Limit(500).
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
}
// GetRepoTopTags returns the top N tags for a specific repo's published blog posts.
func GetRepoTopTags(ctx context.Context, repoID int64, limit int) ([]*TagCount, error) {
type tagRow struct {
Tags string `xorm:"tags"`
}
var rows []tagRow
err := db.GetEngine(ctx).Table("blog_post").
Cols("tags").
Where("repo_id = ? AND status >= ? AND tags != ''", repoID, BlogPostPublic).
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
}
// CountPublicBlogPosts returns the total number of public/published blog posts across all repos.
func CountPublicBlogPosts(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where("status >= ?", BlogPostPublic).Count(new(BlogPost))
}
// GetPublicBlogPostsPage returns a page of public/published blog posts for sitemap generation.
func GetPublicBlogPostsPage(ctx context.Context, page, pageSize int) ([]*BlogPost, error) {
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
posts := make([]*BlogPost, 0, pageSize)
err := db.GetEngine(ctx).Where("status >= ?", BlogPostPublic).
OrderBy("id ASC").
Limit(pageSize, (page-1)*pageSize).
Find(&posts)
return posts, err
}
// 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))
}
// HasSubscriptionOnlyBlogPosts returns true if the repo has any published subscription-only blog posts.
func HasSubscriptionOnlyBlogPosts(ctx context.Context, repoID int64) (bool, error) {
count, err := db.GetEngine(ctx).Where("repo_id = ? AND status >= ? AND subscription_only = ?", repoID, BlogPostPublic, true).Count(new(BlogPost))
return count > 0, err
}
// IsPublishedBlogFeaturedImage checks if the given attachment ID is used as a
// featured image by any published blog post.
func IsPublishedBlogFeaturedImage(ctx context.Context, attachmentID int64) (bool, error) {
return db.GetEngine(ctx).
Where("featured_image_id = ? AND status >= ?", attachmentID, BlogPostPublic).
Exist(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
}
// IncrementBlogPostViewCount atomically increments the view count for a blog post.
func IncrementBlogPostViewCount(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).Exec("UPDATE blog_post SET view_count = view_count + 1 WHERE id = ?", id)
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
}