diff --git a/app/assets/javascripts/discourse/adapters/published-page.js b/app/assets/javascripts/discourse/adapters/published-page.js
new file mode 100644
index 00000000000..19226739359
--- /dev/null
+++ b/app/assets/javascripts/discourse/adapters/published-page.js
@@ -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}`;
+ }
+});
diff --git a/app/assets/javascripts/discourse/components/text-field.js b/app/assets/javascripts/discourse/components/text-field.js
index bb57f25309c..3477520d0bc 100644
--- a/app/assets/javascripts/discourse/components/text-field.js
+++ b/app/assets/javascripts/discourse/components/text-field.js
@@ -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() {
diff --git a/app/assets/javascripts/discourse/controllers/publish-page.js b/app/assets/javascripts/discourse/controllers/publish-page.js
new file mode 100644
index 00000000000..37462fce381
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/publish-page.js
@@ -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();
+ }
+});
diff --git a/app/assets/javascripts/discourse/lib/transform-post.js b/app/assets/javascripts/discourse/lib/transform-post.js
index 585bb02af33..986dc46c3c6 100644
--- a/app/assets/javascripts/discourse/lib/transform-post.js
+++ b/app/assets/javascripts/discourse/lib/transform-post.js
@@ -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 =
diff --git a/app/assets/javascripts/discourse/models/published-page.js b/app/assets/javascripts/discourse/models/published-page.js
new file mode 100644
index 00000000000..7f0c12ddf56
--- /dev/null
+++ b/app/assets/javascripts/discourse/models/published-page.js
@@ -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}`;
+ })
+});
diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js
index 045f31123d6..41734e6e8fd 100644
--- a/app/assets/javascripts/discourse/models/topic.js
+++ b/app/assets/javascripts/discourse/models/topic.js
@@ -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]));
},
diff --git a/app/assets/javascripts/discourse/routes/topic.js b/app/assets/javascripts/discourse/routes/topic.js
index 33e60412c63..5aebefe6a43 100644
--- a/app/assets/javascripts/discourse/routes/topic.js
+++ b/app/assets/javascripts/discourse/routes/topic.js
@@ -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");
diff --git a/app/assets/javascripts/discourse/templates/modal/publish-page.hbs b/app/assets/javascripts/discourse/templates/modal/publish-page.hbs
new file mode 100644
index 00000000000..027f3ee7649
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/publish-page.hbs
@@ -0,0 +1,62 @@
+{{#d-modal-body}}
+ {{#if unpublished}}
+
@@ -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}}
diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js b/app/assets/javascripts/discourse/widgets/post-admin-menu.js
index 40dc1d3c85d..2b94b8a85c4 100644
--- a/app/assets/javascripts/discourse/widgets/post-admin-menu.js
+++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js
@@ -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",
diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss
index 90a20854fdf..cb7e0a4c354 100644
--- a/app/assets/stylesheets/common/base/modal.scss
+++ b/app/assets/stylesheets/common/base/modal.scss
@@ -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;
diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss
index 927ee0e250e..cd7c7d70cc0 100644
--- a/app/assets/stylesheets/common/base/topic.scss
+++ b/app/assets/stylesheets/common/base/topic.scss
@@ -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;
+}
diff --git a/app/assets/stylesheets/publish.scss b/app/assets/stylesheets/publish.scss
new file mode 100644
index 00000000000..b59a480ef06
--- /dev/null
+++ b/app/assets/stylesheets/publish.scss
@@ -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;
+ }
+}
diff --git a/app/controllers/published_pages_controller.rb b/app/controllers/published_pages_controller.rb
new file mode 100644
index 00000000000..a1337328677
--- /dev/null
+++ b/app/controllers/published_pages_controller.rb
@@ -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
diff --git a/app/models/published_page.rb b/app/models/published_page.rb
new file mode 100644
index 00000000000..da89df53bc6
--- /dev/null
+++ b/app/models/published_page.rb
@@ -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
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 4f69e3055d0..dd9460ce3a2 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -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
diff --git a/app/models/user_history.rb b/app/models/user_history.rb
index 3adbb62eb1b..49a545b8156 100644
--- a/app/models/user_history.rb
+++ b/app/models/user_history.rb
@@ -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
diff --git a/app/serializers/published_page_serializer.rb b/app/serializers/published_page_serializer.rb
new file mode 100644
index 00000000000..bdee8d14f70
--- /dev/null
+++ b/app/serializers/published_page_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class PublishedPageSerializer < ApplicationSerializer
+ attributes :id, :slug
+
+ def id
+ object.topic_id
+ end
+end
diff --git a/app/serializers/topic_view_details_serializer.rb b/app/serializers/topic_view_details_serializer.rb
index d21b1a778b0..32a1737527d 100644
--- a/app/serializers/topic_view_details_serializer.rb
+++ b/app/serializers/topic_view_details_serializer.rb
@@ -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
diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb
index 824dfcb068f..1aec5f442d5 100644
--- a/app/serializers/topic_view_serializer.rb
+++ b/app/serializers/topic_view_serializer.rb
@@ -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
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index 9130f346925..117353cb17e 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -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)
diff --git a/app/views/layouts/publish.html.erb b/app/views/layouts/publish.html.erb
new file mode 100644
index 00000000000..2b3d727dc7d
--- /dev/null
+++ b/app/views/layouts/publish.html.erb
@@ -0,0 +1,15 @@
+
+
+
+
+
+ <%= discourse_stylesheet_link_tag 'publish', theme_ids: nil %>
+
+ <%- if @canonical_url -%>
+
+ <%- end -%>
+
+
+ <%= yield %>
+
+
diff --git a/app/views/published_pages/show.html.erb b/app/views/published_pages/show.html.erb
new file mode 100644
index 00000000000..08f5474b817
--- /dev/null
+++ b/app/views/published_pages/show.html.erb
@@ -0,0 +1,19 @@
+
+
+
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 62cfd14763e..7e28c6620d2 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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."
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 5574f69ae85..f5f03a1b9d6 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -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."
diff --git a/config/routes.rb b/config/routes.rb
index 8306dcc40ac..1a92a5440d1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index ccbcc8150a1..e2fc35aac48 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -933,6 +933,8 @@ posting:
enum: "TrustLevelSetting"
returning_users_days:
default: 120
+ enable_page_publishing:
+ default: false
email:
email_time_window_mins:
diff --git a/db/migrate/20200401172023_create_published_pages.rb b/db/migrate/20200401172023_create_published_pages.rb
new file mode 100644
index 00000000000..201b08c9d6f
--- /dev/null
+++ b/db/migrate/20200401172023_create_published_pages.rb
@@ -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
diff --git a/lib/guardian.rb b/lib/guardian.rb
index 851dddf8811..00d496e46eb 100644
--- a/lib/guardian.rb
+++ b/lib/guardian.rb
@@ -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)
diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb
index 384f7515d10..d2d49c2ab93 100644
--- a/lib/stylesheet/watcher.rb
+++ b/lib/stylesheet/watcher.rb
@@ -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
diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index 94f31f44b18..82ae26c573c 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -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
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index bea0404696c..97a1b0c2e06 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -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
diff --git a/spec/fabricators/published_page_fabricator.rb b/spec/fabricators/published_page_fabricator.rb
new file mode 100644
index 00000000000..44f8da98da2
--- /dev/null
+++ b/spec/fabricators/published_page_fabricator.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+Fabricator(:published_page) do
+ topic
+ slug "published-page-test"
+end
diff --git a/spec/models/published_page_spec.rb b/spec/models/published_page_spec.rb
new file mode 100644
index 00000000000..e863cdb4730
--- /dev/null
+++ b/spec/models/published_page_spec.rb
@@ -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
diff --git a/spec/requests/published_pages_controller_spec.rb b/spec/requests/published_pages_controller_spec.rb
new file mode 100644
index 00000000000..6b3a6894f46
--- /dev/null
+++ b/spec/requests/published_pages_controller_spec.rb
@@ -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
diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb
index b0627ab05f5..a80f884812f 100644
--- a/spec/serializers/topic_view_serializer_spec.rb
+++ b/spec/serializers/topic_view_serializer_spec.rb
@@ -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
diff --git a/test/javascripts/acceptance/page-publishing-test.js b/test/javascripts/acceptance/page-publishing-test.js
new file mode 100644
index 00000000000..2b51229bb28
--- /dev/null
+++ b/test/javascripts/acceptance/page-publishing-test.js
@@ -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"));
+});
diff --git a/test/javascripts/fixtures/topic.js b/test/javascripts/fixtures/topic.js
index 7c380de3a00..dedf9814b83 100644
--- a/test/javascripts/fixtures/topic.js
+++ b/test/javascripts/fixtures/topic.js
@@ -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,