diff --git a/models/blog/blog_comment.go b/models/blog/blog_comment.go new file mode 100644 index 0000000000..045a4ccb7d --- /dev/null +++ b/models/blog/blog_comment.go @@ -0,0 +1,140 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogComment represents a comment or reply on a blog post. +// ParentID = 0 means top-level comment; ParentID > 0 means a reply. +type BlogComment struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BlogPostID int64 `xorm:"INDEX NOT NULL"` + ParentID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + GuestName string `xorm:"VARCHAR(100)"` + GuestEmail string `xorm:"VARCHAR(255)"` + Content string `xorm:"LONGTEXT NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + + // Not persisted + User *user_model.User `xorm:"-"` + Replies []*BlogComment `xorm:"-"` +} + +func init() { + db.RegisterModel(new(BlogComment)) +} + +// IsGuest returns true if the comment was posted by an anonymous guest. +func (c *BlogComment) IsGuest() bool { + return c.UserID == 0 +} + +// DisplayName returns the commenter's display name. +func (c *BlogComment) DisplayName() string { + if c.User != nil { + return c.User.DisplayName() + } + if c.GuestName != "" { + return c.GuestName + } + return "Anonymous" +} + +// LoadUser loads the user for non-guest comments. +func (c *BlogComment) LoadUser(ctx context.Context) error { + if c.UserID == 0 || c.User != nil { + return nil + } + u, err := user_model.GetUserByID(ctx, c.UserID) + if err != nil { + return err + } + c.User = u + return nil +} + +// CreateBlogComment inserts a new blog comment. +func CreateBlogComment(ctx context.Context, c *BlogComment) error { + _, err := db.GetEngine(ctx).Insert(c) + return err +} + +// GetBlogCommentByID returns a single comment by ID. +func GetBlogCommentByID(ctx context.Context, id int64) (*BlogComment, error) { + c := &BlogComment{} + has, err := db.GetEngine(ctx).ID(id).Get(c) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return c, nil +} + +// GetBlogCommentsByPostID returns all comments for a post, organized as top-level with nested replies. +func GetBlogCommentsByPostID(ctx context.Context, blogPostID int64) ([]*BlogComment, error) { + var all []*BlogComment + err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID). + OrderBy("created_unix ASC"). + Find(&all) + if err != nil { + return nil, err + } + + // Load users for non-guest comments + for _, c := range all { + if err := c.LoadUser(ctx); err != nil { + return nil, err + } + } + + // Build tree: top-level + replies + commentMap := make(map[int64]*BlogComment, len(all)) + topLevel := make([]*BlogComment, 0) + for _, c := range all { + commentMap[c.ID] = c + } + for _, c := range all { + if c.ParentID == 0 { + topLevel = append(topLevel, c) + } else if parent, ok := commentMap[c.ParentID]; ok { + parent.Replies = append(parent.Replies, c) + } else { + // Orphaned reply — show as top-level + topLevel = append(topLevel, c) + } + } + + return topLevel, nil +} + +// CountBlogComments returns the total count of comments for a blog post. +func CountBlogComments(ctx context.Context, blogPostID int64) (int64, error) { + return db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Count(new(BlogComment)) +} + +// DeleteBlogComment deletes a comment and all its replies. +func DeleteBlogComment(ctx context.Context, commentID int64) error { + // Delete replies first + if _, err := db.GetEngine(ctx).Where("parent_id = ?", commentID).Delete(new(BlogComment)); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(commentID).Delete(new(BlogComment)) + return err +} + +// DeleteBlogCommentsByPostID removes all comments for a blog post. +func DeleteBlogCommentsByPostID(ctx context.Context, blogPostID int64) error { + _, err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Delete(new(BlogComment)) + return err +} diff --git a/models/blog/blog_guest_token.go b/models/blog/blog_guest_token.go new file mode 100644 index 0000000000..57e260afdb --- /dev/null +++ b/models/blog/blog_guest_token.go @@ -0,0 +1,116 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "math/big" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogGuestToken represents an email-verified anonymous commenter session. +type BlogGuestToken struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + Email string `xorm:"VARCHAR(255) NOT NULL"` + Name string `xorm:"VARCHAR(100) NOT NULL"` + Token string `xorm:"VARCHAR(64) UNIQUE NOT NULL"` + Code string `xorm:"VARCHAR(6) NOT NULL"` + Verified bool `xorm:"NOT NULL DEFAULT false"` + IP string `xorm:"VARCHAR(45)"` + ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +func init() { + db.RegisterModel(new(BlogGuestToken)) +} + +// GenerateToken creates a random hex token. +func GenerateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// GenerateVerificationCode creates a random 6-digit code. +func GenerateVerificationCode() (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} + +// CreateGuestToken inserts a new guest token. +func CreateGuestToken(ctx context.Context, t *BlogGuestToken) error { + _, err := db.GetEngine(ctx).Insert(t) + return err +} + +// GetGuestTokenByToken returns a guest token by its token string. +func GetGuestTokenByToken(ctx context.Context, token string) (*BlogGuestToken, error) { + t := &BlogGuestToken{} + has, err := db.GetEngine(ctx).Where("token = ?", token).Get(t) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return t, nil +} + +// GetGuestTokenByEmail returns an existing non-expired token for the given email. +func GetGuestTokenByEmail(ctx context.Context, email string) (*BlogGuestToken, error) { + t := &BlogGuestToken{} + has, err := db.GetEngine(ctx). + Where("email = ? AND expires_unix > ?", email, timeutil.TimeStampNow()). + OrderBy("created_unix DESC"). + Get(t) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return t, nil +} + +// VerifyGuestToken marks a token as verified if the code matches. +func VerifyGuestToken(ctx context.Context, token, code string) (*BlogGuestToken, error) { + t, err := GetGuestTokenByToken(ctx, token) + if err != nil { + return nil, err + } + if t == nil { + return nil, nil + } + + if t.Code != code { + return nil, nil + } + + if timeutil.TimeStampNow() > t.ExpiresUnix { + return nil, nil + } + + t.Verified = true + if _, err := db.GetEngine(ctx).ID(t.ID).Cols("verified").Update(t); err != nil { + return nil, err + } + return t, nil +} + +// CleanupExpiredGuestTokens removes expired guest tokens. +func CleanupExpiredGuestTokens(ctx context.Context) error { + _, err := db.GetEngine(ctx).Where("expires_unix < ?", timeutil.TimeStampNow()).Delete(new(BlogGuestToken)) + return err +} diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go index 1134eb1407..0a29969972 100644 --- a/models/blog/blog_post.go +++ b/models/blog/blog_post.go @@ -48,6 +48,7 @@ type BlogPost struct { //revive:disable-line:exported 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"` @@ -206,3 +207,32 @@ 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 +} diff --git a/models/blog/blog_reaction.go b/models/blog/blog_reaction.go new file mode 100644 index 0000000000..fd0859af63 --- /dev/null +++ b/models/blog/blog_reaction.go @@ -0,0 +1,101 @@ +// 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" +) + +// BlogReaction represents a thumbs up/down reaction on a blog post. +type BlogReaction struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BlogPostID int64 `xorm:"INDEX NOT NULL"` + UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + GuestIP string `xorm:"VARCHAR(45) NOT NULL DEFAULT ''"` + IsLike bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func init() { + db.RegisterModel(new(BlogReaction)) +} + +// BlogReactionCounts holds aggregated reaction counts for a blog post. +type BlogReactionCounts struct { //revive:disable-line:exported + Likes int64 + Dislikes int64 +} + +// ToggleBlogReaction creates, updates, or removes a reaction. +// If the user already reacted with the same type, it removes the reaction (toggle off). +// If the user reacted with a different type, it switches. +// If no reaction exists, it creates one. +func ToggleBlogReaction(ctx context.Context, blogPostID, userID int64, guestIP string, isLike bool) (reacted bool, err error) { + existing, err := GetUserBlogReaction(ctx, blogPostID, userID, guestIP) + if err != nil { + return false, err + } + + if existing != nil { + if existing.IsLike == isLike { + // Same type — toggle off (remove) + _, err = db.GetEngine(ctx).ID(existing.ID).Delete(new(BlogReaction)) + return false, err + } + // Different type — switch + existing.IsLike = isLike + _, err = db.GetEngine(ctx).ID(existing.ID).Cols("is_like").Update(existing) + return true, err + } + + // No existing reaction — create + _, err = db.GetEngine(ctx).Insert(&BlogReaction{ + BlogPostID: blogPostID, + UserID: userID, + GuestIP: guestIP, + IsLike: isLike, + }) + return true, err +} + +// GetBlogReactionCounts returns aggregated like/dislike counts for a blog post. +func GetBlogReactionCounts(ctx context.Context, blogPostID int64) (*BlogReactionCounts, error) { + likes, err := db.GetEngine(ctx).Where("blog_post_id = ? AND is_like = ?", blogPostID, true).Count(new(BlogReaction)) + if err != nil { + return nil, err + } + dislikes, err := db.GetEngine(ctx).Where("blog_post_id = ? AND is_like = ?", blogPostID, false).Count(new(BlogReaction)) + if err != nil { + return nil, err + } + return &BlogReactionCounts{Likes: likes, Dislikes: dislikes}, nil +} + +// GetUserBlogReaction returns the existing reaction for a user/guest on a blog post, or nil. +func GetUserBlogReaction(ctx context.Context, blogPostID, userID int64, guestIP string) (*BlogReaction, error) { + r := &BlogReaction{} + var has bool + var err error + if userID > 0 { + has, err = db.GetEngine(ctx).Where("blog_post_id = ? AND user_id = ?", blogPostID, userID).Get(r) + } else { + has, err = db.GetEngine(ctx).Where("blog_post_id = ? AND user_id = 0 AND guest_ip = ?", blogPostID, guestIP).Get(r) + } + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return r, nil +} + +// DeleteBlogReactionsByPostID removes all reactions for a blog post (used when deleting a post). +func DeleteBlogReactionsByPostID(ctx context.Context, blogPostID int64) error { + _, err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Delete(new(BlogReaction)) + return err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c4d14a0503..b5aafe24c0 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -426,6 +426,10 @@ func prepareMigrationTasks() []*migration { 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), + newMigration(352, "Create blog_reaction table", v1_26.CreateBlogReactionTable), + newMigration(353, "Create blog_comment table", v1_26.CreateBlogCommentTable), + newMigration(354, "Add allow_comments to blog_post", v1_26.AddAllowCommentsToBlogPost), + newMigration(355, "Create blog_guest_token table", v1_26.CreateBlogGuestTokenTable), } return preparedMigrations } diff --git a/models/migrations/v1_26/v352.go b/models/migrations/v1_26/v352.go new file mode 100644 index 0000000000..bafff09888 --- /dev/null +++ b/models/migrations/v1_26/v352.go @@ -0,0 +1,24 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogReactionTable adds the blog_reaction table for thumbs up/down on blog posts. +func CreateBlogReactionTable(x *xorm.Engine) error { + type BlogReaction struct { + ID int64 `xorm:"pk autoincr"` + BlogPostID int64 `xorm:"INDEX NOT NULL"` + UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + GuestIP string `xorm:"VARCHAR(45) NOT NULL DEFAULT ''"` + IsLike bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + } + + return x.Sync(new(BlogReaction)) +} diff --git a/models/migrations/v1_26/v353.go b/models/migrations/v1_26/v353.go new file mode 100644 index 0000000000..9d9a0002ac --- /dev/null +++ b/models/migrations/v1_26/v353.go @@ -0,0 +1,27 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogCommentTable adds the blog_comment table for comments and replies on blog posts. +func CreateBlogCommentTable(x *xorm.Engine) error { + type BlogComment struct { + ID int64 `xorm:"pk autoincr"` + BlogPostID int64 `xorm:"INDEX NOT NULL"` + ParentID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + GuestName string `xorm:"VARCHAR(100)"` + GuestEmail string `xorm:"VARCHAR(255)"` + Content string `xorm:"LONGTEXT NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + return x.Sync(new(BlogComment)) +} diff --git a/models/migrations/v1_26/v354.go b/models/migrations/v1_26/v354.go new file mode 100644 index 0000000000..fac3651b6e --- /dev/null +++ b/models/migrations/v1_26/v354.go @@ -0,0 +1,15 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +// AddAllowCommentsToBlogPost adds a flag to control commenting per blog post. +func AddAllowCommentsToBlogPost(x *xorm.Engine) error { + type BlogPost struct { + AllowComments bool `xorm:"NOT NULL DEFAULT true"` + } + + return x.Sync(new(BlogPost)) +} diff --git a/models/migrations/v1_26/v355.go b/models/migrations/v1_26/v355.go new file mode 100644 index 0000000000..08ba937dca --- /dev/null +++ b/models/migrations/v1_26/v355.go @@ -0,0 +1,27 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +// CreateBlogGuestTokenTable adds the blog_guest_token table for anonymous commenter email verification. +func CreateBlogGuestTokenTable(x *xorm.Engine) error { + type BlogGuestToken struct { + ID int64 `xorm:"pk autoincr"` + Email string `xorm:"VARCHAR(255) NOT NULL"` + Name string `xorm:"VARCHAR(100) NOT NULL"` + Token string `xorm:"VARCHAR(64) UNIQUE NOT NULL"` + Code string `xorm:"VARCHAR(6) NOT NULL"` + Verified bool `xorm:"NOT NULL DEFAULT false"` + IP string `xorm:"VARCHAR(45)"` + ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + return x.Sync(new(BlogGuestToken)) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index d749d133f4..358e5ec074 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -495,6 +495,10 @@ "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.blog_comment.verify_subject": "Verify your comment on \"%s\"", + "mail.blog_comment.verify_greeting": "Hi %s,", + "mail.blog_comment.verify_body": "You requested to comment on the blog post \"%s\". Please use the following code to verify your email address:", + "mail.blog_comment.verify_expires": "This code expires in 24 hours.", "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", @@ -617,6 +621,7 @@ "user.code": "Code", "user.projects": "Projects", "user.overview": "Overview", + "user.blogs": "Blogs", "user.following": "Following", "user.follow": "Follow", "user.unfollow": "Unfollow", @@ -2006,6 +2011,30 @@ "repo.blog.editor_image_hint": "Drag & drop or paste images directly into the editor. Use > for pull quotes.", "repo.blog.content_placeholder": "Write your blog post content here...", "repo.blog.tags_help": "Separate tags with commas.", + "repo.blog.share_link": "Share Link", + "repo.blog.link_copied": "Link Copied!", + "repo.blog.allow_comments": "Allow Comments", + "repo.blog.allow_comments_help": "When enabled, visitors can leave comments on this post.", + "repo.blog.reactions.admin_hint": "Thumbs down counts are only visible to repo admins.", + "repo.blog.comments": "Comments", + "repo.blog.comments.empty": "No comments yet. Be the first to share your thoughts!", + "repo.blog.comment.posted": "Comment posted successfully.", + "repo.blog.comment.deleted": "Comment deleted.", + "repo.blog.comment.delete_confirm": "Are you sure you want to delete this comment?", + "repo.blog.comment.reply": "Reply", + "repo.blog.comment.reply_placeholder": "Write a reply...", + "repo.blog.comment.delete": "Delete", + "repo.blog.comment.guest": "Guest", + "repo.blog.comment.leave": "Leave a Comment", + "repo.blog.comment.placeholder": "Write your comment...", + "repo.blog.comment.submit": "Post Comment", + "repo.blog.comment.guest_commenting_as": "Commenting as %s", + "repo.blog.comment.guest_intro": "Enter your name and email to verify before commenting.", + "repo.blog.comment.guest_name": "Your name", + "repo.blog.comment.guest_email": "Your email address", + "repo.blog.comment.guest_send_code": "Send verification code", + "repo.blog.comment.guest_enter_code": "Enter the 6-digit code sent to your email.", + "repo.blog.comment.guest_verify": "Verify", "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.", diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go index e351e4621c..ae22ff955e 100644 --- a/routers/web/repo/blog.go +++ b/routers/web/repo/blog.go @@ -189,10 +189,55 @@ func BlogView(ctx *context.Context) { 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 + + // Determine user's current reaction + 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 + + // Load comments if allowed + if post.AllowComments { + 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 + } + + // Check guest token cookie for anonymous commenters + 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 + } + } + } + ctx.Data["Title"] = post.Title ctx.Data["PageIsRepoBlog"] = true ctx.Data["BlogPost"] = post ctx.Data["IsWriter"] = isWriter + ctx.Data["IsSigned"] = ctx.Doer != nil + if ctx.Doer != nil { + ctx.Data["SignedUserID"] = ctx.Doer.ID + } ctx.HTML(http.StatusOK, tplBlogView) } @@ -222,13 +267,14 @@ func BlogNewPost(ctx *context.Context) { 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), + 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), + AllowComments: form.AllowComments, } // Link featured image if provided @@ -316,6 +362,7 @@ func BlogEditPost(ctx *context.Context) { post.Content = form.Content post.Tags = strings.TrimSpace(form.Tags) post.Status = blog_model.BlogPostStatus(form.Status) + post.AllowComments = form.AllowComments // Link featured image if provided if form.FeaturedImage != "" { @@ -597,6 +644,267 @@ func BlogUnsplashSelect(ctx *context.Context) { }) } +// BlogReact handles thumbs up/down reactions on blog posts. +func BlogReact(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil || post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + reactionType := ctx.FormString("type") + var isLike bool + switch reactionType { + case "like": + isLike = true + case "dislike": + isLike = false + default: + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid reaction type"}) + return + } + + var userID int64 + guestIP := ctx.Req.RemoteAddr + if ctx.Doer != nil { + userID = ctx.Doer.ID + guestIP = "" + } + + reacted, err := blog_model.ToggleBlogReaction(ctx, post.ID, userID, guestIP, isLike) + if err != nil { + ctx.ServerError("ToggleBlogReaction", err) + return + } + + counts, err := blog_model.GetBlogReactionCounts(ctx, post.ID) + if err != nil { + ctx.ServerError("GetBlogReactionCounts", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "reacted": reacted, + "type": reactionType, + "likes": counts.Likes, + "dislikes": counts.Dislikes, + }) +} + +// BlogCommentPost creates a new comment or reply on a blog post. +func BlogCommentPost(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil || post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + if !post.AllowComments { + ctx.JSON(http.StatusForbidden, map[string]string{"error": "comments are disabled"}) + return + } + + content := strings.TrimSpace(ctx.FormString("content")) + if content == "" { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "content is required"}) + return + } + + parentID := ctx.FormInt64("parent_id") + + comment := &blog_model.BlogComment{ + BlogPostID: post.ID, + ParentID: parentID, + Content: content, + } + + if ctx.Doer != nil { + comment.UserID = ctx.Doer.ID + } else { + // Anonymous: verify guest token from cookie + tokenCookie, cookieErr := ctx.Req.Cookie("blog_guest_token") + if cookieErr != nil { + ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "guest verification required"}) + return + } + guestToken, _ := blog_model.GetGuestTokenByToken(ctx, tokenCookie.Value) + if guestToken == nil || !guestToken.Verified { + ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "guest verification required"}) + return + } + comment.GuestName = guestToken.Name + comment.GuestEmail = guestToken.Email + } + + if err := blog_model.CreateBlogComment(ctx, comment); err != nil { + ctx.ServerError("CreateBlogComment", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.blog.comment.posted")) + ctx.Redirect(fmt.Sprintf("%s/blog/%d#comment-%d", ctx.Repo.RepoLink, post.ID, comment.ID)) +} + +// BlogCommentDelete deletes a comment (writer or own comment). +func BlogCommentDelete(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil || post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + commentID := ctx.PathParamInt64("commentID") + comment, err := blog_model.GetBlogCommentByID(ctx, commentID) + if err != nil || comment == nil || comment.BlogPostID != post.ID { + ctx.NotFound(err) + return + } + + // Permission check: writer can delete any, user can delete own + isWriter := ctx.Repo.CanWrite(unit.TypeCode) + canDelete := isWriter + if !canDelete && ctx.Doer != nil && comment.UserID > 0 && comment.UserID == ctx.Doer.ID { + canDelete = true + } + if !canDelete { + ctx.JSON(http.StatusForbidden, map[string]string{"error": "permission denied"}) + return + } + + if err := blog_model.DeleteBlogComment(ctx, commentID); err != nil { + ctx.ServerError("DeleteBlogComment", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.blog.comment.deleted")) + ctx.Redirect(fmt.Sprintf("%s/blog/%d", ctx.Repo.RepoLink, post.ID)) +} + +// BlogGuestVerifyRequest handles a guest providing name + email for verification. +func BlogGuestVerifyRequest(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + email := strings.TrimSpace(ctx.FormString("email")) + name := strings.TrimSpace(ctx.FormString("name")) + + if email == "" || name == "" { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "name and email are required"}) + return + } + + // Check for existing valid token + existing, _ := blog_model.GetGuestTokenByEmail(ctx, email) + if existing != nil && existing.Verified { + // Already verified, return the token + ctx.JSON(http.StatusOK, map[string]any{ + "token": existing.Token, + "verified": true, + }) + return + } + + token, err := blog_model.GenerateToken() + if err != nil { + ctx.ServerError("GenerateToken", err) + return + } + + code, err := blog_model.GenerateVerificationCode() + if err != nil { + ctx.ServerError("GenerateVerificationCode", err) + return + } + + guestToken := &blog_model.BlogGuestToken{ + Email: email, + Name: name, + Token: token, + Code: code, + IP: ctx.Req.RemoteAddr, + ExpiresUnix: timeutil.TimeStampNow().Add(24 * 60 * 60), // 24 hours + } + + if err := blog_model.CreateGuestToken(ctx, guestToken); err != nil { + ctx.ServerError("CreateGuestToken", err) + return + } + + // Get blog post title for the email + postTitle := "Blog Post" + if post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")); err == nil { + postTitle = post.Title + } + + // Send verification email + if err := mailer.SendBlogCommentVerification(email, name, code, postTitle); err != nil { + log.Error("SendBlogCommentVerification: %v", err) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "token": token, + "verified": false, + }) +} + +// BlogGuestVerifyCode handles the code verification step. +func BlogGuestVerifyCode(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + token := ctx.FormString("token") + code := ctx.FormString("code") + + if token == "" || code == "" { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "token and code are required"}) + return + } + + verified, err := blog_model.VerifyGuestToken(ctx, token, code) + if err != nil { + ctx.ServerError("VerifyGuestToken", err) + return + } + if verified == nil { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid or expired code"}) + return + } + + // Set cookie + http.SetCookie(ctx.Resp, &http.Cookie{ + Name: "blog_guest_token", + Value: token, + Path: "/", + MaxAge: 24 * 60 * 60, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + ctx.JSON(http.StatusOK, map[string]any{ + "verified": true, + "name": verified.Name, + }) +} + // 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 { diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 25e022e95e..17efdd46b9 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -6,6 +6,7 @@ package user import ( "net/url" + blog_model "code.gitcaddy.com/server/v3/models/blog" "code.gitcaddy.com/server/v3/models/db" "code.gitcaddy.com/server/v3/models/organization" access_model "code.gitcaddy.com/server/v3/models/perm/access" @@ -192,5 +193,13 @@ func loadHeaderCount(ctx *context.Context) error { } ctx.Data["ProjectCount"] = projectCount + if !ctx.ContextUser.IsOrganization() { + blogCount, err := blog_model.CountPublishedBlogPostsByAuthorID(ctx, ctx.ContextUser.ID) + if err != nil { + return err + } + ctx.Data["BlogCount"] = blogCount + } + return nil } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 094fef838c..42680fc6e7 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -11,6 +11,7 @@ import ( "strings" activities_model "code.gitcaddy.com/server/v3/models/activities" + blog_model "code.gitcaddy.com/server/v3/models/blog" "code.gitcaddy.com/server/v3/models/db" "code.gitcaddy.com/server/v3/models/organization" "code.gitcaddy.com/server/v3/models/renderhelper" @@ -289,6 +290,18 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R } } } + case "blogs": + blogPosts, blogCount, err := blog_model.GetPublishedBlogPostsByAuthorID(ctx, ctx.ContextUser.ID, page, pagingNum) + if err != nil { + ctx.ServerError("GetPublishedBlogPostsByAuthorID", err) + return + } + for _, post := range blogPosts { + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) + } + ctx.Data["BlogPosts"] = blogPosts + total = int(blogCount) case "organizations": orgs, count, err := db.FindAndCount[organization.Organization](ctx, organization.FindOrgOptions{ UserID: ctx.ContextUser.ID, diff --git a/routers/web/web.go b/routers/web/web.go index 28d8a03d71..d798b580a4 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1714,6 +1714,11 @@ func registerWebRoutes(m *web.Router) { m.Group("/{username}/{reponame}/blog", func() { m.Get("", repo.BlogList) m.Get("/{id}", repo.BlogView) + m.Post("/{id}/react", repo.BlogReact) + m.Post("/{id}/comment", repo.BlogCommentPost) + m.Post("/{id}/comment/{commentID}/delete", repo.BlogCommentDelete) + m.Post("/{id}/guest/verify", repo.BlogGuestVerifyRequest) + m.Post("/{id}/guest/confirm", repo.BlogGuestVerifyCode) m.Group("", func() { m.Get("/new", repo.BlogNew) m.Post("/new", web.Bind(forms.BlogPostForm{}), repo.BlogNewPost) diff --git a/services/forms/blog_form.go b/services/forms/blog_form.go index 79d16a362e..25514363e9 100644 --- a/services/forms/blog_form.go +++ b/services/forms/blog_form.go @@ -20,6 +20,7 @@ type BlogPostForm struct { Tags string `binding:"MaxSize(1000)"` FeaturedImage string // attachment UUID Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published + AllowComments bool } // Validate validates the fields diff --git a/services/mailer/mail_blog_comment.go b/services/mailer/mail_blog_comment.go new file mode 100644 index 0000000000..523b1f2d73 --- /dev/null +++ b/services/mailer/mail_blog_comment.go @@ -0,0 +1,48 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "fmt" + + "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 tplBlogCommentVerifyMail templates.TplName = "repo/blog_comment_verify" + +// SendBlogCommentVerification sends a 6-digit verification code to a guest commenter. +func SendBlogCommentVerification(email, name, code, blogPostTitle string) error { + if setting.MailService == nil { + return nil + } + + locale := translation.NewLocale("en-US") + subject := locale.TrString("mail.blog_comment.verify_subject", blogPostTitle) + + mailMeta := map[string]any{ + "locale": locale, + "Subject": subject, + "Name": name, + "Code": code, + "PostTitle": blogPostTitle, + "Language": locale.Language(), + } + + var mailBody bytes.Buffer + if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplBlogCommentVerifyMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplBlogCommentVerifyMail)+"/body", err) + return fmt.Errorf("execute template: %w", err) + } + + msg := sender_service.NewMessage(email, subject, mailBody.String()) + msg.Info = subject + + SendAsync(msg) + return nil +} diff --git a/templates/mail/repo/blog_comment_verify.tmpl b/templates/mail/repo/blog_comment_verify.tmpl new file mode 100644 index 0000000000..82e0e0e510 --- /dev/null +++ b/templates/mail/repo/blog_comment_verify.tmpl @@ -0,0 +1,17 @@ + + +
+ +{{.locale.Tr "mail.blog_comment.verify_greeting" .Name}}
+{{.locale.Tr "mail.blog_comment.verify_body" .PostTitle}}
+{{.locale.Tr "mail.blog_comment.verify_expires"}}
+ + diff --git a/templates/repo/blog/editor.tmpl b/templates/repo/blog/editor.tmpl index 44b7e88fd8..053305ea0c 100644 --- a/templates/repo/blog/editor.tmpl +++ b/templates/repo/blog/editor.tmpl @@ -123,6 +123,18 @@ + +
+ {{svg "octicon-comment" 20}} + {{ctx.Locale.Tr "repo.blog.comments"}} + {{if .CommentCount}}{{.CommentCount}}{{end}} +
+ + {{if .BlogComments}} +{{ctx.Locale.Tr "repo.blog.comments.empty"}}
+ {{end}} + + +{{ctx.Locale.Tr "repo.blog.comment.leave"}}
+ + {{else}} +{{ctx.Locale.Tr "repo.blog.comment.leave"}}
+ {{if .GuestToken}} + +{{ctx.Locale.Tr "repo.blog.comment.guest_commenting_as" .GuestToken.Name}}
+ + {{else}} + +{{ctx.Locale.Tr "repo.blog.comment.guest_intro"}}
+{{ctx.Locale.Tr "repo.blog.comment.guest_enter_code"}}
+