From e1f8014acd2877cda539b14b4694d9c077f6b36d Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 8 Apr 2020 12:52:36 -0400 Subject: [PATCH] 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. --- .../discourse/adapters/published-page.js | 9 + .../discourse/components/text-field.js | 21 +++ .../discourse/controllers/publish-page.js | 121 +++++++++++++ .../discourse/lib/transform-post.js | 5 +- .../discourse/models/published-page.js | 8 + .../javascripts/discourse/models/topic.js | 7 + .../javascripts/discourse/routes/topic.js | 8 + .../templates/modal/publish-page.hbs | 62 +++++++ .../javascripts/discourse/templates/topic.hbs | 22 ++- .../discourse/widgets/post-admin-menu.js | 9 + app/assets/stylesheets/common/base/modal.scss | 21 +++ app/assets/stylesheets/common/base/topic.scss | 11 ++ app/assets/stylesheets/publish.scss | 32 ++++ app/controllers/published_pages_controller.rb | 59 ++++++ app/models/published_page.rb | 46 +++++ app/models/topic.rb | 1 + app/models/user_history.rb | 8 +- app/serializers/published_page_serializer.rb | 9 + .../topic_view_details_serializer.rb | 7 +- app/serializers/topic_view_serializer.rb | 8 +- app/services/staff_action_logger.rb | 16 ++ app/views/layouts/publish.html.erb | 15 ++ app/views/published_pages/show.html.erb | 19 ++ config/locales/client.en.yml | 16 ++ config/locales/server.en.yml | 7 + config/routes.rb | 6 + config/site_settings.yml | 2 + .../20200401172023_create_published_pages.rb | 13 ++ lib/guardian.rb | 8 + lib/stylesheet/watcher.rb | 2 +- lib/topic_view.rb | 4 + spec/components/guardian_spec.rb | 32 ++++ spec/fabricators/published_page_fabricator.rb | 6 + spec/models/published_page_spec.rb | 27 +++ .../published_pages_controller_spec.rb | 170 ++++++++++++++++++ .../serializers/topic_view_serializer_spec.rb | 32 ++++ .../acceptance/page-publishing-test.js | 40 +++++ test/javascripts/fixtures/topic.js | 1 + 38 files changed, 883 insertions(+), 7 deletions(-) create mode 100644 app/assets/javascripts/discourse/adapters/published-page.js create mode 100644 app/assets/javascripts/discourse/controllers/publish-page.js create mode 100644 app/assets/javascripts/discourse/models/published-page.js create mode 100644 app/assets/javascripts/discourse/templates/modal/publish-page.hbs create mode 100644 app/assets/stylesheets/publish.scss create mode 100644 app/controllers/published_pages_controller.rb create mode 100644 app/models/published_page.rb create mode 100644 app/serializers/published_page_serializer.rb create mode 100644 app/views/layouts/publish.html.erb create mode 100644 app/views/published_pages/show.html.erb create mode 100644 db/migrate/20200401172023_create_published_pages.rb create mode 100644 spec/fabricators/published_page_fabricator.rb create mode 100644 spec/models/published_page_spec.rb create mode 100644 spec/requests/published_pages_controller_spec.rb create mode 100644 test/javascripts/acceptance/page-publishing-test.js 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}} +

{{i18n "topic.publish_page.unpublished"}}

+ {{else}} + {{#conditional-loading-spinner condition=initializing}} +

{{i18n "topic.publish_page.description"}}

+ +
+ + {{text-field value=publishedPage.slug onChange=(action "checkSlug") onChangeImmediate=(action "startCheckSlug") disabled=existing class="publish-slug"}} +
+ +
+ {{conditional-loading-spinner condition=checking}} + + {{#if existing}} +
+ {{i18n "topic.publish_page.publish_url"}} +
+ {{publishedPage.url}} +
+
+ {{else}} + {{#if showUrl}} +
+ {{i18n "topic.publish_page.preview_url"}} +
{{publishedPage.url}}
+
+ {{/if}} + + {{#if invalid}} + {{i18n "topic.publish_page.invalid_slug"}} {{reason}}. + {{/if}} + {{/if}} + +
+ {{/conditional-loading-spinner}} + {{/if}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 138c39b8924..ed26ae62ba9 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -85,6 +85,25 @@ {{topic-category topic=model class="topic-category"}} {{/if}} {{/topic-title}} + + {{#if model.publishedPage}} +
+
+ {{i18n "topic.publish_page.topic_published"}} +
+ {{model.publishedPage.url}} +
+
+
+ {{d-button + icon="file" + label="topic.publish_page.publishing_settings" + action=(route-action "showPagePublish") + }} +
+
+ {{/if}} + {{/if}}
@@ -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 @@ +
+
+

<%= @topic.title %>

+ +
+ +
+
<%= @topic.user.username %>
+
<%= short_date(@topic.created_at) %>
+
+
+ + <%- if @topic.first_post.present? %> +
+ <%= @topic.first_post.cooked.html_safe %> +
+ <%- end -%> +
+
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,