├── .discourse-compatibility ├── .github └── workflows │ └── discourse-plugin.yml ├── .gitignore ├── .npmrc ├── .prettierrc.cjs ├── .rubocop.yml ├── .streerc ├── .template-lintrc.cjs ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── app ├── controllers │ ├── encrypt_controller.rb │ └── encrypted_post_timers_controller.rb ├── jobs │ └── scheduled │ │ ├── encrypt_consistency.rb │ │ └── encrypted_post_timer_evaluator.rb ├── mailers │ └── user_notifications_extensions.rb ├── models │ ├── encrypted_post_timer.rb │ ├── encrypted_topics_data.rb │ ├── encrypted_topics_user.rb │ └── user_encryption_key.rb └── services │ └── problem_check │ └── unsafe_csp.rb ├── assets ├── javascripts │ ├── discourse │ │ ├── api-initializers │ │ │ ├── encrypt-delete-topic.js │ │ │ ├── encrypt-icons.gjs │ │ │ └── encrypt-model-transformers.js │ │ ├── components │ │ │ ├── encrypt-enable-dropdown.js │ │ │ ├── encrypt-preferences-dropdown.js │ │ │ ├── encrypted-post-timer-dropdown.js │ │ │ └── modal │ │ │ │ ├── activate-encrypt.hbs │ │ │ │ ├── activate-encrypt.js │ │ │ │ ├── decrypt-all.gjs │ │ │ │ ├── export-key-pair.hbs │ │ │ │ ├── export-key-pair.js │ │ │ │ ├── generate-paper-key.hbs │ │ │ │ ├── generate-paper-key.js │ │ │ │ ├── manage-paper-keys.hbs │ │ │ │ ├── manage-paper-keys.js │ │ │ │ ├── reset-key-pair.hbs │ │ │ │ ├── reset-key-pair.js │ │ │ │ ├── rotate-key-pair.hbs │ │ │ │ └── rotate-key-pair.js │ │ ├── connectors │ │ │ ├── composer-action-after │ │ │ │ ├── encrypt.hbs │ │ │ │ └── encrypt.js │ │ │ ├── topic-above-post-stream │ │ │ │ └── encrypt-topic-class.gjs │ │ │ ├── topic-title │ │ │ │ └── decrypt-topic-button.gjs │ │ │ └── user-preferences-security │ │ │ │ ├── encrypt.hbs │ │ │ │ └── encrypt.js │ │ ├── initializers │ │ │ ├── add-search-results.js │ │ │ ├── auto-enable-encrypt.js │ │ │ ├── decrypt-post-revision.js │ │ │ ├── decrypt-posts.js │ │ │ ├── encrypt-composer.js │ │ │ ├── encrypt-drafts.js │ │ │ ├── encrypt-posts.js │ │ │ ├── encrypt-status.js │ │ │ ├── encrypt-uploads.js │ │ │ ├── encrypt-user-options.js │ │ │ ├── fetch-encrypt-keys.js │ │ │ └── invite-to-encrypted-topic.js │ │ ├── services │ │ │ └── encrypt-widget-store.js │ │ └── widgets │ │ │ └── encrypted-post-timer-counter.js │ └── lib │ │ ├── base64.js │ │ ├── database.js │ │ ├── debounced-queue.js │ │ ├── discourse.js │ │ ├── pack.js │ │ ├── paper-key.js │ │ ├── permanent-topic-decrypter.js │ │ ├── protocol-v0.js │ │ ├── protocol-v1.js │ │ ├── protocol.js │ │ ├── uploads.js │ │ ├── uppy-upload-encrypt-plugin.js │ │ └── utils.js └── stylesheets │ ├── colors.scss │ └── common │ └── encrypt.scss ├── config ├── locales │ ├── client.ar.yml │ ├── client.be.yml │ ├── client.bg.yml │ ├── client.bs_BA.yml │ ├── client.ca.yml │ ├── client.cs.yml │ ├── client.da.yml │ ├── client.de.yml │ ├── client.el.yml │ ├── client.en.yml │ ├── client.en_GB.yml │ ├── client.es.yml │ ├── client.et.yml │ ├── client.fa_IR.yml │ ├── client.fi.yml │ ├── client.fr.yml │ ├── client.gl.yml │ ├── client.he.yml │ ├── client.hr.yml │ ├── client.hu.yml │ ├── client.hy.yml │ ├── client.id.yml │ ├── client.it.yml │ ├── client.ja.yml │ ├── client.ko.yml │ ├── client.lt.yml │ ├── client.lv.yml │ ├── client.nb_NO.yml │ ├── client.nl.yml │ ├── client.pl_PL.yml │ ├── client.pt.yml │ ├── client.pt_BR.yml │ ├── client.ro.yml │ ├── client.ru.yml │ ├── client.sk.yml │ ├── client.sl.yml │ ├── client.sq.yml │ ├── client.sr.yml │ ├── client.sv.yml │ ├── client.sw.yml │ ├── client.te.yml │ ├── client.th.yml │ ├── client.tr_TR.yml │ ├── client.ug.yml │ ├── client.uk.yml │ ├── client.ur.yml │ ├── client.vi.yml │ ├── client.zh_CN.yml │ ├── client.zh_TW.yml │ ├── server.ar.yml │ ├── server.be.yml │ ├── server.bg.yml │ ├── server.bs_BA.yml │ ├── server.ca.yml │ ├── server.cs.yml │ ├── server.da.yml │ ├── server.de.yml │ ├── server.el.yml │ ├── server.en.yml │ ├── server.en_GB.yml │ ├── server.es.yml │ ├── server.et.yml │ ├── server.fa_IR.yml │ ├── server.fi.yml │ ├── server.fr.yml │ ├── server.gl.yml │ ├── server.he.yml │ ├── server.hr.yml │ ├── server.hu.yml │ ├── server.hy.yml │ ├── server.id.yml │ ├── server.it.yml │ ├── server.ja.yml │ ├── server.ko.yml │ ├── server.lt.yml │ ├── server.lv.yml │ ├── server.nb_NO.yml │ ├── server.nl.yml │ ├── server.pl_PL.yml │ ├── server.pt.yml │ ├── server.pt_BR.yml │ ├── server.ro.yml │ ├── server.ru.yml │ ├── server.sk.yml │ ├── server.sl.yml │ ├── server.sq.yml │ ├── server.sr.yml │ ├── server.sv.yml │ ├── server.sw.yml │ ├── server.te.yml │ ├── server.th.yml │ ├── server.tr_TR.yml │ ├── server.ug.yml │ ├── server.uk.yml │ ├── server.ur.yml │ ├── server.vi.yml │ ├── server.zh_CN.yml │ └── server.zh_TW.yml └── settings.yml ├── db └── migrate │ ├── 20190803111542_update_protocol.rb │ ├── 20190916115531_add_paper_keys.rb │ ├── 20200129230327_create_encrypted_topics_users.rb │ ├── 20200130050409_create_user_encryption_keys.rb │ ├── 20200223214818_create_encrypted_topics_data.rb │ ├── 20201027233335_create_encrypted_post_timers.rb │ ├── 20221102045508_add_encrypt_pms_default_to_user_options.rb │ └── 20221123235603_allow_encrypt_pms_default_to_be_null.rb ├── eslint.config.mjs ├── lib ├── encrypted_post_creator.rb ├── encrypted_search.rb ├── grouped_search_result_serializer_extension.rb ├── openssl.rb ├── post_actions_controller_extensions.rb ├── post_extensions.rb ├── site_setting_extensions.rb ├── site_settings_type_supervisor_extensions.rb ├── topic_extensions.rb ├── topic_guardian_extensions.rb ├── topic_view_serializer_extension.rb ├── topics_controller_extensions.rb ├── upload_validator_extensions.rb ├── user_extensions.rb ├── user_notification_renderer_extensions.rb └── validators │ └── encrypt_enabled_validator.rb ├── package.json ├── plugin.rb ├── pnpm-lock.yaml ├── spec ├── fixtures │ ├── test_paper_key_1.txt │ ├── test_paper_key_2.txt │ ├── test_private_key_1.txt │ ├── test_private_key_2.txt │ ├── test_public_key_1.txt │ └── test_public_key_2.txt ├── jobs │ ├── encrypt_consistency_spec.rb │ └── encrypted_post_timer_evaluator_spec.rb ├── lib │ ├── email_sender_spec.rb │ ├── encrypted_post_creator_spec.rb │ ├── site_setting_extensions_spec.rb │ └── upload_validator_spec.rb ├── models │ ├── post_spec.rb │ ├── topic_spec.rb │ └── user_spec.rb ├── plugin_helper.rb ├── plugin_spec.rb ├── requests │ ├── encrypt_controller_spec.rb │ ├── encrypted_post_timers_controller_spec.rb │ ├── posts_controller_spec.rb │ └── topics_controller_spec.rb ├── serializers │ ├── current_user_serialier_spec.rb │ ├── topic_serializers_spec.rb │ └── topic_view_serializer_spec.rb ├── services │ └── problem_check │ │ └── unsafe_csp_spec.rb └── system │ ├── decrypt_encrypted_topic_posts_spec.rb │ ├── enable_encryption_spec.rb │ └── permanent_decrypt_spec.rb ├── svg-icons └── plugin-icons.svg ├── test └── javascripts │ ├── acceptance │ └── encrypt-test.js │ └── lib │ ├── base64-test.js │ ├── database-safari-test.js │ ├── database-test.js │ ├── protocol-test.js │ ├── protocol-v0-test.js │ ├── protocol-v1-test.js │ └── uploads-test.js └── translator.yml /.discourse-compatibility: -------------------------------------------------------------------------------- 1 | < 3.4.0.beta4-dev: 804f5efe5651ab08f93fd6eb1d54971d98f40944 2 | < 3.4.0.beta2-dev: 9a9f6af455cb2497aa64cd32031aca020b497829 3 | < 3.4.0.beta1-dev: 14e0fb988ba91eaa514341575cdde8042b823c5b 4 | < 3.3.0.beta2-dev: 54a59be6176d2ce02a7aaae8c6b7cb57b1f4bfff 5 | < 3.3.0.beta1-dev: 9013a8ea8c7c7df55b36f329b3bb09b754a04cfe 6 | < 3.2.0.beta3-dev: 47dcd1ec558660065f5a4224d990f1188c88a368 7 | 3.1.0.beta5: e0eb21f7a97c54cfe40eb07b2c5ca53cde7b370a 8 | 3.1.0.beta3: 0f3c612bd1d274ba71362c423f16fd19a32bbd46 9 | 2.9.0.beta3: adef8c76aaeccaeb081a61bdd8ce7ab90ca41538 10 | 2.9.0.beta2: dc2b844e386351c778b097c7a4e0ec65726296ec 11 | -------------------------------------------------------------------------------- /.github/workflows/discourse-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Discourse Plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /gems 3 | /auto_generated 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | auto-install-peers = false 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/prettier"); 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma 3 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/template-lint"); 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "rubocop-discourse" 7 | gem "syntax_tree" 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (7.1.3.3) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | minitest (>= 5.1) 12 | mutex_m 13 | tzinfo (~> 2.0) 14 | ast (2.4.2) 15 | base64 (0.2.0) 16 | bigdecimal (3.1.8) 17 | concurrent-ruby (1.2.3) 18 | connection_pool (2.4.1) 19 | drb (2.2.1) 20 | i18n (1.14.5) 21 | concurrent-ruby (~> 1.0) 22 | json (2.7.2) 23 | language_server-protocol (3.17.0.3) 24 | minitest (5.23.1) 25 | mutex_m (0.2.0) 26 | parallel (1.24.0) 27 | parser (3.3.1.0) 28 | ast (~> 2.4.1) 29 | racc 30 | prettier_print (1.2.1) 31 | racc (1.8.0) 32 | rack (3.0.11) 33 | rainbow (3.1.1) 34 | regexp_parser (2.9.2) 35 | rexml (3.3.9) 36 | rubocop (1.64.0) 37 | json (~> 2.3) 38 | language_server-protocol (>= 3.17.0) 39 | parallel (~> 1.10) 40 | parser (>= 3.3.0.2) 41 | rainbow (>= 2.2.2, < 4.0) 42 | regexp_parser (>= 1.8, < 3.0) 43 | rexml (>= 3.2.5, < 4.0) 44 | rubocop-ast (>= 1.31.1, < 2.0) 45 | ruby-progressbar (~> 1.7) 46 | unicode-display_width (>= 2.4.0, < 3.0) 47 | rubocop-ast (1.31.3) 48 | parser (>= 3.3.1.0) 49 | rubocop-capybara (2.20.0) 50 | rubocop (~> 1.41) 51 | rubocop-discourse (3.8.0) 52 | activesupport (>= 6.1) 53 | rubocop (>= 1.59.0) 54 | rubocop-capybara (>= 2.0.0) 55 | rubocop-factory_bot (>= 2.0.0) 56 | rubocop-rails (>= 2.25.0) 57 | rubocop-rspec (>= 2.25.0) 58 | rubocop-factory_bot (2.25.1) 59 | rubocop (~> 1.41) 60 | rubocop-rails (2.25.0) 61 | activesupport (>= 4.2.0) 62 | rack (>= 1.1) 63 | rubocop (>= 1.33.0, < 2.0) 64 | rubocop-ast (>= 1.31.1, < 2.0) 65 | rubocop-rspec (2.29.2) 66 | rubocop (~> 1.40) 67 | rubocop-capybara (~> 2.17) 68 | rubocop-factory_bot (~> 2.22) 69 | rubocop-rspec_rails (~> 2.28) 70 | rubocop-rspec_rails (2.28.3) 71 | rubocop (~> 1.40) 72 | ruby-progressbar (1.13.0) 73 | syntax_tree (6.2.0) 74 | prettier_print (>= 1.2.0) 75 | tzinfo (2.0.6) 76 | concurrent-ruby (~> 1.0) 77 | unicode-display_width (2.5.0) 78 | 79 | PLATFORMS 80 | ruby 81 | 82 | DEPENDENCIES 83 | rubocop-discourse 84 | syntax_tree 85 | 86 | BUNDLED WITH 87 | 2.5.10 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2019 Civilized Discourse Construction Kit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discourse-encrypt 2 | 3 | Discourse Encrypt is a plugin that provides a secure communication channel 4 | through Discourse. [Read more about the plugin on Meta...](https://meta.discourse.org/t/discourse-encrypt-for-private-messages/107918) 5 | 6 | ## Installation 7 | 8 | Follow [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) 9 | how-to from the official Discourse Meta, using `git clone https://github.com/discourse/discourse-encrypt.git` 10 | as the plugin command. 11 | 12 | Please note that WebCrypto API is restricted to secure origins, which basically 13 | means that you must enable HTTPS before using this plugin. 14 | -------------------------------------------------------------------------------- /app/controllers/encrypted_post_timers_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DiscourseEncrypt::EncryptedPostTimersController < ApplicationController 4 | requires_plugin DiscourseEncrypt::PLUGIN_NAME 5 | 6 | before_action :ensure_logged_in 7 | before_action :ensure_can_encrypt 8 | 9 | def create 10 | delete_at = 1.minutes.from_now 11 | Array.wrap(params[:post_id]).each { |post_id| create_for_post(post_id, delete_at) } 12 | render json: { delete_at: delete_at } 13 | end 14 | 15 | def destroy 16 | post = Post.with_deleted.find(params[:post_id]) 17 | encrypted_post_timer = EncryptedPostTimer.find_by(post: post) 18 | return unless encrypted_post_timer 19 | if post.is_first_post? 20 | topic = Topic.with_deleted.find(post.topic_id) 21 | guardian.ensure_can_recover_topic!(topic) 22 | else 23 | guardian.ensure_can_recover_post!(post) 24 | end 25 | encrypted_post_timer.destroy! 26 | end 27 | 28 | private 29 | 30 | def create_for_post(post_id, delete_at) 31 | post = Post.find(post_id) 32 | return unless post.is_encrypted? 33 | guardian.ensure_can_delete!(post.is_first_post? ? post.topic : post) 34 | 35 | EncryptedPostTimer.create!(post: post, delete_at: delete_at) 36 | end 37 | 38 | def ensure_can_encrypt 39 | current_user.guardian.ensure_can_encrypt! 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/jobs/scheduled/encrypt_consistency.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class EncryptConsistency < ::Jobs::Scheduled 5 | every 1.day 6 | 7 | def execute(args) 8 | DB 9 | .query(<<~SQL) 10 | SELECT taf.user_id, taf.topic_id 11 | FROM topic_allowed_users taf 12 | JOIN encrypted_topics_data etd ON taf.topic_id = etd.topic_id 13 | WHERE taf.user_id NOT IN 14 | (SELECT user_id 15 | FROM encrypted_topics_users 16 | WHERE topic_id = taf.topic_id) 17 | SQL 18 | .each do |row| 19 | Discourse.warn( 20 | "User was invited to encrypted topic, but has no topic key.", 21 | user_id: row.user_id, 22 | topic_id: row.topic_id, 23 | ) 24 | TopicAllowedUser.find_by(user_id: row.user_id, topic_id: row.topic_id).delete 25 | end 26 | 27 | DB 28 | .query(<<~SQL) 29 | SELECT etu.user_id, etu.topic_id 30 | FROM encrypted_topics_users etu 31 | LEFT JOIN topic_allowed_users taf ON etu.topic_id = taf.topic_id AND etu.user_id = taf.user_id 32 | WHERE taf.id IS NULL 33 | SQL 34 | .each do |row| 35 | Discourse.warn( 36 | "User has topic key, but was not invited to topic.", 37 | user_id: row.user_id, 38 | topic_id: row.topic_id, 39 | ) 40 | TopicAllowedUser.create(user_id: row.user_id, topic_id: row.topic_id) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/jobs/scheduled/encrypted_post_timer_evaluator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class EncryptedPostTimerEvaluator < ::Jobs::Scheduled 5 | every 1.minute 6 | 7 | def execute(args) 8 | EncryptedPostTimer.pending.find_each do |encrypted_post_timer| 9 | ActiveRecord::Base.transaction do 10 | encrypted_post_timer.touch(:destroyed_at) 11 | 12 | timer_post = Post.with_deleted.find_by(id: encrypted_post_timer.post_id) 13 | next if !timer_post 14 | 15 | timer_topic = Topic.with_deleted.find_by(id: timer_post.topic_id) 16 | next if !timer_topic 17 | 18 | posts_to_delete = find_posts_to_delete(timer_topic, timer_post) 19 | next if posts_to_delete.blank? 20 | 21 | timer_topic.update_columns(deleted_at: nil) 22 | 23 | posts_to_delete.each do |post| 24 | next if !post&.persisted? 25 | PostDestroyer.new( 26 | post.user || Discourse.system_user, 27 | post, 28 | permanent: true, 29 | force_destroy: true, 30 | ).destroy 31 | end 32 | end 33 | end 34 | end 35 | 36 | private 37 | 38 | def find_posts_to_delete(topic, post) 39 | (post.is_first_post? ? topic.posts.with_deleted.order(created_at: :desc) : [post]).compact 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/mailers/user_notifications_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::UserNotificationsExtensions 4 | def notification_email(user, opts) 5 | if opts[:post] && opts[:post].is_encrypted? 6 | opts[:allow_reply_by_email] = false 7 | opts[:notification_data_hash][ 8 | :topic_title 9 | ] = "#{opts[:post].topic.title} ##{opts[:post].topic.id}" 10 | end 11 | super 12 | end 13 | 14 | module ClassMethods 15 | def get_context_posts(post, topic_user, user) 16 | return [] if post.is_encrypted? 17 | super 18 | end 19 | end 20 | 21 | def self.prepended(mod) 22 | mod.singleton_class.prepend(ClassMethods) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/models/encrypted_post_timer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptedPostTimer < ActiveRecord::Base 4 | belongs_to :post 5 | 6 | validates :post_id, presence: true 7 | validates :delete_at, presence: true 8 | 9 | scope :pending, -> { where(destroyed_at: nil).where("delete_at < ?", Time.zone.now) } 10 | end 11 | 12 | # == Schema Information 13 | # 14 | # Table name: encrypted_post_timers 15 | # 16 | # id :bigint not null, primary key 17 | # post_id :integer not null 18 | # delete_at :datetime not null 19 | # destroyed_at :datetime 20 | # created_at :datetime not null 21 | # updated_at :datetime not null 22 | # 23 | # Indexes 24 | # 25 | # index_encrypted_post_timers_on_delete_at (delete_at) 26 | # index_encrypted_post_timers_on_destroyed_at (destroyed_at) 27 | # index_encrypted_post_timers_on_post_id (post_id) 28 | # 29 | -------------------------------------------------------------------------------- /app/models/encrypted_topics_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptedTopicsData < ActiveRecord::Base 4 | belongs_to :topic 5 | end 6 | 7 | # == Schema Information 8 | # 9 | # Table name: encrypted_topics_data 10 | # 11 | # id :bigint not null, primary key 12 | # topic_id :integer 13 | # title :text 14 | # created_at :datetime not null 15 | # updated_at :datetime not null 16 | # 17 | # Indexes 18 | # 19 | # index_encrypted_topics_data_on_topic_id (topic_id) 20 | # 21 | -------------------------------------------------------------------------------- /app/models/encrypted_topics_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptedTopicsUser < ActiveRecord::Base 4 | belongs_to :topic 5 | belongs_to :user 6 | end 7 | 8 | # == Schema Information 9 | # 10 | # Table name: encrypted_topics_users 11 | # 12 | # id :bigint not null, primary key 13 | # user_id :integer 14 | # topic_id :integer 15 | # key :text 16 | # 17 | # Indexes 18 | # 19 | # index_encrypted_topics_users_on_topic_id (topic_id) 20 | # index_encrypted_topics_users_on_user_id (user_id) 21 | # index_encrypted_topics_users_on_user_id_and_topic_id (user_id,topic_id) UNIQUE 22 | # 23 | -------------------------------------------------------------------------------- /app/models/user_encryption_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserEncryptionKey < ActiveRecord::Base 4 | belongs_to :user 5 | end 6 | 7 | # == Schema Information 8 | # 9 | # Table name: user_encryption_keys 10 | # 11 | # id :bigint not null, primary key 12 | # user_id :integer 13 | # encrypt_public :text 14 | # encrypt_private :text 15 | # created_at :datetime not null 16 | # updated_at :datetime not null 17 | # 18 | # Indexes 19 | # 20 | # index_user_encryption_keys_on_user_id (user_id) 21 | # 22 | -------------------------------------------------------------------------------- /app/services/problem_check/unsafe_csp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProblemCheck::UnsafeCsp < ProblemCheck 4 | self.priority = "low" 5 | 6 | def call 7 | return no_problem if !SiteSetting.encrypt_enabled? 8 | return no_problem if !SiteSetting.content_security_policy? 9 | return no_problem if safe_policy? 10 | 11 | problem 12 | end 13 | 14 | private 15 | 16 | def safe_policy? 17 | DiscourseEncrypt.safe_csp_src?(SiteSetting.content_security_policy_script_src) 18 | end 19 | 20 | def translation_key 21 | "site_settings.errors.encrypt_unsafe_csp" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/api-initializers/encrypt-icons.gjs: -------------------------------------------------------------------------------- 1 | import { computed } from "@ember/object"; 2 | import { apiInitializer } from "discourse/lib/api"; 3 | import icon from "discourse-common/helpers/d-icon"; 4 | import i18n from "discourse-common/helpers/i18n"; 5 | import { withSilencedDeprecations } from "discourse-common/lib/deprecated"; 6 | import I18n from "discourse-i18n"; 7 | 8 | export default apiInitializer("2.0.0", (api) => { 9 | withSilencedDeprecations("discourse.hbr-topic-list-overrides", () => { 10 | let topicStatusIcons; 11 | try { 12 | topicStatusIcons = 13 | require("discourse/helpers/topic-status-icons").default; 14 | } catch {} 15 | 16 | if ( 17 | topicStatusIcons && 18 | !topicStatusIcons.entries.find( 19 | ({ attribute }) => attribute === "encrypted_title" 20 | ) 21 | ) { 22 | topicStatusIcons?.addObject([ 23 | "encrypted_title", 24 | "user-secret", 25 | "encrypted", 26 | ]); 27 | } 28 | 29 | // topic-list-item icon 30 | api.modifyClass("raw-view:topic-status", { 31 | pluginId: "encrypt", 32 | 33 | statuses: computed(function () { 34 | const results = this._super(...arguments); 35 | 36 | if (this.topic.encrypted_title) { 37 | results.push({ 38 | openTag: "span", 39 | closeTag: "span", 40 | title: I18n.t("topic-statuses.encrypted.help"), 41 | icon: "user-secret", 42 | key: "encrypted", 43 | }); 44 | } 45 | 46 | return results; 47 | }), 48 | }); 49 | }); 50 | 51 | // Main topic title 52 | api.renderInOutlet("after-topic-status", ); 60 | }); 61 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/api-initializers/encrypt-model-transformers.js: -------------------------------------------------------------------------------- 1 | import { apiInitializer } from "discourse/lib/api"; 2 | import { escapeExpression } from "discourse/lib/utilities"; 3 | import { 4 | ENCRYPT_ACTIVE, 5 | getEncryptionStatus, 6 | getTopicTitle, 7 | putTopicKey, 8 | putTopicTitle, 9 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 10 | 11 | export default apiInitializer("0.8", (api) => { 12 | const currentUser = api.getCurrentUser(); 13 | if (getEncryptionStatus(currentUser) !== ENCRYPT_ACTIVE) { 14 | // No point adding these transforms if we can't actually decrypt 15 | return; 16 | } 17 | 18 | api.registerModelTransformer("topic", async (topics) => { 19 | for (const topic of topics) { 20 | if (topic.topic_key && topic.encrypted_title) { 21 | putTopicKey(topic.id, topic.topic_key); 22 | putTopicTitle(topic.id, topic.encrypted_title); 23 | try { 24 | const decryptedTitle = await getTopicTitle(topic.id); 25 | if (decryptedTitle) { 26 | topic.set("fancy_title", escapeExpression(decryptedTitle)); 27 | topic.set("unicode_title", decryptedTitle); 28 | } 29 | } catch (err) { 30 | // eslint-disable-next-line no-console 31 | console.warn( 32 | `Decrypting the title of encrypted message (topicId: ${topic.id}) failed with the following error:`, 33 | err, 34 | err?.stack 35 | ); 36 | } 37 | } 38 | } 39 | }); 40 | 41 | api.registerModelTransformer("bookmark", async (bookmarks) => { 42 | for (const bookmark of bookmarks) { 43 | if (bookmark.topic_id && bookmark.topic_key && bookmark.encrypted_title) { 44 | putTopicKey(bookmark.topic_id, bookmark.topic_key); 45 | putTopicTitle(bookmark.topic_id, bookmark.encrypted_title); 46 | try { 47 | const decryptedTitle = await getTopicTitle(bookmark.topic_id); 48 | if (decryptedTitle) { 49 | bookmark.title = decryptedTitle; 50 | bookmark.fancy_title = escapeExpression(decryptedTitle); 51 | } 52 | } catch (err) { 53 | // eslint-disable-next-line no-console 54 | console.warn( 55 | `Decrypting the title of encrypted message (topicId: ${bookmark.topic_id}) failed with the following error:`, 56 | err, 57 | err?.stack 58 | ); 59 | } 60 | } 61 | } 62 | }); 63 | 64 | api.registerModelTransformer("notification", async (notifications) => { 65 | for (const notification of notifications) { 66 | if ( 67 | notification.topic_id && 68 | notification.topic_key && 69 | notification.encrypted_title 70 | ) { 71 | putTopicKey(notification.topic_id, notification.topic_key); 72 | putTopicTitle(notification.topic_id, notification.encrypted_title); 73 | try { 74 | const decryptedTitle = await getTopicTitle(notification.topic_id); 75 | if (decryptedTitle) { 76 | notification.fancy_title = escapeExpression(decryptedTitle); 77 | } 78 | } catch (err) { 79 | // eslint-disable-next-line no-console 80 | console.warn( 81 | `Decrypting the title of encrypted message (topicId: ${notification.topic_id}) failed with the following error:`, 82 | err, 83 | err?.stack 84 | ); 85 | } 86 | } 87 | } 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/encrypt-enable-dropdown.js: -------------------------------------------------------------------------------- 1 | import { computed } from "@ember/object"; 2 | import { classNames } from "@ember-decorators/component"; 3 | import I18n from "I18n"; 4 | import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; 5 | import { selectKitOptions } from "select-kit/components/select-kit"; 6 | 7 | @selectKitOptions({ 8 | icon: "bars", 9 | showFullTitle: false, 10 | }) 11 | @classNames("encrypt-enable-dropdown") 12 | export default class EncryptEnableDropdown extends DropdownSelectBoxComponent { 13 | @computed("importIdentity", "isEncryptEnabled") 14 | get content() { 15 | const content = []; 16 | 17 | content.push({ 18 | id: "import", 19 | icon: "file-import", 20 | name: this.importIdentity 21 | ? this.isEncryptEnabled 22 | ? I18n.t("encrypt.preferences.use_paper_key") 23 | : I18n.t("encrypt.preferences.generate_key") 24 | : I18n.t("encrypt.preferences.import"), 25 | }); 26 | 27 | if (this.isEncryptEnabled) { 28 | content.push({ 29 | id: "reset", 30 | icon: "trash-can", 31 | name: I18n.t("encrypt.reset.title"), 32 | }); 33 | } 34 | 35 | return content; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/encrypt-preferences-dropdown.js: -------------------------------------------------------------------------------- 1 | import { classNames } from "@ember-decorators/component"; 2 | import I18n from "I18n"; 3 | import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; 4 | import { selectKitOptions } from "select-kit/components/select-kit"; 5 | 6 | @selectKitOptions({ 7 | icon: "wrench", 8 | showFullTitle: false, 9 | }) 10 | @classNames("encrypt-preferences-dropdown") 11 | export default class EncryptPreferencesDropdown extends DropdownSelectBoxComponent { 12 | content = [ 13 | { 14 | id: "export", 15 | icon: "file-export", 16 | name: I18n.t("encrypt.export.title"), 17 | }, 18 | { 19 | id: "managePaperKeys", 20 | icon: "ticket-simple", 21 | name: I18n.t("encrypt.manage_paper_keys.title"), 22 | }, 23 | { 24 | id: "decryptAll", 25 | icon: "unlock", 26 | name: I18n.t("encrypt.decrypt_all.button"), 27 | }, 28 | { 29 | id: "rotate", 30 | icon: "arrows-rotate", 31 | name: I18n.t("encrypt.rotate.title"), 32 | }, 33 | { 34 | id: "reset", 35 | icon: "trash-can", 36 | name: I18n.t("encrypt.reset.title"), 37 | }, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/encrypted-post-timer-dropdown.js: -------------------------------------------------------------------------------- 1 | import { computed } from "@ember/object"; 2 | import { empty } from "@ember/object/computed"; 3 | import { classNameBindings, classNames } from "@ember-decorators/component"; 4 | import I18n from "I18n"; 5 | import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; 6 | import { selectKitOptions } from "select-kit/components/select-kit"; 7 | 8 | const TIMER_OPTIONS = [ 9 | { id: "", name: I18n.t("encrypt.time_bomb.never") }, 10 | { id: "3", name: I18n.t("encrypt.time_bomb.3_minutes") }, 11 | { id: "60", name: I18n.t("encrypt.time_bomb.1_hour") }, 12 | { id: "180", name: I18n.t("encrypt.time_bomb.3_hours") }, 13 | { id: "720", name: I18n.t("encrypt.time_bomb.12_hours") }, 14 | { id: "1440", name: I18n.t("encrypt.time_bomb.24_hours") }, 15 | { id: "4320", name: I18n.t("encrypt.time_bomb.3_days") }, 16 | { id: "10080", name: I18n.t("encrypt.time_bomb.7_days") }, 17 | ]; 18 | 19 | @selectKitOptions({ 20 | icon: "discourse-trash-clock", 21 | showFullTitle: true, 22 | }) 23 | @classNames("encrypted-post-timer-dropdown") 24 | @classNameBindings("hidden:hidden") 25 | export default class EncryptedPostTimerDropdown extends DropdownSelectBoxComponent { 26 | @empty("content") hidden; 27 | 28 | @computed("topicDeleteAt") 29 | get content() { 30 | if (this.topicDeleteAt) { 31 | return TIMER_OPTIONS.filter((option) => { 32 | return ( 33 | option.id.length > 0 && 34 | moment().add(option.id, "minutes") < moment(this.topicDeleteAt) 35 | ); 36 | }); 37 | } else { 38 | return TIMER_OPTIONS; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/activate-encrypt.hbs: -------------------------------------------------------------------------------- 1 | 8 | <:body> 9 |

{{i18n "encrypt.preferences.status_enabled_but_inactive"}}

10 | 11 | {{html-safe (i18n "encrypt.preferences.notice_active")}} 12 | 13 |

14 | 17 | 25 |

26 | 27 | 28 | <:footer> 29 | 35 | 36 | 37 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/activate-encrypt.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { service } from "@ember/service"; 5 | import I18n from "I18n"; 6 | import { activateEncrypt } from "discourse/plugins/discourse-encrypt/lib/discourse"; 7 | 8 | export default class ActivateEncrypt extends Component { 9 | @service currentUser; 10 | @service appEvents; 11 | @service encryptWidgetStore; 12 | 13 | @tracked inProgress = false; 14 | @tracked passphrase; 15 | @tracked error; 16 | 17 | @action 18 | async activate() { 19 | this.inProgress = true; 20 | 21 | try { 22 | await activateEncrypt(this.currentUser, this.passphrase); 23 | 24 | this.appEvents.trigger("encrypt:status-changed"); 25 | 26 | for (const widget of this.encryptWidgetStore.widgets) { 27 | widget.state.encryptState = "decrypting"; 28 | widget.scheduleRerender(); 29 | } 30 | 31 | this.encryptWidgetStore.reset(); 32 | this.args.closeModal(); 33 | } catch { 34 | this.error = I18n.t("encrypt.preferences.paper_key_invalid"); 35 | } finally { 36 | this.inProgress = false; 37 | } 38 | } 39 | 40 | @action 41 | close() { 42 | for (const widget of this.encryptWidgetStore.widgets) { 43 | widget.state.encryptState = "error"; 44 | widget.state.error = I18n.t( 45 | "encrypt.preferences.status_enabled_but_inactive" 46 | ); 47 | widget.scheduleRerender(); 48 | } 49 | 50 | this.encryptWidgetStore.reset(); 51 | this.args.closeModal(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/export-key-pair.hbs: -------------------------------------------------------------------------------- 1 | 6 | <:body> 7 |

{{i18n "encrypt.export.instructions"}}

8 |
{{this.exported}}
9 | 10 | 11 | <:footer> 12 | 23 | 24 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/export-key-pair.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { later } from "@ember/runloop"; 5 | import copyText from "discourse/lib/copy-text"; 6 | import { bind } from "discourse-common/utils/decorators"; 7 | import { getIdentity } from "discourse/plugins/discourse-encrypt/lib/discourse"; 8 | import { packIdentity } from "discourse/plugins/discourse-encrypt/lib/pack"; 9 | import { exportIdentity } from "discourse/plugins/discourse-encrypt/lib/protocol"; 10 | 11 | export default class ExportKeyPair extends Component { 12 | @tracked inProgress = true; 13 | @tracked exported = ""; 14 | @tracked copied; 15 | 16 | @bind 17 | async export() { 18 | try { 19 | const identity = await getIdentity(); 20 | const exported = await exportIdentity(identity); 21 | this.exported = packIdentity(exported.private); 22 | this.inProgress = false; 23 | } catch { 24 | this.inProgress = false; 25 | } 26 | } 27 | 28 | @action 29 | copy() { 30 | const copyRange = document.querySelector("pre.exported-key-pair"); 31 | 32 | if (copyText("", copyRange)) { 33 | this.copied = true; 34 | later(() => (this.copied = false), 2000); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/generate-paper-key.hbs: -------------------------------------------------------------------------------- 1 | 13 | <:body> 14 |

15 | {{i18n 16 | (if 17 | @model.device 18 | "encrypt.generate_paper_key.instructions_device" 19 | "encrypt.generate_paper_key.instructions" 20 | ) 21 | }} 22 |

23 |

{{this.paperKey}}

24 | 25 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/generate-paper-key.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { ajax } from "discourse/lib/ajax"; 4 | import { bind } from "discourse-common/utils/decorators"; 5 | import { getIdentity } from "discourse/plugins/discourse-encrypt/lib/discourse"; 6 | import { generatePaperKey } from "discourse/plugins/discourse-encrypt/lib/paper-key"; 7 | import { exportIdentity } from "discourse/plugins/discourse-encrypt/lib/protocol"; 8 | 9 | export default class GeneratePaperKey extends Component { 10 | @tracked paperKey; 11 | 12 | @bind 13 | async generate() { 14 | const paperKey = generatePaperKey(); 15 | const label = this.args.model.device 16 | ? "device" 17 | : `paper_${paperKey.substr(0, paperKey.indexOf(" ")).toLowerCase()}`; 18 | 19 | const identity = await getIdentity(); 20 | const exported = await exportIdentity(identity, paperKey); 21 | 22 | this.paperKey = paperKey; 23 | 24 | await ajax("/encrypt/keys", { 25 | type: "PUT", 26 | data: { 27 | public: exported.public, 28 | private: exported.private, 29 | label, 30 | }, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/manage-paper-keys.hbs: -------------------------------------------------------------------------------- 1 | 5 | <:body> 6 | {{#if this.keys}} 7 |

{{i18n "encrypt.manage_paper_keys.instructions"}}

8 | 9 | 10 | 11 | {{#each this.keys as |key|}} 12 | 13 | 26 | 27 | 35 | 36 | {{/each}} 37 | 38 |
14 | {{d-icon "key"}} 15 | 16 | {{#if key.isPaper}} 17 | {{key.name}} 18 | ... 19 | {{else if key.isPassphrase}} 20 | 21 | {{i18n "encrypt.manage_paper_keys.passphrase"}} 22 | {{i18n "encrypt.manage_paper_keys.not_recommended"}} 23 | 24 | {{/if}} 25 | 28 | 34 |
39 | {{else}} 40 |

{{i18n "encrypt.manage_paper_keys.no_key"}}

41 | {{/if}} 42 | 43 | 44 | <:footer> 45 | 50 | 51 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/manage-paper-keys.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { action } from "@ember/object"; 3 | import { dependentKeyCompat } from "@ember/object/compat"; 4 | import { service } from "@ember/service"; 5 | import { ajax } from "discourse/lib/ajax"; 6 | import GeneratePaperKey from "./generate-paper-key"; 7 | 8 | export default class ManagePaperKeys extends Component { 9 | @service modal; 10 | 11 | @dependentKeyCompat 12 | get keys() { 13 | if (!this.args.model.user.get("encrypt_private")) { 14 | return []; 15 | } 16 | 17 | const privateKeys = JSON.parse(this.args.model.user.get("encrypt_private")); 18 | const keys = []; 19 | 20 | for (const label of Object.keys(privateKeys)) { 21 | if (label.startsWith("paper_")) { 22 | keys.push({ 23 | isPaper: true, 24 | label, 25 | name: label.substring("paper_".length), 26 | }); 27 | } else if (label === "passphrase") { 28 | keys.unshift({ 29 | isPassphrase: true, 30 | label: "passphrase", 31 | }); 32 | } 33 | } 34 | 35 | return keys; 36 | } 37 | 38 | @action 39 | generatePaperKey() { 40 | this.modal.show(GeneratePaperKey, { 41 | model: { device: false }, 42 | }); 43 | } 44 | 45 | @action 46 | delete(label) { 47 | return ajax("/encrypt/keys", { 48 | type: "DELETE", 49 | data: { label }, 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/reset-key-pair.hbs: -------------------------------------------------------------------------------- 1 | 6 | <:body> 7 | 8 | {{#if this.encryptedPmsCount}} 9 |

{{i18n "encrypt.reset.instructions"}}

10 |

11 | {{html-safe 12 | (i18n 13 | "encrypt.reset.instructions_lost_pms" count=this.encryptedPmsCount 14 | ) 15 | }} 16 | {{html-safe 17 | (i18n 18 | "encrypt.reset.confirm_instructions" 19 | username=this.currentUser.username 20 | ) 21 | }} 22 |

23 | 24 | {{else}} 25 |

{{i18n "encrypt.reset.instructions_safe"}}

26 | {{/if}} 27 |
28 | 29 | 30 | <:footer> 31 | 38 | 39 | 40 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/reset-key-pair.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { service } from "@ember/service"; 5 | import { ajax } from "discourse/lib/ajax"; 6 | import { popupAjaxError } from "discourse/lib/ajax-error"; 7 | import { deleteDb } from "discourse/plugins/discourse-encrypt/lib/database"; 8 | 9 | export default class ResetKeyPair extends Component { 10 | @service currentUser; 11 | @service appEvents; 12 | 13 | @tracked isLoadingStats = true; 14 | @tracked inProgress = false; 15 | @tracked encryptedPmsCount; 16 | @tracked confirmation = ""; 17 | 18 | get disabled() { 19 | return ( 20 | this.isLoadingStats || 21 | this.inProgress || 22 | (this.encryptedPmsCount > 0 && 23 | this.currentUser.username !== this.confirmation) 24 | ); 25 | } 26 | 27 | @action 28 | async loadStats() { 29 | try { 30 | const result = await ajax("/encrypt/stats", { 31 | data: { user_id: this.args.model.user.id }, 32 | }); 33 | 34 | if (result.encrypted_pms_count > 0) { 35 | this.encryptedPmsCount = result.encrypted_pms_count; 36 | } 37 | } finally { 38 | this.isLoadingStats = false; 39 | } 40 | } 41 | 42 | @action 43 | async reset() { 44 | this.inProgress = true; 45 | 46 | try { 47 | await Promise.all([ 48 | ajax("/encrypt/reset", { 49 | type: "POST", 50 | data: { user_id: this.args.model.user.id }, 51 | }), 52 | deleteDb, 53 | ]); 54 | 55 | this.currentUser.setProperties({ 56 | encrypt_public: null, 57 | encrypt_private: null, 58 | }); 59 | 60 | this.appEvents.trigger("encrypt:status-changed"); 61 | this.args.closeModal(); 62 | } catch (error) { 63 | popupAjaxError(error); 64 | } finally { 65 | this.inProgress = false; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/rotate-key-pair.hbs: -------------------------------------------------------------------------------- 1 | 7 | <:body> 8 |

{{i18n "encrypt.rotate.instructions"}}

9 |

10 | {{html-safe 11 | (i18n 12 | "encrypt.reset.confirm_instructions" 13 | username=this.currentUser.username 14 | ) 15 | }} 16 |

17 | 18 | 19 | 20 | <:footer> 21 | 28 | 29 | 30 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/rotate-key-pair.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { service } from "@ember/service"; 5 | import { ajax } from "discourse/lib/ajax"; 6 | import { extractError } from "discourse/lib/ajax-error"; 7 | import { saveDbIdentity } from "discourse/plugins/discourse-encrypt/lib/database"; 8 | import { getIdentity } from "discourse/plugins/discourse-encrypt/lib/discourse"; 9 | import { 10 | exportIdentity, 11 | exportKey, 12 | generateIdentity, 13 | importKey, 14 | } from "discourse/plugins/discourse-encrypt/lib/protocol"; 15 | 16 | export default class RotateKeyPair extends Component { 17 | @service currentUser; 18 | @service appEvents; 19 | 20 | @tracked confirmation = ""; 21 | @tracked loadingState; 22 | @tracked error; 23 | 24 | get label() { 25 | return this.loadingState 26 | ? `encrypt.rotate.loading_states.${this.loadingState}` 27 | : "encrypt.rotate.title"; 28 | } 29 | 30 | get disabled() { 31 | return this.loadingState || this.currentUser.username !== this.confirmation; 32 | } 33 | 34 | @action 35 | async rotate() { 36 | this.loadingState = "fetching"; 37 | this.error = null; 38 | 39 | try { 40 | const [data, oldIdentity, newIdentity] = await Promise.all([ 41 | ajax("/encrypt/rotate"), 42 | getIdentity(), 43 | generateIdentity(), 44 | ]); 45 | this.loadingState = "rotating"; 46 | 47 | // Don't rotate signatures because that will invalidate all previous 48 | // signatures. 49 | // When the old identity is v0, there's no keypair for signing, so don't 50 | // overwrite the new identity's signing keypair with nothing (undefined) 51 | if (oldIdentity.signPublic && oldIdentity.signPrivate) { 52 | newIdentity.signPublic = oldIdentity.signPublic; 53 | newIdentity.signPrivate = oldIdentity.signPrivate; 54 | } 55 | 56 | const topicKeys = {}; 57 | 58 | await Promise.all( 59 | Object.entries(data.topic_keys).map(async ([topicId, topicKey]) => { 60 | const key = await importKey(topicKey, oldIdentity.encryptPrivate); 61 | topicKeys[topicId] = await exportKey(key, newIdentity.encryptPublic); 62 | }) 63 | ); 64 | 65 | const exportedIdentity = await exportIdentity(newIdentity); 66 | 67 | this.loadingState = "saving"; 68 | await ajax("/encrypt/rotate", { 69 | type: "PUT", 70 | data: { 71 | public: exportedIdentity.public, 72 | keys: topicKeys, 73 | }, 74 | }); 75 | 76 | this.loadingState = "updating"; 77 | await saveDbIdentity(newIdentity); 78 | 79 | this.loadingState = "finished"; 80 | this.appEvents.trigger("encrypt:status-changed"); 81 | } catch (error) { 82 | this.confirmation = ""; 83 | this.loadingState = null; 84 | this.error = extractError(error); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/composer-action-after/encrypt.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.showEncryptControls}} 2 |
3 | {{#if this.isEncryptActive}} 4 | 12 | {{d-icon (if this.model.isEncrypted "lock" "unlock")}} 13 | 14 | 15 | {{~#if this.model.isEncrypted}} 16 | {{~#unless this.model.editingPost~}} 17 |
{{! inline to avoid whitespace}} 23 | 24 | {{#if this.model.deleteAfterMinutesLabel}} 25 | 31 | {{this.model.deleteAfterMinutesLabel}} 32 | 33 | {{/if}} 34 | {{/unless}} 35 | {{/if}} 36 | 37 | {{#if this.model.showEncryptError}} 38 | {{this.model.encryptError}} 39 | {{/if}} 40 | {{else if this.isEncryptEnabled}} 41 | 42 | {{d-icon "unlock" class="disabled"}} 43 | 44 | {{/if}} 45 |
46 | {{/if}} -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/topic-above-post-stream/encrypt-topic-class.gjs: -------------------------------------------------------------------------------- 1 | import bodyClass from "discourse/helpers/body-class"; 2 | 3 | 8 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/auto-enable-encrypt.js: -------------------------------------------------------------------------------- 1 | import { 2 | enableEncrypt, 3 | ENCRYPT_DISABLED, 4 | getEncryptionStatus, 5 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 6 | 7 | const AUTO_ENABLE_KEY = "discourse-encrypt-auto-enable"; 8 | 9 | export default { 10 | name: "auto-enable-encrypt", 11 | 12 | initialize(container) { 13 | const siteSettings = container.lookup("service:site-settings"); 14 | if (!siteSettings.auto_enable_encrypt) { 15 | return; 16 | } 17 | 18 | const currentUser = container.lookup("service:current-user"); 19 | if (currentUser) { 20 | if ( 21 | !window.localStorage.getItem(AUTO_ENABLE_KEY) && 22 | getEncryptionStatus(currentUser) === ENCRYPT_DISABLED 23 | ) { 24 | window.localStorage.setItem(AUTO_ENABLE_KEY, true); 25 | enableEncrypt(currentUser).then(() => { 26 | const appEvents = container.lookup("service:app-events"); 27 | appEvents.trigger("encrypt:status-changed"); 28 | }); 29 | } 30 | } else { 31 | window.localStorage.removeItem(AUTO_ENABLE_KEY); 32 | } 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/decrypt-post-revision.js: -------------------------------------------------------------------------------- 1 | import { Promise } from "rsvp"; 2 | import { withPluginApi } from "discourse/lib/plugin-api"; 3 | import { cook } from "discourse/lib/text"; 4 | import { 5 | ENCRYPT_ACTIVE, 6 | getEncryptionStatus, 7 | getTopicKey, 8 | hasTopicKey, 9 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 10 | import { decrypt } from "discourse/plugins/discourse-encrypt/lib/protocol"; 11 | 12 | export default { 13 | name: "decrypt-post-revisions", 14 | 15 | initialize(container) { 16 | const currentUser = container.lookup("service:current-user"); 17 | if (getEncryptionStatus(currentUser) !== ENCRYPT_ACTIVE) { 18 | return; 19 | } 20 | 21 | withPluginApi("0.11.3", (api) => { 22 | api.modifyClassStatic("model:post", { 23 | pluginId: "decrypt-post-revisions", 24 | 25 | loadRevision() { 26 | return this._super(...arguments).then((result) => { 27 | if (!hasTopicKey(result.topic_id)) { 28 | return result; 29 | } 30 | 31 | const topicKey = getTopicKey(result.topic_id); 32 | return Promise.all([ 33 | topicKey.then((k) => decrypt(k, result.raws.previous)), 34 | topicKey.then((k) => decrypt(k, result.raws.current)), 35 | ]) 36 | .then(([previous, current]) => 37 | Promise.all([ 38 | previous.raw, 39 | cook(previous.raw), 40 | current.raw, 41 | cook(current.raw), 42 | ]) 43 | ) 44 | .then(([prevRaw, prevCooked, currRaw, currCooked]) => { 45 | result.body_changes.side_by_side = ` 46 |
${prevCooked}
47 |
${currCooked}
`; 48 | result.body_changes.side_by_side_markdown = ` 49 | 50 | 51 | 52 | 53 | 54 |
${prevRaw}${currRaw}
`; 55 | return result; 56 | }); 57 | }); 58 | }, 59 | }); 60 | }); 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/encrypt-status.js: -------------------------------------------------------------------------------- 1 | import { htmlSafe } from "@ember/template"; 2 | import { withPluginApi } from "discourse/lib/plugin-api"; 3 | import { isTesting } from "discourse-common/config/environment"; 4 | import getURL from "discourse-common/lib/get-url"; 5 | import I18n from "I18n"; 6 | import { deleteDb } from "discourse/plugins/discourse-encrypt/lib/database"; 7 | import { 8 | ENCRYPT_ACTIVE, 9 | ENCRYPT_DISABLED, 10 | getEncryptionStatus, 11 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 12 | 13 | export default { 14 | name: "encrypt-status", 15 | 16 | initialize(container) { 17 | const currentUser = container.lookup("service:current-user"); 18 | const appEvents = container.lookup("service:app-events"); 19 | appEvents.on("encrypt:status-changed", (skipReload) => { 20 | if (!skipReload && !isTesting()) { 21 | window.location.reload(); 22 | } 23 | }); 24 | 25 | const status = getEncryptionStatus(currentUser); 26 | if (!currentUser || status !== ENCRYPT_ACTIVE) { 27 | deleteDb(); 28 | } 29 | 30 | if ( 31 | currentUser && 32 | status === ENCRYPT_ACTIVE && 33 | (!currentUser.encrypt_private || 34 | Object.keys(JSON.parse(currentUser.encrypt_private)).length === 0) 35 | ) { 36 | withPluginApi("0.11.3", (api) => { 37 | let basePath = getURL("/").replace(/\/$/, ""); 38 | const warning = I18n.t("encrypt.no_backup_warn", { basePath }); 39 | 40 | api.addGlobalNotice(htmlSafe(warning), "key-backup-notice", { 41 | level: "warn", 42 | dismissable: true, 43 | dismissDuration: moment.duration(1, "day"), 44 | }); 45 | }); 46 | } 47 | 48 | const messageBus = container.lookup("service:message-bus"); 49 | if (messageBus && status !== ENCRYPT_DISABLED) { 50 | messageBus.subscribe("/plugin/encrypt/keys", function (data) { 51 | currentUser.setProperties({ 52 | encrypt_public: data.public, 53 | encrypt_private: data.private, 54 | }); 55 | appEvents.trigger("encrypt:status-changed", true); 56 | }); 57 | } 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/encrypt-uploads.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LIST } from "pretty-text/allow-lister"; 2 | import { withPluginApi } from "discourse/lib/plugin-api"; 3 | import { getUploadMarkdown } from "discourse/lib/uploads"; 4 | import { 5 | ENCRYPT_ACTIVE, 6 | getEncryptionStatus, 7 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 8 | import UppyUploadEncrypt from "discourse/plugins/discourse-encrypt/lib/uppy-upload-encrypt-plugin"; 9 | 10 | export default { 11 | name: "encrypt-uploads", 12 | 13 | initialize(container) { 14 | const currentUser = container.lookup("service:current-user"); 15 | const siteSettings = container.lookup("service:site-settings"); 16 | if (getEncryptionStatus(currentUser) !== ENCRYPT_ACTIVE) { 17 | return; 18 | } 19 | 20 | withPluginApi("0.11.3", (api) => { 21 | DEFAULT_LIST.push("a[data-key]"); 22 | DEFAULT_LIST.push("a[data-type]"); 23 | DEFAULT_LIST.push("img[data-key]"); 24 | DEFAULT_LIST.push("img[data-type]"); 25 | 26 | const uploads = {}; 27 | api.addComposerUploadPreProcessor( 28 | UppyUploadEncrypt, 29 | ({ composerModel }) => { 30 | return { 31 | composerModel, 32 | siteSettings, 33 | storeEncryptedUpload: (fileName, data) => { 34 | uploads[fileName] = data; 35 | }, 36 | }; 37 | } 38 | ); 39 | 40 | api.addComposerUploadMarkdownResolver((upload) => { 41 | const encryptedUpload = 42 | uploads[upload.original_filename.replace(/\.encrypted$/, "")] || 43 | Object.values(uploads).find((u) => u.filesize === upload.filesize); 44 | if (!encryptedUpload) { 45 | return; 46 | } 47 | 48 | Object.assign(upload, encryptedUpload.metadata); 49 | const markdown = getUploadMarkdown(upload).replace( 50 | "](", 51 | `|type=${encryptedUpload.type}|key=${encryptedUpload.key}](` 52 | ); 53 | delete uploads[encryptedUpload.original_filename]; 54 | return markdown; 55 | }); 56 | }); 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/encrypt-user-options.js: -------------------------------------------------------------------------------- 1 | import { withPluginApi } from "discourse/lib/plugin-api"; 2 | 3 | const ENCRYPT_PMS_DEFAULT = "encrypt_pms_default"; 4 | 5 | export default { 6 | name: "encrypt-user-options", 7 | 8 | initialize(container) { 9 | withPluginApi("0.11.0", (api) => { 10 | const siteSettings = container.lookup("service:site-settings"); 11 | if (siteSettings.encrypt_enabled) { 12 | api.addSaveableUserOptionField(ENCRYPT_PMS_DEFAULT); 13 | } 14 | }); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/invite-to-encrypted-topic.js: -------------------------------------------------------------------------------- 1 | import { getOwner } from "@ember/application"; 2 | import { Promise } from "rsvp"; 3 | import { ajax } from "discourse/lib/ajax"; 4 | import { withPluginApi } from "discourse/lib/plugin-api"; 5 | import I18n from "I18n"; 6 | import { 7 | ENCRYPT_ACTIVE, 8 | getEncryptionStatus, 9 | getTopicKey, 10 | getUserIdentities, 11 | hasTopicKey, 12 | } from "discourse/plugins/discourse-encrypt/lib/discourse"; 13 | import { exportKey } from "discourse/plugins/discourse-encrypt/lib/protocol"; 14 | 15 | export default { 16 | name: "invite-to-encrypted-topic", 17 | 18 | initialize(container) { 19 | const currentUser = container.lookup("service:current-user"); 20 | if (getEncryptionStatus(currentUser) !== ENCRYPT_ACTIVE) { 21 | return; 22 | } 23 | 24 | withPluginApi("0.11.3", (api) => { 25 | api.modifyClass("model:topic", { 26 | pluginId: "invite-to-encrypted-topic", 27 | 28 | createInvite(user, group_ids, custom_message) { 29 | // TODO: https://github.com/emberjs/ember.js/issues/15291 30 | let { _super } = this; 31 | if (!hasTopicKey(this.id)) { 32 | return _super.call(this, ...arguments); 33 | } 34 | 35 | return Promise.all([getTopicKey(this.id), getUserIdentities([user])]) 36 | .then(([key, identities]) => 37 | exportKey(key, identities[user].encryptPublic) 38 | ) 39 | .then((key) => { 40 | ajax(`/t/${this.id}/invite`, { 41 | type: "POST", 42 | data: { user, key, group_ids, custom_message }, 43 | }); 44 | }) 45 | .catch((username) => { 46 | const dialog = getOwner(this).lookup("service:dialog"); 47 | dialog.alert( 48 | I18n.t("encrypt.composer.user_has_no_key", { username }) 49 | ); 50 | return Promise.reject(username); 51 | }); 52 | }, 53 | }); 54 | }); 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/services/encrypt-widget-store.js: -------------------------------------------------------------------------------- 1 | import Service from "@ember/service"; 2 | import { TrackedArray } from "@ember-compat/tracked-built-ins"; 3 | 4 | export default class EncryptWidgetStore extends Service { 5 | widgets = new TrackedArray(); 6 | 7 | add(widget) { 8 | this.widgets.push(widget); 9 | } 10 | 11 | reset() { 12 | this.widgets.length = 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/encrypted-post-timer-counter.js: -------------------------------------------------------------------------------- 1 | import { later } from "@ember/runloop"; 2 | import { h } from "virtual-dom"; 3 | import { createWidget } from "discourse/widgets/widget"; 4 | import { iconNode } from "discourse-common/lib/icon-library"; 5 | import i18n from "I18n"; 6 | 7 | createWidget("encrypted-post-timer-counter", { 8 | tagName: "div.encrypted-post-timer-counter", 9 | 10 | init(attrs) { 11 | if (attrs.post.delete_at) { 12 | later(() => { 13 | this.scheduleRerender(); 14 | }, 60000); 15 | } 16 | }, 17 | 18 | formattedClock(attrs) { 19 | const milliseconds = Math.max( 20 | moment(attrs.post.delete_at) - moment().utc(), 21 | 60000 22 | ); 23 | 24 | return moment.duration(milliseconds).humanize(); 25 | }, 26 | 27 | html(attrs) { 28 | if (attrs.post.delete_at) { 29 | return h( 30 | "div", 31 | { 32 | attributes: { 33 | title: i18n.t("encrypt.time_bomb.title", { 34 | after: this.formattedClock(attrs), 35 | }), 36 | }, 37 | }, 38 | [iconNode("discourse-trash-clock"), this.formattedClock(attrs)] 39 | ); 40 | } 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /assets/javascripts/lib/base64.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @var {String} BASE64 Alphabet of Base64 encoding. 3 | */ 4 | const BASE64 = 5 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; 6 | 7 | /** 8 | * Converts a Base64 string to an bytes array. 9 | * 10 | * @param {String} str 11 | * 12 | * @return {Uint8Array} 13 | */ 14 | export function base64ToBuffer(str) { 15 | let length = str.length; 16 | while (str.charAt(length - 1) === "=") { 17 | --length; 18 | } 19 | length = Math.floor((length / 4) * 3); 20 | 21 | let ret = new Uint8Array(length); 22 | 23 | /* eslint-disable no-bitwise */ 24 | for (let i = 0, j = 0; i < length; i += 3) { 25 | let enc1 = BASE64.indexOf(str.charAt(j++)); 26 | let enc2 = BASE64.indexOf(str.charAt(j++)); 27 | let enc3 = BASE64.indexOf(str.charAt(j++)); 28 | let enc4 = BASE64.indexOf(str.charAt(j++)); 29 | 30 | ret[i] = (enc1 << 2) | (enc2 >> 4); 31 | if (enc3 !== 64) { 32 | ret[i + 1] = ((enc2 & 15) << 4) | (enc3 >> 2); 33 | } 34 | if (enc4 !== 64) { 35 | ret[i + 2] = ((enc3 & 3) << 6) | enc4; 36 | } 37 | } 38 | /* eslint-enable no-bitwise */ 39 | 40 | return ret; 41 | } 42 | 43 | /** 44 | * Converts a bytes array to a Base64 string. 45 | * 46 | * @param {ArrayBuffer} buffer 47 | * 48 | * @return {String} 49 | */ 50 | export function bufferToBase64(buffer) { 51 | let ret = ""; 52 | 53 | let bytes = new Uint8Array(buffer); 54 | let length = bytes.byteLength - (bytes.byteLength % 3); 55 | 56 | /* eslint-disable no-bitwise */ 57 | for (let i = 0; i < length; i = i + 3) { 58 | let bits = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; 59 | let enc1 = (bits >> 18) & 63; 60 | let enc2 = (bits >> 12) & 63; 61 | let enc3 = (bits >> 6) & 63; 62 | let enc4 = bits & 63; 63 | ret += BASE64[enc1] + BASE64[enc2] + BASE64[enc3] + BASE64[enc4]; 64 | } 65 | 66 | length = bytes.byteLength; 67 | 68 | if (length % 3 === 1) { 69 | let bits = bytes[length - 1]; 70 | let enc1 = (bits >> 2) & 63; 71 | let enc2 = (bits << 4) & 63; 72 | ret += BASE64[enc1] + BASE64[enc2] + "=="; 73 | } else if (length % 3 === 2) { 74 | let bits = (bytes[length - 2] << 8) | bytes[length - 1]; 75 | let enc1 = (bits >> 10) & 63; 76 | let enc2 = (bits >> 4) & 63; 77 | let enc3 = (bits << 2) & 63; 78 | ret += BASE64[enc1] + BASE64[enc2] + BASE64[enc3] + "="; 79 | } 80 | /* eslint-enable no-bitwise */ 81 | 82 | return ret; 83 | } 84 | -------------------------------------------------------------------------------- /assets/javascripts/lib/debounced-queue.js: -------------------------------------------------------------------------------- 1 | import { Promise } from "rsvp"; 2 | import discourseDebounce from "discourse-common/lib/debounce"; 3 | 4 | export default class DebouncedQueue { 5 | constructor(wait, handler) { 6 | this.wait = wait; 7 | this.handler = handler; 8 | this.queue = null; 9 | this.promise = null; 10 | this.resolve = null; 11 | this.reject = null; 12 | } 13 | 14 | push(...items) { 15 | if (!this.queue) { 16 | this.queue = []; 17 | this.promise = new Promise((resolve, reject) => { 18 | this.resolve = resolve; 19 | this.reject = reject; 20 | }); 21 | discourseDebounce(this, this.pop, this.wait); 22 | } 23 | 24 | this.queue.push(...items); 25 | return this.promise; 26 | } 27 | 28 | pop() { 29 | const items = Array.from(new Set(this.queue)); 30 | this.handler(items).then(this.resolve).catch(this.reject); 31 | 32 | this.queue = null; 33 | this.promise = null; 34 | this.resolve = null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /assets/javascripts/lib/pack.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Useful variables for key import and export format. 3 | */ 4 | 5 | export const PACKED_KEY_COLUMNS = 71; 6 | export const PACKED_KEY_HEADER = 7 | "============== BEGIN EXPORTED DISCOURSE ENCRYPT KEY PAIR =============="; 8 | export const PACKED_KEY_FOOTER = 9 | "=============== END EXPORTED DISCOURSE ENCRYPT KEY PAIR ==============="; 10 | 11 | export function packIdentity(identity) { 12 | const segments = []; 13 | segments.push(PACKED_KEY_HEADER); 14 | for (let i = 0, len = identity.length; i < len; i += PACKED_KEY_COLUMNS) { 15 | segments.push(identity.substr(i, PACKED_KEY_COLUMNS)); 16 | } 17 | segments.push(PACKED_KEY_FOOTER); 18 | return segments.join("\n"); 19 | } 20 | 21 | export function unpackIdentity(identity) { 22 | let ret = identity 23 | .replace(PACKED_KEY_HEADER, "") 24 | .replace(PACKED_KEY_FOOTER, "") 25 | .split(/\s+/) 26 | .map((x) => x.trim()) 27 | .join(""); 28 | 29 | // Backwards compatibility pre-refactoring. 30 | const PACKED_KEY_SEPARATOR = 31 | "-----------------------------------------------------------------------"; 32 | if (ret.indexOf(PACKED_KEY_SEPARATOR) !== -1) { 33 | ret = "0$" + ret.split(PACKED_KEY_SEPARATOR).join("$"); 34 | } 35 | 36 | return ret; 37 | } 38 | 39 | export function getPackedPlaceholder() { 40 | return ( 41 | PACKED_KEY_HEADER + 42 | "\n" + 43 | (".".repeat(PACKED_KEY_COLUMNS) + "\n").repeat(3) + 44 | PACKED_KEY_FOOTER 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /assets/javascripts/lib/uploads.js: -------------------------------------------------------------------------------- 1 | import { Promise } from "rsvp"; 2 | import { isImage } from "discourse/lib/uploads"; 3 | 4 | export function getMetadata(file, siteSettings) { 5 | if (!isImage(file.name)) { 6 | return Promise.resolve({ original_filename: file.name }); 7 | } 8 | 9 | return new Promise((resolve, reject) => { 10 | const img = new Image(); 11 | img.onload = () => resolve(img); 12 | img.onerror = (err) => reject(err); 13 | img.src = window.URL.createObjectURL(file); 14 | }).then((img) => { 15 | let ratio = 1; 16 | 17 | if ( 18 | img.width > siteSettings.max_image_width || 19 | img.height > siteSettings.max_image_height 20 | ) { 21 | ratio = Math.min( 22 | siteSettings.max_image_width / img.width, 23 | siteSettings.max_image_height / img.height 24 | ); 25 | } 26 | 27 | return { 28 | original_filename: file.name, 29 | url: img.src, 30 | width: img.width, 31 | height: img.height, 32 | thumbnail_width: Math.floor(img.width * ratio), 33 | thumbnail_height: Math.floor(img.height * ratio), 34 | }; 35 | }); 36 | } 37 | 38 | export function readFile(file) { 39 | return new Promise((resolve, reject) => { 40 | const reader = new FileReader(); 41 | reader.onloadend = () => resolve(reader.result); 42 | reader.onerror = (err) => reject(err); 43 | reader.readAsArrayBuffer(file); 44 | }); 45 | } 46 | 47 | export function generateUploadKey() { 48 | return new Promise((resolve, reject) => { 49 | window.crypto.subtle 50 | .generateKey({ name: "AES-GCM", length: 256 }, true, [ 51 | "encrypt", 52 | "decrypt", 53 | ]) 54 | .then(resolve, reject); 55 | }); 56 | } 57 | 58 | export function downloadEncryptedFile(url, keyPromise, opts) { 59 | opts = opts || {}; 60 | 61 | const downloadPromise = new Promise((resolve, reject) => { 62 | let req = new XMLHttpRequest(); 63 | req.open("GET", url, true); 64 | req.responseType = "arraybuffer"; 65 | req.onload = function () { 66 | let filename = req.getResponseHeader("Content-Disposition"); 67 | if (filename) { 68 | // Requires Access-Control-Expose-Headers: Content-Disposition. 69 | filename = filename.match(/filename="(.*?)"/)[1]; 70 | } 71 | resolve({ buffer: req.response, filename }); 72 | }; 73 | req.onerror = reject; 74 | req.send(); 75 | }); 76 | 77 | return Promise.all([keyPromise, downloadPromise]).then(([key, download]) => { 78 | const iv = download.buffer.slice(0, 12); 79 | const content = download.buffer.slice(12); 80 | 81 | return new Promise((resolve, reject) => { 82 | window.crypto.subtle 83 | .decrypt({ name: "AES-GCM", iv, tagLength: 128 }, key, content) 84 | .then(resolve, reject); 85 | }).then((buffer) => ({ 86 | blob: new Blob([buffer], { type: opts.type || "application/x-binary" }), 87 | name: download.filename, 88 | })); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /assets/javascripts/lib/uppy-upload-encrypt-plugin.js: -------------------------------------------------------------------------------- 1 | import { Promise } from "rsvp"; 2 | import { HUGE_FILE_THRESHOLD_BYTES } from "discourse/lib/uppy/uppy-upload"; 3 | import { UploadPreProcessorPlugin } from "discourse/lib/uppy-plugin-base"; 4 | import { bufferToBase64 } from "discourse/plugins/discourse-encrypt/lib/base64"; 5 | import { hasTopicKey } from "discourse/plugins/discourse-encrypt/lib/discourse"; 6 | import { 7 | generateUploadKey, 8 | getMetadata, 9 | readFile, 10 | } from "discourse/plugins/discourse-encrypt/lib/uploads"; 11 | 12 | export default class UppyUploadEncrypt extends UploadPreProcessorPlugin { 13 | static pluginId = "uppy-upload-encrypt"; 14 | 15 | constructor(uppy, opts) { 16 | super(uppy, opts); 17 | this.composerModel = opts.composerModel; 18 | this.storeEncryptedUpload = opts.storeEncryptedUpload; 19 | this.siteSettings = opts.siteSettings; 20 | } 21 | 22 | async _encryptFile(fileId) { 23 | const file = this._getFile(fileId); 24 | 25 | if (file.size > HUGE_FILE_THRESHOLD_BYTES) { 26 | return this._emitError( 27 | file, 28 | "The provided file is too large to upload to an encrypted message." 29 | ); 30 | } 31 | 32 | this._emitProgress(file); 33 | 34 | const key = await generateUploadKey(); 35 | let exportedKey = await window.crypto.subtle.exportKey("raw", key); 36 | exportedKey = bufferToBase64(exportedKey); 37 | 38 | const metadata = await getMetadata(file.data, this.siteSettings); 39 | const plaintext = await readFile(file.data); 40 | 41 | const iv = window.crypto.getRandomValues(new Uint8Array(12)); 42 | 43 | const ciphertext = await window.crypto.subtle.encrypt( 44 | { name: "AES-GCM", iv, tagLength: 128 }, 45 | key, 46 | plaintext 47 | ); 48 | 49 | const blob = new Blob([iv, ciphertext], { 50 | type: "application/x-binary", 51 | }); 52 | 53 | this._setFileState(fileId, { 54 | data: blob, 55 | size: blob.size, 56 | name: `${file.name}.encrypted`, 57 | }); 58 | 59 | this._setFileMeta(fileId, { 60 | name: `${file.name}.encrypted`, 61 | }); 62 | 63 | this.storeEncryptedUpload(file.name, { 64 | key: exportedKey, 65 | metadata, 66 | type: file.type, 67 | filesize: blob.size, 68 | }); 69 | this._emitComplete(file); 70 | } 71 | 72 | async _encryptFiles(fileIds) { 73 | if ( 74 | !this.composerModel.isEncrypted && 75 | !hasTopicKey(this.composerModel.get("topic.id")) 76 | ) { 77 | this._consoleDebug( 78 | "Composer is not being used in an encrypted context, skipping all files." 79 | ); 80 | return this._skipAll(fileIds, true); 81 | } 82 | 83 | const encryptPromises = fileIds.map((fileId) => this._encryptFile(fileId)); 84 | return Promise.all(encryptPromises); 85 | } 86 | 87 | install() { 88 | this._install(this._encryptFiles.bind(this)); 89 | } 90 | 91 | uninstall() { 92 | this._uninstall(this._encryptFiles.bind(this)); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /assets/javascripts/lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps an object in a proxy which makes lookups case insensitive. 3 | * 4 | * @param {Object} object 5 | * @return {Proxy} 6 | */ 7 | export function getCaseInsensitiveObj(object) { 8 | object = object || {}; 9 | return new Proxy(object, { 10 | get(obj, key) { 11 | if (obj[key]) { 12 | return obj[key]; 13 | } 14 | 15 | key = key.toLowerCase(); 16 | key = Object.keys(obj).find((k) => key === k.toLowerCase()); 17 | return obj[key]; 18 | }, 19 | }); 20 | } 21 | 22 | /** 23 | * Creates a new object containing a subset of the object keys. 24 | * 25 | * @param {Object} obj 26 | * @param {Array} keys 27 | * 28 | * @return {Object} 29 | */ 30 | export function filterObjectKeys(obj, keys) { 31 | const newObj = {}; 32 | 33 | keys.forEach((key) => { 34 | if (key in obj) { 35 | newObj[key] = obj[key]; 36 | } 37 | }); 38 | 39 | return newObj; 40 | } 41 | -------------------------------------------------------------------------------- /assets/stylesheets/colors.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --encrypt-warn: #{mix($danger, #ffff00)}; 3 | } 4 | -------------------------------------------------------------------------------- /config/locales/client.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "абнавіць" 12 | decrypt_all: 13 | done: "Выканана" 14 | preferences: 15 | save: "Захаваць" 16 | manage_paper_keys: 17 | delete: "выдаліць" 18 | rotate: 19 | loading_states: 20 | finished: "Выканана!" 21 | time_bomb: 22 | never: "ніколі" 23 | -------------------------------------------------------------------------------- /config/locales/client.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Обнови" 12 | decrypt_all: 13 | done: "Готово" 14 | preferences: 15 | save: "Запазете промените" 16 | manage_paper_keys: 17 | delete: "Изтрий" 18 | export: 19 | copy_to_clipboard: "Копиране в клипборда" 20 | copied_to_clipboard: "Копирано в клипборда" 21 | rotate: 22 | loading_states: 23 | finished: "Готово!" 24 | time_bomb: 25 | never: "Никога" 26 | 3_minutes: "3 минути" 27 | 1_hour: "1 час" 28 | 3_hours: "3 часа" 29 | 12_hours: "12 часа" 30 | 24_hours: "24 часа" 31 | -------------------------------------------------------------------------------- /config/locales/client.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Refresh" 12 | decrypt_all: 13 | done: "Urađeno" 14 | preferences: 15 | save: "Spremiti promjene" 16 | manage_paper_keys: 17 | delete: "Izbriši" 18 | export: 19 | copy_to_clipboard: "Kopirati u clipboard" 20 | copied_to_clipboard: "Kopirano u međuspremnik" 21 | rotate: 22 | loading_states: 23 | finished: "Urađeno!" 24 | time_bomb: 25 | never: "Nikad" 26 | -------------------------------------------------------------------------------- /config/locales/client.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Actualitza" 12 | decrypt_all: 13 | done: "Fet" 14 | preferences: 15 | save: "Desa els canvis" 16 | manage_paper_keys: 17 | delete: "Suprimeix" 18 | export: 19 | copy_to_clipboard: "Copia al porta-retalls" 20 | copied_to_clipboard: "Copiat al porta-retalls." 21 | rotate: 22 | loading_states: 23 | finished: "Fet!" 24 | time_bomb: 25 | never: "Mai" 26 | -------------------------------------------------------------------------------- /config/locales/client.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Aktualizovat" 12 | decrypt_all: 13 | done: "Hotovo" 14 | post: 15 | delete: 16 | title: "Jste si jisti?" 17 | preferences: 18 | save: "Uložit změny" 19 | manage_paper_keys: 20 | delete: "Smazat" 21 | export: 22 | copy_to_clipboard: "Kopírovat do schránky" 23 | copied_to_clipboard: "Zkopírováno do schránky" 24 | rotate: 25 | loading_states: 26 | finished: "Hotovo!" 27 | time_bomb: 28 | never: "Nikdy" 29 | 3_minutes: "3 minut" 30 | 1_hour: "1 hodina" 31 | 3_hours: "3 hodin" 32 | 12_hours: "12 hodin" 33 | 24_hours: "24 hodin" 34 | 3_days: "3 dní" 35 | 7_days: "7 dní" 36 | -------------------------------------------------------------------------------- /config/locales/client.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Genindlæs" 12 | decrypt_all: 13 | done: "Færdig" 14 | preferences: 15 | save: "Gem ændringer" 16 | manage_paper_keys: 17 | delete: "Slet" 18 | export: 19 | copy_to_clipboard: "Kopiér til udklipsholder" 20 | copied_to_clipboard: "Kopieret til udklipsholder" 21 | rotate: 22 | loading_states: 23 | finished: "Færdig!" 24 | time_bomb: 25 | never: "Aldrig" 26 | 3_minutes: "3 minutter" 27 | 1_hour: "1 time" 28 | 3_hours: "3 timer" 29 | 12_hours: "12 timer" 30 | 24_hours: "24 timer" 31 | -------------------------------------------------------------------------------- /config/locales/client.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Ανανέωση" 12 | decrypt_all: 13 | done: "Ολοκληρώθηκε" 14 | preferences: 15 | save: "Αποθήκευση αλλαγών" 16 | manage_paper_keys: 17 | delete: "Σβήσιμο" 18 | export: 19 | copy_to_clipboard: "Αντιγραφή στο Clipboard" 20 | copied_to_clipboard: "Αντιγράφτηκε στο Clipboard" 21 | rotate: 22 | loading_states: 23 | finished: "Ολοκληρώθηκε!" 24 | time_bomb: 25 | never: "Ποτέ" 26 | 3_minutes: "3 λεπτά" 27 | 1_hour: "1 ώρα" 28 | 3_hours: "3 ώρες" 29 | 12_hours: "12 ώρες" 30 | 24_hours: "24 ώρες" 31 | -------------------------------------------------------------------------------- /config/locales/client.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/client.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Värskenda" 12 | decrypt_all: 13 | done: "Tehtud" 14 | preferences: 15 | save: "Salvesta muudatused" 16 | manage_paper_keys: 17 | delete: "Kustuta" 18 | export: 19 | copy_to_clipboard: "Kopeeri lõikelauale" 20 | copied_to_clipboard: "Kopeeritud lõikelauale" 21 | rotate: 22 | loading_states: 23 | finished: "Tehtud!" 24 | time_bomb: 25 | never: "Mitte kunagi" 26 | 3_minutes: "3 minutit" 27 | 1_hour: "1 tund" 28 | 3_hours: "3 tundi" 29 | 12_hours: "12 tundi" 30 | 24_hours: "24 tundi" 31 | 3_days: "3 päeva" 32 | 7_days: "7 päeva" 33 | -------------------------------------------------------------------------------- /config/locales/client.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | js: 9 | encrypt: 10 | title: "پیام‌های رمزگذاری شده" 11 | decrypting: "در حال رمزگشایی..." 12 | missing_topic_key: "رمزگشایی این پیام امکان پذیر نیست، زیرا شما کلید موضوعی این پیام را ندارید. آیا شما به این موضوع دعوت شده‌اید؟" 13 | invalid_ciphertext: "پیام قابل رمزگشایی نیست، زیرا متن رمز این پیام معتبر نیست." 14 | invalid_topic_key: "پیام قابل رمزگشایی نیست، زیرا کلید موضوع شما معتبر نیست." 15 | invalid_identity: "پیام قابل رمزگشایی نیست، زیرا هویت کاربر معتبر نیست." 16 | decrypt_permanently: 17 | refresh_page: "تازه‌سازی" 18 | decrypt_all: 19 | done: "انجام شد" 20 | post: 21 | delete: 22 | title: "مطمئنی؟" 23 | confirm: "محتوا به طور کامل حذف خواهد شد، هیچ راهی برای بازیابی آن وجود نخواهد داشت." 24 | preferences: 25 | paper_key_label: "کلید مقاله:" 26 | paper_key_placeholder: "دوازده کلمه..." 27 | paper_key_invalid: "کلید مقاله‌ای که وارد کردید درست نیست." 28 | encrypt_pms_default: "تمام پیا‌م‌های شخصی جدید را به طور پیش‌فرض رمزگذاری کنید" 29 | save: "ذخیره تغییرات" 30 | manage_paper_keys: 31 | delete: "حذف" 32 | export: 33 | copy_to_clipboard: "کپی در clipboard" 34 | copied_to_clipboard: "کپی شد" 35 | rotate: 36 | loading_states: 37 | finished: "انجام شد!" 38 | time_bomb: 39 | never: "هرگز" 40 | 3_minutes: "۳ دقیقه" 41 | 1_hour: "۱ ساعت" 42 | 12_hours: "۱۲ ساعت" 43 | 24_hours: "۲۴ ساعت" 44 | 3_days: "۳ روز" 45 | 7_days: "۷ روز" 46 | -------------------------------------------------------------------------------- /config/locales/client.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Actualizar" 12 | decrypt_all: 13 | done: "Feito" 14 | preferences: 15 | save: "Gardar os cambios" 16 | manage_paper_keys: 17 | delete: "Eliminar" 18 | export: 19 | copy_to_clipboard: "Copiar ao portapapeis" 20 | copied_to_clipboard: "Copiado ao portapapeis" 21 | rotate: 22 | loading_states: 23 | finished: "Feito!" 24 | time_bomb: 25 | never: "Nunca" 26 | 3_minutes: "3 minutos" 27 | 1_hour: "1 hora" 28 | 3_hours: "3 horas" 29 | 12_hours: "12 horas" 30 | 24_hours: "24 horas" 31 | -------------------------------------------------------------------------------- /config/locales/client.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Osvježi" 12 | decrypt_all: 13 | done: "Gotovo" 14 | preferences: 15 | save: "Zabilježi promjene" 16 | manage_paper_keys: 17 | delete: "Pobriši" 18 | export: 19 | copy_to_clipboard: "Kopirati u međuspremnik" 20 | copied_to_clipboard: "Kopirano u međuspremnik" 21 | rotate: 22 | loading_states: 23 | finished: "Gotovo!" 24 | time_bomb: 25 | never: "Nikad" 26 | 3_minutes: "3 minuta" 27 | 1_hour: "1 sat" 28 | 3_hours: "3 sata" 29 | 12_hours: "12 sata" 30 | 24_hours: "24 sata" 31 | 3_days: "3 dana" 32 | 7_days: "7 dana" 33 | -------------------------------------------------------------------------------- /config/locales/client.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Újratöltés" 12 | decrypt_all: 13 | done: "Kész" 14 | post: 15 | delete: 16 | title: "Biztos vagy benne?" 17 | preferences: 18 | save: "Módosítások mentése" 19 | manage_paper_keys: 20 | delete: "Törlés" 21 | export: 22 | copy_to_clipboard: "Vágólapra másolás" 23 | copied_to_clipboard: "Vágólapra másolva" 24 | rotate: 25 | loading_states: 26 | finished: "Kész!" 27 | time_bomb: 28 | never: "Soha" 29 | 3_minutes: "3 percig" 30 | 1_hour: "1 óráig" 31 | 3_hours: "3 óráig" 32 | 12_hours: "12 óráig" 33 | 24_hours: "24 óráig" 34 | 3_days: "3 nap" 35 | 7_days: "7 nap" 36 | -------------------------------------------------------------------------------- /config/locales/client.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Թարմացնել" 12 | decrypt_all: 13 | done: "Կատարված է" 14 | preferences: 15 | save: "Պահպանել Փոփոխությունները" 16 | manage_paper_keys: 17 | delete: "Ջնջել" 18 | export: 19 | copy_to_clipboard: "Կրկնօրինակել Փոխանակման Հարթակում" 20 | copied_to_clipboard: "Կրկնօրինակված է Փոխանակման Հարթակում" 21 | rotate: 22 | loading_states: 23 | finished: "Կատարված է!" 24 | time_bomb: 25 | never: "Երբեք" 26 | -------------------------------------------------------------------------------- /config/locales/client.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Segarkan" 12 | preferences: 13 | save: "Simpan Perubahan" 14 | manage_paper_keys: 15 | delete: "Hapus" 16 | export: 17 | copy_to_clipboard: "Menyalin ke Clipboard" 18 | time_bomb: 19 | never: "Tidak pernah" 20 | 3_hours: "3 jam" 21 | 12_hours: "12 jam" 22 | 24_hours: "24 jam" 23 | 3_days: "3 hari" 24 | 7_days: "7 hari" 25 | -------------------------------------------------------------------------------- /config/locales/client.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "새로 고침" 12 | decrypt_all: 13 | done: "완료" 14 | preferences: 15 | save: "변경사항 저장" 16 | manage_paper_keys: 17 | delete: "삭제" 18 | export: 19 | copy_to_clipboard: "클립 보드에 복사" 20 | copied_to_clipboard: "클립보드로 복사됨" 21 | rotate: 22 | loading_states: 23 | finished: "완료!" 24 | time_bomb: 25 | never: "알림 받지 않기" 26 | 3_minutes: "3분" 27 | 1_hour: "1시간" 28 | 3_hours: "3시간" 29 | 12_hours: "12시간" 30 | 24_hours: "24시간" 31 | -------------------------------------------------------------------------------- /config/locales/client.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Atnaujinti" 12 | decrypt_all: 13 | done: "Baigta" 14 | preferences: 15 | save: "Išsaugoti pakeitimus" 16 | manage_paper_keys: 17 | delete: "Pašalinti" 18 | export: 19 | copy_to_clipboard: "Nukopijuoti į iškarpinę" 20 | copied_to_clipboard: "Nukopijuota į iškirptę" 21 | rotate: 22 | loading_states: 23 | finished: "Baigta!" 24 | time_bomb: 25 | never: "Niekada" 26 | 3_minutes: "3 minučių" 27 | 1_hour: "1 valanda" 28 | 3_hours: "3 valandos" 29 | 12_hours: "12 valandos" 30 | 24_hours: "24 valandos" 31 | -------------------------------------------------------------------------------- /config/locales/client.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Pārlādēt" 12 | decrypt_all: 13 | done: "Pabeigt" 14 | preferences: 15 | save: "Saglabāt izmaiņas" 16 | manage_paper_keys: 17 | delete: "Dzēst" 18 | export: 19 | copy_to_clipboard: "Kopēt uz starpliktuvi (clipboard)" 20 | copied_to_clipboard: "Nokopēts uz starpliktuvi (clipboard)" 21 | rotate: 22 | loading_states: 23 | finished: "Pabeigt!" 24 | time_bomb: 25 | never: "Nekad" 26 | -------------------------------------------------------------------------------- /config/locales/client.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Last inn siden på nytt" 12 | decrypt_all: 13 | done: "Ferdig" 14 | preferences: 15 | save: "Lagre endringer" 16 | manage_paper_keys: 17 | delete: "Slett" 18 | export: 19 | copy_to_clipboard: "Kopier til utklippstavle" 20 | copied_to_clipboard: "Kopiert til utklippstavle" 21 | rotate: 22 | loading_states: 23 | finished: "Ferdig!" 24 | time_bomb: 25 | never: "Aldri" 26 | 3_minutes: "3 minutter" 27 | 1_hour: "1 time" 28 | 3_hours: "3 timer" 29 | 12_hours: "12 timer" 30 | 24_hours: "24 timer" 31 | -------------------------------------------------------------------------------- /config/locales/client.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | js: 9 | encrypt: 10 | title: "Mensagens Encriptadas" 11 | decrypting: "A desencriptar..." 12 | integrity_check_pass: "A verificação de integridade para esta publicação expirou." 13 | integrity_check_fail: "A verificação de integridade para esta publicação falhou (disparidade %{fields})." 14 | decrypt_permanently: 15 | refresh_page: "Atualizar" 16 | decrypt_all: 17 | done: "Concluído" 18 | preferences: 19 | save: "Guardar alterações" 20 | manage_paper_keys: 21 | delete: "Eliminar" 22 | export: 23 | copy_to_clipboard: "Copiar para a Área de Transferência" 24 | copied_to_clipboard: "Copiado para a Área de Transferência" 25 | rotate: 26 | loading_states: 27 | finished: "Concluído!" 28 | time_bomb: 29 | never: "Nunca" 30 | 3_minutes: "3 minutos" 31 | 1_hour: "1 hora" 32 | 3_hours: "3 horas" 33 | 12_hours: "12 horas" 34 | 24_hours: "24 horas" 35 | 3_days: "3 dias" 36 | 7_days: "7 dias" 37 | -------------------------------------------------------------------------------- /config/locales/client.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Reîmprospătează" 12 | decrypt_all: 13 | done: "Terminat" 14 | preferences: 15 | save: "Salvează schimbările" 16 | manage_paper_keys: 17 | delete: "Șterge" 18 | export: 19 | copy_to_clipboard: "Copiază în clipboard" 20 | copied_to_clipboard: "Copiat în clipboard" 21 | rotate: 22 | loading_states: 23 | finished: "Terminat!" 24 | time_bomb: 25 | never: "Niciodată" 26 | 3_minutes: "3 de minute" 27 | 1_hour: "1 oră" 28 | 3_hours: "3 ore" 29 | 12_hours: "12 ore" 30 | 24_hours: "24 ore" 31 | -------------------------------------------------------------------------------- /config/locales/client.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | js: 9 | encrypt: 10 | decrypting: "Dešifrujem..." 11 | encrypted_title: "Tajná správa" 12 | decrypt_permanently: 13 | refresh_page: "Obnoviť" 14 | decrypt_all: 15 | done: "Hotovo" 16 | post: 17 | delete: 18 | title: "Ste si istý?" 19 | preferences: 20 | save: "Uložiť zmeny" 21 | manage_paper_keys: 22 | delete: "Odstrániť" 23 | export: 24 | copy_to_clipboard: "Skopírovať do schránky" 25 | copied_to_clipboard: "Skopírované do schránky" 26 | rotate: 27 | loading_states: 28 | finished: "Hotovo!" 29 | time_bomb: 30 | never: "Nikdy" 31 | 3_minutes: "3 minút" 32 | 1_hour: "1 hodina" 33 | 3_hours: "3 hodiny" 34 | 12_hours: "12 hodiny" 35 | 24_hours: "24 hodín" 36 | 3_days: "3 dní" 37 | 7_days: "7 dní" 38 | -------------------------------------------------------------------------------- /config/locales/client.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Osveži" 12 | decrypt_all: 13 | done: "Končano" 14 | preferences: 15 | save: "Shrani spremembe" 16 | manage_paper_keys: 17 | delete: "Izbriši" 18 | export: 19 | copy_to_clipboard: "Kopiraj v odložišče" 20 | copied_to_clipboard: "Kopirano v odložišče" 21 | rotate: 22 | loading_states: 23 | finished: "Končano!" 24 | time_bomb: 25 | never: "Nikoli" 26 | 3_minutes: "3 minut" 27 | 1_hour: "1 ura" 28 | 3_hours: "3 uri" 29 | 12_hours: "12 uri" 30 | 24_hours: "24 uri" 31 | 3_days: "3 dni" 32 | 7_days: "7 dni" 33 | -------------------------------------------------------------------------------- /config/locales/client.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Rifresko" 12 | decrypt_all: 13 | done: "Përfundo" 14 | preferences: 15 | save: "Ruaj ndryshimet" 16 | manage_paper_keys: 17 | delete: "Fshij" 18 | rotate: 19 | loading_states: 20 | finished: "Përfundo!" 21 | time_bomb: 22 | never: "Asnjëherë" 23 | -------------------------------------------------------------------------------- /config/locales/client.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Osveži" 12 | decrypt_all: 13 | done: "Gotovo" 14 | preferences: 15 | save: "Sačuvaj izmene" 16 | manage_paper_keys: 17 | delete: "Obriši" 18 | rotate: 19 | loading_states: 20 | finished: "Gotovo!" 21 | time_bomb: 22 | never: "Nikad" 23 | -------------------------------------------------------------------------------- /config/locales/client.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Rudisha Tena" 12 | decrypt_all: 13 | done: "Imekwisha" 14 | preferences: 15 | save: "Hifadhi Mabadiliko" 16 | manage_paper_keys: 17 | delete: "Futa" 18 | export: 19 | copy_to_clipboard: "Umenakili kwenye Ubao Nakili" 20 | copied_to_clipboard: "Umenakili kwenye Ubao Nakili" 21 | rotate: 22 | loading_states: 23 | finished: "Imekwisha!" 24 | time_bomb: 25 | never: "Kamwe" 26 | -------------------------------------------------------------------------------- /config/locales/client.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "రిఫ్రెష్ చేయండి" 12 | decrypt_all: 13 | done: "పూర్తయింది" 14 | post: 15 | delete: 16 | title: "ఖచితమేనా?" 17 | preferences: 18 | save: "మార్పులను భద్రపరచు" 19 | manage_paper_keys: 20 | delete: "తొలగించు" 21 | export: 22 | copy_to_clipboard: "క్లిప్‌బోర్డ్‌కు కాపీ చేయండి" 23 | copied_to_clipboard: "క్లిప్‌బోర్డ్‌కి కాపీ చేయబడింది" 24 | time_bomb: 25 | never: "ఎప్పటికీ వద్దు" 26 | 3_minutes: "3 నిముషాలు" 27 | 1_hour: "1 గంట" 28 | 3_hours: "3 గంటలు" 29 | 12_hours: "12 గంటలు" 30 | 24_hours: "24 గంటలు" 31 | 3_days: "3 రోజులు" 32 | 7_days: "7 రోజులు" 33 | -------------------------------------------------------------------------------- /config/locales/client.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "รีเฟรช" 12 | decrypt_all: 13 | done: "เสร็จ" 14 | preferences: 15 | save: "บันทึกการเปลี่ยนแปลง" 16 | manage_paper_keys: 17 | delete: "ลบ" 18 | rotate: 19 | loading_states: 20 | finished: "เสร็จ!" 21 | time_bomb: 22 | never: "ไม่เคย" 23 | -------------------------------------------------------------------------------- /config/locales/client.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "يېڭىلا" 12 | decrypt_all: 13 | done: "تامام" 14 | post: 15 | delete: 16 | title: "راستىنلا شۇنداق قىلامسىز؟" 17 | preferences: 18 | save: "ئۆزگەرتىشنى ساقلا" 19 | manage_paper_keys: 20 | delete: "ئۆچۈر" 21 | export: 22 | copy_to_clipboard: "چاپلاش تاختىسىغا كۆچۈر" 23 | copied_to_clipboard: "چاپلاش تاختىسىغا كۆچۈرۈلدى" 24 | time_bomb: 25 | never: "ھەرگىز" 26 | 3_minutes: "3 مىنۇت" 27 | 1_hour: "1 سائەت" 28 | 3_hours: "3 سائەت" 29 | 12_hours: "12 سائەت" 30 | 24_hours: "24 سائەت" 31 | 3_days: "3 كۈن" 32 | 7_days: "7 كۈن" 33 | -------------------------------------------------------------------------------- /config/locales/client.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | js: 9 | encrypt: 10 | title: "Зашифровані повідомлення" 11 | decrypting: "Дешифрування ..." 12 | integrity_check_pass: "Перевірка цілісності для цієї публікації пройшла." 13 | integrity_check_fail: "Не вдалося перевірити цілісність цієї публікації (невідповідність %{fields})." 14 | integrity_check_warn_updated_at: "Метадані публікації оновлено, без підписування знову." 15 | encrypted_title: "Таємне повідомлення" 16 | encrypted_post: "Це таємне повідомлення з шифруванням. Щоб переглянути його, вас потрібно бути запрошено до цієї теми." 17 | encrypted_post_email: "Це таємне повідомлення з шифруванням. Ви повинні відвідати тему, щоб переглянути це." 18 | encrypted_uploads: "Наразі завантаження не можна зашифрувати." 19 | cannot_encrypt: "Ви не можете шифрувати повідомлення в цій розмові." 20 | cannot_invite: "Ви не можете запросити до цієї розмови." 21 | cannot_invite_group: "Не можна запрошувати групи до зашифрованих повідомлень." 22 | checkbox: 23 | checked: "Це повідомлення буде зашифровано." 24 | unchecked: "Клацніть символ замка, щоб зашифрувати це повідомлення." 25 | decrypt_permanently: 26 | refresh_page: "Оновити" 27 | decrypt_all: 28 | done: "Виконано" 29 | post: 30 | delete: 31 | title: "Ви впевнені?" 32 | preferences: 33 | ie11: "Необхідний набір шифрів недоступний в Internet Explorer 11." 34 | insecure_context: "Шифрування не можна використовувати в незахищеному контексті (не HTTPS)." 35 | status_enabled: "Ви ввімкнули шифрування та активували його на цьому пристрої." 36 | status_enabled_other: "Цей користувач включив шифрування і може отримувати зашифровані повідомлення." 37 | status_enabled_but_inactive: "Ви ввімкнули шифрування, але не активували його на цьому пристрої." 38 | status_disabled: "Ви не ввімкнули шифрування цього облікового запису." 39 | status_disabled_other: "Цей користувач не ввімкнув шифрування і не може отримувати зашифровані повідомлення." 40 | notice_import: | 41 |

Будь ласка, вставте ключ шифрування, який ви експортували раніше.

42 | save: "Зберегти зміни" 43 | generate_paper_key: 44 | instructions: "Ось ваш унікальний паперовий ключ, він може дозволити вам активувати майбутні зашифровані пристрої. Це єдиний раз, коли ви це побачите, тому обов'язково запишіть його та зберігайте десь у безпеці." 45 | manage_paper_keys: 46 | instructions: "Нижче ви можете видалити будь-який зі створених раніше паперових ключів. Після видалення ключ неможливо відновити, тому переконайтеся, що у вас є хоча б один паперовий ключ або інші активні пристрої." 47 | delete: "Видалити" 48 | no_key: "У вас немає паперових ключів. Переконайтеся, що у вас є принаймні один ключ на папері або інші активні пристрої." 49 | passphrase: "на основі парольної фрази" 50 | not_recommended: "(не рекомендовано)" 51 | export: 52 | instructions: "Експорт вашої пари ключів - ще один механізм резервного копіювання. Зауважте, що експортована пара ключів незахищена, і вона зможе розшифрувати всі ваші приватні розмови." 53 | copy_to_clipboard: "Скопіювати в буфер" 54 | copied_to_clipboard: "Скопійовано у буфер обміну" 55 | rotate: 56 | loading_states: 57 | finished: "Виконано!" 58 | time_bomb: 59 | never: "Ніколи" 60 | 3_minutes: "3 хвилин" 61 | 1_hour: "1 година" 62 | 3_hours: "3 годин" 63 | 12_hours: "12 годин" 64 | 24_hours: "24 години" 65 | 3_days: "3 днів" 66 | 7_days: "7 днів" 67 | topic-statuses: 68 | encrypted: 69 | help: "Це повідомлення зашифроване." 70 | -------------------------------------------------------------------------------- /config/locales/client.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "رِیفریش" 12 | decrypt_all: 13 | done: "مکمل" 14 | preferences: 15 | save: "تبدیلیاں محفوظ کریں" 16 | manage_paper_keys: 17 | delete: "حذف کریں" 18 | export: 19 | copy_to_clipboard: "کلِپ بورڈ میں کاپی کریں" 20 | copied_to_clipboard: "کلِپ بورڈ میں کاپی کر لیا گیا" 21 | rotate: 22 | loading_states: 23 | finished: "مکمل!" 24 | time_bomb: 25 | never: "کبھی نہیں" 26 | 3_minutes: "3 منٹ" 27 | 1_hour: "1 گھنٹہ" 28 | 3_hours: "3 گھنٹے" 29 | 12_hours: "12 گھنٹے" 30 | 24_hours: "24 گھنٹے" 31 | -------------------------------------------------------------------------------- /config/locales/client.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "Làm mới" 12 | decrypt_all: 13 | done: "Hoàn tất" 14 | post: 15 | delete: 16 | title: "Bạn có chắc không?" 17 | preferences: 18 | save: "Lưu thay đổi" 19 | manage_paper_keys: 20 | delete: "Xóa" 21 | export: 22 | copy_to_clipboard: "Sao chép vào clipboard" 23 | copied_to_clipboard: "Sao chép vào Clipboard" 24 | rotate: 25 | loading_states: 26 | finished: "Hoàn tất!" 27 | time_bomb: 28 | never: "Không bao giờ" 29 | 3_minutes: "3 phút" 30 | 1_hour: "1 tiếng" 31 | 3_hours: "3 tiếng" 32 | 12_hours: "12 tiếng" 33 | 24_hours: "24 tiếng" 34 | 3_days: "3 ngày" 35 | 7_days: "7 ngày" 36 | -------------------------------------------------------------------------------- /config/locales/client.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | js: 9 | encrypt: 10 | decrypt_permanently: 11 | refresh_page: "重新整理" 12 | decrypt_all: 13 | done: "完成" 14 | preferences: 15 | save: "儲存變更" 16 | manage_paper_keys: 17 | delete: "刪除" 18 | export: 19 | copy_to_clipboard: "複製至剪貼簿" 20 | copied_to_clipboard: "已複製至剪貼簿" 21 | rotate: 22 | loading_states: 23 | finished: "完成!" 24 | time_bomb: 25 | never: "永不" 26 | 3_minutes: "3 分鐘" 27 | 1_hour: "1 小時" 28 | 3_hours: "3 小時" 29 | 12_hours: "12 小時" 30 | 24_hours: "24 小時" 31 | -------------------------------------------------------------------------------- /config/locales/server.ar.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ar: 8 | encrypt: 9 | encrypted_excerpt: "هذه الرسالة مشفرة، يُرجى قراءتها في المتصفح." 10 | enabled_already: "لقد فعَّلت الرسائل المشفرة بالفعل." 11 | only_pms: "يمكن تشفير الرسائل الخاصة فقط." 12 | no_encrypt_keys: "حدث خطأ ما. لم يتم تضمين أي مفاتيح تشفير في الحمولة." 13 | site_settings: 14 | encrypt_enabled: "تفعيل الرسائل الخاصة المشفرة" 15 | auto_enable_encrypt: "تمكين التشفير تلقائيًا لجميع المستخدمين الذين سجَّلوا الدخول." 16 | encrypt_groups: "اسم المجموعات التي يمكنها استخدام التشفير (ترك الاسم فارغًا يعني أن الجميع يمكنهم استخدام التشفير)" 17 | encrypt_pms_default: "تشفير جميع الرسائل الخاصة الجديدة افتراضيًا" 18 | allow_new_encrypted_pms: "السماح للمستخدمين بإنشاء رسائل خاصة مشفرة جديدة. سيؤدي تعطيل هذا الإعداد إلى تقييد استخدام discourse-encrypt إلى الرسائل الخاصة الحالية." 19 | allow_decrypting_pms: "السماح للمستخدمين بفك تشفير الرسائل الخاصة المشفرة بشكلٍ دائم" 20 | errors: 21 | encrypt_unsafe_csp: "لا يمكن استخدام توجيهات CSP غير الآمنة مثل 'unsafe-eval' و'unsafe-inline' عند تفعيل المكوِّن الإضافي للتشفير في Discourse." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: تم إيقاف استخدام المكوِّن الإضافي discourse-encrypt، وسيتم إيقاف الدعم في الربع الأول من عام 2025. للمزيد من المعلومات، راجع Discourse Meta. قم بإلغاء تثبيت المكوِّن الإضافي لإزالة هذه الرسالة. 25 | -------------------------------------------------------------------------------- /config/locales/server.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | -------------------------------------------------------------------------------- /config/locales/server.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | -------------------------------------------------------------------------------- /config/locales/server.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | -------------------------------------------------------------------------------- /config/locales/server.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | -------------------------------------------------------------------------------- /config/locales/server.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | -------------------------------------------------------------------------------- /config/locales/server.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | -------------------------------------------------------------------------------- /config/locales/server.de.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | de: 8 | encrypt: 9 | encrypted_excerpt: "Diese Nachricht ist verschlüsselt, lies sie bitte im Browser." 10 | enabled_already: "Du hast bereits verschlüsselte Nachrichten aktiviert." 11 | only_pms: "Nur private Nachrichten können verschlüsselt werden." 12 | no_encrypt_keys: "Etwas ist schiefgelaufen. Es sind keine Verschlüsselungsschlüssel im Payload enthalten." 13 | site_settings: 14 | encrypt_enabled: "Aktiviere verschlüsselte private Nachrichten." 15 | auto_enable_encrypt: "Verschlüsselung für alle angemeldeten Benutzer automatisch aktivieren." 16 | encrypt_groups: "Der Name der Gruppen, die die Verschlüsselung nutzen können (leer bedeutet alle)." 17 | encrypt_pms_default: "Neue private Nachrichten standardmäßig verschlüsseln." 18 | allow_new_encrypted_pms: "Erlaube Benutzern, neue verschlüsselte private Nachrichten zu erstellen. Wenn du diese Option deaktivierst, wird die Nutzung von discourse-encrypt auf bestehende verschlüsselte PN beschränkt." 19 | allow_decrypting_pms: "Erlaube Benutzern, verschlüsselte private Nachrichten dauerhaft zu entschlüsseln." 20 | errors: 21 | encrypt_unsafe_csp: "Unsichere CSP-Direktiven wie „unsafe-eval“ und „unsafe-inline“ können nicht verwendet werden, wenn das Discourse-Encrypt-Plug-in aktiviert ist." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: Das Plug-in „discourse-encrypt“ ist veraltet und wird ab dem 1. Quartal 2025 nicht mehr unterstützt. Weitere Informationen findest du unter Discourse Meta. Deinstalliere das Plug-in, um diese Meldung zu entfernen. 25 | -------------------------------------------------------------------------------- /config/locales/server.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | encrypt: 3 | encrypted_excerpt: "This message is encrypted, please read it in the browser." 4 | enabled_already: "You have already enabled encrypted messages." 5 | only_pms: "Only private messages can be encrypted." 6 | no_encrypt_keys: "Something went wrong. No encryption keys were included in the payload." 7 | 8 | site_settings: 9 | encrypt_enabled: "Enable encrypted private messages." 10 | auto_enable_encrypt: "Automatically enable encrypt for all logged in users." 11 | encrypt_groups: "The name of groups that are able to use encryption (empty means everyone)." 12 | encrypt_pms_default: "Encrypt all new private messages by default." 13 | allow_new_encrypted_pms: "Allow users to create new encrypted private messages. Disabling this will limit discourse-encrypt use to existing encrypted PMs." 14 | allow_decrypting_pms: "Allow users to permanently decrypt encrypted private messages." 15 | 16 | errors: 17 | encrypt_unsafe_csp: "Unsafe CSP directives like 'unsafe-eval' and 'unsafe-inline' cannot be used when the Discourse Encrypt plugin is enabled." 18 | 19 | dashboard: 20 | problem: 21 | encrypt_deprecated: The discourse-encrypt plugin is deprecated, and support will be dropped in Q1 2025. For more information, see Discourse Meta. Uninstall the plugin to remove this message. -------------------------------------------------------------------------------- /config/locales/server.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | es: 8 | encrypt: 9 | encrypted_excerpt: "Este mensaje está cifrado, léelo en el navegador." 10 | enabled_already: "Ya has habilitado los mensajes cifrados." 11 | only_pms: "Solo se pueden cifrar los mensajes privados." 12 | no_encrypt_keys: "Algo salió mal. No se incluyeron claves de cifrado en la carga útil." 13 | site_settings: 14 | encrypt_enabled: "Habilitar los mensajes privados cifrados." 15 | auto_enable_encrypt: "Habilitar automáticamente el cifrado para todos los usuarios que hayan iniciado sesión." 16 | encrypt_groups: "El nombre de los grupos que pueden usar cifrado (vacío significa todos)." 17 | encrypt_pms_default: "Cifrar todos los mensajes privados nuevos por defecto." 18 | allow_new_encrypted_pms: "Permitir a los usuarios crear nuevos mensajes privados cifrados. Desactivar esta opción limitará el uso del cifrado de Discourse a los MP cifrados existentes." 19 | allow_decrypting_pms: "Permitir a los usuarios descifrar permanentemente los mensajes privados cifrados." 20 | errors: 21 | encrypt_unsafe_csp: "Las directivas CSP inseguras, como «unsafe-eval» y «unsafe-inline», no pueden utilizarse cuando el complemento Discourse Encrypt está activado." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: El complemento discourse-encrypt está obsoleto y dejará de ser compatible en el primer trimestre de 2025. Para más información, consulta Discourse Meta. Desinstala el plugin para eliminar este mensaje. 25 | -------------------------------------------------------------------------------- /config/locales/server.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | -------------------------------------------------------------------------------- /config/locales/server.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | site_settings: 9 | auto_enable_encrypt: "به طور خودکار رمزگذاری را برای همه کاربران وارد شده در سیستم فعال کنید." 10 | encrypt_pms_default: "تمام پیام‌های خصوصی جدید را به طور پیش‌فرض رمزگذاری کنید." 11 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fi: 8 | encrypt: 9 | encrypted_excerpt: "Tämä viesti on salattu, lue se selaimessa." 10 | enabled_already: "Olet jo ottanut salatut viestit käyttöön." 11 | only_pms: "Vain yksityisiä viestejä voi salata." 12 | no_encrypt_keys: "Jokin meni vikaan. Salausavaimia ei sisällytetty tietoihin." 13 | site_settings: 14 | encrypt_enabled: "Ota salatut yksityisviestit käyttöön." 15 | auto_enable_encrypt: "Ota salaus käyttöön automaattisesti kaikille kirjautuneille käyttäjille." 16 | encrypt_groups: "Niiden ryhmien nimet, jotka voivat käyttää salausta (tyhjä tarkoittaa kaikkia)." 17 | encrypt_pms_default: "Salaa kaikki uudet yksityisviestit oletuksena." 18 | allow_new_encrypted_pms: "Salli käyttäjien luoda uusia salattuja yksityisviestejä. Tämän poistaminen käytöstä rajoittaa discourse-encryptin käytön olemassa oleviin salattuihin yksityisviesteihin." 19 | allow_decrypting_pms: "Salli käyttäjien purkaa salattujen yksityisviestien salaus pysyvästi." 20 | errors: 21 | encrypt_unsafe_csp: "Turvattomia CSP-komentoja, kuten \"unsafe-eval\" ja \"unsafe-inline\", ei voida käyttää, kun Discoursen salauslisäosa on käytössä." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: Discourse-encrypt-lisäosa on vanhentunut, ja sen tuki lopetetaan vuoden 2025 ensimmäisellä neljänneksellä. Lisätietoja on Discourse Metassa. Poista lisäosa poistaaksesi tämän viestin. 25 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fr: 8 | encrypt: 9 | encrypted_excerpt: "Ce message est chiffré, veuillez le lire dans le navigateur." 10 | enabled_already: "Vous avez déjà activé les messages chiffrés." 11 | only_pms: "Seuls les messages privés peuvent être chiffrés." 12 | no_encrypt_keys: "Une erreur s'est produite. Aucune clé de chiffrement n'a été incluse dans les données reçues." 13 | site_settings: 14 | encrypt_enabled: "Activer les messages privés chiffrés." 15 | auto_enable_encrypt: "Activez automatiquement le chiffrement pour tous les utilisateurs connectés." 16 | encrypt_groups: "Le nom des groupes qui peuvent utiliser le chiffrement (laissez l'espace vide pour accorder cette autorisation à tout le monde)." 17 | encrypt_pms_default: "Chiffrez par défaut tous les nouveaux messages privés." 18 | allow_new_encrypted_pms: "Autoriser les utilisateurs à créer de nouveaux messages privés chiffrés. La désactivation de cette option limitera l'utilisation de discourse-encrypt aux MD chiffrés existants." 19 | allow_decrypting_pms: "Permettre aux utilisateurs de déchiffrer de façon permanente les messages privés chiffrés." 20 | errors: 21 | encrypt_unsafe_csp: "Les directives CSP non sécurisées telles que « unsafe-eval » et « unsafe-inline » ne peuvent pas être utilisées lorsque l'extension Discourse Encrypt est activée." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: Le plugin discourse-encrypt est obsolète et sa prise en charge sera abandonnée au premier trimestre 2025. Pour en savoir plus, consultez Discourse Meta. Désinstallez l'extension pour supprimer ce message. 25 | -------------------------------------------------------------------------------- /config/locales/server.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | -------------------------------------------------------------------------------- /config/locales/server.he.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | he: 8 | encrypt: 9 | encrypted_excerpt: "הודעה זו מוצפנת. נא לקרוא אותה בדפדפן." 10 | enabled_already: "כבר הפעלת הודעות מוצפנות." 11 | only_pms: "ניתן להצפין הודעות פרטיות בלבד." 12 | no_encrypt_keys: "משהו השתבש. לא נמצאו מפתחות הצפנה במטען." 13 | site_settings: 14 | encrypt_enabled: "הפעלת הודעות פרטיות מוצפנות." 15 | auto_enable_encrypt: "להפעיל הצפנה אוטומטית לכל המשתמשים שנכנסו למערכת." 16 | encrypt_groups: "שמות הקבוצות שיכולים להשתמש בהצפנה (ריק כלומר כולן יכולות)." 17 | encrypt_pms_default: "להצפין את כל ההודעות הפרטיות החדשות כבררת מחדל." 18 | allow_new_encrypted_pms: "לאפשר למשתמש ליצור הודעות מוצפנות חדשות. השבתת האפשרות הזאת תגביל את discourse-encrypt להשתמש בהודעות מוצפנות קיימות." 19 | allow_decrypting_pms: "לאפשר למשתמשים לפענח הודעות פרטיות מוצפנות לצמיתות." 20 | errors: 21 | encrypt_unsafe_csp: "אי אפשר להשתמש בהנחיות CSP מפוקפקות כגון ‚unsafe-eval’ ו־‚unsafe-inline’ כאשר התוסף Encrypt של Discourse פעיל." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: תוסף discourse-encrypt לא בפיתוח עוד והתמיכה תוסר ברבעון הראשון של 2025. למידע נוסף, נא לגשת אל Discourse Meta. הסרת התקנת התוסף תסיר את ההודעה הזאת. 25 | -------------------------------------------------------------------------------- /config/locales/server.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | -------------------------------------------------------------------------------- /config/locales/server.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | -------------------------------------------------------------------------------- /config/locales/server.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | -------------------------------------------------------------------------------- /config/locales/server.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | -------------------------------------------------------------------------------- /config/locales/server.it.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | it: 8 | encrypt: 9 | encrypted_excerpt: "Questo messaggio è crittografato, per favore leggilo nel browser." 10 | enabled_already: "Hai già abilitato i messaggi crittografati." 11 | only_pms: "Solo i messaggi privati possono essere crittografati." 12 | no_encrypt_keys: "Qualcosa è andato storto. Nessuna chiave di crittografia è stata inclusa nel payload." 13 | site_settings: 14 | encrypt_enabled: "Abilita messaggi privati crittografati." 15 | auto_enable_encrypt: "Abilita automaticamente la crittografia per tutti gli utenti che hanno effettuato l'accesso." 16 | encrypt_groups: "Nome dei gruppi che possono utilizzare la crittografia (vuoto significa tutti)." 17 | encrypt_pms_default: "Applica crittografia a tutti i nuovi messaggi privati per impostazione predefinita." 18 | allow_new_encrypted_pms: "Consenti agli utenti di creare nuovi messaggi privati crittografati. Disattivando questa opzione, si limiterà l'uso di discourse-encrypt ai MP crittografati esistenti." 19 | allow_decrypting_pms: "Consenti agli utenti di decifrare in modo permanente i messaggi privati crittografati." 20 | errors: 21 | encrypt_unsafe_csp: "Le direttive CSP non sicure come \"unsafe-eval\" e \"unsafe-inline\" non possono essere utilizzate quando il plug-in Discourse Encrypt è abilitato." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: Il plugin discourse-encrypt è deprecato e il supporto verrà interrotto nel primo trimestre del 2025. Per maggiori informazioni, consulta il Meta di Discourse. Disinstalla il plugin per far sparire questo messaggio. 25 | -------------------------------------------------------------------------------- /config/locales/server.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | encrypt: 9 | encrypted_excerpt: "このメッセージは暗号化されています。ブラウザでお読みください。" 10 | enabled_already: "暗号化メッセージはすでに有効になっています。" 11 | only_pms: "プライベートメッセージのみを暗号化できます。" 12 | no_encrypt_keys: "問題が発生しました。ペイロードに暗号化キーが含まれていません。" 13 | site_settings: 14 | encrypt_enabled: "暗号化プライベートメッセージを有効にします。" 15 | auto_enable_encrypt: "ログインしているすべてのユーザーに暗号化を自動的に有効にします。" 16 | encrypt_groups: "暗号化を使用できるグループの名前(全員の場合は空白にします)。" 17 | encrypt_pms_default: "デフォルトですべての新しいプライベートメッセージを暗号化します。" 18 | allow_new_encrypted_pms: "ユーザーが暗号化されたプライベートメッセージを新規に作成することを許可する。これを無効にすると、discourse-encrypt の使用が既存の暗号化された PM に制限されます。" 19 | allow_decrypting_pms: "ユーザーが暗号化されたプライベートメッセージを永久的に復号化することを許可する。" 20 | errors: 21 | encrypt_unsafe_csp: "Discourse Encrypt プラグインが有効である場合、「unsafe-eval」や「unsafe-inline」などの安全でない CSP ディレクティブを使用できません。" 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: discourse-encrypt プラグインは使用廃止であり、2025 年第 1 四半期にサポート終了となる予定です。詳細については、Discourse Meta をご覧ください。このメッセージを削除するには、プラグインをアンインストールしてください。 25 | -------------------------------------------------------------------------------- /config/locales/server.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | -------------------------------------------------------------------------------- /config/locales/server.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | -------------------------------------------------------------------------------- /config/locales/server.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | -------------------------------------------------------------------------------- /config/locales/server.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | -------------------------------------------------------------------------------- /config/locales/server.nl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nl: 8 | encrypt: 9 | encrypted_excerpt: "Dit bericht is versleuteld; lees het in de browser." 10 | enabled_already: "Je hebt versleutelde berichten al ingeschakeld." 11 | only_pms: "Alleen privéberichten kunnen worden versleuteld." 12 | no_encrypt_keys: "Er is iets misgegaan. Er waren geen sleutels opgenomen in de payload." 13 | site_settings: 14 | encrypt_enabled: "Versleutelde privéberichten inschakelen." 15 | auto_enable_encrypt: "Automatisch versleuteling inschakelen voor alle aangemelde gebruikers." 16 | encrypt_groups: "De naam van groepen die versleuteling kunnen gebruiken (leeg betekent iedereen)." 17 | encrypt_pms_default: "Alle nieuwe privéberichten standaard versleutelen." 18 | allow_new_encrypted_pms: "Sta gebruikers toe om nieuwe versleutelde privéberichten te maken. Door dit uit te schakelen, wordt het gebruik van discourse-encrypt beperkt tot bestaande versleutelde PB's." 19 | allow_decrypting_pms: "Sta gebruikers toestaan om versleutelde privéberichten permanent te ontsleutelen." 20 | errors: 21 | encrypt_unsafe_csp: "Onveilige CSP-instructies zoals 'unsafe-eval' en 'unsafe-inline' kunnen niet worden gebruikt als de Discourse Encrypt-plug-in is ingeschakeld." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: De plug-in discourse-encrypt is verouderd en de ondersteuning wordt gestopt in het eerste kwartaal van 2025. Zie Discourse Meta voor meer informatie. Verwijder de plug-in om deze melding niet meer te zien. 25 | -------------------------------------------------------------------------------- /config/locales/server.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | encrypt: 9 | encrypted_excerpt: "Ta wiadomość jest zaszyfrowana, przeczytaj ją w przeglądarce." 10 | enabled_already: "Masz już włączone szyfrowane wiadomości." 11 | only_pms: "Można szyfrować tylko wiadomości prywatne." 12 | no_encrypt_keys: "Coś poszło nie tak. W zapytaniu nie uwzględniono kluczy szyfrowania." 13 | site_settings: 14 | encrypt_enabled: "Włącz zaszyfrowane wiadomości prywatne." 15 | auto_enable_encrypt: "Automatycznie włącz szyfrowanie dla wszystkich zalogowanych użytkowników." 16 | encrypt_groups: "Nazwy grup, które mogą używać szyfrowania (puste oznacza wszystkich)." 17 | encrypt_pms_default: "Domyślnie szyfruj wszystkie nowe wiadomości prywatne." 18 | errors: 19 | encrypt_unsafe_csp: "Niebezpieczne dyrektywy CSP, takie jak 'unsafe-eval' i 'unsafe-inline', nie mogą być używane, gdy wtyczka Discourse Encrypt jest włączona." 20 | -------------------------------------------------------------------------------- /config/locales/server.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | encrypt: 9 | encrypted_excerpt: "Esta mensagem está encriptada, por favor, leia-a no navegador." 10 | enabled_already: "Já ativou as mensagens encriptadas." 11 | only_pms: "Apenas as mensagens privadas podem ser encriptadas." 12 | no_encrypt_keys: "Ocorreu algo de errado. Não foram incluídas chaves de encriptação na carga útil." 13 | site_settings: 14 | encrypt_enabled: "Ative as mensagens privadas encriptadas." 15 | encrypt_groups: "O nome dos grupos que podem utilizar a encriptação (vazio significa toda a gente)." 16 | -------------------------------------------------------------------------------- /config/locales/server.pt_BR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt_BR: 8 | encrypt: 9 | encrypted_excerpt: "Esta mensagem está criptografada, leia-a no navegador." 10 | enabled_already: "Você já ativou mensagens criptografadas." 11 | only_pms: "É possível criptografar apenas mensagens privadas" 12 | no_encrypt_keys: "Algo deu errado. Nenhuma chave de criptografia foi inclusa no conteúdo." 13 | site_settings: 14 | encrypt_enabled: "Ative mensagens privadas criptografadas." 15 | auto_enable_encrypt: "Ative criptografia automaticamente para todos(as) os(as) usuários(as) que entraram." 16 | encrypt_groups: "O nome dos grupos que podem usar criptografia (vazio quer dizer todas as pessoas)." 17 | encrypt_pms_default: "Criptografe todas as mensagens privadas por padrão." 18 | allow_new_encrypted_pms: "Permitir que usuários(as) criem novas mensagens privadas criptografadas. Desabilite essa opção para limitar o uso de criptografia do Discourse a todas as PM criptografadas." 19 | allow_decrypting_pms: "Permitir que usuários(as) descriptografem permanentemente mensagens privadas criptografadas." 20 | errors: 21 | encrypt_unsafe_csp: "Diretivas CSP não seguras, como \"unsafe-eval\" e \"unsafe-inline\", não podem ser usadas quando o plugin Discourse Encrypt estiver ativado." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: O plugin discourse-encrypt está obsoleto, e o suporte será descontinuado no primeiro trimestre de 2025. Para obter mais informações, consulte Discourse Meta. Desinstale o plugin para remover esta mensagem. 25 | -------------------------------------------------------------------------------- /config/locales/server.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | -------------------------------------------------------------------------------- /config/locales/server.ru.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ru: 8 | encrypt: 9 | encrypted_excerpt: "Сообщение зашифровано, откройте его в браузере." 10 | enabled_already: "Вы уже включили шифрование сообщений." 11 | only_pms: "Зашифрованы могут быть только личные сообщения." 12 | no_encrypt_keys: "Что-то пошло не так. Ключи шифрования не были задействованы." 13 | site_settings: 14 | encrypt_enabled: "Включить шифрование личных сообщений." 15 | auto_enable_encrypt: "Автоматически включать шифрование для всех входящих в систему пользователей." 16 | encrypt_groups: "Названия групп, которые могут использовать шифрование (оставьте поле пустым, если необходимо включить функционал для всех групп)." 17 | encrypt_pms_default: "По умолчанию шифровать все новые личные сообщения." 18 | allow_new_encrypted_pms: "Разрешает пользователям создавать новые зашифрованные личные сообщения. Отключение этого параметра ограничит использование плагина discourse-encrypt существующими зашифрованными ЛС." 19 | allow_decrypting_pms: "Разрешает пользователям навсегда расшифровать зашифрованные личные сообщения." 20 | errors: 21 | encrypt_unsafe_csp: "Небезопасные директивы CSP, такие как 'unsafe-eval' и 'unsafe-inline', нельзя использовать с включенным плагином Discourse Encrypt." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: Плагин discourse-encrypt устарел, и его поддержка будет прекращена в первом квартале 2025 года. Для получения дополнительной информации см. Discourse Meta. Удалите плагин, чтобы убрать это сообщение. 25 | -------------------------------------------------------------------------------- /config/locales/server.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | -------------------------------------------------------------------------------- /config/locales/server.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | -------------------------------------------------------------------------------- /config/locales/server.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | -------------------------------------------------------------------------------- /config/locales/server.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | -------------------------------------------------------------------------------- /config/locales/server.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | encrypt: 9 | encrypted_excerpt: "Detta meddelande är krypterat, läs det i webbläsaren." 10 | enabled_already: "Du har redan aktiverat krypterade meddelanden." 11 | only_pms: "Enbart privata meddelanden kan krypteras." 12 | no_encrypt_keys: "Något gick fel. Inga krypteringsnycklar inkluderades i innehållet." 13 | site_settings: 14 | encrypt_enabled: "Aktivera krypterade privata meddelanden." 15 | auto_enable_encrypt: "Aktivera automatiskt kryptering för alla inloggade användare." 16 | encrypt_groups: "Namnen på grupper som ska vara tillåtna att använda kryptering (tomt innebär samtliga)" 17 | encrypt_pms_default: "Kryptera alla nya privata meddelanden som standard." 18 | errors: 19 | encrypt_unsafe_csp: "Osäkra CSP-direktiv som 'unsafe-eval' och 'unsafe-inline' kan inte användas när insticksprogrammet Discourse Encrypt är aktiverad." 20 | -------------------------------------------------------------------------------- /config/locales/server.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | -------------------------------------------------------------------------------- /config/locales/server.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | -------------------------------------------------------------------------------- /config/locales/server.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | -------------------------------------------------------------------------------- /config/locales/server.tr_TR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | tr_TR: 8 | encrypt: 9 | encrypted_excerpt: "Bu mesaj şifreli, lütfen tarayıcıda okuyun." 10 | enabled_already: "Şifreli mesajları zaten etkinleştirdiniz." 11 | only_pms: "Yalnızca özel mesajlar şifrelenebilir." 12 | no_encrypt_keys: "Bir şeyler ters gitti. Yüke şifreleme anahtarı dahil edilmedi." 13 | site_settings: 14 | encrypt_enabled: "Şifrelenmiş özel mesajları etkinleştirin." 15 | auto_enable_encrypt: "Oturum açmış tüm kullanıcılar için şifrelemeyi otomatik olarak etkinleştirin." 16 | encrypt_groups: "Şifreleme kullanabilen grupların adı (boş, herkes anlamına gelir)." 17 | encrypt_pms_default: "Tüm yeni özel mesajları varsayılan olarak şifreleyin." 18 | allow_new_encrypted_pms: "Kullanıcıların yeni şifreli kişisel mesajlar oluşturmasına izin verin. Bunu devre dışı bırakmak discourse-encrypt kullanımını mevcut şifrelenmiş kişisel mesajlarla sınırlar." 19 | allow_decrypting_pms: "Kullanıcıların şifrelenmiş özel mesajların şifresini kalıcı olarak çözmesine izin verin." 20 | errors: 21 | encrypt_unsafe_csp: "Discourse Encrypt eklentisi etkinleştirildiğinde \"unsafe-eval\" ve \"unsafe-inline\" gibi güvenli olmayan CSP yönergeleri kullanılamaz." 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: discourse-encrypt eklentisi kullanımdan kaldırıldı ve destek, 2025'in 1. çeyreğinde sona erecek. Daha fazla bilgi için Discourse Meta bölümüne bakın. Bu mesajı silmek için eklentiyi kaldırın. 25 | -------------------------------------------------------------------------------- /config/locales/server.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | -------------------------------------------------------------------------------- /config/locales/server.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | -------------------------------------------------------------------------------- /config/locales/server.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | -------------------------------------------------------------------------------- /config/locales/server.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | encrypt: 9 | encrypted_excerpt: "此消息已加密,请在浏览器中阅读。" 10 | enabled_already: "您已启用加密消息。" 11 | only_pms: "只能加密私信。" 12 | no_encrypt_keys: "出了点问题。有效负载中未包含加密密钥。" 13 | site_settings: 14 | encrypt_enabled: "启用加密私信。" 15 | auto_enable_encrypt: "自动为所有登录用户启用加密。" 16 | encrypt_groups: "能够使用加密的群组的名称(留空表示所有人都可以使用)。" 17 | encrypt_pms_default: "默认情况下加密所有新的私信。" 18 | allow_new_encrypted_pms: "允许用户创建新的加密私信。禁用此功能会将 discourse-encrypt 的使用限定为现有加密私信。" 19 | allow_decrypting_pms: "允许用户永久解密加密私信。" 20 | errors: 21 | encrypt_unsafe_csp: "启用 Discourse Encrypt 插件后,将无法使用诸如 'unsafe-eval' 和 'unsafe-inline' 等不安全的 CSP 指令。" 22 | dashboard: 23 | problem: 24 | encrypt_deprecated: discourse-encrypt 插件已弃用,并将于 2025 年第一季度停止提供支持。有关详情,请参阅 Discourse Meta。请卸载该插件以移除此消息。 25 | -------------------------------------------------------------------------------- /config/locales/server.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | encrypt_enabled: 3 | default: true 4 | client: true 5 | refresh: true 6 | validator: "EncryptEnabledValidator" 7 | auto_enable_encrypt: 8 | client: true 9 | default: false 10 | refresh: true 11 | encrypt_groups: 12 | default: "" 13 | type: list 14 | list_type: simple 15 | encrypt_pms_default: 16 | client: true 17 | default: false 18 | allow_new_encrypted_pms: 19 | default: true 20 | client: true 21 | allow_decrypting_pms: 22 | default: false 23 | client: true -------------------------------------------------------------------------------- /db/migrate/20190803111542_update_protocol.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateProtocol < ActiveRecord::Migration[5.2] 4 | def up 5 | execute <<~SQL 6 | INSERT INTO user_custom_fields(user_id, name, value, created_at, updated_at) 7 | WITH public_keys AS ( SELECT user_id, value FROM user_custom_fields WHERE name = 'encrypt_public_key' ), 8 | private_keys AS ( SELECT user_id, value FROM user_custom_fields WHERE name = 'encrypt_private_key' ), 9 | salts AS ( SELECT user_id, value FROM user_custom_fields WHERE name = 'encrypt_salt' ) 10 | SELECT users.id, 11 | 'encrypt_private', 12 | '0$' || public_keys.value || '$' || private_keys.value || '$' || salts.value, 13 | NOW(), 14 | NOW() 15 | FROM users 16 | JOIN public_keys ON users.id = public_keys.user_id 17 | JOIN private_keys ON users.id = private_keys.user_id 18 | JOIN salts ON users.id = salts.user_id 19 | SQL 20 | 21 | execute <<~SQL 22 | UPDATE user_custom_fields 23 | SET name = 'encrypt_public', value = '0$' || value 24 | WHERE name = 'encrypt_public_key' 25 | SQL 26 | 27 | execute <<~SQL 28 | DELETE FROM user_custom_fields 29 | WHERE name IN ('encrypt_public_key', 'encrypt_private_key', 'encrypt_salt') 30 | SQL 31 | 32 | execute <<~SQL 33 | UPDATE topic_custom_fields 34 | SET value = '0$' || value 35 | WHERE name = 'encrypted_title' 36 | SQL 37 | 38 | execute <<~SQL 39 | UPDATE posts 40 | SET raw = '0$' || raw 41 | FROM topic_custom_fields tcf 42 | WHERE posts.topic_id = tcf.topic_id AND 43 | tcf.name = 'encrypted_title' AND 44 | posts.raw ~ '^[A-Za-z0-9+\\\/=$]+(\n.*)?$'; 45 | SQL 46 | end 47 | 48 | def down 49 | raise ActiveRecord::IrreversibleMigration 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /db/migrate/20190916115531_add_paper_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPaperKeys < ActiveRecord::Migration[5.2] 4 | def up 5 | execute <<~SQL 6 | UPDATE user_custom_fields 7 | SET value = '{"passphrase": "' || value || '"}' 8 | WHERE name = 'encrypt_private' 9 | SQL 10 | end 11 | 12 | def down 13 | raise ActiveRecord::IrreversibleMigration 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20200129230327_create_encrypted_topics_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateEncryptedTopicsUsers < ActiveRecord::Migration[6.0] 4 | def up 5 | create_table :encrypted_topics_users do |t| 6 | t.integer :user_id, index: true 7 | t.integer :topic_id, index: true 8 | t.text :key 9 | end 10 | 11 | add_index :encrypted_topics_users, %i[user_id topic_id], unique: true 12 | 13 | execute <<~SQL 14 | INSERT INTO encrypted_topics_users(user_id, topic_id, key) 15 | SELECT split_part(key, '_', 3)::INTEGER AS user_id, split_part(key, '_', 2)::INTEGER AS topic_id, value 16 | FROM plugin_store_rows 17 | WHERE plugin_name = 'discourse-encrypt' AND key LIKE 'key_%' 18 | SQL 19 | end 20 | 21 | def down 22 | execute <<~SQL 23 | INSERT INTO plugin_store_rows(plugin_name, key, type_name, value) 24 | SELECT 'discourse-encrypt' AS plugin_name, CONCAT('key_', topic_id, '_', user_id) AS key, 'string' AS type_name, key AS value 25 | FROM encrypted_topics_users 26 | SQL 27 | drop_table :encrypted_topics_users 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /db/migrate/20200130050409_create_user_encryption_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUserEncryptionKeys < ActiveRecord::Migration[6.0] 4 | def up 5 | create_table :user_encryption_keys do |t| 6 | t.integer :user_id, index: true, unique: true 7 | t.text :encrypt_public 8 | t.text :encrypt_private 9 | t.timestamps 10 | end 11 | 12 | execute <<~SQL 13 | INSERT INTO user_encryption_keys(user_id, encrypt_public, created_at, updated_at) 14 | SELECT user_id, value AS encrypt_public, created_at, updated_at 15 | FROM user_custom_fields 16 | WHERE name = 'encrypt_public' 17 | SQL 18 | 19 | execute <<~SQL 20 | UPDATE user_encryption_keys 21 | SET encrypt_private = user_custom_fields.value 22 | FROM user_custom_fields 23 | WHERE user_encryption_keys.user_id = user_custom_fields.user_id AND user_custom_fields.name = 'encrypt_private' 24 | SQL 25 | end 26 | 27 | def down 28 | execute <<~SQL 29 | INSERT INTO user_custom_fields(name, user_id, value, created_at, updated_at) 30 | SELECT 'encrypt_public' AS name, user_id, encrypt_public AS value, created_at, updated_at 31 | FROM user_encryption_keys 32 | SQL 33 | 34 | execute <<~SQL 35 | INSERT INTO user_custom_fields(name, user_id, value, created_at, updated_at) 36 | SELECT 'encrypt_private' AS name, user_id, encrypt_private AS value, created_at, updated_at 37 | FROM user_encryption_keys 38 | SQL 39 | drop_table :user_encryption_keys 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /db/migrate/20200223214818_create_encrypted_topics_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateEncryptedTopicsData < ActiveRecord::Migration[6.0] 4 | def up 5 | create_table :encrypted_topics_data do |t| 6 | t.integer :topic_id, index: true 7 | t.text :title 8 | t.timestamps 9 | end 10 | 11 | execute <<~SQL 12 | INSERT INTO encrypted_topics_data(topic_id, title, created_at, updated_at) 13 | SELECT topic_id, value AS title, created_at, updated_at 14 | FROM topic_custom_fields 15 | WHERE name = 'encrypted_title' 16 | SQL 17 | end 18 | 19 | def down 20 | execute <<~SQL 21 | INSERT INTO topic_custom_fields(topic_id, value, name, created_at, updated_at) 22 | SELECT topic_id, title AS value, 'encrypted_title' AS name, created_at, updated_at 23 | FROM encrypted_topics_data 24 | SQL 25 | drop_table :encrypted_topics_data 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /db/migrate/20201027233335_create_encrypted_post_timers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateEncryptedPostTimers < ActiveRecord::Migration[6.0] 4 | def change 5 | create_table :encrypted_post_timers do |t| 6 | t.integer :post_id, null: false 7 | t.datetime :delete_at, null: false 8 | t.datetime :destroyed_at 9 | t.timestamps 10 | end 11 | 12 | add_index :encrypted_post_timers, :post_id 13 | add_index :encrypted_post_timers, :delete_at 14 | add_index :encrypted_post_timers, :destroyed_at 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20221102045508_add_encrypt_pms_default_to_user_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddEncryptPmsDefaultToUserOptions < ActiveRecord::Migration[6.1] 4 | def up 5 | add_column :user_options, :encrypt_pms_default, :boolean, null: true 6 | 7 | execute "UPDATE user_options SET encrypt_pms_default = #{default_value}" 8 | 9 | change_column :user_options, :encrypt_pms_default, :boolean, null: false 10 | end 11 | 12 | def down 13 | remove_column :user_options, :encrypt_pms_default 14 | end 15 | 16 | def default_value 17 | setting_value = 18 | DB.query_single("SELECT value FROM site_settings WHERE name = 'encrypt_pms_default'").first 19 | setting_value == "t" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/20221123235603_allow_encrypt_pms_default_to_be_null.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AllowEncryptPmsDefaultToBeNull < ActiveRecord::Migration[7.0] 4 | def up 5 | change_column_null(:user_options, :encrypt_pms_default, true) 6 | end 7 | 8 | def down 9 | change_column_null(:user_options, :encrypt_pms_default, false) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import DiscourseRecommended from "@discourse/lint-configs/eslint"; 2 | 3 | export default [...DiscourseRecommended]; 4 | -------------------------------------------------------------------------------- /lib/encrypted_post_creator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptedPostCreator < PostCreator 4 | def create 5 | if encrypt_valid? 6 | topic_key = @opts[:topic_key] || SecureRandom.random_bytes(32) 7 | 8 | # Encrypt title and post contents 9 | @opts[:raw] = EncryptedPostCreator.encrypt(@opts[:raw], topic_key) 10 | if title = @opts[:title] 11 | @opts[:title] = I18n.with_locale(SiteSetting.default_locale) do 12 | I18n.t("js.encrypt.encrypted_title") 13 | end 14 | end 15 | 16 | ret = super 17 | end 18 | 19 | if @post && errors.blank? 20 | # Save the topic key if this is a new topic 21 | if !@opts[:topic_key] 22 | users.each do |user| 23 | key = EncryptedPostCreator.export_key(user, topic_key) 24 | EncryptedTopicsUser.create!(topic_id: @post.topic_id, user_id: user.id, key: key) 25 | end 26 | end 27 | 28 | encrypt_topic_title = EncryptedTopicsData.find_or_initialize_by(topic_id: @post.topic_id) 29 | encrypt_topic_title.update!(title: EncryptedPostCreator.encrypt(title, topic_key)) 30 | end 31 | 32 | ret 33 | end 34 | 35 | def encrypt_valid? 36 | @topic = Topic.find_by(id: @opts[:topic_id]) if @opts[:topic_id] 37 | if @opts[:archetype] != Archetype.private_message && !@topic&.is_encrypted? 38 | errors.add(:base, I18n.t("encrypt.only_pms")) 39 | return false 40 | end 41 | 42 | users.each do |user| 43 | if !user.encrypt_key 44 | errors.add(:base, I18n.t("js.encrypt.composer.user_has_no_key", username: user.username)) 45 | return false 46 | end 47 | end 48 | 49 | true 50 | end 51 | 52 | private 53 | 54 | def users 55 | @users ||= 56 | User 57 | .human_users 58 | .includes(:user_encryption_key) 59 | .where( 60 | username_lower: (@opts[:target_usernames].split(",") << @user.username).map(&:downcase), 61 | ) 62 | .to_a 63 | end 64 | 65 | def self.encrypt(raw, key) 66 | iv = SecureRandom.random_bytes(12) 67 | 68 | cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt 69 | cipher.key = key 70 | cipher.iv = iv 71 | cipher.auth_data = "" 72 | 73 | plaintext = JSON.dump(raw: raw) 74 | ciphertext = cipher.update(plaintext) 75 | ciphertext += cipher.final 76 | ciphertext += cipher.auth_tag 77 | 78 | "1$#{Base64.strict_encode64(iv)}#{Base64.strict_encode64(ciphertext)}" 79 | end 80 | 81 | def self.export_key(user, topic_key) 82 | Base64.strict_encode64(user.encrypt_key.public_encrypt_oaep256(topic_key)) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/encrypted_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is used only to preload all data needed to display encrypted 4 | # search results. The actual search is performed on the client side. 5 | class EncryptedSearch < Search 6 | # Simplified posts_query that does almost nothing, but fetch visible posts. 7 | # The term is looked up on the client side. 8 | def posts_query(limit, type_filter: nil) 9 | posts = 10 | Post 11 | .includes(topic: :encrypted_topics_data) 12 | .where.not(encrypted_topics_data: { title: nil }) 13 | .joins(topic: :encrypted_topics_users) 14 | .where(encrypted_topics_users: { user_id: @guardian.user&.id }) 15 | .where(post_number: 1) 16 | .where(post_type: Topic.visible_post_types(@guardian.user)) 17 | .where("post_search_data.private_message") 18 | 19 | if @filters 20 | @filters.each do |block, match| 21 | if block.arity == 1 22 | posts = instance_exec(posts, &block) || posts 23 | else 24 | posts = instance_exec(posts, match, &block) || posts 25 | end 26 | end 27 | end 28 | 29 | posts.order("posts.created_at DESC").limit(limit) 30 | end 31 | 32 | # Similar to posts_query does almost nothing other than to return a set of 33 | # posts that might be relevant. 34 | def private_messages_search 35 | raise Discourse::InvalidAccess.new if @guardian.anonymous? 36 | 37 | @search_pms = true # needed by posts_eager_loads 38 | posts = 39 | posts_scope(posts_eager_loads(posts_query(@opts[:limit], type_filter: @opts[:type_filter]))) 40 | posts.each { |post| @results.posts << post } 41 | end 42 | 43 | def posts_scope(default_scope = Post.all) 44 | if SiteSetting.use_pg_headlines_for_excerpt 45 | default_scope.select( 46 | "topics.fancy_title AS topic_title_headline", 47 | "posts.cooked AS headline", 48 | "LEFT(posts.cooked, 50) AS leading_raw_data", 49 | "RIGHT(posts.cooked, 50) AS trailing_raw_data", 50 | default_scope.arel.projections, 51 | ) 52 | else 53 | default_scope 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/grouped_search_result_serializer_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::GroupedSearchResultSerializerExtension 4 | def self.prepended(base) 5 | base.attributes :type_filter 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/openssl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "securerandom" 5 | 6 | module OpenSSL 7 | module PKey 8 | class RSA 9 | def public_encrypt_oaep256(msg) 10 | public_encrypt(PKCS1.oaep_mgf1(msg, n.num_bytes), OpenSSL::PKey::RSA::NO_PADDING) 11 | end 12 | end 13 | end 14 | 15 | module PKCS1 16 | # Public-Key Cryptography Standards (PKCS) #1: RSA Cryptography 17 | # Page 18, https://www.ietf.org/rfc/rfc3447 18 | def oaep_mgf1(msg, k) 19 | m_len = msg.bytesize 20 | h_len = OpenSSL::Digest.new("SHA256").digest_length 21 | raise OpenSSL::PKey::RSAError, "message too long" if m_len > k - 2 * h_len - 2 22 | 23 | l_hash = OpenSSL::Digest.digest("SHA256", "") # label = '' 24 | ps = [0] * (k - m_len - 2 * h_len - 2) 25 | db = l_hash + ps.pack("C*") + [1].pack("C") + [msg].pack("a*") 26 | seed = SecureRandom.random_bytes(h_len) 27 | db_mask = mgf1(seed, k - h_len - 1) 28 | masked_db = db.bytes.zip(db_mask).map! { |a, b| a ^ b }.pack("C*") 29 | seed_mask = mgf1(masked_db, h_len) 30 | masked_seed = seed.bytes.zip(seed_mask).map! { |a, b| a ^ b }.pack("C*") 31 | [0, masked_seed, masked_db].pack("Ca*a*") 32 | end 33 | 34 | module_function :oaep_mgf1 35 | 36 | # Public-Key Cryptography Standards (PKCS) #1: RSA Cryptography 37 | # Page 54, https://www.ietf.org/rfc/rfc3447 38 | def mgf1(seed, mask_len) 39 | c = 0 40 | t = [] 41 | while t.size < mask_len 42 | t += OpenSSL::Digest::SHA256.digest([seed, c].pack("a*N")).bytes 43 | c += 1 44 | end 45 | t[0..mask_len] 46 | end 47 | 48 | module_function :mgf1 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/post_actions_controller_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::PostActionsControllerExtensions 4 | def create 5 | if SiteSetting.encrypt_enabled? 6 | raise Discourse::NotFound if @post.blank? 7 | if !guardian.is_user_a_member_of_encrypted_conversation?(@post.topic) 8 | raise Discourse::InvalidAccess 9 | end 10 | end 11 | super 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/post_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::PostExtensions 4 | def self.prepended(base) 5 | base.has_one :encrypted_post_timer 6 | end 7 | 8 | def ciphertext 9 | raw.split("\n")[0] || "" 10 | end 11 | 12 | def is_encrypted? 13 | !!(topic&.is_encrypted? && ciphertext.match(%r{\A[A-Za-z0-9+/=$]+\Z})) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/site_setting_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::SiteSettingExtensions 4 | def authorized_extensions 5 | original_extensions = super 6 | 7 | if SiteSetting.encrypt_enabled? 8 | if extensions = original_extensions.gsub(/[\s\.]+/, "").downcase.split("|") 9 | return (extensions << "encrypted").uniq.join("|") if !extensions.include?("*") 10 | end 11 | end 12 | 13 | original_extensions 14 | end 15 | 16 | def authorized_extensions=(extensions) 17 | super(extensions.split("|").reject { |ext| ext == "encrypted" }.join("|")) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/site_settings_type_supervisor_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::SiteSettingsTypeSupervisorExtensions 4 | def validate_content_security_policy(value) 5 | super if defined?(super) 6 | 7 | if value == "t" && 8 | !DiscourseEncrypt.safe_csp_src?(SiteSetting.content_security_policy_script_src) && 9 | SiteSetting.encrypt_enabled 10 | raise Discourse::InvalidParameters.new(I18n.t("site_settings.errors.encrypt_unsafe_csp")) 11 | end 12 | end 13 | 14 | def validate_content_security_policy_script_src(value) 15 | super if defined?(super) 16 | 17 | if SiteSetting.content_security_policy? && !DiscourseEncrypt.safe_csp_src?(value) && 18 | SiteSetting.encrypt_enabled 19 | raise Discourse::InvalidParameters.new(I18n.t("site_settings.errors.encrypt_unsafe_csp")) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/topic_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::TopicExtensions 4 | def self.prepended(base) 5 | base.has_one :encrypted_topics_data, dependent: :destroy 6 | base.has_many :encrypted_topics_users, dependent: :destroy 7 | end 8 | 9 | def is_encrypted? 10 | !!(private_message? && encrypted_topics_data&.title) 11 | end 12 | 13 | def remove_allowed_user(removed_by, user) 14 | ret = super 15 | EncryptedTopicsUser.delete_by(topic_id: id, user_id: user.id) if ret 16 | ret 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/topic_guardian_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::TopicGuardianExtension 4 | def can_convert_topic?(topic) 5 | super && !topic.is_encrypted? 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/topic_view_serializer_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::TopicViewSerializerExtension 4 | def posts 5 | if SiteSetting.encrypt_enabled? && object.topic.is_encrypted? 6 | posts = object.posts.includes(:encrypted_post_timer) 7 | object.instance_variable_set(:@posts, posts) 8 | end 9 | super 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/topics_controller_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::TopicsControllerExtensions 4 | def show 5 | if SiteSetting.encrypt_enabled? 6 | topic = Topic.find_by(id: (params[:topic_id] || params[:id])) 7 | raise Discourse::InvalidAccess if !guardian.is_user_a_member_of_encrypted_conversation?(topic) 8 | end 9 | super 10 | end 11 | 12 | def update 13 | @topic ||= Topic.find_by(id: params[:topic_id]) 14 | 15 | if @topic&.is_encrypted? && params[:encrypted_title].presence 16 | guardian.ensure_can_edit!(@topic) 17 | @topic.encrypted_topics_data.update!(title: params.delete(:encrypted_title)) 18 | end 19 | 20 | super 21 | end 22 | 23 | def invite 24 | @topic ||= Topic.find_by(id: params[:topic_id]) 25 | 26 | if @topic.is_encrypted? 27 | if params[:key].present? 28 | @user ||= User.find_by_username_or_email(params[:user]) 29 | guardian.ensure_can_invite_to!(@topic) 30 | EncryptedTopicsUser.create!(topic_id: @topic.id, user_id: @user.id, key: params[:key]) 31 | else 32 | return render_json_error(I18n.t("js.encrypt.cannot_invite")) 33 | end 34 | end 35 | 36 | super 37 | end 38 | 39 | def invite_group 40 | @topic ||= Topic.find_by(id: params[:topic_id]) 41 | 42 | return render_json_error(I18n.t("js.encrypt.cannot_invite_group")) if @topic.is_encrypted? 43 | 44 | super 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/upload_validator_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::UploadValidatorExtensions 4 | def validate(upload) 5 | extension = File.extname(upload.original_filename)[1..-1] || "" 6 | 7 | if extension == "encrypted" 8 | filename = upload.original_filename.gsub(/\.encrypted$/, "") 9 | extension = File.extname(filename)[1..-1] || "" 10 | 11 | if is_authorized?(upload, extension) 12 | if FileHelper.is_supported_image?(filename) 13 | authorized_image_extension(upload, extension) 14 | maximum_image_file_size(upload) 15 | else 16 | authorized_attachment_extension(upload, extension) 17 | maximum_attachment_file_size(upload) 18 | end 19 | end 20 | end 21 | 22 | super 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/user_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::UserExtensions 4 | def self.prepended(base) 5 | base.has_one :user_encryption_key 6 | end 7 | 8 | def encrypt_key 9 | @encrypt_key ||= 10 | begin 11 | identity = self.user_encryption_key&.encrypt_public 12 | return nil if !identity 13 | 14 | # Check identity version 15 | version, identity = identity.split("$", 2) 16 | return nil if version.to_i != 1 17 | 18 | jwk = JSON.parse(Base64.decode64(identity))["encryptPublic"] 19 | n = OpenSSL::BN.new(Base64.urlsafe_decode64(jwk["n"]), 2) 20 | e = OpenSSL::BN.new(Base64.urlsafe_decode64(jwk["e"]), 2) 21 | 22 | data_sequence = OpenSSL::ASN1.Sequence([OpenSSL::ASN1.Integer(n), OpenSSL::ASN1.Integer(e)]) 23 | OpenSSL::PKey::RSA.new(data_sequence.to_der) 24 | end 25 | end 26 | 27 | def publish_identity 28 | MessageBus.publish( 29 | "/plugin/encrypt/keys", 30 | { 31 | public: self.user_encryption_key&.encrypt_public, 32 | private: self.user_encryption_key&.encrypt_private, 33 | }, 34 | user_ids: [self.id], 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/user_notification_renderer_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseEncrypt::UserNotificationRendererExtensions 4 | def render(*args) 5 | post = args[0]&.dig(:locals, :post) 6 | args[0][:locals][:in_reply_to_post] = nil if post&.is_encrypted? 7 | super(*args) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/validators/encrypt_enabled_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptEnabledValidator 4 | def initialize(opts = {}) 5 | end 6 | 7 | def valid_value?(value) 8 | !SiteSetting.content_security_policy? || 9 | DiscourseEncrypt.safe_csp_src?(SiteSetting.content_security_policy_script_src) || value == "f" 10 | end 11 | 12 | def error_message 13 | I18n.t("site_settings.errors.encrypt_unsafe_csp") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@discourse/lint-configs": "2.2.2", 5 | "ember-template-lint": "6.0.0", 6 | "eslint": "9.15.0", 7 | "prettier": "2.8.8" 8 | }, 9 | "engines": { 10 | "node": ">= 18", 11 | "npm": "please-use-pnpm", 12 | "yarn": "please-use-pnpm", 13 | "pnpm": ">= 9" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spec/fixtures/test_paper_key_1.txt: -------------------------------------------------------------------------------- 1 | WRESTLE SQUARE REGRET INHERIT ANIMAL GIFT CHEESE EXCLUDE WAIT BLIND BLOOD LAWN 2 | -------------------------------------------------------------------------------- /spec/fixtures/test_paper_key_2.txt: -------------------------------------------------------------------------------- 1 | SPECIAL MAPLE PANDA AUTO EXACT CHEST SPEAK SHUFFLE SHOCK COUSIN FROZEN LIBERTY 2 | -------------------------------------------------------------------------------- /spec/fixtures/test_public_key_1.txt: -------------------------------------------------------------------------------- 1 | 1$eyJlbmNyeXB0UHVibGljIjp7ImFsZyI6IlJTQS1PQUVQLTI1NiIsImUiOiJBUUFCIiwiZXh0Ijp0cnVlLCJrZXlfb3BzIjpbImVuY3J5cHQiLCJ3cmFwS2V5Il0sImt0eSI6IlJTQSIsIm4iOiJ2dmdnd19ZWmN4dGdoVndTOEFNZFBxTW1ISElJY2d0NDRXNTRoMF8wYmtORWJqN3pudGk1YVFxakI0U0ptZm14WU9KQVowLXJtemRIYVZHbjV1d21Pb0VzYktVcWN5bWc0YzZtX3NGUFFQdWpPRF9WajZvN2hIOF84MXN4Y196RE1sdks0TkNudDRldHlsbkxjamlza3VIdWRHMlRGY2NkRng2RklZTUlVZFlQTmRhaGRJLTZDMGxaYVVRVXQzbnJEVGV5N0lKRFIyNm9wbllXSExBSFdfTVZUS0c2SkVmdkRBYkE4ZGgwQ0hsSk45ZTVuY19GSXhDblhhRXhmdWdJQThKYlE3S1pSaERkYzlrbUpYV0RBLWp1SDF5R0ZWUFM5VzZJWTdYUmhhVHlIckFHeWt6Y1VIS0JiU3Z0cW1lcHVQM0JjZXk2YlBhRzlWSXprM3lWTGp4dVhWRUY5Njl5MFRHajAtUWVQLXktN0lWdl90MnhqR0hEcHlVUzJHSlVIZjNKTjdYZWVjSWdVZ1JnUURrcGZNN0xpOEIyQnFlWW9tUjY4amFRd1QwSmpEMDJ1UXlNcTRtWDZhSHk0c1ZFZUJCanJfOUVZM3pNRzVxSmdMdUxfbmZIUzFFcVhWUFpSbFVDcDA2ZlZQWWUzZjZhb29EUFRlVmFCdjdZQTNKNnB2aDBrTFpTMGZtaTNLQmY4NVBfX0FVb291eF9TZnZDRjlzeGliWkw4bTY1em56YXVzZVByWHZpUXduQkxRS2xubWZjQ19BRUYtRmhFS3FjOXZhSnJXRGI2bHJUWFN0MXl0WWYwdWV3cGdpT0RCMkdONTh3SW1icjdGWklmOWMwM21pSlZrNGk5VVpfNVZ3dFkzUGhsVlNQVU5Rbl9zcFZJUXI3M0Q3VjNOMCJ9LCJzaWduUHVibGljIjp7ImFsZyI6IlBTMjU2IiwiZSI6IkFRQUIiLCJleHQiOnRydWUsImtleV9vcHMiOlsidmVyaWZ5Il0sImt0eSI6IlJTQSIsIm4iOiJybzg5R2NjVzBZOVFIeHJYLVJvUWROdTdmYV94bE9tWmF1cnBpR0xOMTRRaWJNSk50TFRSUndEdFpXMzN0cEd4UkNUYWcxQzJ4dFV1MktiYTFxYm5MVVJiYnhlaTdiRXNLTHY0TWFJSmpCd3F2enV0TUJ3WGhKRTdSd2RBQ29MM29XbXlva1BGdEloQUtwd1JiTFNLTlVOTEV6bEJvdGs3UGFwQktWTWk4MWNDTFI4WjFWVmRxMjhLQjNRR2dtMG1kWlFFTEZDVHJ6Q0tSc0ZieFlhMm9xYjVaUWFOemZ4bFd5WkprZGlYVFBuQXVHN09udmlrTGx1TnZUZ0d5NzZHQTBiV0RvRkIxNll2SzB2TTd6Wk5aMXYwYjNUSVRVRzkyZnAwXzhrd3lFNXVsaE1tdmxrd2VDZjBMWHR1Ti0xakJySDVJdGtHX3lQRzVHeVZNeFpaMnFzTk9EYTQ5MDZBSFd6SHp0Qm5YNEg3UmFMa0F2OEkxZlRyMWRlc2JzUEgwX1M4NWJyVjI3cEh5YXljM2JGYVdrSHpjbUtBb3RJRHBRU2pOekJYNlpXNFY2dDFQR1FfYy1zMTdQZkFXeF9MVXJLRnU2N2R4VE5wNzBOem9fTEJVelpCY0p2SVppNThfU1BBUVlRdnBxQ3F0N2pXZXcyME94WmtoU3RaR3ZZU0l1MjE1SHc4blFnSVVoalRFREMwUVA3VG9XQ2duUVkzb1lERHg3RmlhWkJsYWI4OU9xc3Mya191ZDh5aG8tcy1xQ2xLYjZRczhIQTJTYW9RNnFkMEFtUHdGNGtGel82SlpPOUkzSV80cWhvTXVJRF9BNkRpR1FqV0I1bkdMUnVsTVJ5RjhZQlZtS09ZTkczN0JHNk9vTGdlOWhzRGRLTjlLUVFWNUpPMUZjcyJ9fQ== 2 | -------------------------------------------------------------------------------- /spec/fixtures/test_public_key_2.txt: -------------------------------------------------------------------------------- 1 | 1$eyJlbmNyeXB0UHVibGljIjp7ImFsZyI6IlJTQS1PQUVQLTI1NiIsImUiOiJBUUFCIiwiZXh0Ijp0cnVlLCJrZXlfb3BzIjpbImVuY3J5cHQiLCJ3cmFwS2V5Il0sImt0eSI6IlJTQSIsIm4iOiJza2lHOVpVZUFQUWZFS01VSk9fTzdSem84Um56VWZGNzdjM0NwSHFoT2dXZGE3RUcxSXYzYTgya0RacTVvcFBFNlZnWWtBSVpQQzhzNmV1OHdoNXEtbjRJZjRFR3RkWnhMaV8xWXZvNnA1cF9qN1Q4eW9EcFhlRExOZnZyVFpsalVBMlJQVTAyX254OE9sS3BRQ3MzRzlHdXo1N19mLWtCQVg4NlRuRWljeElzZ2RKWDk3aEpOamlia2tFSlNCUDN4VHM1TklkcGFtaTViWDJJMl9UR3QxaFZLLTVwM29fRlFWblBtMVkweEpEamU3eEQwZnItbDhRMGtUSUlBVnpubmZCY0kzWFVwaEdzenZLTHE0UDhpNkFZWUstaGRvNVZMSFl3aHZWSmZjeDN1UUpwek1yLWYwNFhrZVBGTFVxYTBxekNtOWpIRm9wQWZob2xZbGh0d250Z0VPTllzMHp6SEJVVHVZU085NnFGR3Z3c0QtUnJBUklKZEdYZFpwVm1JcjJDeC13VnJ5VjhDU3FURTdESWZYa1RRM1pGS3Y4SHZUZnBXWTYtaDN5Njljc1hraTZHZlVsYjZYc3dlVlFvZ1FBUTNBT2RDVzE3cGtmakh0Xzd0b2w4anZQVVYtRExMRllZUW9hTHo5OUFBTnBuWW52ZVZGc1FYWHQzOTRFU1dyMHppbWc2ZGdUZGc1THJ5d2VQMjdjVFlCSndNQ1NKOU0zcWpyQmp5VVdaYXFkYk92a2VhdTZMMkxIcU5GaTFCT3BjejhlWHMyZTlmbndpQzhtQnNyU1RnajVTUW9tejEyWFgyY1UzdXI0SmJzY1BJN2JQM28ycHBnUWlFSGFIc0NhdnNTa0lYNUlfbmJ3MndtbGtDSDFZT3lIM2h4TEprNkdsaTRMQkR0ayJ9LCJzaWduUHVibGljIjp7ImFsZyI6IlBTMjU2IiwiZSI6IkFRQUIiLCJleHQiOnRydWUsImtleV9vcHMiOlsidmVyaWZ5Il0sImt0eSI6IlJTQSIsIm4iOiI1YnVKT3EteEt2MWxGVHc3UEV1cV9HZTRiS3FDLWJyd3ZLVkZfZzF3NUNRQnNDMHpDQld6bjFaeGJIVkVnRlp2YXlDanMwLVMyQXBrb3pGdWxPcVQxdlVRbEpiWXduSlFicmM2ZXA0VlJMTFh6VE1SanVCbkUzUkVzdDdyc0puR25JYUpiZVczNy01cGNGR0lIM1czTURDNGVXUERyamVoOFEtaWVhQkUzd0xFcENCaEQzOVNjTEhqRWw0ODkxbHVxUEJDR0FFU0xudUdlUnFra0N5dTV6LTZtdzlPLXJBaldKLTd4YlFsOWJVaVRtTW5UNTVfcVRHcWpLWDRmUTkyVUo0My1HcE9QZ2VXcE9fQ0FaYzdtdDVsU0hBdW91WFlJUVFVZTB4VXFZOUZ2NmotOFY1MjZpVkZsX0NDdDAtRXN4a2hpMnU3YmtucmhoTHVHbGlCVTVuMW5hYkV1SkZobHNpUnJiWElLZnZlalZ0N0xTTGpEM19DNmlVeHBjTEZweG9vN3ZXQXgxM0kyeEN3NUJEUDdUSmJvQS0zeDlvRk14N3I1MG0tQll0T3BodFc5X2dtRE0wQm10eEptVTZYOEFHSUllcnhBVTcyWENZb0xQUkdlZUpmOTRaV2VxZW9JUjEwRWRXWE14R2xOOGlIbXRFSzZqNmhQcGFicWg3Ty15VkhmQW9ITW5nODQ0NDFhNk1aQm9FOXBiWXJSbmhlNzJCc0hZbGlpYmhkbzl6bE5aZ2FlcTdDX19UZlI3Qk9GVDBjc3g5bDRDRTZtbi1WbG0zTlZ3VTUzdFFQWWFSSDBzRGpXakJ3NVk5MERBc2IxUno4UTMwb180VndNLWROYWhpd1Q1bGdNc1ZMeEVNam41NWVaUm1UWjMwNFZjVi1ZTUljVGR6T1FhVSJ9fQ== 2 | -------------------------------------------------------------------------------- /spec/jobs/encrypt_consistency_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::EncryptConsistency do 6 | subject(:job) { described_class.new } 7 | 8 | fab!(:topic) { Fabricate(:encrypt_topic) } 9 | fab!(:user_without_invite) { Fabricate(:user) } 10 | fab!(:user_without_key) { Fabricate(:user) } 11 | 12 | before do 13 | EncryptedTopicsUser.create!( 14 | topic_id: topic.id, 15 | user_id: user_without_invite.id, 16 | key: "topic key", 17 | ) 18 | TopicAllowedUser.create(topic_id: topic.id, user_id: user_without_key.id) 19 | end 20 | 21 | it "ensures invites and topic keys are consistent" do 22 | expect { job.execute({}) }.to change { 23 | TopicAllowedUser.exists?(topic: topic, user: user_without_invite) 24 | }.from(false).to(true).and change { 25 | TopicAllowedUser.exists?(topic: topic, user: user_without_key) 26 | }.from(true).to(false) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/jobs/encrypted_post_timer_evaluator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::EncryptedPostTimerEvaluator do 6 | fab!(:topic) { Fabricate(:encrypt_topic) } 7 | fab!(:post1) { Fabricate(:encrypt_post, topic: topic) } 8 | fab!(:post2) { Fabricate(:encrypt_post, topic: topic) } 9 | fab!(:post3) { Fabricate(:encrypt_post, topic: topic) } 10 | 11 | describe "explosion of first post" do 12 | it "when time is right, delete all posts" do 13 | encrypted_post_timer = EncryptedPostTimer.create!(post: post1, delete_at: 1.hour.from_now) 14 | described_class.new.execute({}) 15 | expect(post1.reload.persisted?).to be true 16 | expect(post2.reload.persisted?).to be true 17 | expect(post3.reload.persisted?).to be true 18 | expect(topic.reload.persisted?).to be true 19 | expect(encrypted_post_timer.reload.destroyed_at).to be nil 20 | 21 | freeze_time 61.minutes.from_now 22 | described_class.new.execute({}) 23 | expect { post1.reload }.to raise_error(ActiveRecord::RecordNotFound) 24 | expect { post2.reload }.to raise_error(ActiveRecord::RecordNotFound) 25 | expect { post3.reload }.to raise_error(ActiveRecord::RecordNotFound) 26 | expect { topic.reload }.to raise_error(ActiveRecord::RecordNotFound) 27 | expect(encrypted_post_timer.reload.destroyed_at).not_to be nil 28 | end 29 | end 30 | 31 | describe "explosion of consecutive posts" do 32 | it "when time is right, delete only one post" do 33 | encrypted_post_timer = EncryptedPostTimer.create!(post: post2, delete_at: 1.hour.from_now) 34 | encrypted_post_timer2 = EncryptedPostTimer.create!(post: post3, delete_at: 1.hour.from_now) 35 | described_class.new.execute({}) 36 | expect(post1.reload.persisted?).to be true 37 | expect(post2.reload.persisted?).to be true 38 | expect(post3.reload.persisted?).to be true 39 | expect(encrypted_post_timer.reload.destroyed_at).to be nil 40 | expect(encrypted_post_timer2.reload.destroyed_at).to be nil 41 | 42 | freeze_time 61.minutes.from_now 43 | described_class.new.execute({}) 44 | expect(post1.reload.persisted?).to be true 45 | expect { post2.reload }.to raise_error(ActiveRecord::RecordNotFound) 46 | expect { post3.reload }.to raise_error(ActiveRecord::RecordNotFound) 47 | expect(topic.reload.persisted?).to be true 48 | expect(encrypted_post_timer.reload.destroyed_at).not_to be nil 49 | expect(encrypted_post_timer2.reload.destroyed_at).not_to be nil 50 | end 51 | 52 | it "does not error when post is already deleted" do 53 | encrypted_post_timer = EncryptedPostTimer.create!(post_id: -5, delete_at: 1.hour.from_now) 54 | freeze_time 61.minutes.from_now 55 | described_class.new.execute({}) 56 | expect(encrypted_post_timer.reload.destroyed_at).not_to be nil 57 | end 58 | 59 | it "does not error when user is deleted" do 60 | post2.user.destroy 61 | encrypted_post_timer = 62 | EncryptedPostTimer.create!(post_id: post2.id, delete_at: 1.hour.from_now) 63 | freeze_time 61.minutes.from_now 64 | described_class.new.execute({}) 65 | expect(encrypted_post_timer.reload.destroyed_at).not_to be nil 66 | expect { post2.reload }.to raise_error(ActiveRecord::RecordNotFound) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/lib/site_setting_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe DiscourseEncrypt::SiteSettingExtensions do 6 | let!(:default_extensions) do 7 | SiteSetting.authorized_extensions.split("|").reject { |x| x == "encrypted" } 8 | end 9 | 10 | it "adds 'encrypted' extensions to authorized_extensions" do 11 | expect(SiteSetting.authorized_extensions.split("|")).to match_array( 12 | default_extensions + ["encrypted"], 13 | ) 14 | end 15 | 16 | it "does not add 'encrypted' extensions if * is present" do 17 | SiteSetting.authorized_extensions = "*" 18 | expect(SiteSetting.authorized_extensions.split("|")).to match_array(["*"]) 19 | end 20 | 21 | it "provider does not save 'encrypted' file extensions" do 22 | SiteSetting.authorized_extensions += "|txt" 23 | expect(SiteSetting.authorized_extensions.split("|")).to match_array( 24 | default_extensions + %w[txt encrypted], 25 | ) 26 | expect(SiteSetting.provider.find(:authorized_extensions)&.value&.split("|")).to match_array( 27 | default_extensions + ["txt"], 28 | ) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/upload_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe DiscourseEncrypt::UploadValidatorExtensions do 6 | it "removes '.encrypted' extension and validates the real extension" do 7 | SiteSetting.authorized_extensions = "foo" 8 | 9 | expect { Fabricate(:upload, original_filename: "test.foo") }.not_to raise_exception 10 | expect { Fabricate(:upload, original_filename: "test.foo.encrypted") }.not_to raise_exception 11 | expect { Fabricate(:upload, original_filename: "test.bar") }.to raise_exception( 12 | ActiveRecord::RecordInvalid, 13 | ) 14 | expect { Fabricate(:upload, original_filename: "test.bar.encrypted") }.to raise_exception( 15 | ActiveRecord::RecordInvalid, 16 | ) 17 | 18 | SiteSetting.authorized_extensions = "*" 19 | 20 | expect { Fabricate(:upload, original_filename: "test.bar") }.not_to raise_exception 21 | expect { Fabricate(:upload, original_filename: "test.bar.encrypted") }.not_to raise_exception 22 | end 23 | 24 | it "removes '.encrypted' extension and validates the real image extension" do 25 | expect { Fabricate(:upload, original_filename: "test.jpg") }.not_to raise_exception 26 | expect { Fabricate(:upload, original_filename: "test.jpg.encrypted") }.not_to raise_exception 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/models/post_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Post do 6 | let(:post) { Fabricate(:post) } 7 | let(:encrypt_user) { Fabricate(:encrypt_user) } 8 | let(:encrypt_post) { Fabricate(:encrypt_post, user: encrypt_user) } 9 | 10 | describe "#ciphertext" do 11 | it "works" do 12 | ciphertext = "0$ciphertextbase64encoded==" 13 | encrypt_post.update!(raw: "#{ciphertext}\nmetadata maybe?") 14 | 15 | expect(encrypt_post.ciphertext).to eq(ciphertext) 16 | end 17 | end 18 | 19 | describe "#is_encrypted?" do 20 | it "works" do 21 | expect(post.is_encrypted?).to eq(false) 22 | expect(encrypt_post.is_encrypted?).to eq(true) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/models/topic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Topic do 6 | let(:topic) { Fabricate(:topic) } 7 | let(:encrypt_post) { Fabricate(:encrypt_post) } 8 | let!(:encrypt_topic) { encrypt_post.topic } 9 | 10 | describe "#is_encrypted?" do 11 | it "works" do 12 | expect(topic.is_encrypted?).to eq(false) 13 | expect(encrypt_topic.is_encrypted?).to eq(true) 14 | end 15 | end 16 | 17 | describe "remove_allowed_user" do 18 | it "deletes topic key for user" do 19 | expect { 20 | encrypt_topic.remove_allowed_user(Discourse.system_user, encrypt_topic.user) 21 | }.to change { TopicAllowedUser.count }.by(-1).and change { EncryptedTopicsUser.count }.by(-1) 22 | expect( 23 | EncryptedTopicsUser.find_by( 24 | topic_id: encrypt_topic.id, 25 | user_id: encrypt_topic.user_id, 26 | )&.key, 27 | ).to eq(nil) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe User do 6 | let(:user) { Fabricate(:user) } 7 | 8 | before { SiteSetting.encrypt_enabled = true } 9 | 10 | describe "user option #encrypt_pms_default" do 11 | it "disabled by default" do 12 | expect(user.user_option.encrypt_pms_default).to eq(false) 13 | end 14 | 15 | it "enabled if site setting value is true" do 16 | SiteSetting.encrypt_pms_default = true 17 | expect(user.user_option.encrypt_pms_default).to eq(true) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/plugin_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe ::DiscourseEncrypt do 6 | let(:upload) { Fabricate(:upload) } 7 | let(:post) { Fabricate(:encrypt_post) } 8 | 9 | it "links uploads in encrypted posts" do 10 | Jobs.run_immediately! 11 | 12 | post.update!(raw: "#{post.raw}\n[](#{upload.short_url})") 13 | post.rebake! 14 | 15 | expect(post.uploads).to contain_exactly(upload) 16 | end 17 | 18 | it "can enable encrypt if safe CSP" do 19 | SiteSetting.encrypt_enabled = false # plugin is enabled by default 20 | SiteSetting.content_security_policy_script_src = "'unsafe-eval'" 21 | expect { SiteSetting.encrypt_enabled = true }.not_to raise_error 22 | end 23 | 24 | it "cannot have unsafe CSP if encrypt is enabled" do 25 | SiteSetting.encrypt_enabled = true 26 | expect { 27 | SiteSetting.content_security_policy_script_src = "'unsafe-eval'|'unsafe-inline'" 28 | }.to raise_error(Discourse::InvalidParameters) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/requests/encrypted_post_timers_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe DiscourseEncrypt::EncryptedPostTimersController do 6 | let!(:user) { Fabricate(:encrypt_user, admin: true) } 7 | let!(:user2) { Fabricate(:encrypt_user) } 8 | let!(:topic) do 9 | Fabricate( 10 | :encrypt_topic, 11 | user: user, 12 | topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: user)], 13 | ) 14 | end 15 | let!(:encrypt_post) { Fabricate(:encrypt_post, topic: topic) } 16 | 17 | it "creates and deletes timer if user has access to private message" do 18 | sign_in(user2) 19 | post "/encrypt/encrypted_post_timers.json", params: { post_id: topic.posts.first.id } 20 | expect(response.status).to eq(403) 21 | expect(EncryptedPostTimer.count).to eq(0) 22 | 23 | sign_in(user) 24 | post "/encrypt/encrypted_post_timers.json", params: { post_id: topic.posts.first.id } 25 | expect(response).to be_successful 26 | expect(EncryptedPostTimer.count).to eq(1) 27 | 28 | topic.update(deleted_at: Time.now) 29 | sign_in(user2) 30 | delete "/encrypt/encrypted_post_timers.json", params: { post_id: topic.posts.first.id } 31 | expect(response.status).to eq(403) 32 | expect(EncryptedPostTimer.count).to eq(1) 33 | 34 | sign_in(user) 35 | delete "/encrypt/encrypted_post_timers.json", params: { post_id: topic.posts.first.id } 36 | expect(response).to be_successful 37 | expect(EncryptedPostTimer.count).to eq(0) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/requests/posts_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe PostsController do 6 | let(:encrypt_post) { Fabricate(:encrypt_post) } 7 | let(:user) { Fabricate(:user) } 8 | let(:group) { Fabricate(:group) } 9 | 10 | before { sign_in(Fabricate(:admin)) } 11 | 12 | describe "#create" do 13 | it "works" do 14 | post "/posts.json", 15 | params: { 16 | raw: I18n.t("js.encrypt.encrypted_post"), 17 | title: I18n.t("js.encrypt.encrypted_title"), 18 | archetype: Archetype.private_message, 19 | target_recipients: user.username, 20 | draft_key: Draft::NEW_TOPIC, 21 | is_encrypted: true, 22 | encrypted_title: "1$title", 23 | encrypted_raw: encrypt_post.raw, 24 | encrypted_keys: "{\"#{user.username}\":\"topickey\"}", 25 | } 26 | 27 | expect(response.status).to eq(200) 28 | end 29 | 30 | it "raises an error" do 31 | post "/posts.json", 32 | params: { 33 | raw: I18n.t("js.encrypt.encrypted_post"), 34 | title: I18n.t("js.encrypt.encrypted_title"), 35 | archetype: Archetype.private_message, 36 | target_recipients: user.username, 37 | draft_key: Draft::NEW_TOPIC, 38 | is_encrypted: true, 39 | encrypted_title: "1$title", 40 | encrypted_raw: encrypt_post.raw, 41 | } 42 | 43 | expect(response.status).to eq(422) 44 | expect(JSON.parse(response.body)["errors"]).to include(I18n.t("encrypt.no_encrypt_keys")) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/requests/topics_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe TopicsController do 6 | let(:topic) { Fabricate(:encrypt_topic) } 7 | let(:user) { Fabricate(:user) } 8 | let(:group) { Fabricate(:group) } 9 | let(:admin) { Fabricate(:admin) } 10 | let(:admin2) { Fabricate(:admin) } 11 | 12 | before do 13 | TopicAllowedUser.create!(user_id: admin.id, topic_id: topic.id) 14 | sign_in(admin) 15 | end 16 | 17 | describe "#update" do 18 | it "updates encrypted title" do 19 | put "/t/#{topic.slug}/#{topic.id}.json", params: { encrypted_title: "new encrypted title" } 20 | 21 | expect(response.status).to eq(200) 22 | expect(topic.reload.encrypted_topics_data.title).to eq("new encrypted title") 23 | end 24 | 25 | it "returns invalid access for deleted topic" do 26 | topic.destroy! 27 | put "/t/#{topic.slug}/#{topic.id}.json", params: { encrypted_title: "new encrypted title" } 28 | expect(response.status).to eq(403) 29 | end 30 | end 31 | 32 | it "not invited admin does not have access" do 33 | sign_in(admin2) 34 | get "/t/#{topic.slug}/#{topic.id}.json" 35 | expect(response.status).to eq(403) 36 | 37 | TopicAllowedUser.create!(user_id: admin2.id, topic_id: topic.id) 38 | get "/t/#{topic.slug}/#{topic.id}.json" 39 | expect(response.status).to eq(200) 40 | end 41 | 42 | describe "#invite" do 43 | it "saves user key" do 44 | post "/t/#{topic.id}/invite.json", params: { user: user.username, key: "key of user" } 45 | 46 | expect(response.status).to eq(200) 47 | expect(TopicAllowedUser.where(user_id: user.id, topic_id: topic.id).exists?).to eq(true) 48 | expect(EncryptedTopicsUser.find_by(topic_id: topic.id, user_id: user.id).key).to eq( 49 | "key of user", 50 | ) 51 | end 52 | 53 | it "returns an error with no key" do 54 | post "/t/#{topic.id}/invite.json", params: { user: user.username } 55 | 56 | expect(response.status).to eq(422) 57 | expect(TopicAllowedUser.where(user_id: user.id, topic_id: topic.id).exists?).to eq(false) 58 | expect(EncryptedTopicsUser.where(topic_id: topic.id, user_id: user.id).exists?).to eq(false) 59 | end 60 | end 61 | 62 | describe "#invite_group" do 63 | it "returns an error with no key" do 64 | post "/t/#{topic.id}/invite-group.json", params: { group: group.name } 65 | 66 | expect(response.status).to eq(422) 67 | expect(TopicAllowedGroup.where(group_id: group.id, topic_id: topic.id).exists?).to eq(false) 68 | end 69 | end 70 | 71 | describe "#remove_allowed_user" do 72 | let(:topic) { Fabricate(:encrypt_topic, user: user) } 73 | let(:other_user) { topic.topic_allowed_users.map(&:user).find { |u| u != user } } 74 | 75 | it "uninvites the user" do 76 | put "/t/#{topic.id}/remove-allowed-user.json", params: { username: other_user.username } 77 | 78 | expect(EncryptedTopicsUser.where(topic_id: topic.id, user_id: user.id).exists?).to eq(true) 79 | expect(EncryptedTopicsUser.where(topic_id: topic.id, user_id: other_user.id).exists?).to eq( 80 | false, 81 | ) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/serializers/current_user_serialier_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe CurrentUserSerializer do 6 | let(:user) { Fabricate(:user) } 7 | 8 | it "contains public, private key and encrypt_pms_default" do 9 | UserEncryptionKey.create!( 10 | user_id: user.id, 11 | encrypt_public: "public key", 12 | encrypt_private: "private key", 13 | ) 14 | SiteSetting.encrypt_pms_default = true 15 | serialized = described_class.new(user, scope: Guardian.new(user), root: false).as_json 16 | expect(serialized[:encrypt_public]).to eq("public key") 17 | expect(serialized[:encrypt_private]).to eq("private key") 18 | expect(serialized[:encrypt_pms_default]).to be true 19 | end 20 | 21 | it "use SiteSetting as default when encrypt_pms_default is not set" do 22 | user.user_option.update!(encrypt_pms_default: nil) 23 | UserEncryptionKey.create!( 24 | user_id: user.id, 25 | encrypt_public: "public key", 26 | encrypt_private: "private key", 27 | ) 28 | serialized = described_class.new(user, scope: Guardian.new(user), root: false).as_json 29 | expect(serialized[:encrypt_pms_default]).to be false 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/serializers/topic_serializers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | [BasicTopicSerializer, ListableTopicSerializer, TopicListItemSerializer].each do |klass| 6 | describe klass do 7 | let(:user) { Fabricate(:user) } 8 | 9 | let(:encrypt_topic) do 10 | Fabricate( 11 | :encrypt_topic, 12 | topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: user)], 13 | ) 14 | end 15 | let(:topic) do 16 | Fabricate( 17 | :private_message_topic, 18 | topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: user)], 19 | ) 20 | end 21 | 22 | it "contains encrypted fields only for encrypted topics" do 23 | serialized = 24 | described_class.new(encrypt_topic, scope: Guardian.new(user), root: false).as_json 25 | expect(serialized[:encrypted_title]).not_to eq(nil) 26 | expect(serialized[:topic_key]).not_to eq(nil) 27 | 28 | serialized = described_class.new(topic, scope: Guardian.new(user), root: false).as_json 29 | expect(serialized[:encrypted_title]).to eq(nil) 30 | expect(serialized[:topic_key]).to eq(nil) 31 | 32 | serialized = described_class.new(encrypt_topic, scope: Guardian.new, root: false).as_json 33 | expect(serialized[:encrypted_title]).to eq(nil) 34 | expect(serialized[:topic_key]).to eq(nil) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/serializers/topic_view_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe TopicViewSerializer do 6 | let(:user) { Fabricate(:user) } 7 | 8 | let(:encrypt_topic) do 9 | Fabricate( 10 | :encrypt_topic, 11 | topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: user)], 12 | ) 13 | end 14 | let(:encrypt_post) { Fabricate(:encrypt_post, topic: encrypt_topic) } 15 | let(:topic) do 16 | Fabricate( 17 | :private_message_topic, 18 | topic_allowed_users: [Fabricate.build(:topic_allowed_user, user: user)], 19 | ) 20 | end 21 | 22 | let(:encrypt_topic_view) { TopicView.new(encrypt_topic.id, user) } 23 | let(:topic_view) { TopicView.new(topic.id, user) } 24 | 25 | it "contains encrypted fields only for encrypted topics" do 26 | EncryptedPostTimer.create!(post: encrypt_post, delete_at: 1.hour.from_now) 27 | 28 | serialized = 29 | described_class.new(encrypt_topic_view, scope: Guardian.new(user), root: false).as_json 30 | expect(serialized[:encrypted_title]).not_to eq(nil) 31 | expect(serialized[:topic_key]).not_to eq(nil) 32 | expect(serialized[:delete_at]).not_to eq(nil) 33 | 34 | serialized = described_class.new(topic_view, scope: Guardian.new(user), root: false).as_json 35 | expect(serialized[:encrypted_title]).to eq(nil) 36 | expect(serialized[:topic_key]).to eq(nil) 37 | expect(serialized[:delete_at]).to eq(nil) 38 | 39 | serialized = described_class.new(encrypt_topic_view, scope: Guardian.new, root: false).as_json 40 | expect(serialized[:encrypted_title]).to eq(nil) 41 | expect(serialized[:topic_key]).to eq(nil) 42 | expect(serialized[:delete_at]).to eq(nil) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/services/problem_check/unsafe_csp_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ProblemCheck::UnsafeCsp do 4 | let(:check) { described_class.new } 5 | 6 | context "when encryption is not enabled" do 7 | before { SiteSetting.stubs(encrypt_enabled?: false) } 8 | 9 | it { expect(check).to be_chill_about_it } 10 | end 11 | 12 | context "when no CSP is configured" do 13 | before { SiteSetting.stubs(content_security_policy?: false) } 14 | 15 | it { expect(check).to be_chill_about_it } 16 | end 17 | 18 | context "when using a safe CSP configuration" do 19 | before do 20 | SiteSetting.stubs(encrypt_enabled?: true) 21 | SiteSetting.stubs(content_security_policy?: true) 22 | SiteSetting.stubs(content_security_policy_script_src: "script-src") 23 | end 24 | 25 | it { expect(check).to be_chill_about_it } 26 | end 27 | 28 | context "when using an unsafe CSP configuration" do 29 | before do 30 | SiteSetting.stubs(encrypt_enabled?: true) 31 | SiteSetting.stubs(content_security_policy?: true) 32 | SiteSetting.stubs(content_security_policy_script_src: "script-src 'unsafe-inline'") 33 | end 34 | 35 | it do 36 | expect(check).to have_a_problem.with_priority("low").with_message( 37 | "Unsafe CSP directives like 'unsafe-eval' and 'unsafe-inline' cannot be used when the Discourse Encrypt plugin is enabled.", 38 | ) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/system/decrypt_encrypted_topic_posts_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "Encrypt | Decypting topic posts", type: :system do 4 | fab!(:current_user) { Fabricate(:user) } 5 | 6 | let(:user_preferences_page) { PageObjects::Pages::UserPreferences.new } 7 | let(:topic_page) { PageObjects::Pages::Topic.new } 8 | let(:topic_title) { "This is a new topic for encryption" } 9 | 10 | before do 11 | encrypt_system_bootstrap(current_user) 12 | sign_in(current_user) 13 | enable_encrypt_with_keys_for_user(current_user) 14 | activate_encrypt(user_preferences_page, current_user) 15 | end 16 | 17 | def select_other_user_for_pm 18 | find("#private-message-users").click 19 | find("#private-message-users-filter input[name='filter-input-search']").send_keys( 20 | other_user.username, 21 | ) 22 | find(".email-group-user-chooser-row").click 23 | end 24 | 25 | describe "with hashtags" do 26 | fab!(:category) { Fabricate(:category, name: "TODO", slug: "todo") } 27 | fab!(:tag) { Fabricate(:tag, name: "bugs") } 28 | fab!(:other_user) { Fabricate(:user) } 29 | 30 | it "decrypts the post" do 31 | enable_encrypt_for_user_in_session(other_user, user_preferences_page) 32 | 33 | topic_page.open_new_message 34 | expect(page).to have_css(".encrypt-controls .d-icon-lock") 35 | 36 | select_other_user_for_pm 37 | 38 | topic_page.fill_in_composer_title(topic_title) 39 | topic_page.fill_in_composer("Here are some hashtags for decryption later on #todo #bugs") 40 | topic_page.send_reply 41 | 42 | # encryption loading and processing takes a little longer than usual 43 | using_wait_time(5) do 44 | expect(find(".fancy-title")).to have_content(topic_title) 45 | expect(page).to have_css(".topic-status .d-icon-user-secret") 46 | expect(page).to have_content("Here are some hashtags for decryption later") 47 | end 48 | 49 | # make sure hashtags are rendered by the post decrypter 50 | expect(page).to have_css(".hashtag-cooked[data-slug='todo']") 51 | expect(page).to have_css(".hashtag-cooked[data-slug='bugs']") 52 | end 53 | end 54 | 55 | describe "with mentions" do 56 | fab!(:user_2) { Fabricate(:user) } 57 | fab!(:user_3) { Fabricate(:user) } 58 | fab!(:other_user) { Fabricate(:user) } 59 | 60 | it "decrypts the post" do 61 | enable_encrypt_for_user_in_session(other_user, user_preferences_page) 62 | 63 | topic_page.open_new_message 64 | expect(page).to have_css(".encrypt-controls .d-icon-lock") 65 | 66 | select_other_user_for_pm 67 | 68 | topic_page.fill_in_composer_title(topic_title) 69 | topic_page.fill_in_composer( 70 | "Here are some mentions for decryption later on @#{user_2.username} @#{user_3.username}", 71 | ) 72 | topic_page.send_reply 73 | 74 | # encryption loading and processing takes a little longer than usual 75 | using_wait_time(5) do 76 | expect(find(".fancy-title")).to have_content(topic_title) 77 | expect(page).to have_css(".topic-status .d-icon-user-secret") 78 | expect(page).to have_content("Here are some mentions for decryption later") 79 | end 80 | 81 | # make sure mentions are rendered by the post decrypter 82 | expect(page).to have_css(".mention[href='/u/#{user_2.username}']") 83 | expect(page).to have_css(".mention[href='/u/#{user_3.username}']") 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/system/enable_encryption_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "Encrypt | Enabling encrypted messages", type: :system do 4 | fab!(:current_user) { Fabricate(:user) } 5 | before do 6 | encrypt_system_bootstrap(current_user) 7 | sign_in(current_user) 8 | end 9 | 10 | let(:user_preferences_page) { PageObjects::Pages::UserPreferences.new } 11 | 12 | it "shows warning about paper keys when encryption is enabled" do 13 | user_preferences_page.visit(current_user) 14 | click_link "Security" 15 | find("#enable-encrypted-messages").click 16 | using_wait_time(20) do 17 | expect(page).to have_content(I18n.t("js.encrypt.no_backup_warn")[0..100]) 18 | end 19 | expect(current_user.reload.user_encryption_key.encrypt_public).not_to eq(nil) 20 | expect(current_user.reload.user_encryption_key.encrypt_private).to eq(nil) 21 | end 22 | 23 | it "enables encryption and generates paper keys" do 24 | user_preferences_page.visit(current_user) 25 | click_link "Security" 26 | find("#enable-encrypted-messages").click 27 | using_wait_time(5) { expect(page).to have_content(I18n.t("js.encrypt.no_backup_warn")[0..100]) } 28 | expect(current_user.reload.user_encryption_key.encrypt_public).not_to eq(nil) 29 | find("#encrypt-generate-paper-key").click 30 | expect(page).to have_css(".generate-paper-key-modal .paper-key") 31 | paper_key = find(".generate-paper-key-modal .paper-key").text 32 | expect(paper_key).not_to eq(nil) 33 | try_until_success do 34 | expect(current_user.reload.user_encryption_key.encrypt_private).not_to eq(nil) 35 | end 36 | end 37 | 38 | it "activates encrypted messages on the device" do 39 | enable_encrypt_with_keys_for_user(current_user) 40 | user_preferences_page.visit(current_user) 41 | click_link "Security" 42 | expect(page).to have_content(I18n.t("js.encrypt.preferences.status_enabled_but_inactive")) 43 | find("#passphrase").fill_in(with: test_paper_key) 44 | find("#encrypt-activate").click 45 | expect(page).to have_content(I18n.t("js.encrypt.preferences.status_enabled")) 46 | expect(page.execute_script("return localStorage[\"discourse-encrypt\"]")).to eq("true") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /svg-icons/plugin-icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/javascripts/lib/base64-test.js: -------------------------------------------------------------------------------- 1 | import QUnit, { module, test } from "qunit"; 2 | import { 3 | base64ToBuffer, 4 | bufferToBase64, 5 | } from "discourse/plugins/discourse-encrypt/lib/base64"; 6 | 7 | /* 8 | * Checks if two array-like objects are equal. 9 | * 10 | * @param haystack 11 | * @param needle 12 | * @param message 13 | */ 14 | QUnit.assert.arrayEqual = function (actual, expected) { 15 | if (actual.length !== expected.length) { 16 | this.pushResult({ 17 | result: false, 18 | actual: actual.length, 19 | expected: expected.length, 20 | message: "array lengths are equal", 21 | }); 22 | 23 | return; 24 | } 25 | 26 | let result = true; 27 | 28 | for (let i = 0; i < actual.length; ++i) { 29 | if (actual[i] !== expected[i]) { 30 | result = false; 31 | this.pushResult({ 32 | result, 33 | actual: actual[i], 34 | expected: expected[i], 35 | message: `index ${i} matches`, 36 | }); 37 | } 38 | } 39 | 40 | if (result) { 41 | this.pushResult({ 42 | result, 43 | actual, 44 | expected, 45 | message: "arrays match", 46 | }); 47 | } 48 | }; 49 | 50 | module("discourse-encrypt:lib:base64", function () { 51 | test("base64 to buffer", function (assert) { 52 | let check = (actual, expected) => 53 | assert.arrayEqual(base64ToBuffer(actual), expected); 54 | 55 | check("", []); 56 | check("QQ==", [0x41]); 57 | check("QUI=", [0x41, 0x42]); 58 | check("QUJD", [0x41, 0x42, 0x43]); 59 | check("QUJDRA==", [0x41, 0x42, 0x43, 0x44]); 60 | }); 61 | 62 | test("buffer to base64", function (assert) { 63 | let check = (actual, expected) => 64 | assert.strictEqual(bufferToBase64(new Uint8Array(actual)), expected); 65 | 66 | check([], ""); 67 | check([0x41], "QQ=="); 68 | check([0x41, 0x42], "QUI="); 69 | check([0x41, 0x42, 0x43], "QUJD"); 70 | check([0x41, 0x42, 0x43, 0x44], "QUJDRA=="); 71 | }); 72 | 73 | test("buffer to base64 to buffer", function (assert) { 74 | const array = []; 75 | for (let i = 0; i < 32; ++i) { 76 | const buffer = new Uint8Array(array); 77 | assert.arrayEqual(base64ToBuffer(bufferToBase64(buffer)), buffer); 78 | array.push(i); 79 | } 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/javascripts/lib/database-safari-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { Promise } from "rsvp"; 3 | import { 4 | deleteDb, 5 | loadDbIdentity, 6 | setIndexedDb, 7 | setUserAgent, 8 | } from "discourse/plugins/discourse-encrypt/lib/database"; 9 | 10 | let indexedDbCalls = 0; 11 | 12 | module("discourse-encrypt:lib:database-safari", function (hooks) { 13 | hooks.beforeEach(function () { 14 | indexedDbCalls = 0; 15 | 16 | setIndexedDb({ 17 | open(name, version) { 18 | return window.indexedDB.open(name, version); 19 | }, 20 | 21 | databases() { 22 | return indexedDbCalls++ > 3 23 | ? window.indexedDB.databases() 24 | : new Promise(() => {}); 25 | }, 26 | 27 | deleteDatabase(name) { 28 | indexedDbCalls++; 29 | return window.indexedDB.deleteDatabase(name); 30 | }, 31 | }); 32 | 33 | setUserAgent("iPhone"); 34 | }); 35 | 36 | hooks.afterEach(function () { 37 | setIndexedDb(window.indexedDB); 38 | setUserAgent(window.navigator.userAgent); 39 | }); 40 | 41 | test("IndexedDB is initialized in Safari", async function (assert) { 42 | await deleteDb(); 43 | assert.rejects(loadDbIdentity()); 44 | assert.true(indexedDbCalls > 0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/javascripts/lib/database-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { 3 | DB_NAME, 4 | deleteDb, 5 | loadDbIdentity, 6 | saveDbIdentity, 7 | setUseLocalStorage, 8 | } from "discourse/plugins/discourse-encrypt/lib/database"; 9 | import { generateIdentity } from "discourse/plugins/discourse-encrypt/lib/protocol"; 10 | 11 | module("discourse-encrypt:lib:database", function () { 12 | test("IndexedDB backend", async function (assert) { 13 | setUseLocalStorage(false); 14 | await deleteDb(); 15 | 16 | assert.rejects(loadDbIdentity()); 17 | 18 | await generateIdentity().then((id) => saveDbIdentity(id)); 19 | assert.true(window.localStorage.getItem(DB_NAME).length > 0); 20 | 21 | const identity = await loadDbIdentity(); 22 | assert.true(identity.encryptPublic instanceof CryptoKey); 23 | assert.true(identity.encryptPrivate instanceof CryptoKey); 24 | assert.true(identity.signPublic instanceof CryptoKey); 25 | assert.true(identity.signPrivate instanceof CryptoKey); 26 | 27 | await deleteDb(); 28 | 29 | assert.rejects(loadDbIdentity()); 30 | assert.strictEqual(window.localStorage.getItem(DB_NAME), null); 31 | }); 32 | 33 | test("Web Storage (localStorage) backend", async function (assert) { 34 | setUseLocalStorage(true); 35 | await deleteDb(); 36 | 37 | assert.rejects(loadDbIdentity()); 38 | 39 | await generateIdentity().then((id) => saveDbIdentity(id)); 40 | assert.true(window.localStorage.getItem(DB_NAME).length > 0); 41 | 42 | const identity = await loadDbIdentity(); 43 | assert.true(identity.encryptPublic instanceof CryptoKey); 44 | assert.true(identity.encryptPrivate instanceof CryptoKey); 45 | assert.true(identity.signPublic instanceof CryptoKey); 46 | assert.true(identity.signPrivate instanceof CryptoKey); 47 | 48 | await deleteDb(); 49 | 50 | assert.rejects(loadDbIdentity()); 51 | assert.strictEqual(window.localStorage.getItem(DB_NAME), null); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/javascripts/lib/protocol-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { 3 | decrypt, 4 | encrypt, 5 | exportKey, 6 | generateIdentity, 7 | generateKey, 8 | importKey, 9 | } from "discourse/plugins/discourse-encrypt/lib/protocol"; 10 | 11 | module("discourse-encrypt:lib:protocol", function () { 12 | test("generateKey", async function (assert) { 13 | const key = await generateKey(); 14 | assert.true(key instanceof CryptoKey); 15 | }); 16 | 17 | test("exportKey & importKey", async function (assert) { 18 | const { encryptPublic, encryptPrivate } = await generateIdentity(); 19 | const key = await generateKey(); 20 | const exported = await exportKey(key, encryptPublic); 21 | assert.true( 22 | (await importKey(exported, encryptPrivate)) instanceof CryptoKey 23 | ); 24 | }); 25 | 26 | test("encrypt & decrypt", async function (assert) { 27 | const key = await generateKey(); 28 | const plaintext = "this is a message"; 29 | const ciphertext = await encrypt(key, plaintext); 30 | 31 | assert.strictEqual(plaintext, await decrypt(key, ciphertext)); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/javascripts/lib/protocol-v0-test.js: -------------------------------------------------------------------------------- 1 | import QUnit, { module, test } from "qunit"; 2 | import { 3 | _bufferToString, 4 | _exportPrivateKey, 5 | _exportPublicKey, 6 | _getPassphraseKey, 7 | _getSalt, 8 | _importPrivateKey, 9 | _importPublicKey, 10 | _stringToBuffer, 11 | generateIdentity, 12 | } from "discourse/plugins/discourse-encrypt/lib/protocol-v0"; 13 | 14 | /* 15 | * Checks if two array-like objects are equal. 16 | * 17 | * @param haystack 18 | * @param needle 19 | * @param message 20 | */ 21 | QUnit.assert.arrayEqual = function (actual, expected) { 22 | if (actual.length !== expected.length) { 23 | this.pushResult({ 24 | result: false, 25 | actual: actual.length, 26 | expected: expected.length, 27 | message: "array lengths are not equal", 28 | }); 29 | 30 | return; 31 | } 32 | 33 | let result = true; 34 | 35 | for (let i = 0; i < actual.length; ++i) { 36 | if (actual[i] !== expected[i]) { 37 | result = false; 38 | this.pushResult({ 39 | result, 40 | actual: actual[i], 41 | expected: expected[i], 42 | message: `index ${i} mismatches`, 43 | }); 44 | } 45 | } 46 | 47 | if (result) { 48 | this.pushResult({ 49 | result, 50 | actual, 51 | expected, 52 | message: "arrays match", 53 | }); 54 | } 55 | }; 56 | 57 | module("discourse-encrypt:lib:protocol_v0", function () { 58 | test("string to buffer", function (assert) { 59 | let check = (actual, expected) => 60 | assert.arrayEqual(new Uint16Array(_stringToBuffer(actual)), expected); 61 | 62 | check("", []); 63 | check("A", [0x41]); 64 | check("AB", [0x41, 0x42]); 65 | check("ABC", [0x41, 0x42, 0x43]); 66 | check("ABCD", [0x41, 0x42, 0x43, 0x44]); 67 | }); 68 | 69 | test("buffer to string", function (assert) { 70 | let check = (actual, expected) => 71 | assert.strictEqual(_bufferToString(new Uint16Array(actual)), expected); 72 | 73 | check([], ""); 74 | check([0x41], "A"); 75 | check([0x41, 0x42], "AB"); 76 | check([0x41, 0x42, 0x43], "ABC"); 77 | check([0x41, 0x42, 0x43, 0x44], "ABCD"); 78 | }); 79 | 80 | test("buffer to string to buffer", function (assert) { 81 | const array = []; 82 | for (let i = 0; i < 32; ++i) { 83 | const expected = new Uint16Array(array); 84 | assert.arrayEqual( 85 | new Uint16Array(_stringToBuffer(_bufferToString(expected))), 86 | expected 87 | ); 88 | array.push(i); 89 | } 90 | }); 91 | 92 | test("_exportPublicKey & _importPublicKey", async function (assert) { 93 | const { publicKey } = await generateIdentity(); 94 | const exported = await _exportPublicKey(publicKey); 95 | assert.true((await _importPublicKey(exported)) instanceof CryptoKey); 96 | }); 97 | 98 | test("_exportPrivateKey & _importPrivateKey", async function (assert) { 99 | const key = await _getPassphraseKey("passphrase", _getSalt()); 100 | const { privateKey } = await generateIdentity(); 101 | const exported = await _exportPrivateKey(privateKey, key); 102 | assert.true((await _importPrivateKey(exported, key)) instanceof CryptoKey); 103 | }); 104 | 105 | test("_getPassphraseKey", async function (assert) { 106 | const key = await _getPassphraseKey("passphrase", _getSalt()); 107 | assert.true(key instanceof CryptoKey); 108 | }); 109 | 110 | test("_getSalt", async function (assert) { 111 | assert.strictEqual(_getSalt().length, 24); 112 | }); 113 | 114 | test("generateIdentity", async function (assert) { 115 | const { publicKey, privateKey } = await generateIdentity(); 116 | assert.true(publicKey instanceof CryptoKey); 117 | assert.true(privateKey instanceof CryptoKey); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/javascripts/lib/protocol-v1-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { 3 | exportIdentity, 4 | generateIdentity, 5 | importIdentity, 6 | } from "discourse/plugins/discourse-encrypt/lib/protocol-v1"; 7 | 8 | module("discourse-encrypt:lib:protocol_v1", function () { 9 | test("generateIdentity", async function (assert) { 10 | const { encryptPublic, encryptPrivate, signPublic, signPrivate } = 11 | await generateIdentity(); 12 | 13 | assert.true(encryptPublic instanceof CryptoKey); 14 | assert.true(encryptPrivate instanceof CryptoKey); 15 | assert.true(signPublic instanceof CryptoKey); 16 | assert.true(signPrivate instanceof CryptoKey); 17 | }); 18 | 19 | test("exportIdentity & importIdentity", async function (assert) { 20 | const identity = await generateIdentity(); 21 | 22 | let exported = await exportIdentity(identity); 23 | let imported = await importIdentity(exported.private); 24 | assert.true(imported.encryptPublic instanceof CryptoKey); 25 | assert.true(imported.encryptPrivate instanceof CryptoKey); 26 | assert.true(imported.signPublic instanceof CryptoKey); 27 | assert.true(imported.signPrivate instanceof CryptoKey); 28 | imported = await importIdentity(exported.public); 29 | assert.true(imported.encryptPublic instanceof CryptoKey); 30 | assert.strictEqual(imported.encryptPrivate, undefined); 31 | assert.true(imported.signPublic instanceof CryptoKey); 32 | assert.strictEqual(imported.signPrivate, undefined); 33 | 34 | exported = await exportIdentity(identity, "test"); 35 | imported = await importIdentity(exported.private, "test"); 36 | assert.true(imported.encryptPublic instanceof CryptoKey); 37 | assert.true(imported.encryptPrivate instanceof CryptoKey); 38 | assert.true(imported.signPublic instanceof CryptoKey); 39 | assert.true(imported.signPrivate instanceof CryptoKey); 40 | imported = await importIdentity(exported.public); 41 | assert.true(imported.encryptPublic instanceof CryptoKey); 42 | assert.strictEqual(imported.encryptPrivate, undefined); 43 | assert.true(imported.signPublic instanceof CryptoKey); 44 | assert.strictEqual(imported.signPrivate, undefined); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/javascripts/lib/uploads-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from "qunit"; 2 | import { base64ToBuffer } from "discourse/plugins/discourse-encrypt/lib/base64"; 3 | import { getMetadata } from "discourse/plugins/discourse-encrypt/lib/uploads"; 4 | 5 | const TEST_IMG_BASE64 = 6 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; 7 | const SITE_SETTINGS = { max_image_width: 100, max_image_height: 100 }; 8 | 9 | module("discourse-encrypt:lib:uploadHandler", function () { 10 | test("getMetadata - image file", async function (assert) { 11 | const file = new File([base64ToBuffer(TEST_IMG_BASE64)], "test.png", { 12 | type: "image/png", 13 | }); 14 | const data = await getMetadata(file, SITE_SETTINGS); 15 | assert.strictEqual(data.original_filename, "test.png"); 16 | assert.strictEqual(data.width, 1); 17 | assert.strictEqual(data.height, 1); 18 | assert.strictEqual(data.thumbnail_width, 1); 19 | assert.strictEqual(data.thumbnail_height, 1); 20 | assert.true(data.url.length > 0); 21 | }); 22 | 23 | test("getMetadata - other file", async function (assert) { 24 | const file = new File(["test"], "test.txt", { type: "text/plain" }); 25 | const data = await getMetadata(file, SITE_SETTINGS); 26 | assert.strictEqual(data.original_filename, "test.txt"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /translator.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for discourse-translator-bot 2 | 3 | files: 4 | - source_path: config/locales/client.en.yml 5 | destination_path: client.yml 6 | - source_path: config/locales/server.en.yml 7 | destination_path: server.yml 8 | --------------------------------------------------------------------------------