diff --git a/Gemfile b/Gemfile
index bdb7e1134d..69b0b24d81 100644
--- a/Gemfile
+++ b/Gemfile
@@ -3,9 +3,10 @@
source "https://rubygems.org"
# if there is a super emergency and rubygems is playing up, try
#source 'http://production.cf.rubygems.org'
-
gem "bootsnap", require: false, platform: :mri
-
+gem "rspec"
+gem "listen"
+gem "puma-acme"
gem "actionmailer", "~> 7.2.0"
gem "actionpack", "~> 7.2.0"
gem "actionview", "~> 7.2.0"
@@ -55,6 +56,7 @@ gem "discourse-fonts", require: "discourse_fonts"
gem "message_bus"
+gem "rails"
gem "rails_multisite"
gem "fastimage"
@@ -135,8 +137,6 @@ group :test do
end
group :test, :development do
- gem "rspec"
- gem "listen", require: false
gem "certified", require: false
gem "fabrication", require: false
gem "mocha", require: false
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index d1ac78ecf1..bdb1414ff6 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -293,6 +293,11 @@ class PostsController < ApplicationController
render_json_dump(result)
end
+ def by_external_id
+ post = Post.find_by(external_id: params[:external_id])
+ display_post(post)
+ end
+
def show
post = find_post_from_params
display_post(post)
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 07de153891..ede1b8d174 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -43,13 +43,19 @@ class TopicsController < ApplicationController
render json: { slug: topic.slug, topic_id: topic.id, url: topic.url }
end
- def show_by_external_id
+ def external_id_json
topic = Topic.find_by(external_id: params[:external_id])
raise Discourse::NotFound unless topic
guardian.ensure_can_see!(topic)
redirect_to_correct_topic(topic, params[:post_number])
end
+ def external_id_redir
+ topic = Topic.find_by(external_id: params[:external_id])
+ raise Discourse::NotFound unless topic
+ redirect_to_correct_topic(topic, params[:post_number], true)
+ end
+
def show
if params[:id].is_a?(Array) || params[:id].is_a?(ActionController::Parameters)
raise Discourse::InvalidParameters.new("Show only accepts a single ID")
@@ -1263,7 +1269,7 @@ class TopicsController < ApplicationController
end
end
- def redirect_to_correct_topic(topic, post_number = nil)
+ def redirect_to_correct_topic(topic, post_number = nil, nojson = false)
begin
guardian.ensure_can_see!(topic)
rescue Discourse::InvalidAccess => ex
@@ -1287,7 +1293,9 @@ class TopicsController < ApplicationController
url = topic.relative_url
url << "/#{post_number}" if post_number.to_i > 0
- url << ".json" if request.format.json?
+ unless nojson
+ url << ".json" if request.format.json?
+ end
opts.each do |k, v|
s = url.include?("?") ? "&" : "?"
diff --git a/app/models/post.rb b/app/models/post.rb
index 832e427e0f..febf96b489 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -10,6 +10,8 @@ class Post < ActiveRecord::Base
include HasCustomFields
include LimitedEdit
+ EXTERNAL_ID_MAX_LENGTH = 64
+
self.ignored_columns = [
"avg_time", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
"image_url", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
@@ -505,7 +507,8 @@ class Post < ActiveRecord::Base
end
def external_id
- "#{topic_id}/#{post_number}"
+ self[:external_id]
+ # "#{topic_id}/#{post_number}"
end
def reply_to_post
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 40e22fb82a..f3a72008fe 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -13,7 +13,7 @@ class Topic < ActiveRecord::Base
include LimitedEdit
extend Forwardable
- EXTERNAL_ID_MAX_LENGTH = 50
+ EXTERNAL_ID_MAX_LENGTH = 64
self.ignored_columns = [
"avg_time", # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
diff --git a/app/models/user_action.rb b/app/models/user_action.rb
index b17740e80a..10be47019b 100644
--- a/app/models/user_action.rb
+++ b/app/models/user_action.rb
@@ -208,7 +208,7 @@ class UserAction < ActiveRecord::Base
t.title, a.action_type, a.created_at, t.id topic_id,
t.closed AS topic_closed, t.archived AS topic_archived,
a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username,
- coalesce(p.post_number, 1) post_number, p.id as post_id,
+ coalesce(p.post_number, 1) post_number, p.id as post_id, p.external_id,
p.reply_to_post_number,
pu.username, pu.name, pu.id user_id,
pu.uploaded_avatar_id,
@@ -221,11 +221,13 @@ class UserAction < ActiveRecord::Base
pc.value AS action_code_who,
pc2.value AS action_code_path,
p.edit_reason,
- t.category_id
+ t.category_id,
+ p3.external_id as reply_to_post_external_id
FROM user_actions as a
JOIN topics t on t.id = a.target_topic_id
LEFT JOIN posts p on p.id = a.target_post_id
JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
+ JOIN posts p3 on p3.topic_id = a.target_topic_id and p3.post_number = p.reply_to_post_number
JOIN users u on u.id = a.acting_user_id
JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id)
JOIN users au on au.id = a.user_id
diff --git a/app/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb
index 029b04bdbf..c1082e188c 100644
--- a/app/serializers/basic_post_serializer.rb
+++ b/app/serializers/basic_post_serializer.rb
@@ -2,7 +2,7 @@
# The most basic attributes of a topic that we need to create a link for it.
class BasicPostSerializer < ApplicationSerializer
- attributes :id, :name, :username, :avatar_template, :created_at, :cooked, :cooked_hidden
+ attributes :id, :name, :username, :avatar_template, :created_at, :cooked, :cooked_hidden, :external_id
attr_accessor :topic_view
@@ -26,6 +26,10 @@ class BasicPostSerializer < ApplicationSerializer
cooked_hidden
end
+ def include_external_id?
+ external_id
+ end
+
def cooked
if cooked_hidden
if scope.current_user && object.user_id == scope.current_user.id
diff --git a/app/serializers/basic_topic_serializer.rb b/app/serializers/basic_topic_serializer.rb
index 52cfbe3e0b..f3698f14b9 100644
--- a/app/serializers/basic_topic_serializer.rb
+++ b/app/serializers/basic_topic_serializer.rb
@@ -2,7 +2,7 @@
# The most basic attributes of a topic that we need to create a link for it.
class BasicTopicSerializer < ApplicationSerializer
- attributes :id, :title, :fancy_title, :slug, :posts_count
+ attributes :id, :title, :fancy_title, :slug, :posts_count, :external_id
def fancy_title
f = object.fancy_title
diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb
index 0069c5fe27..702bbcfeff 100644
--- a/app/serializers/topic_view_serializer.rb
+++ b/app/serializers/topic_view_serializer.rb
@@ -78,6 +78,7 @@ class TopicViewSerializer < ApplicationSerializer
:user_last_posted_at,
:is_shared_draft,
:slow_mode_enabled_until,
+ :external_id,
)
has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects
@@ -111,6 +112,10 @@ class TopicViewSerializer < ApplicationSerializer
external_id
end
+ def external_id
+ object.topic.external_id
+ end
+
def draft
object.draft
end
diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb
index 0958de3d9d..e5416c643d 100644
--- a/app/serializers/user_action_serializer.rb
+++ b/app/serializers/user_action_serializer.rb
@@ -33,7 +33,9 @@ class UserActionSerializer < ApplicationSerializer
:edit_reason,
:category_id,
:closed,
- :archived
+ :archived,
+ :external_id,
+ :reply_to_post_external_id
def avatar_template
User.avatar_template(object.username, object.uploaded_avatar_id)
@@ -71,6 +73,10 @@ class UserActionSerializer < ApplicationSerializer
object.action_type == UserAction::REPLY
end
+ def include_reply_to_post_external_id?
+ object.action_type == UserAction::REPLY
+ end
+
def include_edit_reason?
object.action_type == UserAction::EDIT
end
@@ -83,6 +89,14 @@ class UserActionSerializer < ApplicationSerializer
object.topic_archived
end
+ def external_id
+ object&.external_id
+ end
+
+ def reply_to_post_external_id
+ object&.reply_to_post_external_id
+ end
+
def include_action_code_who?
action_code_who.present?
end
diff --git a/config/initializers/008-rack-cors.rb b/config/initializers/008-rack-cors.rb
index f9d23ec771..3618e2a685 100644
--- a/config/initializers/008-rack-cors.rb
+++ b/config/initializers/008-rack-cors.rb
@@ -45,7 +45,7 @@ class Discourse::Cors
headers["Access-Control-Allow-Origin"] = origin || cors_origins[0]
headers[
"Access-Control-Allow-Headers"
- ] = "Content-Type, Cache-Control, X-Requested-With, X-CSRF-Token, Discourse-Present, User-Api-Key, User-Api-Client-Id, Authorization"
+ ] = "Content-Type, Cache-Control, X-Requested-With, X-CSRF-Token, Discourse-Present, User-Api-Key, User-Api-Client-Id, Authorization, Api-Username, Api-Key"
headers["Access-Control-Allow-Credentials"] = "true"
headers["Access-Control-Allow-Methods"] = "POST, PUT, GET, OPTIONS, DELETE"
headers["Access-Control-Max-Age"] = "7200"
diff --git a/config/initializers/012-web_hook_events.rb b/config/initializers/012-web_hook_events.rb
index 9db88e1e1c..e3fa5ac1d3 100644
--- a/config/initializers/012-web_hook_events.rb
+++ b/config/initializers/012-web_hook_events.rb
@@ -32,6 +32,20 @@ DiscourseEvent.on(:post_edited) do |post, topic_changed|
end
end
+# after_initialize do
+# DiscourseEvent.on(:post_created) do |post, opts, user|
+# external_id = opts[:external_id].presence || SecureRandom.alphanumeric(SiteSetting.external_id_length)
+# post.update_column(:external_id, external_id)
+# end
+# end
+#
+# after_initialize do
+# DiscourseEvent.on(:topic_created) do |topic, opts, user|
+# external_id = opts[:external_id].presence || SecureRandom.alphanumeric(SiteSetting.external_id_length)
+# topic.update_column(:external_id, external_id)
+# end
+# end
+
%i[
user_logged_out
user_created
diff --git a/config/puma.rb b/config/puma.rb
index c349148f57..0e936565da 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,18 +1,150 @@
# frozen_string_literal: true
+require "fileutils"
+#require 'puma/acme'
+
+discourse_path = File.expand_path(File.expand_path(File.dirname(__FILE__)) + "/../")
+
+enable_logstash_logger = ENV["ENABLE_LOGSTASH_LOGGER"] == "1"
+puma_stderr_path = "#{discourse_path}/log/puma.stderr.log"
+puma_stdout_path = "#{discourse_path}/log/puma.stdout.log"
+
+# Load logstash logger if enabled
+if enable_logstash_logger
+ require_relative "../lib/discourse_logstash_logger"
+ FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
+ # Note: You may need to adapt the logger initialization for Puma
+ log_formatter = proc do |severity, time, progname, msg|
+ event = {
+ "@timestamp" => Time.now.utc,
+ "message" => msg,
+ "severity" => severity,
+ "type" => "puma"
+ }
+ "#{event.to_json}\n"
+ end
+else
+ stdout_redirect puma_stdout_path, puma_stderr_path, true
+end
+
+# Number of workers (processes)
+workers ENV.fetch("PUMA_WORKERS", 8).to_i
+
+# Set the directory
+directory discourse_path
+
+# Bind to the specified address and port
+bind ENV.fetch("PUMA_BIND", "tcp://#{ENV['PUMA_BIND_ALL'] ? '' : '127.0.0.1:'}#{ENV.fetch('PUMA_PORT', 3000)}")
+#bind 'tcp://0.0.0.0:80'
+#plugin :acme
+#acme_server_name 'xjtu.app'
+#acme_tos_agreed true
+#bind 'acme://0.0.0.0:443'
+
+# PID file location
+FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
+pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")
+
+# State file - used by pumactl
+state_path "#{discourse_path}/tmp/pids/puma.state"
+
+# Environment-specific configuration
if ENV["RAILS_ENV"] == "production"
- # First, you need to change these below to your situation.
- APP_ROOT = ENV["APP_ROOT"] || "/home/discourse/discourse"
- num_workers = ENV["NUM_WEBS"].to_i > 0 ? ENV["NUM_WEBS"].to_i : 4
-
- # Second, you can choose how many threads that you are going to run at same time.
- workers "#{num_workers}"
- threads 8, 32
-
- # Unless you know what you are changing, do not change them.
- bind "unix://#{APP_ROOT}/tmp/sockets/puma.sock"
- stdout_redirect "#{APP_ROOT}/log/puma.log", "#{APP_ROOT}/log/puma.err.log"
- pidfile "#{APP_ROOT}/tmp/pids/puma.pid"
- state_path "#{APP_ROOT}/tmp/pids/puma.state"
- preload_app!
+ # Production timeout
+ worker_timeout 30
+else
+ # Development timeout
+ worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end
+
+# Preload application
+preload_app!
+
+# Handle worker boot and shutdown
+before_fork do
+ Discourse.preload_rails!
+ Discourse.before_fork
+
+ # Supervisor check
+ supervisor_pid = ENV["PUMA_SUPERVISOR_PID"].to_i
+ if supervisor_pid > 0
+ Thread.new do
+ loop do
+ unless File.exist?("/proc/#{supervisor_pid}")
+ puts "Kill self supervisor is gone"
+ Process.kill "TERM", Process.pid
+ end
+ sleep 2
+ end
+ end
+ end
+
+ # Sidekiq workers
+ sidekiqs = ENV["PUMA_SIDEKIQS"].to_i
+ if sidekiqs > 0
+ puts "starting #{sidekiqs} supervised sidekiqs"
+
+ require "demon/sidekiq"
+ Demon::Sidekiq.after_fork { DiscourseEvent.trigger(:sidekiq_fork_started) }
+ Demon::Sidekiq.start(sidekiqs)
+
+ if Discourse.enable_sidekiq_logging?
+ Signal.trap("USR1") do
+ # Delay Sidekiq log reopening
+ sleep 1
+ Demon::Sidekiq.kill("USR2")
+ end
+ end
+ end
+
+ # Email sync demon
+ if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
+ puts "starting up EmailSync demon"
+ Demon::EmailSync.start(1)
+ end
+
+ # Plugin demons
+ DiscoursePluginRegistry.demon_processes.each do |demon_class|
+ puts "starting #{demon_class.prefix} demon"
+ demon_class.start(1)
+ end
+
+ # Demon monitoring thread
+ Thread.new do
+ loop do
+ begin
+ sleep 60
+
+ if sidekiqs > 0
+ Demon::Sidekiq.ensure_running
+ Demon::Sidekiq.heartbeat_check
+ Demon::Sidekiq.rss_memory_check
+ end
+
+ if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
+ Demon::EmailSync.ensure_running
+ Demon::EmailSync.check_email_sync_heartbeat
+ end
+
+ DiscoursePluginRegistry.demon_processes.each(&:ensure_running)
+ rescue => e
+ Rails.logger.warn("Error in demon processes heartbeat check: #{e}\n#{e.backtrace.join("\n")}")
+ end
+ end
+ end
+
+ # Close Redis connection
+ Discourse.redis.close
+end
+
+on_worker_boot do
+ DiscourseEvent.trigger(:web_fork_started)
+ Discourse.after_fork
+end
+
+# Worker timeout handling
+worker_timeout 30
+
+# Low-level worker options
+threads 8, 32
+
diff --git a/config/routes.rb b/config/routes.rb
index ea34bf4be6..3a7d67a861 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -8,16 +8,17 @@ USERNAME_ROUTE_FORMAT = /[%\w.\-]+?/ unless defined?(USERNAME_ROUTE_FORMAT)
BACKUP_ROUTE_FORMAT = /.+\.(sql\.gz|tar\.gz|tgz)/i unless defined?(BACKUP_ROUTE_FORMAT)
Discourse::Application.routes.draw do
- def patch(*)
- end # Disable PATCH requests
+ def patch(*) end
+
+ # Disable PATCH requests
scope path: nil, constraints: { format: %r{(json|html|\*/\*)} } do
relative_url_root =
(
if (
- defined?(Rails.configuration.relative_url_root) &&
- Rails.configuration.relative_url_root
- )
+ defined?(Rails.configuration.relative_url_root) &&
+ Rails.configuration.relative_url_root
+ )
Rails.configuration.relative_url_root + "/"
else
"/"
@@ -1056,6 +1057,7 @@ Discourse::Application.routes.draw do
:constraints => {
format: /(json|rss)/,
}
+ get "posts/by_external_id/:external_id" => "posts#by_external_id", format: :json, constrains: { external_id: /\A[\w-]+\z/ }
get "posts/by_number/:topic_id/:post_number" => "posts#by_number"
get "posts/by-date/:topic_id/:date" => "posts#by_date"
get "posts/:id/reply-history" => "posts#reply_history"
@@ -1366,7 +1368,11 @@ Discourse::Application.routes.draw do
# Topic routes
get "t/id_for/:slug" => "topics#id_for_slug"
- get "t/external_id/:external_id" => "topics#show_by_external_id",
+ get "t_external_id_redir/:external_id" => "topics#external_id_redir",
+ :constraints => {
+ external_id: /[\w-]+/,
+ }
+ get "t_external_id/:external_id" => "topics#external_id_json",
:format => :json,
:constraints => {
external_id: /[\w-]+/,
@@ -1470,7 +1476,6 @@ Discourse::Application.routes.draw do
:constraints => {
topic_id: /\d+/,
}
-
get "p/:post_id(/:user_id)" => "posts#short_link"
get "/posts/:id/cooked" => "posts#cooked"
get "/posts/:id/expand-embed" => "posts#expand_embed"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index bd5184931e..99b06eef93 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -39,6 +39,9 @@
# type: file_size_restriction - A file size restriction in bytes.
#
required:
+ external_id_length:
+ client: true
+ default: 16
title:
client: true
default: "Discourse"
diff --git a/db/migrate/20241213085000_add_external_id_to_posts.rb b/db/migrate/20241213085000_add_external_id_to_posts.rb
new file mode 100644
index 0000000000..e32104e1b9
--- /dev/null
+++ b/db/migrate/20241213085000_add_external_id_to_posts.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddExternalIdToPosts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :posts, :external_id, :string, null: true
+ add_index :posts, :external_id, unique: true, where: "external_id IS NOT NULL"
+ end
+end
diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb
index ba461a74fd..385ec2858a 100644
--- a/lib/auth/default_current_user_provider.rb
+++ b/lib/auth/default_current_user_provider.rb
@@ -78,14 +78,14 @@ class Auth::DefaultCurrentUserProvider
return env[DECRYPTED_AUTH_COOKIE] if env.key?(DECRYPTED_AUTH_COOKIE)
env[DECRYPTED_AUTH_COOKIE] = begin
- request = ActionDispatch::Request.new(env)
- cookie = request.cookies[TOKEN_COOKIE]
-
- # don't even initialize a cookie jar if we don't have a cookie at all
- if cookie&.valid_encoding? && cookie.present?
- request.cookie_jar.encrypted[TOKEN_COOKIE]&.with_indifferent_access
- end
- end
+ request = ActionDispatch::Request.new(env)
+ cookie = request.cookies[TOKEN_COOKIE]
+
+ # don't even initialize a cookie jar if we don't have a cookie at all
+ if cookie&.valid_encoding? && cookie.present?
+ request.cookie_jar.encrypted[TOKEN_COOKIE]&.with_indifferent_access
+ end
+ end
end
# do all current user initialization here
@@ -160,10 +160,10 @@ class Auth::DefaultCurrentUserProvider
current_user = lookup_api_user(api_key, request)
if !current_user
raise Discourse::InvalidAccess.new(
- I18n.t("invalid_api_credentials"),
- nil,
- custom_message: "invalid_api_credentials",
- )
+ I18n.t("invalid_api_credentials"),
+ nil,
+ custom_message: "invalid_api_credentials",
+ )
end
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
@@ -246,10 +246,10 @@ class Auth::DefaultCurrentUserProvider
if needs_rotation
if @user_token.rotate!(
- user_agent: @env["HTTP_USER_AGENT"],
- client_ip: @request.ip,
- path: @env["REQUEST_PATH"],
- )
+ user_agent: @env["HTTP_USER_AGENT"],
+ client_ip: @request.ip,
+ path: @env["REQUEST_PATH"],
+ )
set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar)
DiscourseEvent.trigger(:user_session_refreshed, user)
end
@@ -309,7 +309,7 @@ class Auth::DefaultCurrentUserProvider
# DISCOURSE_DEVELOPER_EMAILS for self-hosters.
def make_developer_admin(user)
if user.active? && !user.admin && Rails.configuration.respond_to?(:developer_emails) &&
- Rails.configuration.developer_emails.include?(user.email)
+ Rails.configuration.developer_emails.include?(user.email)
user.admin = true
user.save
Group.refresh_automatic_groups!(:staff, :admins)
@@ -382,13 +382,13 @@ class Auth::DefaultCurrentUserProvider
user =
if api_key.user
- api_key.user if !api_username || (api_key.user.username_lower == api_username.downcase)
+ api_key.user if !api_username || (api_key.user.username_lower == api_username.downcase) || (URI.encode_uri_component(api_key.user.username_lower) == api_username)
elsif api_username
User.find_by(username_lower: api_username.downcase)
elsif user_id = header_api_key? ? @env[HEADER_API_USER_ID] : request["api_user_id"]
User.find_by(id: user_id.to_i)
elsif external_id =
- header_api_key? ? @env[HEADER_API_USER_EXTERNAL_ID] : request["api_user_external_id"]
+ header_api_key? ? @env[HEADER_API_USER_EXTERNAL_ID] : request["api_user_external_id"]
SingleSignOnRecord.find_by(external_id: external_id.to_s).try(:user)
end
diff --git a/lib/post_creator.rb b/lib/post_creator.rb
index 6d15e67844..47cd315945 100644
--- a/lib/post_creator.rb
+++ b/lib/post_creator.rb
@@ -486,6 +486,13 @@ class PostCreator
return if @topic
begin
opts = @opts[:topic_opts] ? @opts.merge(@opts[:topic_opts]) : @opts
+ if @opts[:external_id].present?
+ opts[:external_id] = @opts[:external_id]
+ else
+ external_id = SecureRandom.alphanumeric(SiteSetting.external_id_length)
+ @opts[:external_id] = external_id
+ opts[:external_id] = external_id
+ end
topic_creator = TopicCreator.new(@user, guardian, opts)
@topic = topic_creator.create
rescue ActiveRecord::Rollback
@@ -552,6 +559,7 @@ class PostCreator
via_email
raw_email
action_code
+ external_id
].each { |a| post.public_send("#{a}=", @opts[a]) if @opts[a].present? }
post.extract_quoted_post_numbers
@@ -573,6 +581,14 @@ class PostCreator
post.hidden_reason_id = @opts[:hidden_reason_id]
end
+ if @opts[:external_id].present?
+ post.external_id = @opts[:external_id]
+ else
+ external_id = SecureRandom.alphanumeric(SiteSetting.external_id_length)
+ @opts[:external_id] = external_id
+ post.external_id = external_id
+ end
+
@post = post
end
diff --git a/lib/tasks/custom.rake b/lib/tasks/custom.rake
new file mode 100644
index 0000000000..5246395fc2
--- /dev/null
+++ b/lib/tasks/custom.rake
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+desc 'add external_id for all topics and posts'
+task 'custom:add-external-id', [:override_existing] => :environment do |task, args|
+ # use `rake 'custom:add-external-id[1]'` to override topics and posts' existing external_id
+ # ensure the first post inside topic has the same external_id with the topic
+ require 'parallel'
+ require 'securerandom'
+ Parallel.each(Post.all, progress: "Posts") do |post|
+ if args[:override_existing].present? || post.external_id.blank?
+ post.update_column(:external_id, SecureRandom.alphanumeric(SiteSetting.external_id_length))
+ if post.post_number == 1
+ topic = Topic.find(post.topic_id)
+ topic.update_column(:external_id, post.external_id)
+ end
+ end
+ end
+end
+
+# rake custom:export-users > users.json
+# rake custom:export-users > /shared/users.json
+desc "Export all users without sensitive data"
+task "custom:export-users" => :environment do
+ require 'json'
+
+ a = []
+ User.find_each(batch_size: 100_000) do |user|
+ payload = { id: user.id, username: user.username, name: user.name,
+ admin:user.admin, moderator:user.moderator, trust_level: user.trust_level,
+ avatar_template: user.avatar_template, title: user.title,
+ groups: user.groups.map{|i| i.name}, locale: user.locale,
+ silenced_till: user.silenced_till , staged: user.staged, active: user.active,
+ created_at:user.created_at.to_i, updated_at:user.updated_at.to_i }
+ a.push payload
+ end
+ puts a.to_json
+end
+
diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js
index 1a8fde55fe..3d37c107c4 100644
--- a/app/assets/javascripts/discourse/app/components/d-editor.js
+++ b/app/assets/javascripts/discourse/app/components/d-editor.js
@@ -530,6 +530,8 @@ export default class DEditor extends Component {
addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value,
toggleDirection: () => this.textManipulation.toggleDirection(),
+ copyLine: () => this.textManipulation.copyLine(),
+ cutLine: () => this.textManipulation.cutLine(),
replaceText: (oldVal, newVal, opts) =>
this.textManipulation.replaceText(oldVal, newVal, opts),
};
diff --git a/app/assets/javascripts/discourse/app/lib/composer/toolbar.js b/app/assets/javascripts/discourse/app/lib/composer/toolbar.js
index 878aadb8a7..52c001d591 100644
--- a/app/assets/javascripts/discourse/app/lib/composer/toolbar.js
+++ b/app/assets/javascripts/discourse/app/lib/composer/toolbar.js
@@ -104,6 +104,28 @@ export default class Toolbar {
"list_item"
),
});
+
+ this.addButton({
+ id: "copyline",
+ group: "fontStyles",
+ icon: "copy",
+ rawlabel: "Copy",
+ shortcut: "C",
+ preventFocus: true,
+ trimLeading: true,
+ perform: (e) => e.copyLine(),
+ });
+
+ this.addButton({
+ id: "cutline",
+ group: "fontStyles",
+ icon: "scissors",
+ rawlabel: "Cut",
+ shortcut: "X",
+ preventFocus: true,
+ trimLeading: true,
+ perform: (e) => e.cutLine(),
+ });
}
if (siteSettings.support_mixed_text_direction) {
diff --git a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js
index 900382d218..36eba0d7fa 100644
--- a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js
+++ b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js
@@ -13,6 +13,7 @@ import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown";
import {
caretPosition,
+ clipboardCopy,
clipboardHelpers,
determinePostReplaceSelection,
inCodeBlock,
@@ -54,6 +55,16 @@ function getHead(head, prev) {
}
}
+function lengthAfterLastLF(str) {
+ const L = str.length;
+ for (let i = 0; i < L; i++) {
+ if (str[L - 1 - i] === "\n") {
+ return i;
+ }
+ }
+ return L;
+}
+
/** @implements {TextManipulation} */
export default class TextareaTextManipulation {
@service appEvents;
@@ -764,6 +775,35 @@ export default class TextareaTextManipulation {
this.$textarea.attr("dir", newDir).focus();
}
+ @bind
+ copyLine() {
+ const sel = this.getSelected("", { lineVal: true });
+ if (sel.start === sel.end) {
+ clipboardCopy(sel.lineVal);
+ setCaretPosition(this.textarea, sel.start - lengthAfterLastLF(sel.pre));
+ } else {
+ clipboardCopy(sel.value);
+ }
+ }
+
+ @bind
+ cutLine() {
+ const sel = this.getSelected("", { lineVal: true });
+ if (sel.start === sel.end) {
+ clipboardCopy(sel.lineVal);
+ let beforeChars = lengthAfterLastLF(sel.pre);
+ insertAtTextarea(
+ this.textarea,
+ sel.start - beforeChars,
+ sel.end + sel.lineVal.length - beforeChars,
+ ""
+ );
+ } else {
+ clipboardCopy(sel.value);
+ insertAtTextarea(this.textarea, sel.start, sel.end, "");
+ }
+ }
+
@bind
applyList(sel, head, exampleKey, opts) {
if (sel.value.includes("\n")) {
diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
index 5e6ea29874..6e6a36331b 100644
--- a/lib/svg_sprite.rb
+++ b/lib/svg_sprite.rb
@@ -212,6 +212,7 @@ module SvgSprite
robot
rocket
rotate
+ scissors
scroll
share
shield-halved