FEATURE: Support for publishing topics as pages (#9364)
If the feature is enabled, staff members can construct a URL and publish a topic for others to browse without the regular Discourse chrome. This is useful if you want to use Discourse like a CMS and publish topics as articles, which can then be embedded into other systems.
This commit is contained in:
parent
b64b590cfb
commit
e1f8014acd
|
@ -0,0 +1,9 @@
|
|||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default RestAdapter.extend({
|
||||
jsonMode: true,
|
||||
|
||||
pathFor(store, type, id) {
|
||||
return `/pub/by-topic/${id}`;
|
||||
}
|
||||
});
|
|
@ -71,6 +71,27 @@ export default TextField.extend({
|
|||
}
|
||||
},
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
this._prevValue = this.value;
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super(...arguments);
|
||||
if (this._prevValue !== this.value) {
|
||||
if (this.onChangeImmediate) {
|
||||
next(() => this.onChangeImmediate(this.value));
|
||||
}
|
||||
if (this.onChange) {
|
||||
debounce(this, this._debouncedChange, DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_debouncedChange() {
|
||||
next(() => this.onChange(this.value));
|
||||
},
|
||||
|
||||
@discourseComputed("placeholderKey")
|
||||
placeholder: {
|
||||
get() {
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
import Controller from "@ember/controller";
|
||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
import { computed, action } from "@ember/object";
|
||||
import { equal, not } from "@ember/object/computed";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
|
||||
const States = {
|
||||
initializing: "initializing",
|
||||
checking: "checking",
|
||||
valid: "valid",
|
||||
invalid: "invalid",
|
||||
saving: "saving",
|
||||
new: "new",
|
||||
existing: "existing",
|
||||
unpublishing: "unpublishing",
|
||||
unpublished: "unpublished"
|
||||
};
|
||||
|
||||
const StateHelpers = {};
|
||||
Object.keys(States).forEach(name => {
|
||||
StateHelpers[name] = equal("state", name);
|
||||
});
|
||||
|
||||
export default Controller.extend(ModalFunctionality, StateHelpers, {
|
||||
state: null,
|
||||
reason: null,
|
||||
publishedPage: null,
|
||||
disabled: not("valid"),
|
||||
publishedPage: null,
|
||||
|
||||
showUrl: computed("state", function() {
|
||||
return (
|
||||
this.state === States.valid ||
|
||||
this.state === States.saving ||
|
||||
this.state === States.existing
|
||||
);
|
||||
}),
|
||||
showUnpublish: computed("state", function() {
|
||||
return this.state === States.existing || this.state === States.unpublishing;
|
||||
}),
|
||||
|
||||
onShow() {
|
||||
this.set("state", States.initializing);
|
||||
|
||||
this.store
|
||||
.find("published_page", this.model.id)
|
||||
.then(page => {
|
||||
this.setProperties({ state: States.existing, publishedPage: page });
|
||||
})
|
||||
.catch(this.startNew);
|
||||
},
|
||||
|
||||
@action
|
||||
startCheckSlug() {
|
||||
if (this.state === States.existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("state", States.checking);
|
||||
},
|
||||
|
||||
@action
|
||||
checkSlug() {
|
||||
if (this.state === States.existing) {
|
||||
return;
|
||||
}
|
||||
return ajax("/pub/check-slug", {
|
||||
data: { slug: this.publishedPage.slug }
|
||||
}).then(result => {
|
||||
if (result.valid_slug) {
|
||||
this.set("state", States.valid);
|
||||
} else {
|
||||
this.setProperties({ state: States.invalid, reason: result.reason });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
unpublish() {
|
||||
this.set("state", States.unpublishing);
|
||||
return this.publishedPage
|
||||
.destroyRecord()
|
||||
.then(() => {
|
||||
this.set("state", States.unpublished);
|
||||
this.model.set("publishedPage", null);
|
||||
})
|
||||
.catch(result => {
|
||||
this.set("state", States.existing);
|
||||
popupAjaxError(result);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
publish() {
|
||||
this.set("state", States.saving);
|
||||
|
||||
return this.publishedPage
|
||||
.update({ slug: this.publishedPage.slug })
|
||||
.then(() => {
|
||||
this.set("state", States.existing);
|
||||
this.model.set("publishedPage", this.publishedPage);
|
||||
})
|
||||
.catch(errResult => {
|
||||
popupAjaxError(errResult);
|
||||
this.set("state", States.existing);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
startNew() {
|
||||
this.setProperties({
|
||||
state: States.new,
|
||||
publishedPage: this.store.createRecord("published_page", {
|
||||
id: this.model.id,
|
||||
slug: this.model.slug
|
||||
})
|
||||
});
|
||||
this.checkSlug();
|
||||
}
|
||||
});
|
|
@ -76,7 +76,8 @@ export function transformBasicPost(post) {
|
|||
replyCount: post.reply_count,
|
||||
locked: post.locked,
|
||||
userCustomFields: post.user_custom_fields,
|
||||
readCount: post.readers_count
|
||||
readCount: post.readers_count,
|
||||
canPublishPage: false
|
||||
};
|
||||
|
||||
_additionalAttributes.forEach(a => (postAtts[a] = post[a]));
|
||||
|
@ -118,6 +119,8 @@ export default function transformPost(
|
|||
currentUser && (currentUser.id === post.user_id || currentUser.staff);
|
||||
postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
|
||||
postAtts.canReviewTopic = !!details.can_review_topic;
|
||||
postAtts.canPublishPage =
|
||||
!!details.can_publish_page && post.post_number === 1;
|
||||
postAtts.isWarning = topic.is_warning;
|
||||
postAtts.links = post.get("internalLinks");
|
||||
postAtts.replyDirectlyBelow =
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
import { computed } from "@ember/object";
|
||||
|
||||
export default RestModel.extend({
|
||||
url: computed("slug", function() {
|
||||
return `${Discourse.BaseUrl}/pub/${this.slug}`;
|
||||
})
|
||||
});
|
|
@ -545,6 +545,13 @@ const Topic = RestModel.extend({
|
|||
this.details.updateFromJson(json.details);
|
||||
|
||||
keys.removeObjects(["details", "post_stream"]);
|
||||
|
||||
if (json.published_page) {
|
||||
this.set(
|
||||
"publishedPage",
|
||||
this.store.createRecord("published-page", json.published_page)
|
||||
);
|
||||
}
|
||||
}
|
||||
keys.forEach(key => this.set(key, json[key]));
|
||||
},
|
||||
|
|
|
@ -89,6 +89,14 @@ const TopicRoute = DiscourseRoute.extend({
|
|||
controller.setProperties({ flagTopic: true });
|
||||
},
|
||||
|
||||
showPagePublish() {
|
||||
const model = this.modelFor("topic");
|
||||
showModal("publish-page", {
|
||||
model,
|
||||
title: "topic.publish_page.title"
|
||||
});
|
||||
},
|
||||
|
||||
showTopicStatusUpdate() {
|
||||
const model = this.modelFor("topic");
|
||||
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
{{#d-modal-body}}
|
||||
{{#if unpublished}}
|
||||
<p>{{i18n "topic.publish_page.unpublished"}}</p>
|
||||
{{else}}
|
||||
{{#conditional-loading-spinner condition=initializing}}
|
||||
<p class="publish-description">{{i18n "topic.publish_page.description"}}</p>
|
||||
|
||||
<form>
|
||||
<label>{{i18n "topic.publish_page.slug"}}</label>
|
||||
{{text-field value=publishedPage.slug onChange=(action "checkSlug") onChangeImmediate=(action "startCheckSlug") disabled=existing class="publish-slug"}}
|
||||
</form>
|
||||
|
||||
<div class="publish-url">
|
||||
{{conditional-loading-spinner condition=checking}}
|
||||
|
||||
{{#if existing}}
|
||||
<div class='current-url'>
|
||||
{{i18n "topic.publish_page.publish_url"}}
|
||||
<div>
|
||||
<a href={{publishedPage.url}} target="_blank" rel="noopener">{{publishedPage.url}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if showUrl}}
|
||||
<div class="valid-slug">
|
||||
{{i18n "topic.publish_page.preview_url"}}
|
||||
<div class='example-url'>{{publishedPage.url}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if invalid}}
|
||||
{{i18n "topic.publish_page.invalid_slug"}} <span class="invalid-slug">{{reason}}.</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/if}}
|
||||
{{/d-modal-body}}
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#if showUnpublish}}
|
||||
{{d-button icon="times" label="close" action=(action "closeModal")}}
|
||||
|
||||
{{d-button
|
||||
label="topic.publish_page.unpublish"
|
||||
icon="trash"
|
||||
class="btn-danger"
|
||||
isLoading=unpublishing
|
||||
action=(action "unpublish") }}
|
||||
{{else if unpublished}}
|
||||
{{d-button label="topic.publish_page.publishing_settings" action=(action "startNew")}}
|
||||
{{else}}
|
||||
{{d-button
|
||||
label="topic.publish_page.publish"
|
||||
class="btn-primary publish-page"
|
||||
icon="file"
|
||||
disabled=disabled
|
||||
isLoading=saving
|
||||
action=(action "publish") }}
|
||||
{{/if}}
|
||||
</div>
|
|
@ -85,6 +85,25 @@
|
|||
{{topic-category topic=model class="topic-category"}}
|
||||
{{/if}}
|
||||
{{/topic-title}}
|
||||
|
||||
{{#if model.publishedPage}}
|
||||
<div class='published-page'>
|
||||
<div class="details">
|
||||
{{i18n "topic.publish_page.topic_published"}}
|
||||
<div>
|
||||
<a href={{model.publishedPage.url}} target="_blank" rel="noopener">{{model.publishedPage.url}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
{{d-button
|
||||
icon="file"
|
||||
label="topic.publish_page.publishing_settings"
|
||||
action=(route-action "showPagePublish")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{/if}}
|
||||
|
||||
<div class="container posts">
|
||||
|
@ -230,7 +249,8 @@
|
|||
selectBelow=(action "selectBelow")
|
||||
fillGapBefore=(action "fillGapBefore")
|
||||
fillGapAfter=(action "fillGapAfter")
|
||||
showInvite=(route-action "showInvite")}}
|
||||
showInvite=(route-action "showInvite")
|
||||
showPagePublish=(route-action "showPagePublish")}}
|
||||
{{/unless}}
|
||||
|
||||
{{conditional-loading-spinner condition=model.postStream.loadingBelow}}
|
||||
|
|
|
@ -120,6 +120,15 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
|
|||
}
|
||||
}
|
||||
|
||||
if (attrs.canPublishPage) {
|
||||
contents.push({
|
||||
icon: "file",
|
||||
label: "post.controls.publish_page",
|
||||
action: "showPagePublish",
|
||||
className: "btn-default publish-page"
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.canManage) {
|
||||
contents.push({
|
||||
icon: "cog",
|
||||
|
|
|
@ -684,6 +684,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
.publish-page-modal .modal-body {
|
||||
p.publish-description {
|
||||
margin-top: 0;
|
||||
}
|
||||
input.publish-slug {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-url {
|
||||
margin-bottom: 1em;
|
||||
.example-url,
|
||||
.invalid-slug {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-slug:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.modal:not(.has-tabs) {
|
||||
.modal-tab {
|
||||
position: absolute;
|
||||
|
|
|
@ -295,3 +295,14 @@ a.topic-featured-link {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.published-page {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1em;
|
||||
max-width: calc(
|
||||
#{$topic-body-width} + #{$topic-avatar-width} + #{$topic-body-width-padding *
|
||||
2}
|
||||
);
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
@import "common";
|
||||
|
||||
body {
|
||||
background-color: $secondary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.published-page {
|
||||
margin: 2em auto;
|
||||
max-width: 800px;
|
||||
|
||||
h1 {
|
||||
color: $header_primary;
|
||||
}
|
||||
|
||||
.published-page-author {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 2em;
|
||||
display: flex;
|
||||
|
||||
.avatar {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.topic-created-at {
|
||||
color: $primary-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.published-page-body {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PublishedPagesController < ApplicationController
|
||||
|
||||
skip_before_action :preload_json
|
||||
skip_before_action :check_xhr, :verify_authenticity_token, only: [:show]
|
||||
before_action :ensure_publish_enabled
|
||||
|
||||
def show
|
||||
params.require(:slug)
|
||||
|
||||
pp = PublishedPage.find_by(slug: params[:slug])
|
||||
raise Discourse::NotFound unless pp
|
||||
|
||||
guardian.ensure_can_see!(pp.topic)
|
||||
@topic = pp.topic
|
||||
@canonical_url = @topic.url
|
||||
render layout: 'publish'
|
||||
end
|
||||
|
||||
def details
|
||||
pp = PublishedPage.find_by(topic: fetch_topic)
|
||||
raise Discourse::NotFound if pp.blank?
|
||||
render_serialized(pp, PublishedPageSerializer)
|
||||
end
|
||||
|
||||
def upsert
|
||||
result, pp = PublishedPage.publish!(current_user, fetch_topic, params[:published_page][:slug].strip)
|
||||
json_result(pp, serializer: PublishedPageSerializer) { result }
|
||||
end
|
||||
|
||||
def destroy
|
||||
PublishedPage.unpublish!(current_user, fetch_topic)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def check_slug
|
||||
pp = PublishedPage.new(topic: Topic.new, slug: params[:slug].strip)
|
||||
|
||||
if pp.valid?
|
||||
render json: { valid_slug: true }
|
||||
else
|
||||
render json: { valid_slug: false, reason: pp.errors.full_messages.first }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_topic
|
||||
topic = Topic.find_by(id: params[:topic_id])
|
||||
guardian.ensure_can_publish_page!(topic)
|
||||
topic
|
||||
end
|
||||
|
||||
def ensure_publish_enabled
|
||||
raise Discourse::NotFound unless SiteSetting.enable_page_publishing?
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PublishedPage < ActiveRecord::Base
|
||||
belongs_to :topic
|
||||
|
||||
validates_presence_of :slug
|
||||
validates_uniqueness_of :slug, :topic_id
|
||||
|
||||
validate :slug_format
|
||||
def slug_format
|
||||
if slug !~ /^[a-zA-Z\-\_0-9]+$/
|
||||
errors.add(:slug, I18n.t("publish_page.slug_errors.invalid"))
|
||||
elsif ["check-slug", "by-topic"].include?(slug)
|
||||
errors.add(:slug, I18n.t("publish_page.slug_errors.unavailable"))
|
||||
end
|
||||
end
|
||||
|
||||
def path
|
||||
"/pub/#{slug}"
|
||||
end
|
||||
|
||||
def url
|
||||
"#{Discourse.base_url}#{path}"
|
||||
end
|
||||
|
||||
def self.publish!(publisher, topic, slug)
|
||||
transaction do
|
||||
pp = find_or_initialize_by(topic: topic)
|
||||
pp.slug = slug.strip
|
||||
|
||||
if pp.save
|
||||
StaffActionLogger.new(publisher).log_published_page(topic.id, slug)
|
||||
return [true, pp]
|
||||
end
|
||||
end
|
||||
|
||||
[false, pp]
|
||||
end
|
||||
|
||||
def self.unpublish!(publisher, topic)
|
||||
if pp = PublishedPage.find_by(topic_id: topic.id)
|
||||
pp.destroy!
|
||||
StaffActionLogger.new(publisher).log_unpublished_page(topic.id, pp.slug)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -114,6 +114,7 @@ class Topic < ActiveRecord::Base
|
|||
|
||||
has_one :top_topic
|
||||
has_one :shared_draft, dependent: :destroy
|
||||
has_one :published_page
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
||||
|
|
|
@ -103,7 +103,9 @@ class UserHistory < ActiveRecord::Base
|
|||
api_key_destroy: 82,
|
||||
revoke_title: 83,
|
||||
change_title: 84,
|
||||
override_upload_secure_status: 85
|
||||
override_upload_secure_status: 85,
|
||||
page_published: 86,
|
||||
page_unpublished: 87
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -183,7 +185,9 @@ class UserHistory < ActiveRecord::Base
|
|||
:api_key_create,
|
||||
:api_key_update,
|
||||
:api_key_destroy,
|
||||
:override_upload_secure_status
|
||||
:override_upload_secure_status,
|
||||
:page_published,
|
||||
:page_unpublished
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PublishedPageSerializer < ApplicationSerializer
|
||||
attributes :id, :slug
|
||||
|
||||
def id
|
||||
object.topic_id
|
||||
end
|
||||
end
|
|
@ -15,7 +15,8 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
|||
:can_flag_topic,
|
||||
:can_convert_topic,
|
||||
:can_review_topic,
|
||||
:can_edit_tags]
|
||||
:can_edit_tags,
|
||||
:can_publish_page]
|
||||
end
|
||||
|
||||
attributes(
|
||||
|
@ -133,6 +134,10 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
|||
!scope.can_edit?(object.topic) && scope.can_edit_tags?(object.topic)
|
||||
end
|
||||
|
||||
def include_can_publish_page?
|
||||
scope.can_publish_page?(object.topic)
|
||||
end
|
||||
|
||||
def allowed_users
|
||||
object.topic.allowed_users.reject { |user| object.group_allowed_user_ids.include?(user.id) }
|
||||
end
|
||||
|
|
|
@ -71,12 +71,14 @@ class TopicViewSerializer < ApplicationSerializer
|
|||
:pm_with_non_human_user,
|
||||
:queued_posts_count,
|
||||
:show_read_indicator,
|
||||
:requested_group_name
|
||||
:requested_group_name,
|
||||
)
|
||||
|
||||
has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects
|
||||
has_many :pending_posts, serializer: TopicPendingPostSerializer, root: false, embed: :objects
|
||||
|
||||
has_one :published_page, embed: :objects
|
||||
|
||||
def details
|
||||
object
|
||||
end
|
||||
|
@ -273,4 +275,8 @@ class TopicViewSerializer < ApplicationSerializer
|
|||
def include_requested_group_name?
|
||||
object.personal_message
|
||||
end
|
||||
|
||||
def include_published_page?
|
||||
SiteSetting.enable_page_publishing? && scope.is_staff? && object.published_page.present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -728,6 +728,22 @@ class StaffActionLogger
|
|||
))
|
||||
end
|
||||
|
||||
def log_published_page(topic_id, slug)
|
||||
UserHistory.create!(params.merge(
|
||||
subject: slug,
|
||||
topic_id: topic_id,
|
||||
action: UserHistory.actions[:page_published]
|
||||
))
|
||||
end
|
||||
|
||||
def log_unpublished_page(topic_id, slug)
|
||||
UserHistory.create!(params.merge(
|
||||
subject: slug,
|
||||
topic_id: topic_id,
|
||||
action: UserHistory.actions[:page_unpublished]
|
||||
))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_changes(changes)
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||
<%= discourse_stylesheet_link_tag 'publish', theme_ids: nil %>
|
||||
|
||||
<%- if @canonical_url -%>
|
||||
<link rel="canonical" href="<%= @canonical_url %>" />
|
||||
<%- end -%>
|
||||
</head>
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,19 @@
|
|||
<div class="published-page">
|
||||
<div class="published-page-header">
|
||||
<h1 class="published-page-title"><%= @topic.title %></h1>
|
||||
|
||||
<div class="published-page-author">
|
||||
<img src="<%= @topic.user.small_avatar_url %>" class="avatar">
|
||||
<div class="published-page-author-details">
|
||||
<div class="username"><%= @topic.user.username %></div>
|
||||
<div class="topic-created-at"><%= short_date(@topic.created_at) %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- if @topic.first_post.present? %>
|
||||
<div class="published-page-body">
|
||||
<%= @topic.first_post.cooked.html_safe %>
|
||||
</div>
|
||||
<%- end -%>
|
||||
</div>
|
||||
</div>
|
|
@ -2439,6 +2439,19 @@ en:
|
|||
action: "merge selected posts"
|
||||
error: "There was an error merging the selected posts."
|
||||
|
||||
publish_page:
|
||||
title: "Page Publishing"
|
||||
publish: "Publish"
|
||||
description: "When a topic is published as a page, its URL can be shared and it will displayed with custom styling."
|
||||
slug: "Slug"
|
||||
publish_url: "Your page has been published at:"
|
||||
topic_published: "Your topic has been published at:"
|
||||
preview_url: "Your page will be published at:"
|
||||
invalid_slug: "Sorry, you can't publish this page."
|
||||
unpublish: "Unpublish"
|
||||
unpublished: "Your page has been unpublished and is no longer accessible."
|
||||
publishing_settings: "Publishing Settings"
|
||||
|
||||
change_owner:
|
||||
title: "Change Owner"
|
||||
action: "change ownership"
|
||||
|
@ -2591,6 +2604,7 @@ en:
|
|||
convert_to_moderator: "Add Staff Color"
|
||||
revert_to_regular: "Remove Staff Color"
|
||||
rebake: "Rebuild HTML"
|
||||
publish_page: "Page Publishing"
|
||||
unhide: "Unhide"
|
||||
change_owner: "Change Ownership"
|
||||
grant_badge: "Grant Badge"
|
||||
|
@ -4083,6 +4097,8 @@ en:
|
|||
api_key_update: "api key update"
|
||||
api_key_destroy: "api key destroy"
|
||||
override_upload_secure_status: "override upload secure status"
|
||||
page_published: "page published"
|
||||
page_unpublished: "page unpublished"
|
||||
screened_emails:
|
||||
title: "Screened Emails"
|
||||
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
|
||||
|
|
|
@ -2107,6 +2107,7 @@ en:
|
|||
new_user_notice_tl: "Minimum trust level required to see new user post notices."
|
||||
returning_user_notice_tl: "Minimum trust level required to see returning user post notices."
|
||||
returning_users_days: "How many days should pass before a user is considered to be returning."
|
||||
enable_page_publishing: "Allow staff members to publish topics to new URLs with their own styling."
|
||||
|
||||
default_email_digest_frequency: "How often users receive summary emails by default."
|
||||
default_include_tl0_in_digests: "Include posts from new users in summary emails by default. Users can change this in their preferences."
|
||||
|
@ -2299,6 +2300,12 @@ en:
|
|||
change_owner:
|
||||
post_revision_text: "Ownership transferred"
|
||||
|
||||
publish_page:
|
||||
slug_errors:
|
||||
blank: "can't be blank"
|
||||
unavailable: "is unavailable"
|
||||
invalid: "contains invalid characters"
|
||||
|
||||
topic_statuses:
|
||||
autoclosed_message_max_posts:
|
||||
one: "This message was automatically closed after reaching the maximum limit of %{count} reply."
|
||||
|
|
|
@ -45,6 +45,12 @@ Discourse::Application.routes.draw do
|
|||
get "finish-installation/confirm-email" => "finish_installation#confirm_email"
|
||||
put "finish-installation/resend-email" => "finish_installation#resend_email"
|
||||
|
||||
get "pub/check-slug" => "published_pages#check_slug"
|
||||
get "pub/by-topic/:topic_id" => "published_pages#details"
|
||||
put "pub/by-topic/:topic_id" => "published_pages#upsert"
|
||||
delete "pub/by-topic/:topic_id" => "published_pages#destroy"
|
||||
get "pub/:slug" => "published_pages#show"
|
||||
|
||||
resources :directory_items
|
||||
|
||||
get "site" => "site#site"
|
||||
|
|
|
@ -933,6 +933,8 @@ posting:
|
|||
enum: "TrustLevelSetting"
|
||||
returning_users_days:
|
||||
default: 120
|
||||
enable_page_publishing:
|
||||
default: false
|
||||
|
||||
email:
|
||||
email_time_window_mins:
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreatePublishedPages < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
create_table :published_pages do |t|
|
||||
t.references :topic, null: false, index: { unique: true }
|
||||
t.string :slug, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :published_pages, :slug, unique: true
|
||||
end
|
||||
end
|
|
@ -485,6 +485,14 @@ class Guardian
|
|||
(components - Theme.components_for(parent)).empty?
|
||||
end
|
||||
|
||||
def can_publish_page?(topic)
|
||||
return false unless SiteSetting.enable_page_publishing?
|
||||
return false if topic.blank?
|
||||
return false if topic.private_message?
|
||||
return false unless can_see_topic?(topic)
|
||||
is_staff?
|
||||
end
|
||||
|
||||
def auth_token
|
||||
if cookie = request&.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
|
||||
UserAuthToken.hash_token(cookie)
|
||||
|
|
|
@ -78,7 +78,7 @@ module Stylesheet
|
|||
|
||||
target = nil
|
||||
if !plugin_name
|
||||
target_match = long.match(/admin|desktop|mobile/)
|
||||
target_match = long.match(/admin|desktop|mobile|publish/)
|
||||
if target_match&.length
|
||||
target = target_match[0]
|
||||
end
|
||||
|
|
|
@ -596,6 +596,10 @@ class TopicView
|
|||
ReviewableQueuedPost.viewable_by(@user).where(topic_id: @topic.id).pending.count
|
||||
end
|
||||
|
||||
def published_page
|
||||
@topic.published_page
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def read_posts_set
|
||||
|
|
|
@ -3476,4 +3476,36 @@ describe Guardian do
|
|||
expect(guardian.auth_token).to eq(token.auth_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "can_publish_page?" do
|
||||
context "when disabled" do
|
||||
it "is false for staff" do
|
||||
expect(Guardian.new(admin).can_publish_page?(topic)).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when enabled" do
|
||||
before do
|
||||
SiteSetting.enable_page_publishing = true
|
||||
end
|
||||
|
||||
it "is false for anonymous users" do
|
||||
expect(Guardian.new.can_publish_page?(topic)).to eq(false)
|
||||
end
|
||||
|
||||
it "is false for regular users" do
|
||||
expect(Guardian.new(user).can_publish_page?(topic)).to eq(false)
|
||||
end
|
||||
|
||||
it "is true for staff" do
|
||||
expect(Guardian.new(moderator).can_publish_page?(topic)).to eq(true)
|
||||
expect(Guardian.new(admin).can_publish_page?(topic)).to eq(true)
|
||||
end
|
||||
|
||||
it "is false if the topic is a private message" do
|
||||
post = Fabricate(:private_message_post, user: admin)
|
||||
expect(Guardian.new(admin).can_publish_page?(post.topic)).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:published_page) do
|
||||
topic
|
||||
slug "published-page-test"
|
||||
end
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PublishedPage, type: :model do
|
||||
|
||||
fab!(:topic) { Fabricate(:topic) }
|
||||
|
||||
it "has path and url helpers" do
|
||||
pp = PublishedPage.create!(topic: topic, slug: 'hello-world')
|
||||
expect(pp.path).to eq("/pub/hello-world")
|
||||
expect(pp.url).to eq(Discourse.base_url + "/pub/hello-world")
|
||||
end
|
||||
|
||||
it "validates the slug" do
|
||||
expect(PublishedPage.new(topic: topic, slug: "this-is-valid")).to be_valid
|
||||
expect(PublishedPage.new(topic: topic, slug: "10_things_i_hate_about_slugs")).to be_valid
|
||||
expect(PublishedPage.new(topic: topic, slug: "YELLING")).to be_valid
|
||||
|
||||
expect(PublishedPage.new(topic: topic, slug: "how about some space")).not_to be_valid
|
||||
expect(PublishedPage.new(topic: topic, slug: "slugs are %%%%")).not_to be_valid
|
||||
|
||||
expect(PublishedPage.new(topic: topic, slug: "check-slug")).not_to be_valid
|
||||
expect(PublishedPage.new(topic: topic, slug: "by-topic")).not_to be_valid
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,170 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PublishedPagesController do
|
||||
fab!(:published_page) { Fabricate(:published_page) }
|
||||
fab!(:admin) { Fabricate(:admin) }
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
|
||||
context "when enabled" do
|
||||
before do
|
||||
SiteSetting.enable_page_publishing = true
|
||||
end
|
||||
|
||||
context "check slug availability" do
|
||||
it "returns true for a new slug" do
|
||||
get "/pub/check-slug.json?slug=cool-slug-man"
|
||||
expect(response).to be_successful
|
||||
expect(response.parsed_body["valid_slug"]).to eq(true)
|
||||
end
|
||||
|
||||
it "returns true for a new slug with whitespace" do
|
||||
get "/pub/check-slug.json?slug=cool-slug-man%20"
|
||||
expect(response).to be_successful
|
||||
expect(response.parsed_body["valid_slug"]).to eq(true)
|
||||
end
|
||||
|
||||
it "returns false for an empty value" do
|
||||
get "/pub/check-slug.json?slug="
|
||||
expect(response).to be_successful
|
||||
expect(response.parsed_body["valid_slug"]).to eq(false)
|
||||
expect(response.parsed_body["reason"]).to be_present
|
||||
end
|
||||
|
||||
it "returns false for a reserved value" do
|
||||
get "/pub/check-slug.json", params: { slug: "check-slug" }
|
||||
expect(response).to be_successful
|
||||
expect(response.parsed_body["valid_slug"]).to eq(false)
|
||||
expect(response.parsed_body["reason"]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context "show" do
|
||||
it "returns 404 for a missing article" do
|
||||
get "/pub/no-article-here-no-thx"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
context "private topic" do
|
||||
fab!(:group) { Fabricate(:group) }
|
||||
fab!(:private_category) { Fabricate(:private_category, group: group) }
|
||||
|
||||
before do
|
||||
published_page.topic.update!(category: private_category)
|
||||
end
|
||||
|
||||
it "returns 403 for a topic you can't see" do
|
||||
get published_page.path
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
context "as an admin" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
it "returns 200" do
|
||||
get published_page.path
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an error for an article you can't see" do
|
||||
get "/pub/no-article-here-no-thx"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "returns 200 for a valid article" do
|
||||
get published_page.path
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
context "publishing" do
|
||||
fab!(:topic) { Fabricate(:topic) }
|
||||
|
||||
it "returns invalid access for non-staff" do
|
||||
sign_in(user)
|
||||
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'cant-do-this' } }
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
context "with a valid staff account" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
it "creates the published page record" do
|
||||
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
|
||||
expect(response).to be_successful
|
||||
expect(response.parsed_body['published_page']).to be_present
|
||||
expect(response.parsed_body['published_page']['slug']).to eq("i-hate-salt")
|
||||
|
||||
expect(PublishedPage.exists?(topic_id: response.parsed_body['published_page']['id'])).to eq(true)
|
||||
expect(UserHistory.exists?(
|
||||
acting_user_id: admin.id,
|
||||
action: UserHistory.actions[:page_published],
|
||||
topic_id: topic.id
|
||||
)).to be(true)
|
||||
end
|
||||
|
||||
it "returns an error if the slug is already taken" do
|
||||
PublishedPage.create!(slug: 'i-hate-salt', topic: Fabricate(:topic))
|
||||
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
|
||||
expect(response).not_to be_successful
|
||||
end
|
||||
|
||||
it "returns an error if the topic already has been published" do
|
||||
PublishedPage.create!(slug: 'already-done-pal', topic: topic)
|
||||
put "/pub/by-topic/#{topic.id}.json", params: { published_page: { slug: 'i-hate-salt' } }
|
||||
expect(response).to be_successful
|
||||
expect(PublishedPage.exists?(topic_id: topic.id)).to eq(true)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
context "destroy" do
|
||||
|
||||
it "returns invalid access for non-staff" do
|
||||
sign_in(user)
|
||||
delete "/pub/by-topic/#{published_page.topic_id}.json"
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
context "with a valid staff account" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
it "deletes the record" do
|
||||
topic_id = published_page.topic_id
|
||||
|
||||
delete "/pub/by-topic/#{topic_id}.json"
|
||||
expect(response).to be_successful
|
||||
expect(PublishedPage.exists?(slug: published_page.slug)).to eq(false)
|
||||
|
||||
expect(UserHistory.exists?(
|
||||
acting_user_id: admin.id,
|
||||
action: UserHistory.actions[:page_unpublished],
|
||||
topic_id: topic_id
|
||||
)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when disabled" do
|
||||
before do
|
||||
SiteSetting.enable_page_publishing = false
|
||||
end
|
||||
|
||||
it "returns 404 for any article" do
|
||||
get published_page.path
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
|
@ -258,6 +258,16 @@ describe TopicViewSerializer do
|
|||
expect(details[:allowed_groups].find { |ag| ag[:id] == group.id }).to be_present
|
||||
end
|
||||
|
||||
it "has can_publish_page if possible" do
|
||||
SiteSetting.enable_page_publishing = true
|
||||
|
||||
json = serialize_topic(topic, user)
|
||||
expect(json[:details][:can_publish_page]).to be_blank
|
||||
|
||||
json = serialize_topic(topic, admin)
|
||||
expect(json[:details][:can_publish_page]).to eq(true)
|
||||
end
|
||||
|
||||
context "can_edit_tags" do
|
||||
before do
|
||||
SiteSetting.tagging_enabled = true
|
||||
|
@ -279,4 +289,26 @@ describe TopicViewSerializer do
|
|||
end
|
||||
end
|
||||
|
||||
context "published_page" do
|
||||
fab!(:published_page) { Fabricate(:published_page, topic: topic) }
|
||||
|
||||
it "doesn't return the published page if not enabled" do
|
||||
json = serialize_topic(topic, admin)
|
||||
expect(json[:published_page]).to be_blank
|
||||
end
|
||||
|
||||
it "doesn't return the published page unless staff" do
|
||||
SiteSetting.enable_page_publishing = true
|
||||
json = serialize_topic(topic, user)
|
||||
expect(json[:published_page]).to be_blank
|
||||
end
|
||||
|
||||
it "returns the published page if enabled and staff" do
|
||||
SiteSetting.enable_page_publishing = true
|
||||
json = serialize_topic(topic, admin)
|
||||
expect(json[:published_page]).to be_present
|
||||
expect(json[:published_page][:slug]).to eq("published-page-test")
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { acceptance } from "helpers/qunit-helpers";
|
||||
|
||||
acceptance("Page Publishing", {
|
||||
loggedIn: true,
|
||||
pretend(server, helper) {
|
||||
const validSlug = helper.response({ valid_slug: true });
|
||||
|
||||
server.put("/pub/by-topic/280", () => {
|
||||
return helper.response({});
|
||||
});
|
||||
server.get("/pub/by-topic/280", () => {
|
||||
return helper.response({});
|
||||
});
|
||||
server.get("/pub/check-slug", req => {
|
||||
if (req.queryParams.slug === "internationalization-localization") {
|
||||
return validSlug;
|
||||
}
|
||||
return helper.response({
|
||||
valid_slug: false,
|
||||
reason: "i don't need a reason"
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
QUnit.test("can publish a page via modal", async assert => {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click(".topic-post:eq(0) button.show-more-actions");
|
||||
await click(".topic-post:eq(0) button.show-post-admin-menu");
|
||||
await click(".topic-post:eq(0) .publish-page");
|
||||
|
||||
await fillIn(".publish-slug", "bad-slug");
|
||||
assert.ok(!exists(".valid-slug"));
|
||||
assert.ok(exists(".invalid-slug"));
|
||||
await fillIn(".publish-slug", "internationalization-localization");
|
||||
assert.ok(exists(".valid-slug"));
|
||||
assert.ok(!exists(".invalid-slug"));
|
||||
|
||||
await click(".publish-page");
|
||||
assert.ok(exists(".current-url"));
|
||||
});
|
|
@ -1977,6 +1977,7 @@ export default {
|
|||
pinned: false,
|
||||
pinned_at: null,
|
||||
details: {
|
||||
can_publish_page: true,
|
||||
can_invite_via_email: true,
|
||||
auto_close_at: null,
|
||||
auto_close_hours: null,
|
||||
|
|
Loading…
Reference in New Issue