├── .codeclimate.yml ├── .gitignore ├── .hound.yml ├── .rubocop.yml ├── .rubocop_todo.yml ├── .ruby-version ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app └── jobs │ ├── regular │ ├── nntp_bridge_exporter.rb │ └── nntp_bridge_importer.rb │ └── scheduled │ └── nntp_bridge_import_scheduler.rb ├── config ├── locales │ └── server.en.yml └── settings.yml ├── db └── migrate │ └── 20160215005444_create_nntp_post_associations.rb ├── lib ├── discourse_nntp_bridge.rb ├── discourse_nntp_bridge │ ├── basic_message.rb │ ├── engine.rb │ ├── flowed_format.rb │ ├── new_post_message.rb │ ├── newsgroup_importer.rb │ ├── post_importer.rb │ └── server.rb └── tasks │ └── discourse_nntp_bridge.rake ├── plugin.rb └── spec ├── jobs ├── nntp_bridge_exporter_spec.rb ├── nntp_bridge_import_scheduler_spec.rb └── nntp_bridge_importer_spec.rb ├── models ├── discourse_nntp_bridge_spec.rb ├── nntp_post_spec.rb ├── post_importer_spec.rb └── post_spec.rb └── rails_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | brakeman: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | - javascript 11 | - python 12 | - php 13 | fixme: 14 | enabled: true 15 | rubocop: 16 | enabled: true 17 | exclude_fingerprints: 18 | - de4045a1d1f37547dc45dac3fbe77351 19 | ratings: 20 | paths: 21 | - Gemfile.lock 22 | - "**.erb" 23 | - "**.haml" 24 | - "**.rb" 25 | - "**.rhtml" 26 | - "**.slim" 27 | - "**.inc" 28 | - "**.js" 29 | - "**.jsx" 30 | - "**.module" 31 | - "**.php" 32 | - "**.py" 33 | exclude_paths: 34 | - config/ 35 | - db/ 36 | - spec/ 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gems/ 2 | discourse-plugin-ci/ 3 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | rubocop: 2 | config_file: .rubocop.yml 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | Exclude: 5 | - gems/**/* 6 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2019-06-21 15:35:02 -0700 using RuboCop version 0.71.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | Lint/Loop: 11 | Exclude: 12 | - "lib/tasks/discourse_nntp_bridge.rake" 13 | 14 | # Offense count: 11 15 | Metrics/AbcSize: 16 | Max: 39 17 | 18 | # Offense count: 9 19 | # Configuration parameters: CountComments, ExcludedMethods. 20 | # ExcludedMethods: refine 21 | Metrics/BlockLength: 22 | Max: 66 23 | 24 | # Offense count: 1 25 | # Configuration parameters: CountComments. 26 | Metrics/ClassLength: 27 | Max: 191 28 | 29 | # Offense count: 5 30 | Metrics/CyclomaticComplexity: 31 | Max: 8 32 | 33 | # Offense count: 11 34 | # Configuration parameters: CountComments, ExcludedMethods. 35 | Metrics/MethodLength: 36 | Max: 28 37 | 38 | # Offense count: 5 39 | Metrics/PerceivedComplexity: 40 | Max: 10 41 | 42 | # Offense count: 1 43 | # Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist, MethodDefinitionMacros. 44 | # NamePrefix: is_, has_, have_ 45 | # NamePrefixBlacklist: is_, has_, have_ 46 | # NameWhitelist: is_a? 47 | # MethodDefinitionMacros: define_method, define_singleton_method 48 | Naming/PredicateName: 49 | Exclude: 50 | - "spec/**/*" 51 | - "app/jobs/regular/nntp_bridge_importer.rb" 52 | 53 | # Offense count: 1 54 | # Cop supports --auto-correct. 55 | # Configuration parameters: AutoCorrect, EnforcedStyle. 56 | # SupportedStyles: nested, compact 57 | Style/ClassAndModuleChildren: 58 | Exclude: 59 | - "plugin.rb" 60 | 61 | # Offense count: 1 62 | Style/ClassVars: 63 | Exclude: 64 | - "app/jobs/regular/nntp_bridge_importer.rb" 65 | 66 | # Offense count: 13 67 | Style/Documentation: 68 | Enabled: false 69 | 70 | # Offense count: 5 71 | # Configuration parameters: MinBodyLength. 72 | Style/GuardClause: 73 | Exclude: 74 | - "lib/discourse_nntp_bridge/new_post_message.rb" 75 | - "lib/discourse_nntp_bridge/post_importer.rb" 76 | 77 | # Offense count: 6 78 | # Cop supports --auto-correct. 79 | Style/IfUnlessModifier: 80 | Exclude: 81 | - "lib/discourse_nntp_bridge.rb" 82 | - "lib/discourse_nntp_bridge/new_post_message.rb" 83 | - "lib/discourse_nntp_bridge/post_importer.rb" 84 | 85 | # Offense count: 1 86 | # Cop supports --auto-correct. 87 | # Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods. 88 | # SupportedStyles: predicate, comparison 89 | Style/NumericPredicate: 90 | Exclude: 91 | - "spec/**/*" 92 | - "lib/tasks/discourse_nntp_bridge.rake" 93 | 94 | # Offense count: 61 95 | # Cop supports --auto-correct. 96 | # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 97 | # URISchemes: http, https 98 | Metrics/LineLength: 99 | Max: 209 100 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # We want to use the KVM-based system, so require sudo 2 | sudo: required 3 | services: 4 | - docker 5 | 6 | before_install: 7 | - git clone --depth=1 https://github.com/discourse/discourse-plugin-ci 8 | 9 | install: true # Prevent travis doing bundle install 10 | 11 | script: 12 | - discourse-plugin-ci/script.sh 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changes are managed at [GitHub Releases](https://github.com/sman591/discourse-nntp-bridge/releases) 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stuart Olivera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discourse-nntp-bridge [![Build Status](https://travis-ci.org/sman591/discourse-nntp-bridge.svg?branch=master)](https://travis-ci.org/sman591/discourse-nntp-bridge) [![Code Climate](https://codeclimate.com/github/sman591/discourse-nntp-bridge/badges/gpa.svg)](https://codeclimate.com/github/sman591/discourse-nntp-bridge) [![security](https://hakiri.io/github/sman591/discourse-nntp-bridge/master.svg)](https://hakiri.io/github/sman591/discourse-nntp-bridge/master) 2 | NNTP bridge to keep NNTP & Discourse in sync 3 | 4 | Primarily used for keeping [CSH Discourse](https://discourse.csh.rit.edu) in sync with the CSH news server. 5 | 6 | ## Installation 7 | 8 | 1. Add `https://github.com/sman591/discourse-nntp-bridge.git` [as a plugin](https://meta.discourse.org/t/install-a-plugin/19157) 9 | 2. Add `NEWS_HOST`, `NEWS_USERNAME`, and `NEWS_PASSWORD` environment variables to your `app.yml` 10 | 3. Rebuild your app: `./launcher rebuild app` 11 | 4. Enter the app: `./launcher enter app` 12 | 5. Run `rake discourse_nntp_bridge:assign_newsgroups` to assign newsgroups to your already-created categories 13 | 14 | ## NNTP Communication 15 | 16 | The only required environment variable is `NEWS_HOST`. `NEWS_USERNAME` and `NEWS_PASSWORD` are both optional, and at least one of the two must be present in order to send authentication along with NNTP. 17 | 18 | Most of the NNTP communication was written by Alex Grant for [CSH WebNews](https://github.com/grantovich/CSH-WebNews), and is used/adapted upon heavily throughout this plugin. 19 | 20 | ## Development 21 | 22 | Development requires running a local copy of the latest-release of Discourse. 23 | 24 | Assuming you store repositories in `~/dev`, the Discourse repo should be located at `~/dev/discourse` and the NNTP bridge repo should be at `~/dev/discourse-nntp-bridge`. 25 | 26 | ```bash 27 | cd ~/dev/ 28 | git clone git@github.com:discourse/discourse.git 29 | cd discourse 30 | git fetch --tags 31 | git checkout tags/latest-release 32 | cd plugins && ln -s ../../discourse-nntp-bridge && cd ../ 33 | ``` 34 | 35 | This clones Discourse, checks out the `latest-release` tag, and adds `discourse-nntp-bridge` to the plugin directory via a symlink --- making it easy to make changes to the plugin in development. 36 | 37 | After this, you'll want to get Discourse set up & running locally. See [Discourse's README](https://github.com/discourse/discourse/blob/latest-release/README.md#development) for more info! 38 | -------------------------------------------------------------------------------- /app/jobs/regular/nntp_bridge_exporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class NntpBridgeExporter < Jobs::Base 5 | def execute(args) 6 | post = Post.find(args[:post_id]) 7 | return unless post 8 | 9 | DiscourseNntpBridge.create_article_from_post post 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/jobs/regular/nntp_bridge_importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class NntpBridgeImporter < Jobs::Base 5 | @@active_importers = [] 6 | 7 | def execute(args) 8 | newsgroup = args[:newsgroup] 9 | return if self.class.is_active? newsgroup 10 | 11 | @@active_importers << newsgroup 12 | begin 13 | DiscourseNntpBridge::NewsgroupImporter.new(quiet: ENV['QUIET'].present?).sync! newsgroup 14 | ensure 15 | @@active_importers.delete(newsgroup) 16 | end 17 | end 18 | 19 | def self.is_active?(newsgroup) 20 | @@active_importers.include? newsgroup 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/jobs/scheduled/nntp_bridge_import_scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class NntpBridgeImportScheduler < Jobs::Scheduled 5 | every 1.minute 6 | 7 | def execute(_args) 8 | newsgroups = CategoryCustomField.where(name: 'nntp_bridge_newsgroup').pluck(:value) 9 | 10 | newsgroups.each do |newsgroup| 11 | next if Jobs::NntpBridgeImporter.is_active?(newsgroup) || self.class.importer_queued?(newsgroup) 12 | 13 | Jobs.enqueue(:nntp_bridge_importer, newsgroup: newsgroup) 14 | end 15 | end 16 | 17 | def self.importer_queued?(newsgroup) 18 | queries = [ 19 | proc { Sidekiq::Queue.new }, 20 | proc { Sidekiq::RetrySet.new } 21 | ] 22 | queries.any? do |query| 23 | query.call.any? do |job| 24 | job.klass == 'Jobs::NntpBridgeImporter' && 25 | job.args[0]['newsgroup'] == newsgroup 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | nntp_bridge_enabled: "Enable the NNTP bridge plugin" 4 | nntp_bridge_override_title_validations: "Ignore validations on topic titles when post subject from NNTP is invalid" 5 | nntp_bridge_guest_username: "Guest username for users who aren't in the system. We recommend creating a \"guest\" user instead of using system." 6 | nntp_bridge_guest_notice: "Notice prepended to posts by unregistered/guest users, markdown supported. Use {author} to place the NNTP author field." 7 | nntp_bridge_dethreaded_notice: "Notice prepended to posts when NNTP post couldn't guarantee parent ownership." 8 | nntp_bridge_empty_body_replacement: "Post body to use when the body from NNTP is blank." 9 | nntp_bridge_invalid_body_replacement: "Post body to use when the body from NNTP is invalid." 10 | nntp_bridge_nntp_user_agent: "User agent header for NNTP posts created by Discourse" 11 | nntp_bridge_default_newsgroup: "Default NNTP newsgroup to post to if the Discourse category doesn't have one assigned. Leave blank to only post assigned categories to NNTP." 12 | nntp_bridge_ignore_messages: "Skip importing these message IDs. Comma-separated." 13 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | nntp_bridge_enabled: 3 | default: true 4 | client: true 5 | nntp_bridge_override_title_validations: 6 | default: true 7 | client: true 8 | nntp_bridge_guest_username: 9 | default: system 10 | type: username 11 | client: true 12 | nntp_bridge_guest_notice: 13 | default: "*Post from NNTP guest {author}*" 14 | client: true 15 | nntp_bridge_dethreaded_notice: 16 | default: "*This post was de-threaded from NNTP. Discourse tried to guess where it belongs, but this may not be correct.*" 17 | client: true 18 | nntp_bridge_empty_body_replacement: 19 | default: "*(empty body from NNTP)*" 20 | client: true 21 | nntp_bridge_invalid_body_replacement: 22 | default: "*(invalid body from NNTP)*" 23 | client: true 24 | nntp_bridge_nntp_user_agent: 25 | default: Discourse NNTP Bridge 26 | client: true 27 | nntp_bridge_default_newsgroup: 28 | default: "" 29 | client: true 30 | nntp_bridge_ignore_messages: 31 | default: "" 32 | client: true 33 | -------------------------------------------------------------------------------- /db/migrate/20160215005444_create_nntp_post_associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateNntpPostAssociations < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :discourse_nntp_bridge_nntp_posts do |t| 6 | t.belongs_to :post, index: true 7 | t.string :message_id, index: true 8 | t.timestamps null: false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | require_relative 'discourse_nntp_bridge/engine' 5 | require_relative 'discourse_nntp_bridge/basic_message' 6 | require_relative 'discourse_nntp_bridge/flowed_format' 7 | require_relative 'discourse_nntp_bridge/new_post_message' 8 | require_relative 'discourse_nntp_bridge/newsgroup_importer' 9 | require_relative 'discourse_nntp_bridge/post_importer' 10 | require_relative 'discourse_nntp_bridge/server' 11 | 12 | def self.create_article_from_post(post) 13 | return unless SiteSetting.nntp_bridge_enabled? 14 | 15 | return if post.topic.private_message? 16 | return if post.post_type == Post.types[:small_action] 17 | return if post.post_type == Post.types[:moderator_action] 18 | 19 | if post.is_first_post? 20 | title = post.topic.title 21 | parent_id = nil 22 | else 23 | title = 'Re: ' + post.topic.title 24 | parent_id = NntpPost.where(post_id: post.topic.first_post.id).first.message_id 25 | end 26 | 27 | body = convert_post_body_quotes post.raw 28 | newsgroup_ids = post.topic.category.custom_fields['nntp_bridge_newsgroup'].presence || SiteSetting.nntp_bridge_default_newsgroup 29 | 30 | new_post_params = { 31 | body: body, 32 | parent_id: parent_id, 33 | newsgroup_ids: newsgroup_ids, 34 | subject: title, 35 | user: post.user 36 | } 37 | message = NewPostMessage.new(new_post_params) 38 | message_id = message.transmit 39 | if message_id.present? 40 | NntpPost.create(post: post, message_id: message_id) 41 | else 42 | puts "No message ID returned when posting post #{post.id} to NNTP" 43 | end 44 | end 45 | 46 | # private 47 | 48 | def self.convert_post_body_quotes(body) 49 | converted_body = +'' 50 | body.split('[/quote]').each do |section| 51 | section.sub!(/\n\z/, '') 52 | matches = /\[quote="(.*), post.*\]\n*(.*)/m.match section 53 | unless matches 54 | converted_body << section 55 | next 56 | end 57 | quoted_text = +'' 58 | matches[2].lines.each { |line| quoted_text << "> #{line}" } 59 | if quoted_text.present? 60 | quoted_text = get_name_from_username(matches[1]) + " wrote:\n\n" + quoted_text 61 | end 62 | section.sub! matches[0], quoted_text 63 | converted_body << section 64 | end 65 | converted_body 66 | end 67 | 68 | def self.get_name_from_username(username) 69 | user = User.where(username: username).first 70 | return username if user.blank? || user.name.blank? 71 | 72 | user.name 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/basic_message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class BasicMessage 5 | include ActiveAttr::Model 6 | include ActiveModel::ForbiddenAttributesProtection 7 | 8 | attr_reader :was_accepted 9 | 10 | attribute :user, type: Object 11 | 12 | validates! :user, presence: true 13 | 14 | def transmit 15 | return if was_accepted 16 | return unless valid? 17 | 18 | message_id = begin 19 | Server.new.post(to_mail.to_s) 20 | rescue Net::NNTPError 21 | errors.add(:nntp, $ERROR_INFO.message) 22 | nil 23 | end 24 | 25 | message_id 26 | # if message_id.present? 27 | # @was_accepted = true 28 | 29 | # begin 30 | # DiscourseNntpBridge::NewsgroupImporter.new.sync!(newsgroups) 31 | # rescue 32 | # ExceptionNotifier.notify_exception($!) 33 | # end 34 | 35 | # Post.find_by(id: message_id) 36 | # end 37 | end 38 | 39 | private 40 | 41 | def to_mail 42 | mail = Mail.new(from: from_line) 43 | 44 | mail.header['User-Agent'] = SiteSetting.nntp_bridge_nntp_user_agent 45 | 46 | mail 47 | end 48 | 49 | def from_line 50 | address = Mail::Address.new 51 | address.display_name = user.name 52 | address.address = user.email 53 | address.to_s 54 | end 55 | 56 | def newsgroups 57 | raise 'must be implemented in subclass' 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class Engine < ::Rails::Engine 5 | isolate_namespace DiscourseNntpBridge 6 | 7 | initializer :append_migrations do |app| 8 | unless app.root.to_s.match root.to_s 9 | config.paths['db/migrate'].expanded.each do |expanded_path| 10 | app.config.paths['db/migrate'] << expanded_path 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/flowed_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Decodes and encodes Mail::Message objects from or into the "flowed format" 4 | # specified in RFC3676 (though without support for the "DelSp" parameter) 5 | 6 | module DiscourseNntpBridge 7 | module FlowedFormat 8 | # TODO: This does not actually perform a message-object-to-message-object 9 | # decoding, it instead returns a string that is the decoded message body, 10 | # whether or not it was flowed. Implementing the former is blocked on this: 11 | # https://github.com/mikel/mail/issues/793 12 | def self.decode_message(message) 13 | if message.content_type_parameters.to_h['format'] == 'flowed' 14 | new_body_lines = [] 15 | message.decoded.each_line do |line| 16 | line.chomp! 17 | quotes = line[/^>+/] 18 | line.sub!(/^>+/, '') 19 | line.sub!(/^ /, '') 20 | if (line != '-- ') && 21 | !new_body_lines.empty? && 22 | !new_body_lines[-1][/^-- $/] && 23 | new_body_lines[-1][/ $/] && 24 | (quotes == new_body_lines[-1][/^>+/]) 25 | new_body_lines[-1] << line 26 | else 27 | new_body_lines << quotes.to_s + line 28 | end 29 | end 30 | 31 | new_body_lines.join("\n") 32 | else 33 | message.decoded 34 | end 35 | end 36 | 37 | def self.encode_message(message) 38 | if (!message.has_content_type? || message.content_type == 'text/plain') && 39 | message.content_type_parameters.to_h['format'] != 'flowed' 40 | message = message.dup 41 | message.content_type ||= 'text/plain' 42 | message.content_type_parameters[:format] = 'flowed' 43 | 44 | message.body = message.body.to_s.split("\n").map do |line| 45 | line.rstrip! 46 | quotes = '' 47 | if line[/^>/] 48 | quotes = line[/^([> ]*>)/, 1].delete(' ') 49 | line.gsub!(/^[> ]*>/, '') 50 | end 51 | line = ' ' + line if line[/^ /] 52 | if line.length > 78 53 | line.gsub(/(.{1,#{72 - quotes.length}}|[^\s]+)(\s+|$)/, "#{quotes}\\1 \n").rstrip 54 | else 55 | quotes + line 56 | end 57 | end.join("\n") 58 | end 59 | 60 | message 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/new_post_message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class NewPostMessage < BasicMessage 5 | attribute :body, type: String, default: '' 6 | attribute :followup_newsgroup_id, type: String 7 | attribute :newsgroup_ids, type: String, default: '' 8 | attribute :parent_id, type: String, default: nil 9 | attribute :subject, type: String 10 | 11 | validates :newsgroup_ids, :subject, presence: true 12 | # validate :followup_newsgroup_must_exist 13 | # validate :newsgroups_must_exist_and_allow_posting 14 | # validate :parent_must_exist 15 | 16 | private 17 | 18 | def to_mail 19 | mail = super 20 | mail.subject = subject 21 | mail.body = body 22 | mail = FlowedFormat.encode_message(mail) 23 | 24 | mail.header['Newsgroups'] = newsgroup_ids 25 | if parsed_newsgroup_ids.size > 1 26 | mail.header['Followup-To'] = followup_newsgroup.id 27 | end 28 | 29 | if parent.present? 30 | # Mail gem does not automatically wrap Message-IDs in brackets 31 | mail.references = [ 32 | parent_message 33 | ].flatten.compact.map { |message_id| "<#{message_id}>" } 34 | end 35 | 36 | mail 37 | end 38 | 39 | def newsgroups 40 | @newsgroups ||= Newsgroup.where(id: parsed_newsgroup_ids) 41 | end 42 | 43 | def parsed_newsgroup_ids 44 | @parsed_newsgroup_ids ||= newsgroup_ids.split(',') 45 | end 46 | 47 | def followup_newsgroup 48 | @followup_newsgroup ||= Newsgroup.find_by(id: followup_newsgroup_id) 49 | end 50 | 51 | def parent_message 52 | @parent_message ||= parent.present? ? parent_id : nil 53 | end 54 | 55 | def parent 56 | @parent ||= parent_id 57 | end 58 | 59 | def followup_newsgroup_must_exist 60 | if parsed_newsgroup_ids.size > 1 61 | if followup_newsgroup_id.blank? 62 | errors.add(:followup_newsgroup_id, 'must be provided if posting to multiple newsgroups') 63 | elsif followup_newsgroup.blank? 64 | errors.add(:followup_newsgroup_id, 'specifies a nonexistent newsgroup') 65 | end 66 | end 67 | end 68 | 69 | def newsgroups_must_exist_and_allow_posting 70 | if newsgroups.size != parsed_newsgroup_ids.size 71 | errors.add(:newsgroup_ids, 'specifies one or more nonexistent newsgroups') 72 | elsif newsgroups.size != newsgroups.where_posting_allowed.size 73 | errors.add(:newsgroup_ids, 'specifies one or more read-only newsgroups') 74 | end 75 | end 76 | 77 | def parent_must_exist 78 | if parent_id.present? && parent.blank? 79 | errors.add(:parent_id, 'specifies a nonexistent post') 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/newsgroup_importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class NewsgroupImporter 5 | def initialize(quiet: false) 6 | @server = Server.new 7 | @importer = PostImporter.new(quiet: quiet) 8 | end 9 | 10 | def sync_all! 11 | return unless SiteSetting.nntp_bridge_enabled? 12 | 13 | newsgroups = CategoryCustomField.where(name: 'nntp_bridge_newsgroup').pluck(:value).reverse 14 | 15 | newsgroups.each do |newsgroup| 16 | sync! newsgroup 17 | end 18 | 19 | puts if File.basename($PROGRAM_NAME) == 'rake' 20 | end 21 | 22 | def sync!(newsgroup) 23 | return unless SiteSetting.nntp_bridge_enabled? 24 | 25 | local_message_ids = NntpPost.pluck(:message_id) 26 | remote_message_ids = @server.message_ids([newsgroup]) 27 | # message_ids_to_destroy = local_message_ids - remote_message_ids 28 | ignore_ids = SiteSetting.nntp_bridge_ignore_messages.split(',').collect(&:strip) 29 | 30 | message_ids_to_import = remote_message_ids - local_message_ids - ignore_ids 31 | 32 | # if message_ids_to_destroy.any? 33 | # puts "Deleting #{message_ids_to_destroy.size} posts" if File.basename($0) == 'rake' 34 | # Post.where(id: message_ids_to_destroy).destroy_all 35 | # end 36 | 37 | puts 38 | puts "Importing #{message_ids_to_import.size} posts from #{newsgroup}" if File.basename($PROGRAM_NAME) == 'rake' 39 | message_ids_to_import.each do |message_id| 40 | @importer.import!(@server.article(message_id), newsgroup) 41 | print '.' if File.basename($PROGRAM_NAME) == 'rake' 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/post_importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class PostImporter 5 | def initialize(quiet: false) 6 | @quiet = quiet 7 | end 8 | 9 | def import!(article, newsgroup, post = Post.new) 10 | return unless SiteSetting.nntp_bridge_enabled? 11 | 12 | ActiveRecord::Base.transaction do 13 | update_post_from_article(article, post, newsgroup) 14 | # process_subscriptions(post) unless @quiet 15 | end 16 | 17 | post 18 | end 19 | 20 | private 21 | 22 | def create_article_from_nntp(message, newsgroup) 23 | article = Article.new 24 | article.message = message 25 | mail = Mail.new(article.message) 26 | 27 | article.message_id = mail.message_id 28 | article.author_raw = header_from_message(mail, 'From') 29 | article.author_name = author_name_from_message(mail) 30 | article.author_email = author_email_from_message(mail) 31 | article.subject = header_from_message(mail, 'Subject') 32 | article.created_at = date_from_message(mail) 33 | 34 | article.headers, article.body = headers_and_body_from_message(mail) 35 | threading = guess_threading_for_post(article, newsgroup) 36 | 37 | article.parent = threading.parent 38 | article.is_dethreaded = !threading.is_correct 39 | 40 | # followup_newsgroup = if mail.header['Followup-To'].present? 41 | # Newsgroup.find_by(id: mail.header['Followup-To'].to_s) 42 | # end 43 | 44 | article 45 | end 46 | 47 | def update_post_from_article(article, post, newsgroup) 48 | article = create_article_from_nntp(article, newsgroup) 49 | 50 | user_id = find_user_from_article(article).id 51 | topic_id = find_or_create_topic_from_article(article, user_id, newsgroup).id 52 | 53 | if article.body.blank? 54 | article.body = SiteSetting.nntp_bridge_empty_body_replacement.presence || '*(empty body from NNTP)*' 55 | elsif !TextSentinel.body_sentinel(article.body).valid? 56 | article.body = SiteSetting.nntp_bridge_invalid_body_replacement.presence || '*(invalid body from NNTP)*' 57 | end 58 | 59 | post.assign_attributes( 60 | user_id: user_id, 61 | topic_id: topic_id, 62 | created_at: article.created_at, 63 | updated_at: article.created_at, 64 | raw: article.body 65 | ) 66 | begin 67 | post.save! 68 | rescue PrettyText::JavaScriptError 69 | puts "JS error while parsing article #{article.message_id}" if File.basename($PROGRAM_NAME) == 'rake' 70 | raise ActiveRecord::Rollback 71 | end 72 | DiscourseNntpBridge::NntpPost.create!( 73 | message_id: article.message_id, 74 | post_id: post.id 75 | ) 76 | if post.topic.bumped_at < post.created_at 77 | post.topic.update_attribute(:bumped_at, post.created_at) 78 | end 79 | end 80 | 81 | # def process_subscriptions(post) 82 | # User.active.each do |user| 83 | # if not post.authored_by?(user) 84 | # subscriptions = user.subscriptions.for(post.newsgroups) 85 | # unread_level = subscriptions.where.not(unread_level: nil).minimum(:unread_level) 86 | # unread_level ||= user.default_subscription.unread_level 87 | # email_level = subscriptions.where.not(email_level: nil).minimum(:email_level) 88 | # email_level ||= user.default_subscription.email_level 89 | 90 | # potential_unread = Unread.new(user: user, post: post) 91 | # personal_level = potential_unread.personal_level 92 | 93 | # if personal_level >= unread_level 94 | # potential_unread.save! 95 | # end 96 | 97 | # if personal_level >= email_level 98 | # Mailer.post_notification(post, user).deliver_now 99 | # end 100 | # end 101 | # end 102 | # end 103 | 104 | def guess_threading_for_post(article, newsgroup) 105 | guess_threading_from_references(article) || 106 | guess_threading_from_subject(article, newsgroup) || 107 | Threading.new(nil, true) 108 | end 109 | 110 | def guess_threading_from_references(article) 111 | references = Array(Mail.new(article.headers).references) 112 | 113 | if references.present? 114 | parent_from_references = DiscourseNntpBridge::NntpPost.find_by(message_id: references[-1]) 115 | root_from_references = DiscourseNntpBridge::NntpPost.find_by(message_id: references[0]) 116 | 117 | if parent_from_references.present? && parent_from_references.post.present? 118 | Threading.new(parent_from_references.post.topic, true) 119 | elsif root_from_references.present? && root_from_references.post.present? 120 | Threading.new(root_from_references.post.topic) 121 | end 122 | end 123 | end 124 | 125 | def guess_threading_from_subject(article, newsgroup) 126 | if article.subject =~ /Re:/i 127 | topics = category_for_newsgroup(newsgroup).topics 128 | guessed_topic = topics 129 | .where( 130 | 'created_at < ? AND created_at > ?', 131 | article.created_at, 132 | article.created_at - 3.months 133 | ) 134 | .where( 135 | 'title = ? OR title = ? OR title = ?', 136 | article.subject, 137 | article.subject.sub(/^Re: ?/i, ''), 138 | article.subject.sub(/^Re: ?(\[.+\] )?/i, '') 139 | ) 140 | .order(:created_at).first 141 | 142 | Threading.new(guessed_topic) if guessed_topic.present? 143 | end 144 | end 145 | 146 | # def initialize_postings_for_message(mail) 147 | # followup_newsgroup_name = mail.header['Followup-To'].to_s 148 | # xrefs = mail.header['Xref'].to_s.split[1..-1].map{ |xref| xref.split(':') } 149 | 150 | # xrefs.map do |(newsgroup_name, number)| 151 | # if Newsgroup.exists?(newsgroup_name) 152 | # Posting.new(newsgroup_id: newsgroup_name, number: number) 153 | # end 154 | # end.compact 155 | # end 156 | 157 | def header_from_message(mail, header) 158 | # Mail gem likes to pretend that incorrectly-encoded headers don't exist, 159 | # so if we still want to salvage something we have to do it ourselves 160 | utf8_encode(mail.header[header].to_s).presence || 161 | utf8_encode(mail.header.raw_source)[/^#{header}: (.*)$/, 1].try(:chomp) 162 | end 163 | 164 | def author_name_from_message(mail) 165 | utf8_encode(mail.header['From'].addrs.first.display_name) 166 | rescue StandardError 167 | nil 168 | end 169 | 170 | def author_email_from_message(mail) 171 | utf8_encode(mail.header['From'].addrs.first.address) 172 | rescue StandardError 173 | nil 174 | end 175 | 176 | def date_from_message(mail) 177 | DATE_HEADERS.map { |h| mail.header[h] }.compact.first.to_s.to_datetime 178 | end 179 | 180 | def headers_and_body_from_message(mail) 181 | target_part = mail 182 | headers = mail.header.raw_source.dup 183 | 184 | if mail.multipart? 185 | target_part = mail.text_part.presence || mail.parts.first 186 | headers << "X-WebNews-Part-Headers-Follow: true\n" 187 | headers << target_part.header.raw_source 188 | end 189 | 190 | [ 191 | utf8_encode(headers), 192 | utf8_encode(FlowedFormat.decode_message(target_part)) 193 | ] 194 | end 195 | 196 | def utf8_encode(text) 197 | text.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') 198 | end 199 | 200 | def category_for_newsgroup(newsgroup) 201 | CategoryCustomField.where( 202 | name: 'nntp_bridge_newsgroup', 203 | value: newsgroup 204 | ).first.category 205 | end 206 | 207 | def find_user_from_article(article) 208 | find_existing_user(article) || use_default_user!(article) 209 | end 210 | 211 | def find_existing_user(article) 212 | User.with_email(article.author_email).first || 213 | User.where(name: article.author_name).first || 214 | User.with_email(article.author_raw).first || 215 | User.where(name: article.author_raw).first 216 | end 217 | 218 | def use_default_user!(article) 219 | notice = "#{SiteSetting.nntp_bridge_guest_notice.gsub('{author}', article.author_raw)}\n\n" 220 | article.body = notice + article.body 221 | guest_username = SiteSetting.nntp_bridge_guest_username 222 | user = User.where(username: guest_username).first if guest_username.present? 223 | user || User.find(-1) 224 | end 225 | 226 | def find_or_create_topic_from_article(article, user_id, newsgroup) 227 | if article.is_dethreaded 228 | article.body = "#{SiteSetting.nntp_bridge_dethreaded_notice}\n\n" + article.body 229 | end 230 | 231 | return article.parent if article.parent 232 | 233 | # TODO: probably better to do this in a begin/rescue as this only happens to a select number of posts 234 | 235 | old_subject = article.subject 236 | subject = article.subject 237 | 238 | unless TextSentinel.title_sentinel(subject).valid? 239 | if SiteSetting.nntp_bridge_override_title_validations? 240 | subject = 'Temporary subject for complexity reasons' 241 | # TODO: this could error if the site doesn't allow duplicate titles 242 | else 243 | puts "\nInvalid subject from message #{article.message_id}, skipping" if File.basename($PROGRAM_NAME) == 'rake' 244 | raise ActiveRecord::Rollback 245 | end 246 | end 247 | 248 | topic = Topic.create!( 249 | title: subject[0...SiteSetting.max_topic_title_length], 250 | user_id: user_id, 251 | category_id: category_for_newsgroup(newsgroup).id, 252 | created_at: article.created_at, 253 | updated_at: article.created_at, 254 | bumped_at: article.created_at 255 | ) 256 | 257 | if !TextSentinel.title_sentinel(old_subject).valid? && SiteSetting.nntp_bridge_override_title_validations? 258 | topic.update_attribute(:title, old_subject) 259 | end 260 | 261 | topic 262 | end 263 | 264 | DATE_HEADERS = ['Injection-Date', 'NNTP-Posting-Date', 'Date'].freeze 265 | Threading = Struct.new(:parent, :is_correct) 266 | Article = Struct.new( 267 | :message, 268 | :message_id, 269 | :headers, 270 | :body, 271 | :author_raw, 272 | :author_name, 273 | :author_email, 274 | :subject, 275 | :created_at, 276 | :parent, 277 | :is_dethreaded 278 | ) 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/discourse_nntp_bridge/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseNntpBridge 4 | class Server 5 | def newsgroups 6 | nntp.list[1].map(&:split).map do |fields| 7 | RemoteNewsgroup.new.tap do |n| 8 | n.name = fields[0] 9 | n.status = fields[3] 10 | # n.description = newsgroup_descriptions[n.name] 11 | end 12 | end 13 | end 14 | 15 | def message_ids(newsgroup_names = []) 16 | wildmat = newsgroup_names.any? ? newsgroup_names.join(',') : '*' 17 | nntp.newnews(wildmat, '19700101', '000000')[1].uniq.map { |message_id| message_id[1..-2] } 18 | end 19 | 20 | def article(message_id) 21 | # nntp-lib calls sprintf with this parameter internally, so any percent 22 | # signs in the Message-ID must be doubled 23 | nntp.article("<#{message_id.sub('%', '%%')}>")[1].join("\n") 24 | end 25 | 26 | def post(message) 27 | nntp.post(message)[1][/<(.*?)>/, 1] # Errors should be handled by caller 28 | end 29 | 30 | # private 31 | 32 | def newsgroup_descriptions 33 | @newsgroup_descriptions ||= 34 | nntp.list('newsgroups')[1].map { |line| line.split(/\t+/) }.to_h 35 | end 36 | 37 | def nntp 38 | # Hack to get around nntp-lib trying to authenticate twice in `start` 39 | # TODO: Figure out why only `original` auth works, it's rather insecure 40 | @nntp ||= Net::NNTP.start(ENV['NEWS_HOST']).tap do |nntp| 41 | nntp.send(:authenticate, ENV['NEWS_USERNAME'], ENV['NEWS_PASSWORD'], :original) if ENV['NEWS_USERNAME'].present? || ENV['NEWS_PASSWORD'].present? 42 | end 43 | end 44 | 45 | RemoteNewsgroup = Struct.new(:name, :description, :status) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/tasks/discourse_nntp_bridge.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :discourse_nntp_bridge do 4 | desc 'Utility to assign NNTP newsgroups to categories' 5 | task assign_newsgroups: :environment do 6 | assigned_categories = CategoryCustomField.where(name: 'nntp_bridge_newsgroup').pluck(:category_id) 7 | categories_to_assign = Category.pluck(:id) - assigned_categories 8 | newsgroups = DiscourseNntpBridge::Server.new.newsgroups.map(&:name) 9 | 10 | puts "#{categories_to_assign.count} categories without newsgroup associations!" 11 | puts "#{newsgroups.count} NNTP newsgroups exist" 12 | 13 | categories_to_assign.each do |category_id| 14 | category = Category.find(category_id) 15 | begin 16 | print "Assign #{category.full_slug} a newsgroup? (y/n): " 17 | input = STDIN.gets.strip.downcase 18 | end until %w[y n].include?(input) 19 | 20 | next if input == 'n' 21 | 22 | begin 23 | print 'Newsgroup: ' 24 | input = STDIN.gets.strip.downcase 25 | duplicates = CategoryCustomField.where(name: 'nntp_bridge_newsgroup', value: input) 26 | if duplicates.count > 0 27 | duplicate_names = "'" + duplicates.map(&:category).map(&:full_slug).join("' and '") + "'" 28 | puts "#{input} is already assigned to #{duplicate_names}" 29 | redo 30 | end 31 | end until newsgroups.include?(input) 32 | 33 | CategoryCustomField.create!( 34 | category_id: category_id, 35 | name: 'nntp_bridge_newsgroup', 36 | value: input 37 | ) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # name: discourse-nntp-bridge 4 | # about: Discourse plugin to keep NNTP & Discourse in sync 5 | # version: 0.1.14 6 | # authors: Stuart Olivera 7 | # url: https://github.com/sman591/discourse-nntp-bridge 8 | 9 | enabled_site_setting :nntp_bridge_enabled 10 | 11 | # install dependencies 12 | gem 'active_attr', '0.10.3' 13 | gem 'thoughtafter-nntp', '1.0.0.3', require: false 14 | gem 'rfc2047', '0.3', github: 'ConradIrwin/rfc2047-ruby' 15 | 16 | gem 'codeclimate-test-reporter', '0.5.0', require: nil if ENV['RUN_COVERAGE'] 17 | 18 | require 'nntp' 19 | require_relative 'lib/discourse_nntp_bridge' 20 | 21 | after_initialize do 22 | class DiscourseNntpBridge::NntpPost < ActiveRecord::Base 23 | belongs_to :post 24 | 25 | validates :post_id, :message_id, presence: true 26 | validates :message_id, uniqueness: true 27 | end 28 | 29 | Post.class_eval do 30 | has_many :nntp_posts, class_name: 'DiscourseNntpBridge::NntpPost', dependent: :destroy 31 | end 32 | 33 | on(:post_created) do |post| 34 | require_dependency File.expand_path('app/jobs/regular/nntp_bridge_exporter.rb', __dir__) 35 | 36 | Jobs.enqueue(:nntp_bridge_exporter, post_id: post.id) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/jobs/nntp_bridge_exporter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe Jobs::NntpBridgeExporter do 6 | describe 'enqueue' do 7 | it 'should support enqueue' do 8 | Jobs.enqueue(:nntp_bridge_exporter, post_id: 123) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/jobs/nntp_bridge_import_scheduler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | require 'sidekiq/testing' 5 | 6 | describe Jobs::NntpBridgeImportScheduler do 7 | describe 'enqueue' do 8 | it 'should support enqueue' do 9 | Jobs.enqueue(:nntp_bridge_import_scheduler) 10 | end 11 | 12 | context 'with two existing categories' do 13 | let!(:category1) { Fabricate(:category, custom_fields: { nntp_bridge_newsgroup: 'general' }) } 14 | let!(:category2) { Fabricate(:category, custom_fields: { nntp_bridge_newsgroup: 'test' }) } 15 | 16 | before do 17 | Sidekiq::Testing.disable! 18 | Sidekiq::Queue.new.clear 19 | SiteSetting.queue_jobs = true 20 | end 21 | 22 | it 'should queue an importer for each newsgroup' do 23 | expect do 24 | Jobs::NntpBridgeImportScheduler.new.execute({}) 25 | end.to change { Sidekiq::Queue.new.size }.by(2) 26 | end 27 | 28 | it 'should mark importers as queued' do 29 | expect(Jobs::NntpBridgeImportScheduler.importer_queued?('test')).to eq(false) 30 | expect(Jobs::NntpBridgeImportScheduler.importer_queued?('general')).to eq(false) 31 | 32 | Jobs::NntpBridgeImportScheduler.new.execute({}) 33 | 34 | expect(Jobs::NntpBridgeImportScheduler.importer_queued?('test')).to eq(true) 35 | expect(Jobs::NntpBridgeImportScheduler.importer_queued?('general')).to eq(true) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/jobs/nntp_bridge_importer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe Jobs::NntpBridgeImporter do 6 | describe 'enqueue' do 7 | it 'should support enqueue' do 8 | Jobs.enqueue(:nntp_bridge_importer, newsgroup: 'test') 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/discourse_nntp_bridge_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe DiscourseNntpBridge do 6 | subject { DiscourseNntpBridge } 7 | 8 | describe 'create_article_from_post' do 9 | it 'leaves quote-less body as-is' do 10 | original_body = "Hello world!\nThis post has no quotes." 11 | converted_body = subject.convert_post_body_quotes original_body 12 | expect(converted_body).to eq(original_body) 13 | end 14 | 15 | it 'converts quotes to simple syntax' do 16 | original_body = "[quote=\"guest, post:1, topic:1\"]\nThis is a quote\n[/quote]" 17 | expected_body = "guest wrote:\n\n> This is a quote" 18 | converted_body = subject.convert_post_body_quotes original_body 19 | expect(converted_body).to eq(expected_body) 20 | end 21 | 22 | it 'converts mid-body quotes to simple syntax' do 23 | original_body = "The start of my response\n\n[quote=\"guest, post:1, topic:1\"]\nThis is a quote\n[/quote]\n\nThe end of my response" 24 | expected_body = "The start of my response\n\nguest wrote:\n\n> This is a quote\n\nThe end of my response" 25 | converted_body = subject.convert_post_body_quotes original_body 26 | expect(converted_body).to eq(expected_body) 27 | end 28 | 29 | it 'removes blank quotes' do 30 | original_bodies = [ 31 | "[quote=\"guest, post:1, topic:1\"]\n[/quote]", 32 | "[quote=\"guest, post:1, topic:1\"]\n\n[/quote]" 33 | ] 34 | expected_body = '' 35 | original_bodies.each do |original_body| 36 | converted_body = subject.convert_post_body_quotes original_body 37 | expect(converted_body).to eq(expected_body) 38 | end 39 | end 40 | 41 | it 'converts multiple quotes to simple syntax' do 42 | original_body = "[quote=\"guest, post:1, topic:1\"]\nThis is a quote\n[/quote]\n\nThis is my response\n\n[quote=\"admin, post:1, topic:1\"]\nThis is another quote\n[/quote]\n\nThis is my second response" 43 | expected_body = "guest wrote:\n\n> This is a quote\n\nThis is my response\n\nadmin wrote:\n\n> This is another quote\n\nThis is my second response" 44 | converted_body = subject.convert_post_body_quotes original_body 45 | expect(converted_body).to eq(expected_body) 46 | end 47 | 48 | it 'uses author real name' do 49 | Fabricate(:user, username: 'guest', name: 'Guest Account') 50 | original_body = "[quote=\"guest, post:1, topic:1\"]\nThis is a quote\n[/quote]" 51 | expected_body = "Guest Account wrote:\n\n> This is a quote" 52 | converted_body = subject.convert_post_body_quotes original_body 53 | expect(converted_body).to eq(expected_body) 54 | end 55 | 56 | context 'with plugin settings' do 57 | before do 58 | SiteSetting.load_settings(File.join(Rails.root, 'plugins', 'discourse-nntp-bridge', 'config', 'settings.yml')) 59 | end 60 | 61 | it 'will not process small action posts' do 62 | post = Fabricate(:post, post_type: 3) 63 | expect(subject.create_article_from_post(post)).to eq(nil) 64 | end 65 | 66 | it 'will not process moderator action posts' do 67 | post = Fabricate(:post, post_type: 2) 68 | expect(subject.create_article_from_post(post)).to eq(nil) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/models/nntp_post_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe ::DiscourseNntpBridge::NntpPost do 6 | it { is_expected.to belong_to :post } 7 | it { is_expected.to validate_presence_of :message_id } 8 | it { is_expected.to validate_presence_of :post_id } 9 | it { is_expected.to validate_uniqueness_of :message_id } 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/post_importer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe DiscourseNntpBridge::PostImporter do 6 | subject { DiscourseNntpBridge::PostImporter.new } 7 | 8 | describe '.find_user_from_article' do 9 | let!(:article) { DiscourseNntpBridge::PostImporter::Article.new } 10 | 11 | context 'with a known author' do 12 | let!(:user) { Fabricate(:user, email: 'test@example.com', name: 'Test User') } 13 | 14 | context 'with only a valid author_email' do 15 | before { article.author_email = 'test@example.com' } 16 | 17 | it 'finds the user' do 18 | found_user = subject.send(:find_user_from_article, article) 19 | expect(found_user).to eq(user) 20 | end 21 | end 22 | 23 | context 'with only a valid author_name' do 24 | before { article.author_name = 'Test User' } 25 | 26 | it 'finds the user' do 27 | found_user = subject.send(:find_user_from_article, article) 28 | expect(found_user).to eq(user) 29 | end 30 | end 31 | 32 | context 'with only an unparsed author_raw that has an email' do 33 | before { article.author_raw = 'test@example.com' } 34 | 35 | it 'finds the user' do 36 | found_user = subject.send(:find_user_from_article, article) 37 | expect(found_user).to eq(user) 38 | end 39 | end 40 | 41 | context 'with only an unparsed author_raw that has a name' do 42 | before { article.author_raw = 'Test User' } 43 | 44 | it 'finds the user' do 45 | found_user = subject.send(:find_user_from_article, article) 46 | expect(found_user).to eq(user) 47 | end 48 | end 49 | end 50 | 51 | context 'with an unkown author' do 52 | before do 53 | SiteSetting.load_settings(File.join(Rails.root, 'plugins', 'discourse-nntp-bridge', 'config', 'settings.yml')) 54 | article.body = 'Hello world!' 55 | article.author_raw = "I don't exist" 56 | end 57 | 58 | context 'with the default bridge settings' do 59 | let!(:user) { User.find_by(username: 'system') } 60 | let!(:found_user) { subject.send(:find_user_from_article, article) } 61 | 62 | it 'uses the system user' do 63 | expect(found_user).to eq(user) 64 | end 65 | 66 | it 'prepends the body with a notice' do 67 | expect(article.body).to eq("*Post from NNTP guest I don't exist*\n\nHello world!") 68 | end 69 | end 70 | 71 | context 'with a custom guest username set' do 72 | before do 73 | Fabricate(:user, username: 'my_guest', email: 'my_guest@example.com', name: 'Guest Account') 74 | SiteSetting.nntp_bridge_guest_username = 'my_guest' 75 | end 76 | 77 | let!(:user) { User.find_by(username: 'my_guest') } 78 | let!(:found_user) { subject.send(:find_user_from_article, article) } 79 | 80 | it 'uses the custom set user' do 81 | expect(found_user).to eq(user) 82 | end 83 | 84 | it 'prepends the body with a notice' do 85 | expect(article.body).to eq("*Post from NNTP guest I don't exist*\n\nHello world!") 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/models/post_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './plugins/discourse-nntp-bridge/spec/rails_helper' 4 | 5 | describe Post do 6 | it { is_expected.to have_many :nntp_posts } 7 | 8 | describe 'instance' do 9 | before do 10 | SiteSetting.load_settings(File.join(Rails.root, 'plugins', 'discourse-nntp-bridge', 'config', 'settings.yml')) 11 | end 12 | 13 | let!(:post) { Fabricate(:post) } 14 | 15 | it 'destroys NNTP posts when destroyed' do 16 | DiscourseNntpBridge::NntpPost.create(post_id: post.id, message_id: 'abc123@example.com') 17 | expect do 18 | post.destroy 19 | end.to change(DiscourseNntpBridge::NntpPost, :count).by(-1) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | if ENV['RUN_COVERAGE'] 6 | require 'simplecov' 7 | SimpleCov.add_filter 'discourse/app' 8 | SimpleCov.add_filter 'discourse/lib' 9 | SimpleCov.start 10 | end 11 | 12 | path = './plugins/discourse-nntp-bridge/plugin.rb' 13 | source = File.read(path) 14 | plugin = Plugin::Instance.new(Plugin::Metadata.parse(source), path) 15 | plugin.activate! 16 | plugin.initializers.first.call 17 | 18 | require './plugins/discourse-nntp-bridge/app/jobs/regular/nntp_bridge_exporter' 19 | require './plugins/discourse-nntp-bridge/app/jobs/regular/nntp_bridge_importer' 20 | require './plugins/discourse-nntp-bridge/app/jobs/scheduled/nntp_bridge_import_scheduler' 21 | --------------------------------------------------------------------------------