diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go new file mode 100644 index 0000000000..1134eb1407 --- /dev/null +++ b/models/blog/blog_post.go @@ -0,0 +1,208 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + "fmt" + + "code.gitcaddy.com/server/v3/models/db" + repo_model "code.gitcaddy.com/server/v3/models/repo" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// 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"` + 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 +} + +// 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 +} diff --git a/models/blog/blog_subscription.go b/models/blog/blog_subscription.go new file mode 100644 index 0000000000..e484b1586b --- /dev/null +++ b/models/blog/blog_subscription.go @@ -0,0 +1,59 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogSubscription represents a user's subscription to a repo's blog. +type BlogSubscription struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` + RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +func init() { + db.RegisterModel(new(BlogSubscription)) +} + +// IsSubscribedToBlog checks if a user is subscribed to a repo's blog. +func IsSubscribedToBlog(ctx context.Context, userID, repoID int64) (bool, error) { + return db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Exist(new(BlogSubscription)) +} + +// SubscribeToBlog adds a blog subscription. +func SubscribeToBlog(ctx context.Context, userID, repoID int64) error { + exists, err := IsSubscribedToBlog(ctx, userID, repoID) + if err != nil { + return err + } + if exists { + return nil + } + _, err = db.GetEngine(ctx).Insert(&BlogSubscription{ + UserID: userID, + RepoID: repoID, + }) + return err +} + +// UnsubscribeFromBlog removes a blog subscription. +func UnsubscribeFromBlog(ctx context.Context, userID, repoID int64) error { + _, err := db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Delete(new(BlogSubscription)) + return err +} + +// GetBlogSubscriberIDs returns the user IDs subscribed to a repo's blog. +func GetBlogSubscriberIDs(ctx context.Context, repoID int64) ([]int64, error) { + ids := make([]int64, 0, 64) + return ids, db.GetEngine(ctx).Table("blog_subscription"). + Where("repo_id = ?", repoID). + Select("user_id"). + Find(&ids) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 423c1efede..c4d14a0503 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -423,6 +423,9 @@ func prepareMigrationTasks() []*migration { newMigration(346, "Create repo_subscription_product table", v1_26.CreateRepoSubscriptionProductTable), newMigration(347, "Create repo_subscription table for user subscriptions", v1_26.CreateRepoSubscriptionTable), newMigration(348, "Add subscriptions_enabled to repository", v1_26.AddSubscriptionsEnabledToRepository), + newMigration(349, "Create blog_post table", v1_26.CreateBlogPostTable), + newMigration(350, "Create blog_subscription table", v1_26.CreateBlogSubscriptionTable), + newMigration(351, "Add blog_enabled to repository", v1_26.AddBlogEnabledToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v349.go b/models/migrations/v1_26/v349.go new file mode 100644 index 0000000000..9c78ef9543 --- /dev/null +++ b/models/migrations/v1_26/v349.go @@ -0,0 +1,30 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogPostTable creates the blog_post table. +func CreateBlogPostTable(x *xorm.Engine) error { + type BlogPost struct { + 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"` + Tags string `xorm:"TEXT"` + FeaturedImageID int64 `xorm:"DEFAULT 0"` + Status int `xorm:"SMALLINT NOT NULL DEFAULT 0"` + PublishedUnix timeutil.TimeStamp `xorm:"INDEX"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + return x.Sync(new(BlogPost)) +} diff --git a/models/migrations/v1_26/v350.go b/models/migrations/v1_26/v350.go new file mode 100644 index 0000000000..6214cb0704 --- /dev/null +++ b/models/migrations/v1_26/v350.go @@ -0,0 +1,22 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogSubscriptionTable creates the blog_subscription table. +func CreateBlogSubscriptionTable(x *xorm.Engine) error { + type BlogSubscription struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` + RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + return x.Sync(new(BlogSubscription)) +} diff --git a/models/migrations/v1_26/v351.go b/models/migrations/v1_26/v351.go new file mode 100644 index 0000000000..60cfd39b71 --- /dev/null +++ b/models/migrations/v1_26/v351.go @@ -0,0 +1,15 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +// AddBlogEnabledToRepository adds a flag to enable blog per repository. +func AddBlogEnabledToRepository(x *xorm.Engine) error { + type Repository struct { + BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` + } + + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index d4a838e6c8..7e0f92bc81 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -219,6 +219,7 @@ type Repository struct { SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"` Topics []string `xorm:"TEXT JSON"` SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"` + BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"` TrustModel TrustModelType diff --git a/modules/setting/config.go b/modules/setting/config.go index b36f4791bb..26e8238ed8 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -67,6 +67,7 @@ type ThemeStruct struct { CustomHomeTagline *config.Value[string] PinnedOrgDisplayFormat *config.Value[string] ExploreOrgDisplayFormat *config.Value[string] + EnableBlogs *config.Value[bool] } type ConfigStruct struct { @@ -105,6 +106,7 @@ func initDefaultConfig() { CustomHomeTagline: config.ValueJSON[string]("theme.custom_home_tagline").WithDefault(""), PinnedOrgDisplayFormat: config.ValueJSON[string]("theme.pinned_org_display_format").WithDefault("condensed"), ExploreOrgDisplayFormat: config.ValueJSON[string]("theme.explore_org_display_format").WithDefault("list"), + EnableBlogs: config.ValueJSON[bool]("theme.enable_blogs").WithDefault(false), }, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 42f7f1e316..1d2036f3f5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -363,6 +363,7 @@ "explore.users": "Users", "explore.organizations": "Organizations", "explore.packages": "Packages", + "explore.blogs": "Blogs", "explore.packages.empty.description": "No public or global packages are available yet.", "explore.go_to": "Go to", "explore.code": "Code", @@ -491,6 +492,9 @@ "mail.release.downloads": "Downloads:", "mail.release.download.zip": "Source Code (ZIP)", "mail.release.download.targz": "Source Code (TAR.GZ)", + "mail.blog.published_subject": "New blog post: \"%[1]s\" in %[2]s", + "mail.blog.published_body": "@%[1]s published a new blog post %[2]s in %[3]s", + "mail.blog.read_more": "Read More", "mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s", "mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you", "mail.repo.transfer.to_you": "you", @@ -1967,6 +1971,41 @@ "repo.signing.wont_sign.not_signed_in": "You are not signed in.", "repo.ext_wiki": "Access to External Wiki", "repo.ext_wiki.desc": "Link to an external wiki.", + "repo.blog": "Blog", + "repo.blog.new": "New Post", + "repo.blog.edit": "Edit Post", + "repo.blog.delete": "Delete Post", + "repo.blog.delete_confirm": "Are you sure you want to delete this blog post?", + "repo.blog.title": "Title", + "repo.blog.subtitle": "Subtitle", + "repo.blog.content": "Content", + "repo.blog.tags": "Tags", + "repo.blog.tags_placeholder": "Comma-separated tags", + "repo.blog.featured_image": "Featured Image", + "repo.blog.status": "Status", + "repo.blog.draft": "Draft", + "repo.blog.public": "Public", + "repo.blog.published": "Published", + "repo.blog.save_draft": "Save Draft", + "repo.blog.save_public": "Save Public", + "repo.blog.publish": "Publish", + "repo.blog.subscribe": "Subscribe to Blog", + "repo.blog.unsubscribe": "Unsubscribe", + "repo.blog.subscribed": "Subscribed", + "repo.blog.no_posts": "No blog posts yet.", + "repo.blog.no_posts_member": "No blog posts yet. Create the first one!", + "repo.blog.featured": "Featured", + "repo.blog.read_more": "Read More", + "repo.blog.by_author": "by %s", + "repo.blog.published_notification": "%s published a new blog post: %s", + "repo.blog.featured_image_uuid": "Attachment UUID", + "repo.blog.featured_image_help": "Enter the UUID of an uploaded attachment to use as the featured image.", + "repo.blog.content_placeholder": "Write your blog post content here...", + "repo.blog.tags_help": "Separate tags with commas.", + "repo.settings.blog": "Blog", + "repo.settings.blog.enable": "Enable Blog", + "repo.settings.blog.enable_desc": "Allow blog posts to be created and published from this repository.", + "repo.settings.blog.saved": "Blog settings saved.", "repo.wiki": "Wiki", "repo.wiki.welcome": "Welcome to the Wiki.", "repo.wiki.welcome_desc": "The wiki lets you write and share documentation with collaborators.", @@ -4001,6 +4040,8 @@ "admin.config.hide_explore_button_desc": "Hide the Explore button from the header navigation bar", "admin.config.enable_explore_packages": "Enable Explore Packages", "admin.config.enable_explore_packages_desc": "Show a Packages tab in the Explore menu to browse public and global packages", + "admin.config.enable_blogs": "Enable Blogs", + "admin.config.enable_blogs_desc": "Enable the Blogs feature across the platform. Repos can publish blog posts visible under Explore > Blogs.", "admin.config.custom_home_title": "Homepage Title", "admin.config.custom_home_title_placeholder": "Leave empty to use app name", "admin.config.custom_home_title_help": "Custom title displayed on the homepage. Leave empty to use the default app name.", diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 68638ea9c6..009f506e88 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -249,6 +249,7 @@ func ChangeConfig(ctx *context.Context) { cfg.Theme.CustomHomeTagline.DynKey(): marshalString(""), cfg.Theme.PinnedOrgDisplayFormat.DynKey(): marshalString("condensed"), cfg.Theme.ExploreOrgDisplayFormat.DynKey(): marshalString("list"), + cfg.Theme.EnableBlogs.DynKey(): marshalBool, } _ = ctx.Req.ParseForm() diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go new file mode 100644 index 0000000000..cde478e3bc --- /dev/null +++ b/routers/web/explore/blog.go @@ -0,0 +1,74 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/services/context" +) + +const tplExploreBlogs templates.TplName = "explore/blogs" + +// 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["PageIsExplore"] = true + ctx.Data["PageIsExploreBlogs"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + page := max(ctx.FormInt("page"), 1) + pageSize := setting.UI.IssuePagingNum + + posts, total, err := blog_model.GetPublishedBlogPosts(ctx, page, pageSize) + if err != nil { + ctx.ServerError("GetPublishedBlogPosts", err) + return + } + + // Load authors, repos, and featured images + for _, post := range posts { + if err := post.LoadAuthor(ctx); err != nil { + ctx.ServerError("LoadAuthor", err) + return + } + if err := post.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + } + + // Separate featured post (most recent) from the rest + if len(posts) > 0 { + ctx.Data["FeaturedPost"] = posts[0] + if len(posts) > 1 { + ctx.Data["Posts"] = posts[1:] + } + } + + 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) +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index d09e250a09..36806e4558 100644 --- a/routers/web/explore/code.go +++ b/routers/web/explore/code.go @@ -31,6 +31,7 @@ func Code(ctx *context.Context) { ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx) ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index fd029f2891..da3e05d6bb 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -22,6 +22,7 @@ func Organizations(ctx *context.Context) { ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage || setting.Config().Theme.HideExploreUsers.Value(ctx) ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = setting.Service.Explore.EnablePackagesPage || setting.Config().Theme.EnableExplorePackages.Value(ctx) + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExploreOrganizations"] = true diff --git a/routers/web/explore/package.go b/routers/web/explore/package.go index 1ad4997788..f858dc6d98 100644 --- a/routers/web/explore/package.go +++ b/routers/web/explore/package.go @@ -28,6 +28,7 @@ func Packages(ctx *context.Context) { ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["PackagesPageIsEnabled"] = true + ctx.Data["BlogsPageIsEnabled"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExplorePackages"] = true diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index cbca085c67..57aa19ad17 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -154,6 +154,7 @@ func Repos(ctx *context.Context) { 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"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["ShowRepoOwnerOnList"] = true diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index 6384a8464b..944d7b618b 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -166,6 +166,7 @@ func Users(ctx *context.Context) { 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"] = setting.Config().Theme.EnableBlogs.Value(ctx) ctx.Data["Title"] = ctx.Tr("explore_title") ctx.Data["PageIsExplore"] = true ctx.Data["PageIsExploreUsers"] = true diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go new file mode 100644 index 0000000000..f7b78715a6 --- /dev/null +++ b/routers/web/repo/blog.go @@ -0,0 +1,418 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + "code.gitcaddy.com/server/v3/models/renderhelper" + 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/markup/markdown" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/timeutil" + "code.gitcaddy.com/server/v3/modules/web" + "code.gitcaddy.com/server/v3/services/context" + "code.gitcaddy.com/server/v3/services/forms" + "code.gitcaddy.com/server/v3/services/mailer" +) + +const ( + tplBlogList templates.TplName = "repo/blog/list" + tplBlogView templates.TplName = "repo/blog/view" + tplBlogEditor templates.TplName = "repo/blog/editor" +) + +func blogEnabled(ctx *context.Context) bool { + return ctx.Repo.Repository.BlogEnabled && setting.Config().Theme.EnableBlogs.Value(ctx) +} + +// BlogList renders the repo blog listing page. +func BlogList(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog") + ctx.Data["PageIsRepoBlog"] = true + + page := max(ctx.FormInt("page"), 1) + pageSize := setting.UI.IssuePagingNum + + opts := &blog_model.BlogPostSearchOptions{ + RepoID: ctx.Repo.Repository.ID, + Page: page, + PageSize: pageSize, + Status: -1, // all statuses for members + } + + // Non-members only see public + published + isWriter := ctx.Repo.CanWrite(unit.TypeCode) + if !isWriter { + opts.AnyPublicStatus = true + } + + posts, total, err := blog_model.GetBlogPostsByRepoID(ctx, opts) + if err != nil { + ctx.ServerError("GetBlogPostsByRepoID", err) + return + } + + for _, post := range posts { + if err := post.LoadAuthor(ctx); err != nil { + ctx.ServerError("LoadAuthor", err) + return + } + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + } + + // Featured post: most recent published + if len(posts) > 0 { + ctx.Data["FeaturedPost"] = posts[0] + if len(posts) > 1 { + ctx.Data["Posts"] = posts[1:] + } + } + + ctx.Data["IsWriter"] = isWriter + ctx.Data["Total"] = total + + // Blog subscription status + if ctx.Doer != nil { + subscribed, err := blog_model.IsSubscribedToBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("IsSubscribedToBlog", err) + return + } + ctx.Data["IsSubscribed"] = subscribed + } + + // Cross-promoted repos for right pane + crossPromoted, err := repo_model.GetCrossPromotedRepos(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetCrossPromotedRepos", err) + return + } + // Load target repos in batch + repoIDs := make([]int64, len(crossPromoted)) + for i, cp := range crossPromoted { + repoIDs[i] = cp.TargetRepoID + } + if len(repoIDs) > 0 { + repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + visible := make([]*repo_model.RepoCrossPromote, 0, len(crossPromoted)) + for _, cp := range crossPromoted { + if repo, ok := repos[cp.TargetRepoID]; ok { + cp.TargetRepo = repo + visible = append(visible, cp) + } + } + crossPromoted = visible + } + ctx.Data["CrossPromotedRepos"] = crossPromoted + + pager := context.NewPagination(int(total), pageSize, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplBlogList) +} + +// BlogView renders a single blog post. +func BlogView(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + // Verify post belongs to this repo + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + // Draft posts only visible to writers + isWriter := ctx.Repo.CanWrite(unit.TypeCode) + if post.Status == blog_model.BlogPostDraft && !isWriter { + ctx.NotFound(nil) + return + } + + if err := post.LoadAuthor(ctx); err != nil { + ctx.ServerError("LoadAuthor", err) + return + } + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + + // Render markdown content + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) + 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, ",") + } + + ctx.Data["Title"] = post.Title + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["BlogPost"] = post + ctx.Data["IsWriter"] = isWriter + + ctx.HTML(http.StatusOK, tplBlogView) +} + +// BlogNew renders the new blog post form. +func BlogNew(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog.new") + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["IsNewPost"] = true + + ctx.HTML(http.StatusOK, tplBlogEditor) +} + +// BlogNewPost handles the new blog post form submission. +func BlogNewPost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + form := web.GetForm(ctx).(*forms.BlogPostForm) + + post := &blog_model.BlogPost{ + RepoID: ctx.Repo.Repository.ID, + AuthorID: ctx.Doer.ID, + Title: form.Title, + Subtitle: form.Subtitle, + Content: form.Content, + Tags: strings.TrimSpace(form.Tags), + Status: blog_model.BlogPostStatus(form.Status), + } + + // Link featured image if provided + if form.FeaturedImage != "" { + attach, err := repo_model.GetAttachmentByUUID(ctx, form.FeaturedImage) + if err == nil { + post.FeaturedImageID = attach.ID + } + } + + // Set published timestamp on first publish + if post.Status == blog_model.BlogPostPublished { + post.PublishedUnix = timeutil.TimeStampNow() + } + + if err := blog_model.CreateBlogPost(ctx, post); err != nil { + ctx.ServerError("CreateBlogPost", err) + return + } + + // Trigger notifications on publish + if post.Status == blog_model.BlogPostPublished { + go notifyBlogPublished(ctx, post) + } + + // Redirect to the blog list since we don't have the ID from insert yet + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogEdit renders the edit blog post form. +func BlogEdit(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.blog.edit") + ctx.Data["PageIsRepoBlog"] = true + ctx.Data["BlogPost"] = post + ctx.Data["IsNewPost"] = false + + ctx.HTML(http.StatusOK, tplBlogEditor) +} + +// BlogEditPost handles the edit blog post form submission. +func BlogEditPost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + form := web.GetForm(ctx).(*forms.BlogPostForm) + oldStatus := post.Status + + post.Title = form.Title + post.Subtitle = form.Subtitle + post.Content = form.Content + post.Tags = strings.TrimSpace(form.Tags) + post.Status = blog_model.BlogPostStatus(form.Status) + + // Link featured image if provided + if form.FeaturedImage != "" { + attach, err := repo_model.GetAttachmentByUUID(ctx, form.FeaturedImage) + if err == nil { + post.FeaturedImageID = attach.ID + } + } + + // Set published timestamp on first publish + if post.Status == blog_model.BlogPostPublished && post.PublishedUnix == 0 { + post.PublishedUnix = timeutil.TimeStampNow() + } + + if err := blog_model.UpdateBlogPost(ctx, post); err != nil { + ctx.ServerError("UpdateBlogPost", err) + return + } + + // Trigger notifications if status changed to published for the first time + if post.Status == blog_model.BlogPostPublished && oldStatus != blog_model.BlogPostPublished { + go notifyBlogPublished(ctx, post) + } + + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogDeletePost handles blog post deletion. +func BlogDeletePost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + if post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + if err := blog_model.DeleteBlogPost(ctx, post.ID); err != nil { + ctx.ServerError("DeleteBlogPost", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.blog.delete")) + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogSubscribe subscribes the current user to the repo's blog. +func BlogSubscribe(ctx *context.Context) { + if err := blog_model.SubscribeToBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("SubscribeToBlog", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// BlogUnsubscribe unsubscribes the current user from the repo's blog. +func BlogUnsubscribe(ctx *context.Context) { + if err := blog_model.UnsubscribeFromBlog(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("UnsubscribeFromBlog", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/blog") +} + +// notifyBlogPublished sends notifications to repo watchers and blog subscribers. +func notifyBlogPublished(ctx *context.Context, post *blog_model.BlogPost) { + if err := post.LoadRepo(ctx); err != nil { + return + } + if err := post.LoadAuthor(ctx); err != nil { + return + } + + // Collect recipients: repo watchers + blog subscribers (deduplicated) + watcherIDs, _ := repo_model.GetRepoWatchersIDs(ctx, post.RepoID) + subscriberIDs, _ := blog_model.GetBlogSubscriberIDs(ctx, post.RepoID) + + recipientSet := make(map[int64]struct{}) + for _, id := range watcherIDs { + recipientSet[id] = struct{}{} + } + for _, id := range subscriberIDs { + recipientSet[id] = struct{}{} + } + // Exclude author + delete(recipientSet, post.AuthorID) + + if len(recipientSet) == 0 { + return + } + + recipientIDs := make([]int64, 0, len(recipientSet)) + for id := range recipientSet { + recipientIDs = append(recipientIDs, id) + } + + recipients, _ := user_model.GetUsersByIDs(ctx, recipientIDs) + + // Send email notifications + if setting.MailService != nil { + _ = mailer.MailBlogPublished(ctx, post, recipients) + } +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 0950c8b5a3..a598cdfecd 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -525,6 +525,11 @@ func handleSettingsPostAdvanced(ctx *context.Context) { repoChanged = true } + if repo.BlogEnabled != form.EnableBlog { + repo.BlogEnabled = form.EnableBlog + repoChanged = true + } + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) } else if !unit_model.TypeCode.UnitGlobalDisabled() { diff --git a/routers/web/web.go b/routers/web/web.go index 039e6785f0..0f26862fd8 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -566,6 +566,7 @@ func registerWebRoutes(m *web.Router) { m.Get("/users/sitemap-{idx}.xml", sitemapEnabled, explore.Users) m.Get("/organizations", explore.Organizations) m.Get("/packages", explore.Packages) + m.Get("/blogs", explore.Blogs) m.Get("/code", func(ctx *context.Context) { if unit.TypeCode.UnitGlobalDisabled() { ctx.NotFound(nil) @@ -1709,6 +1710,23 @@ func registerWebRoutes(m *web.Router) { }) // end "/{username}/{reponame}/wiki" + m.Group("/{username}/{reponame}/blog", func() { + m.Get("", repo.BlogList) + m.Get("/{id}", repo.BlogView) + m.Group("", func() { + m.Get("/new", repo.BlogNew) + m.Post("/new", web.Bind(forms.BlogPostForm{}), repo.BlogNewPost) + m.Get("/{id}/edit", repo.BlogEdit) + m.Post("/{id}/edit", web.Bind(forms.BlogPostForm{}), repo.BlogEditPost) + m.Post("/{id}/delete", repo.BlogDeletePost) + }, reqSignIn, reqRepoCodeWriter) + m.Post("/subscribe", reqSignIn, repo.BlogSubscribe) + m.Post("/unsubscribe", reqSignIn, repo.BlogUnsubscribe) + }, optSignIn, context.RepoAssignment, func(ctx *context.Context) { + ctx.Data["PageIsRepoBlog"] = true + }) + // end "/{username}/{reponame}/blog" + m.Group("/{username}/{reponame}/pages", func() { m.Get("", pages.ServeRepoLandingPage) m.Get("/assets/*", pages.ServeRepoPageAsset) diff --git a/services/context/repo.go b/services/context/repo.go index 74680bf394..6b7c90bb1c 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -15,6 +15,7 @@ import ( "strings" asymkey_model "code.gitcaddy.com/server/v3/models/asymkey" + blog_model "code.gitcaddy.com/server/v3/models/blog" "code.gitcaddy.com/server/v3/models/db" git_model "code.gitcaddy.com/server/v3/models/git" issues_model "code.gitcaddy.com/server/v3/models/issues" @@ -558,6 +559,16 @@ func RepoAssignment(ctx *Context) { return } + // Blog post count for repo header tab + if repo.BlogEnabled && setting.Config().Theme.EnableBlogs.Value(ctx) { + blogCount, err := blog_model.CountPublishedBlogsByRepoID(ctx, repo.ID) + if err != nil { + ctx.ServerError("CountPublishedBlogsByRepoID", err) + return + } + ctx.Data["BlogPostCount"] = blogCount + } + ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName ctx.Data["Repository"] = repo diff --git a/services/forms/blog_form.go b/services/forms/blog_form.go new file mode 100644 index 0000000000..79d16a362e --- /dev/null +++ b/services/forms/blog_form.go @@ -0,0 +1,29 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package forms + +import ( + "net/http" + + "code.gitcaddy.com/server/v3/modules/web/middleware" + "code.gitcaddy.com/server/v3/services/context" + + "gitea.com/go-chi/binding" +) + +// BlogPostForm is the form for creating/editing blog posts. +type BlogPostForm struct { + Title string `binding:"Required;MaxSize(255)"` + Subtitle string `binding:"MaxSize(500)"` + Content string `binding:"Required"` + Tags string `binding:"MaxSize(1000)"` + FeaturedImage string // attachment UUID + Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published +} + +// Validate validates the fields +func (f *BlogPostForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e082bbcf23..a63a7a9c31 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -125,6 +125,7 @@ type RepoSettingForm struct { ExternalTrackerRegexpPattern string EnableCloseIssuesViaCommitInAnyBranch bool HideDotfiles bool + EnableBlog bool EnableProjects bool ProjectsMode string diff --git a/services/mailer/mail_blog.go b/services/mailer/mail_blog.go new file mode 100644 index 0000000000..53f8e74543 --- /dev/null +++ b/services/mailer/mail_blog.go @@ -0,0 +1,75 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + + blog_model "code.gitcaddy.com/server/v3/models/blog" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/log" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/translation" + sender_service "code.gitcaddy.com/server/v3/services/mailer/sender" +) + +const tplBlogPublishedMail templates.TplName = "repo/blog" + +func generateMessageIDForBlogPost(post *blog_model.BlogPost) string { + return fmt.Sprintf("<%s/blog/%d@%s>", post.Repo.FullName(), post.ID, setting.Domain) +} + +// MailBlogPublished sends notification emails to recipients when a blog post is published. +func MailBlogPublished(_ context.Context, post *blog_model.BlogPost, recipients []*user_model.User) error { + if setting.MailService == nil { + return nil + } + + langMap := make(map[string][]*user_model.User) + for _, user := range recipients { + langMap[user.Language] = append(langMap[user.Language], user) + } + + for lang, tos := range langMap { + mailBlogPublished(lang, tos, post) + } + + return nil +} + +func mailBlogPublished(lang string, tos []*user_model.User, post *blog_model.BlogPost) { + locale := translation.NewLocale(lang) + + subject := locale.TrString("mail.blog.published_subject", post.Title, post.Repo.FullName()) + blogURL := fmt.Sprintf("%s%s/blog/%d", setting.AppURL, post.Repo.FullName(), post.ID) + + mailMeta := map[string]any{ + "locale": locale, + "Post": post, + "Subject": subject, + "Language": locale.Language(), + "Link": blogURL, + } + + var mailBody bytes.Buffer + if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplBlogPublishedMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplBlogPublishedMail)+"/body", err) + return + } + + msgs := make([]*sender_service.Message, 0, len(tos)) + publisherName := fromDisplayName(post.Author) + msgID := generateMessageIDForBlogPost(post) + for _, to := range tos { + msg := sender_service.NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String()) + msg.Info = subject + msg.SetHeader("Message-ID", msgID) + msgs = append(msgs, msg) + } + + SendAsync(msgs...) +} diff --git a/templates/admin/config_settings/theme.tmpl b/templates/admin/config_settings/theme.tmpl index 1ef4f83591..e22b6ef886 100644 --- a/templates/admin/config_settings/theme.tmpl +++ b/templates/admin/config_settings/theme.tmpl @@ -31,6 +31,13 @@
+