2
0

feat(blog): add subscription-only content gating

Adds SubscriptionOnly flag to blog posts to restrict full content access to active subscribers. Shows teaser/preview for non-subscribers with subscribe CTA. Integrates with repository subscription system when monetization is enabled. Updates v2 API structs and editor UI with subscription toggle. Admins and repo writers bypass the gate.
This commit is contained in:
2026-02-02 13:22:01 -05:00
parent 6bc3693cef
commit 2ba1596b02
12 changed files with 210 additions and 57 deletions

View File

@@ -43,21 +43,22 @@ func (s BlogPostStatus) String() string {
// 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"`
PublishedUnix timeutil.TimeStamp `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
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"`
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:"-"`

View File

@@ -16,6 +16,7 @@ type BlogPostV2 struct {
Tags []string `json:"tags"`
Status string `json:"status"`
AllowComments bool `json:"allow_comments"`
SubscriptionOnly bool `json:"subscription_only"`
FeaturedImageURL string `json:"featured_image_url,omitempty"`
Author *BlogAuthorV2 `json:"author,omitempty"`
Repo *BlogRepoRefV2 `json:"repo,omitempty"`
@@ -56,6 +57,7 @@ type CreateBlogPostV2Option struct {
Tags []string `json:"tags"`
Status string `json:"status"` // draft, public, published
AllowComments bool `json:"allow_comments"`
SubscriptionOnly bool `json:"subscription_only"`
FeaturedImageUUID string `json:"featured_image_uuid"`
}
@@ -68,5 +70,6 @@ type UpdateBlogPostV2Option struct {
Tags []string `json:"tags"`
Status *string `json:"status"` // draft, public, published
AllowComments *bool `json:"allow_comments"`
SubscriptionOnly *bool `json:"subscription_only"`
FeaturedImageUUID *string `json:"featured_image_uuid"`
}

View File

@@ -2027,6 +2027,11 @@
"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.subscription_only": "Subscription Only",
"repo.blog.subscription_only_help": "Only subscribers with an active subscription can read the full content of this post.",
"repo.blog.subscription_required": "Subscription Required",
"repo.blog.subscription_required_desc": "This post is available exclusively to subscribers. Subscribe to unlock the full content.",
"repo.blog.subscribe_to_read": "Subscribe to Read",
"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!",

View File

@@ -34,16 +34,17 @@ func blogPostStatusFromString(s string) (blog_model.BlogPostStatus, bool) {
func toBlogPostV2(ctx *context.APIContext, post *blog_model.BlogPost) *api.BlogPostV2 {
result := &api.BlogPostV2{
ID: post.ID,
Title: post.Title,
Subtitle: post.Subtitle,
Series: post.Series,
Content: post.Content,
Tags: []string{},
Status: post.Status.String(),
AllowComments: post.AllowComments,
CreatedAt: post.CreatedUnix.AsTime(),
UpdatedAt: post.UpdatedUnix.AsTime(),
ID: post.ID,
Title: post.Title,
Subtitle: post.Subtitle,
Series: post.Series,
Content: post.Content,
Tags: []string{},
Status: post.Status.String(),
AllowComments: post.AllowComments,
SubscriptionOnly: post.SubscriptionOnly,
CreatedAt: post.CreatedUnix.AsTime(),
UpdatedAt: post.UpdatedUnix.AsTime(),
}
if post.Tags != "" {
@@ -195,15 +196,16 @@ func CreateBlogPostV2(ctx *context.APIContext) {
}
post := &blog_model.BlogPost{
RepoID: repo.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Subtitle: form.Subtitle,
Series: strings.TrimSpace(form.Series),
Content: form.Content,
Tags: strings.Join(form.Tags, ","),
Status: status,
AllowComments: form.AllowComments,
RepoID: repo.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Subtitle: form.Subtitle,
Series: strings.TrimSpace(form.Series),
Content: form.Content,
Tags: strings.Join(form.Tags, ","),
Status: status,
AllowComments: form.AllowComments,
SubscriptionOnly: form.SubscriptionOnly,
}
if status == blog_model.BlogPostPublished {
@@ -276,6 +278,9 @@ func UpdateBlogPostV2(ctx *context.APIContext) {
if form.AllowComments != nil {
post.AllowComments = *form.AllowComments
}
if form.SubscriptionOnly != nil {
post.SubscriptionOnly = *form.SubscriptionOnly
}
if form.Status != nil {
status, ok := blogPostStatusFromString(*form.Status)
if !ok {

View File

@@ -10,6 +10,7 @@ import (
"time"
blog_model "code.gitcaddy.com/server/v3/models/blog"
monetize_model "code.gitcaddy.com/server/v3/models/monetize"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
"code.gitcaddy.com/server/v3/models/renderhelper"
"code.gitcaddy.com/server/v3/models/unit"
@@ -254,6 +255,20 @@ func StandaloneBlogView(ctx *context.Context) {
}
}
// Subscription-only gate
subscriptionGated := false
if post.SubscriptionOnly && !isWriter && setting.Monetize.Enabled && post.Repo.SubscriptionsEnabled {
if ctx.Doer == nil || !ctx.Doer.IsAdmin {
hasAccess := false
if ctx.Doer != nil {
hasAccess, _ = monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, post.Repo.ID)
}
if !hasAccess {
subscriptionGated = true
}
}
}
// RepoLink is needed for form actions (reactions, comments)
repoLink := post.Repo.Link()
@@ -261,6 +276,7 @@ func StandaloneBlogView(ctx *context.Context) {
ctx.Data["BlogPost"] = post
ctx.Data["IsWriter"] = isWriter
ctx.Data["IsSigned"] = ctx.Doer != nil
ctx.Data["SubscriptionGated"] = subscriptionGated
ctx.Data["RepoLink"] = repoLink
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID

View File

@@ -13,6 +13,7 @@ import (
"time"
blog_model "code.gitcaddy.com/server/v3/models/blog"
monetize_model "code.gitcaddy.com/server/v3/models/monetize"
"code.gitcaddy.com/server/v3/models/renderhelper"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
@@ -178,6 +179,20 @@ func BlogView(ctx *context.Context) {
return
}
// Subscription-only gate
subscriptionGated := false
if post.SubscriptionOnly && !isWriter && setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled {
if ctx.Doer == nil || !ctx.Doer.IsAdmin {
hasAccess := false
if ctx.Doer != nil {
hasAccess, _ = monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
}
if !hasAccess {
subscriptionGated = true
}
}
}
if err := post.LoadAuthor(ctx); err != nil {
ctx.ServerError("LoadAuthor", err)
return
@@ -258,6 +273,7 @@ func BlogView(ctx *context.Context) {
ctx.Data["BlogPost"] = post
ctx.Data["IsWriter"] = isWriter
ctx.Data["IsSigned"] = ctx.Doer != nil
ctx.Data["SubscriptionGated"] = subscriptionGated
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
@@ -281,6 +297,7 @@ func BlogNew(ctx *context.Context) {
ctx.Data["PageIsRepoBlog"] = true
ctx.Data["IsNewPost"] = true
ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled
ctx.Data["SubscriptionsEnabled"] = setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled
ctx.HTML(http.StatusOK, tplBlogEditor)
}
@@ -295,15 +312,16 @@ 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),
Series: strings.TrimSpace(form.Series),
Status: blog_model.BlogPostStatus(form.Status),
AllowComments: form.AllowComments,
RepoID: ctx.Repo.Repository.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Subtitle: form.Subtitle,
Content: form.Content,
Tags: strings.TrimSpace(form.Tags),
Series: strings.TrimSpace(form.Series),
Status: blog_model.BlogPostStatus(form.Status),
AllowComments: form.AllowComments,
SubscriptionOnly: form.SubscriptionOnly,
}
// Link featured image if provided
@@ -361,6 +379,7 @@ func BlogEdit(ctx *context.Context) {
ctx.Data["BlogPost"] = post
ctx.Data["IsNewPost"] = false
ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled
ctx.Data["SubscriptionsEnabled"] = setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled
ctx.HTML(http.StatusOK, tplBlogEditor)
}
@@ -393,6 +412,7 @@ func BlogEditPost(ctx *context.Context) {
post.Series = strings.TrimSpace(form.Series)
post.Status = blog_model.BlogPostStatus(form.Status)
post.AllowComments = form.AllowComments
post.SubscriptionOnly = form.SubscriptionOnly
// Link featured image if provided
if form.FeaturedImage != "" {

View File

@@ -14,14 +14,15 @@ import (
// 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)"`
Series string `binding:"MaxSize(255)"`
FeaturedImage string // attachment UUID
Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published
AllowComments bool
Title string `binding:"Required;MaxSize(255)"`
Subtitle string `binding:"MaxSize(500)"`
Content string `binding:"Required"`
Tags string `binding:"MaxSize(1000)"`
Series string `binding:"MaxSize(255)"`
FeaturedImage string // attachment UUID
Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published
AllowComments bool
SubscriptionOnly bool
}
// Validate validates the fields

View File

@@ -26,7 +26,10 @@
{{if .BlogPost.Series}}
<a class="blog-view-series" href="{{AppSubUrl}}/blogs?series={{.BlogPost.Series}}">{{.BlogPost.Series}}</a>
{{end}}
<h1 class="blog-view-title">{{.BlogPost.Title}}</h1>
<h1 class="blog-view-title">
{{.BlogPost.Title}}
{{if .BlogPost.SubscriptionOnly}}<span class="blog-sub-badge" data-tooltip-content="{{ctx.Locale.Tr "repo.blog.subscription_only"}}">{{svg "octicon-lock" 16}}</span>{{end}}
</h1>
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
@@ -62,9 +65,20 @@
{{end}}
</header>
{{if .SubscriptionGated}}
<div class="blog-subscription-gate">
<div class="blog-subscription-gate-icon">{{svg "octicon-lock" 32}}</div>
<h3>{{ctx.Locale.Tr "repo.blog.subscription_required"}}</h3>
<p>{{ctx.Locale.Tr "repo.blog.subscription_required_desc"}}</p>
<a href="{{.RepoLink}}/subscribe" class="ui primary button">
{{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.blog.subscribe_to_read"}}
</a>
</div>
{{else}}
<div class="blog-view-body markup markdown">
{{.BlogPost.RenderedContent | SafeHTML}}
</div>
{{end}}
</article>
{{if .IsWriter}}
@@ -81,6 +95,7 @@
</div>
{{end}}
{{if not .SubscriptionGated}}
<!-- Reaction Bar -->
<div class="blog-reactions" id="blog-reactions">
<button class="blog-reaction-btn{{if and .UserReaction .UserReaction.IsLike}} active{{end}}" id="btn-like"
@@ -280,6 +295,7 @@
{{end}}
</div>
{{end}}
{{end}}{{/* end if not SubscriptionGated */}}
</div>
</div>
</div>
@@ -643,6 +659,35 @@
padding-top: 20px;
border-top: 1px solid var(--color-secondary-alpha-40);
}
/* Subscription gate */
.blog-subscription-gate {
text-align: center;
padding: 48px 24px;
border: 2px dashed var(--color-secondary-alpha-40);
border-radius: 12px;
background: var(--color-secondary-alpha-10);
margin-bottom: 32px;
}
.blog-subscription-gate-icon {
color: var(--color-text-light-3);
margin-bottom: 16px;
}
.blog-subscription-gate h3 {
margin: 0 0 8px;
font-size: 20px;
}
.blog-subscription-gate p {
color: var(--color-text-light);
margin: 0 0 20px;
font-size: 15px;
}
.blog-sub-badge {
display: inline-flex;
align-items: center;
vertical-align: middle;
color: var(--color-text-light-3);
margin-left: 6px;
}
</style>
<script>
(function() {

View File

@@ -11,7 +11,7 @@
</div>
{{end}}
<div class="blog-featured-content">
<h2 class="blog-featured-title">{{.FeaturedPost.Title}}</h2>
<h2 class="blog-featured-title">{{.FeaturedPost.Title}}{{if .FeaturedPost.SubscriptionOnly}} {{svg "octicon-lock" 16}}{{end}}</h2>
{{if .FeaturedPost.Subtitle}}
<p class="blog-featured-subtitle">{{.FeaturedPost.Subtitle}}</p>
{{end}}
@@ -81,7 +81,7 @@
{{if .Series}}
<span class="blog-list-item-series">{{.Series}}</span>
{{end}}
<h3 class="blog-list-item-title">{{.Title}}</h3>
<h3 class="blog-list-item-title">{{.Title}}{{if .SubscriptionOnly}} {{svg "octicon-lock" 14}}{{end}}</h3>
{{if .Subtitle}}
<p class="blog-list-item-subtitle">{{.Subtitle}}</p>
{{end}}

View File

@@ -140,6 +140,18 @@
<div class="help">{{ctx.Locale.Tr "repo.blog.allow_comments_help"}}</div>
</div>
{{if .SubscriptionsEnabled}}
<!-- Subscription Only -->
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="subscription_only"
{{if and .BlogPost .BlogPost.SubscriptionOnly}}checked{{end}}>
<label><b>{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "repo.blog.subscription_only"}}</b></label>
</div>
<div class="help">{{ctx.Locale.Tr "repo.blog.subscription_only_help"}}</div>
</div>
{{end}}
<div class="ui divider"></div>
<!-- Status indicator (edit only) -->

View File

@@ -16,7 +16,7 @@
{{if and (eq .FeaturedPost.Status 0) $.IsWriter}}
<span class="ui mini label yellow">{{ctx.Locale.Tr "repo.blog.draft"}}</span>
{{end}}
<h2 class="blog-featured-title">{{.FeaturedPost.Title}}</h2>
<h2 class="blog-featured-title">{{.FeaturedPost.Title}}{{if .FeaturedPost.SubscriptionOnly}} {{svg "octicon-lock" 16}}{{end}}</h2>
{{if .FeaturedPost.Subtitle}}
<p class="blog-featured-subtitle">{{.FeaturedPost.Subtitle}}</p>
{{end}}
@@ -53,7 +53,7 @@
{{if and (eq .Status 0) $.IsWriter}}
<span class="ui mini label yellow">{{ctx.Locale.Tr "repo.blog.draft"}}</span>
{{end}}
<h3 class="blog-post-tile-title">{{.Title}}</h3>
<h3 class="blog-post-tile-title">{{.Title}}{{if .SubscriptionOnly}} {{svg "octicon-lock" 14}}{{end}}</h3>
</div>
{{if .Subtitle}}
<p class="blog-post-tile-subtitle">{{.Subtitle}}</p>

View File

@@ -17,7 +17,10 @@
{{if .BlogPost.Series}}
<a class="blog-view-series" href="{{AppSubUrl}}/blogs?series={{.BlogPost.Series}}">{{.BlogPost.Series}}</a>
{{end}}
<h1 class="blog-view-title">{{.BlogPost.Title}}</h1>
<h1 class="blog-view-title">
{{.BlogPost.Title}}
{{if .BlogPost.SubscriptionOnly}}<span class="blog-sub-badge" data-tooltip-content="{{ctx.Locale.Tr "repo.blog.subscription_only"}}">{{svg "octicon-lock" 16}}</span>{{end}}
</h1>
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
@@ -53,9 +56,20 @@
{{end}}
</header>
{{if .SubscriptionGated}}
<div class="blog-subscription-gate">
<div class="blog-subscription-gate-icon">{{svg "octicon-lock" 32}}</div>
<h3>{{ctx.Locale.Tr "repo.blog.subscription_required"}}</h3>
<p>{{ctx.Locale.Tr "repo.blog.subscription_required_desc"}}</p>
<a href="{{.RepoLink}}/subscribe" class="ui primary button">
{{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.blog.subscribe_to_read"}}
</a>
</div>
{{else}}
<div class="blog-view-body markup markdown">
{{.BlogPost.RenderedContent | SafeHTML}}
</div>
{{end}}
</article>
{{if .IsWriter}}
@@ -72,6 +86,7 @@
</div>
{{end}}
{{if not .SubscriptionGated}}
<!-- Reaction Bar -->
<div class="blog-reactions" id="blog-reactions">
<button class="blog-reaction-btn{{if and .UserReaction .UserReaction.IsLike}} active{{end}}" id="btn-like"
@@ -266,6 +281,7 @@
{{end}}
</div>
{{end}}
{{end}}{{/* end if not SubscriptionGated */}}
</div>
</div>
</div>
@@ -608,6 +624,35 @@
padding-top: 20px;
border-top: 1px solid var(--color-secondary-alpha-40);
}
/* Subscription gate */
.blog-subscription-gate {
text-align: center;
padding: 48px 24px;
border: 2px dashed var(--color-secondary-alpha-40);
border-radius: 12px;
background: var(--color-secondary-alpha-10);
margin-bottom: 32px;
}
.blog-subscription-gate-icon {
color: var(--color-text-light-3);
margin-bottom: 16px;
}
.blog-subscription-gate h3 {
margin: 0 0 8px;
font-size: 20px;
}
.blog-subscription-gate p {
color: var(--color-text-light);
margin: 0 0 20px;
font-size: 15px;
}
.blog-sub-badge {
display: inline-flex;
align-items: center;
vertical-align: middle;
color: var(--color-text-light-3);
margin-left: 6px;
}
</style>
<script>
(function() {