记录 另类 Discourse 部署

why

Discourse 官方仅支持一种部署方法,即 用 dicourse_docker 仓库里的 shell, yaml, ruby 混合的代码里的代码 构建容器,
导致 每次改) 代码都要花 20 分钟重构容器,非常难受。

本 thread 将记录我的另类部署方式.

为什么要用官方不支持的方式运行 Discourse?

Discourse 唯一的部署方式不允许别人修改源代码的 URL,
导致我想要修改默认运行的方式只能写插件并花 20 分钟重建容器,不能 fork 完直接在源代码上面改,迭代周期长,开发效率低

Discourse 是个牛逼的软件,界面也很现代,但是安装部署的方式真的是 10 年前的方法:

创始人 Sam 在十几年前用 ruby 脚本写了个程序 pups 构建容器

Broadcom Bitnami 维护了 另一种部署方法,但也没比官方的好到哪里去。

原厂的 Gemfile 里甚至没有 rails, 只有个自家的 rails_multisite

导致我想要用 Rubymine 调试程序的时候提示我 rails 没有安装?:sweat_smile: 然而我用命令行运行rail s却是可以的,害得我以为是 Rubymine 出 bug 或者配置错了或者 rvm 出 bug 了

procedures

以开发模式运行

开发环境运行 rails 应用的命令:

ALLOW_EMBER_CLI_PROXY_BYPASS=1 DISCOURSE_DEV_LOG_LEVEL=warn DISCOURSE_ENABLE_CORS=true RAILS_DEVELOPMENT_HOSTS=xjtu.app RAILS_ENV=development HOST_URL=xjtu.app DISCOURSE_HOSTNAME=xjtu.app NUM_WEBS=8 rails s

rails 区分开发/生产运行模式,使用的配置不一样,例如开发模式缺少 cache 和 asset minimization,所以访问起来性能非常低下,低得夸张:

从 Docker 里复制

本来我想比较一下 config/environments/development.rb 和 production.rb 的配置选项,但无奈 assets pipeline pre-compilation 不太懂,就不学了。直接开大招,把容器里的 discourse 复制出来,找到启动命令,在容器外面直接运行得了

先看看容器的入口 ./launcher start-cmd webxj

  • true run --shm-size=512m --link dataxj:dataxj -d --restart=always -e LANG=en_US.UTF-8 -e RAILS_ENV=production … --name webxj -t -v /var/discourse/shared/webxj:/shared … local_discourse/webxj /sbin/boot

查看 /sbin/boot

root@mnz-webxj:/var/www/discourse# cat /sbin/boot
#!/bin/bash
# we use this to boot up cause runit will not handle TERM and will not exit when done

shutdown() {
  echo Shutting Down
  /etc/runit/3
  ls /etc/service | SHELL=/bin/sh parallel sv force-stop {}
  kill -HUP $RUNSVDIR
  wait $RUNSVDIR

  # give stuff a bit of time to finish
  sleep 0.1

  ORPHANS=`ps -eo pid | grep -v PID  | tr -d ' ' | grep -v '^1$'`
  SHELL=/bin/bash parallel 'timeout 5 /bin/bash -c "kill {} && wait {}" || kill -9 {}' ::: $ORPHANS 2> /dev/null
  exit
}

/etc/runit/1 || exit $?
/etc/runit/2&
RUNSVDIR=$!
echo "Started runsvdir, PID is $RUNSVDIR"
trap shutdown SIGTERM SIGHUP
wait $RUNSVDIR

shutdown

容器里面用的 sv 管理进程,查看cat /etc/service/unicorn/run

#!/bin/bash
exec 2>&1
# redis
# postgres
cd /var/www/discourse
chown -R discourse:www-data /shared/log/rails
PRECOMPILE_ON_BOOT=0
if [[ -z "$PRECOMPILE_ON_BOOT" ]]; then
  PRECOMPILE_ON_BOOT=1
fi
if [ -f /usr/local/bin/create_db ] && [ "$CREATE_DB_ON_BOOT" = "1" ]; then /usr/local/bin/create_db; fi;
if [ "$MIGRATE_ON_BOOT" = "1" ]; then su discourse -c 'bundle exec rake db:migrate'; fi
if [ "$PRECOMPILE_ON_BOOT" = "1" ]; then SKIP_EMBER_CLI_COMPILE=1 su discourse -c 'bundle exec rake assets:precompile'; fi
LD_PRELOAD=$RUBY_ALLOCATOR HOME=/home/discourse USER=discourse exec thpoff chpst -u discourse:www-data -U discourse:www-data bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb

参考 https://meta.discourse.org/t/install-discourse-on-ubuntu-or-debian-for-development/14727 安装 ImageMagick, oxipng, jhead

安装 pnpm, rvm 和 ruby

pnpm env use --global lts                                                   
rvm install 3.3      
rvm use 3.3 --default
## 看看是否支持 YJIT
ruby --yjit -v                                                              

把 redis 和 PG 的数据复制一份,再把 start-cmd 里的环境变量复制到.zshrc,新建数据库用户,配置 config/discourse.conf, 复制 bundle 那行启动命令启动bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb

# pnpm
export PNPM_HOME="/home/discourse/.local/share/pnpm"
case ":$PATH:" in
  *":$PNPM_HOME:"*) ;;
  *) export PATH="$PNPM_HOME:$PATH" ;;
esac
# pnpm end
alias npm='pnpm'
alias npx='pnpx'
export PATH="$PATH:$HOME/.rvm/bin"

export RAILS_ENV=production
export UNICORN_WORKERS=6
export UNICORN_SIDEKIQS=1
export RUBY_GC_HEAP_GROWTH_MAX_SLOTS=40000
export RUBY_GC_HEAP_INIT_SLOTS=400000
export RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=1.5
export RUBY_YJIT_ENABLE=1
export RUBY_CONFIGURE_OPTS="--enable-yjit"
export DISCOURSE_HOSTNAME=xjtu.app
...

https://www.postgresql.org/download/linux/ubuntu/

apt install postgresql postgresql-client-17 postgresql-common  postgresql-contrib postgresql-client-common postgresql-server-dev-17 postgresql-17 postgresql-17-pgvector libpq-dev
sudo -u postgres createuser -s discourse                                                   
sudo -u postgres createdb discourse 
$sudo -u postgres psql discourse
psql>
ALTER USER discourse WITH PASSWORD 'xxx';
CREATE EXTENSION hstore;CREATE EXTENSION pg_trgm;
CREATE EXTENSION plpgsql;
CREATE EXTENSION unaccent;
CREATE EXTENSION vector;
$ gunzip < dump.sql.gz | psql discourse      

dump.sql.gz 是 Discourse 备份解压出来的,容器里是 PG13,导入到新安装的 PG17 竟然无比丝滑

systemd file:
/etc/systemd/system/dis.service

[Unit]
Description=Discourse Puma Server
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=discourse
Group=discourse

#EnvironmentFile=/home/discourse/.zshrc

WorkingDirectory=/var/www/discourse
# requires running `rvm 3.3.6 --default` before this service is run
ExecStart=/usr/bin/zsh -lc 'source /home/discourse/.zshrc && /home/discourse/.rvm/gems/ruby-3.3.6/bin/puma -C config/puma.rb'
ExecReload=/usr/bin/zsh -lc 'source /home/discourse/.zshrc && /home/discourse/.rvm/gems/ruby-3.3.6/bin/pumactl restart'

# Restart configuration
Restart=always
RestartSec=5s

# Basic security measures
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

再把容器里的 nginx 复制出来,把 cache 和文件目录重新配一下,启动成功

cd /shared/
chown discourse:www-data backups tmp uploads

upgrade

参考:

chown -R discourse:discourse /var/www/discourse/     
chown -R discourse:www-data /var/www/discourse/public
chown discourse:www-data /home/discourse/
chmod 750 /home/discourse
su - discourse
cd /var/www/discourse
git stash
git pull
git checkout tests-passed 
rm lib/tasks/custom.rake db/migrate/20241213085000_add_external_id_to_posts.rb
git apply mypatch-20250310-v1.patch  

# LOAD_PLUGINS=0 bundle exec rake plugin:pull_compatible_all
cd plugins
for plugin in *
do
    echo $plugin; cd ${plugin}; git pull; cd ..
done
cd ../
# may need this when migrate to another system
# rm plugins/*/gems -r
bundle install && pnpm i && bundle exec rake db:migrate  && bundle exec rake themes:update assets:precompile && pumactl restart
#pumactl phased-restart

直接用 pg_dump 备份

discour+ 1142358  0.0  0.0   2384  1408 pts/3    S+   13:54   0:00 sh -c PGPASSWORD='191549' pg_dump --schema=public -T public.pg_* --file='/var/www/discourse/tmp/backups/default/2024-12-14-135427/dump.sql.gz' --no-owner --no-privileges --verbose --compress=4 --host=localhost  --username=discourse discourse 2>&1
discour+ 1142359 94.1  0.1  32240 18388 pts/3    R+   13:54   0:12 /usr/lib/postgresql/17/bin/pg_dump --schema=public -T public.pg_* --file=/var/www/discourse/tmp/backups/default/2024-12-14-135427/dump.sql.gz --no-owner --no-privileges --verbose --compress=4 --host=localhost --username=discourse discourse

官方文档有时候会用到 discourse 命令,其实就是bundle exec script/discourse

6 Likes

爆得有点频繁啊 :fearful:

不稳定 & unicorn 换 puma

最近不定期出现访问不了的诡异情况,检查 unicorn log:

==> ./log/unicorn.stdout.log <==
I, [2024-12-17T09:45:52.927751 #3990911]  INFO -- : worker=4 ready
I, [2024-12-17T09:45:54.856311 #3991183]  INFO -- : worker=5 ready
E, [2024-12-17T09:48:57.888043 #3989435] ERROR -- : Kill self supervisor is gone
I, [2024-12-17T09:48:57.924044 #3989435]  INFO -- : reaped #<Process::Status: pid 3990359 exit 0> worker=0
I, [2024-12-17T09:48:57.924329 #3989435]  INFO -- : reaped #<Process::Status: pid 3990445 exit 0> worker=1
I, [2024-12-17T09:48:57.924473 #3989435]  INFO -- : reaped #<Process::Status: pid 3990574 exit 0> worker=2
I, [2024-12-17T09:48:57.924616 #3989435]  INFO -- : reaped #<Process::Status: pid 3990736 exit 0> worker=3
I, [2024-12-17T09:48:57.924773 #3989435]  INFO -- : reaped #<Process::Status: pid 3990911 exit 0> worker=4
I, [2024-12-17T09:48:57.924926 #3989435]  INFO -- : reaped #<Process::Status: pid 3991183 exit 0> worker=5
I, [2024-12-17T09:48:57.925019 #3989435]  INFO -- : master complete
==> ./log/unicorn.stderr.log <==
unknown OID 556291: failed to recognize type of 'embeddings'. It will be treated as String.
unknown OID 556178: failed to recognize type of 'embedding'. It will be treated as String.
unknown OID 556291: failed to recognize type of 'embeddings'. It will be treated as String.

然而
bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb
却还活着

诡异在什么地方?

  • 如果是我 patch 的代码质量差导致的崩溃,时间应该不会那么不规律:有时候一整天都没挂,有时候一小时内频繁出现几次
  • 内存和负载都正常
free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       7.9Gi       4.5Gi       584Mi       3.9Gi       7.7Gi
Swap:          8.0Gi       6.5Gi       1.5Gi

我决定把 unicorn 换成 puma, 这也是 Heroku 推荐的 Rails webserver.

问题来了,Discourse 官方没有使用 puma 的文档,看看 unicorn 的两个配置/脚本文件也是一头雾水:config/unicorn_launcherconfig/unicorn.conf.rb

我决定硬干,先从 Heroku 推荐的最简单的 puma 配置文件开始

# frozen_string_literal: true

if ENV["RAILS_ENV"] == "production"
  # First, you need to change these below to your situation.
  APP_ROOT = ENV["APP_ROOT"] || "/var/www/discourse"
  num_workers = ENV["NUM_WEBS"].to_i > 0 ? ENV["NUM_WEBS"].to_i : 8

  # 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!

  port(ENV['PORT'] || 3000, "::")
  # Turn off keepalive support for better long tails response time with Router 2.0
  # Remove this line when https://github.com/puma/puma/issues/3487 is closed, and the fix is released
  enable_keep_alives(false) if respond_to?(:enable_keep_alives)

  rackup      DefaultRackup if defined?(DefaultRackup)
  environment ENV['RAILS_ENV'] || 'development'

  on_worker_boot do
    # Worker-specific setup for Rails 4.1 to 5.2, after 5.2 it's not needed
    # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
    ActiveRecord::Base.establish_connection
  end
end

bundle exec puma -C config/puma.rb
或者直接
puma -C config/puma.rb

奇迹般地能运行

后续继续 观察稳定性

目前可能的不稳定性来源:

  1. 虽然我从 Docker 里面复制了全部文件皆 数据库 和 各种环境变量,配置,但可能存在遗漏
  2. Discourse 官方只支持 Postgres 13, 我一下子升级到了 17
  3. ruby 启用了 YJIT
  4. arm64 机器

发现了一个好玩的,puma 支持两种无缝重启的方式
我感觉后续升级 discourse 时可以做到真正 0-downtime

Search Labs | AI Overview

The main difference between a phased restart and a hot restart in Puma is how they handle connections and when they finish:

  • Phased restart

Puma keeps processing requests with old workers while sending new requests to new workers. This results in zero downtime and no hanging requests. However, phased restarts can’t be used to upgrade gems loaded by the Puma master process.

  • Hot restart

Puma tries to finish current requests and then restart itself with new workers. This results in no lost requests, but there may be some extra latency for new requests while the process restarts.

Here are some other differences between phased and hot restarts:

  • Speed: Hot restarts often complete more quickly than phased restarts.

  • Database schema upgrades: Phased restarts require backwards-compatible database schema upgrades.

  • Mode: Hot restarts work in a single mode, while phased restarts work in cluster mode.

or

今天让 Claude 把 Discourse 的 config/unicorn.rb 转成 config/puma.rb

AI 真牛逼

# 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)}")

# or, use puma without reverse proxy
# require listening to privileged port
# `setcap 'cap_net_bind_service=ep' /home/discourse/.rvm/rubies/ruby-3.3.6/bin/ruby`

#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"
  # 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

改完之后,在线状态的显示正常了
另外,记录一个好玩的命令:

User.all.each do |u| PresenceChannel.new(DiscourseWhosOnline::CHANNEL_NAME).present(user_id: u.id, client_id: "seen") end

清空:

PresenceChannel.clear_all!

另外发现 Last IP 也正常了,不再是 ::ffff:127.0.0.1

2 Likes

运行多个 Discourse 实例:把 /var/www/discourse复制一份,在config/puma.rb里面改一下端口,在config/discourse.conf 改一下数据库名称db_name = 'disxj',在 nginx 里改一下端口和冲突的配置,可以节省为不同数据库运行不同 PG 进程的内存。

目前还没找到用同一套 puma workers 运行多个实例的方法,我猜测 https://github.com/discourse/rails_multisite 可以干这个事情。

mypatch-20250310-v1.patch

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
1 Like