├── log └── .keep ├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── .keep │ ├── view_fragment.rb │ ├── application_record.rb │ ├── post │ │ └── extension │ │ │ └── sage.rb │ ├── reply.rb │ ├── topic.rb │ ├── board.rb │ ├── image.rb │ └── post.rb ├── assets │ ├── images │ │ ├── .keep │ │ └── loading.svg │ ├── stylesheets │ │ ├── pagination.sass │ │ ├── _init.sass │ │ ├── topics.sass │ │ ├── reply.sass │ │ ├── application.css │ │ ├── general.sass │ │ └── posts.sass │ └── javascripts │ │ ├── fetch_omitted.coffee │ │ ├── image_load.coffee │ │ ├── topics.coffee │ │ ├── post_handle.coffee │ │ ├── application.js │ │ ├── image_upload.coffee │ │ ├── reply.coffee │ │ └── jquery.nested_attributes.coffee ├── controllers │ ├── concerns │ │ └── .keep │ ├── images_controller.rb │ ├── application_controller.rb │ ├── topics_controller.rb │ └── posts_controller.rb ├── views │ ├── partials │ │ └── _global │ │ │ └── .gitkeep │ ├── topics │ │ ├── omitted.html.erb │ │ ├── show.html.erb │ │ └── index.html.erb │ ├── posts │ │ └── new.html.erb │ └── layouts │ │ └── application.html.erb ├── cells │ ├── topic │ │ ├── omitted.erb │ │ └── show.erb │ ├── post │ │ ├── images.erb │ │ ├── image_upload_control.erb │ │ ├── form.erb │ │ └── show.erb │ ├── view_fragment_cell.rb │ ├── post_cell.rb │ └── topic_cell.rb ├── presenters │ ├── post_presenter.rb │ ├── board_presenter.rb │ ├── image_presenter.rb │ └── topic_presenter.rb ├── jobs │ ├── topic_callback_job.rb │ └── prune_job.rb ├── lib │ ├── pruner │ │ ├── destroy_remover.rb │ │ └── sort_selector.rb │ ├── board_config_serializer.rb │ ├── omit.rb │ └── raw_options_analyzer.rb ├── helpers │ └── application_helper.rb ├── services │ └── post_form.rb └── uploaders │ └── image_uploader.rb ├── lib ├── assets │ └── .keep ├── tasks │ └── .keep └── auto_html │ └── filters │ ├── newline_to_br.rb │ └── quote.rb ├── public ├── favicon.ico ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── vendor └── assets │ ├── javascripts │ └── .keep │ └── stylesheets │ └── .keep ├── .rspec ├── config ├── initializers │ ├── sucker_punch.rb │ ├── session_store.rb │ ├── mime_types.rb │ ├── filter_parameter_logging.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── backtrace_silencers.rb │ ├── custom_path_helper.rb │ ├── wrap_parameters.rb │ ├── assets.rb │ ├── secret_token.rb │ ├── inflections.rb │ └── content_security_policy.rb ├── spring.rb ├── environment.rb ├── cable.yml ├── boot.rb ├── setting.rb ├── sitemap.rb ├── routes.rb ├── database.yml.example ├── application.rb ├── app_config.yml.example ├── puma.rb ├── locales │ └── en.yml └── environments │ ├── test.rb │ ├── development.rb │ └── production.rb ├── bin ├── rake ├── bundle ├── rails ├── rspec ├── yarn ├── spring ├── update └── setup ├── spec ├── factories │ ├── topics.rb │ ├── posts.rb │ └── boards.rb ├── lib │ ├── pruner │ │ ├── sort_selector_spec.rb │ │ └── destroy_remover_spec.rb │ ├── auto_html │ │ └── filters │ │ │ ├── newline_to_br_spec.rb │ │ │ └── quote_spec.rb │ ├── omit_spec.rb │ ├── raw_options_analyzer_spec.rb │ └── board_config_serializer_spec.rb ├── models │ ├── post │ │ └── extension │ │ │ └── sage_spec.rb │ ├── board_spec.rb │ └── post_spec.rb ├── jobs │ └── prune_job_spec.rb ├── rails_helper.rb ├── services │ └── post_form_spec.rb └── spec_helper.rb ├── config.ru ├── db ├── migrate │ ├── 20160319061350_allow_images_post_id_to_be_null.rb │ ├── 20150419153856_add_bumped_at_to_topics.rb │ ├── 20150512134821_add_config_to_boards.rb │ ├── 20150530134256_add_content_html_to_posts.rb │ ├── 20170523114957_add_file_attachable_to_topics.rb │ ├── 20160405155046_add_lock_to_topics.rb │ ├── 20160320083647_add_remote_url_to_images.rb │ ├── 20150331150050_create_images.rb │ ├── 20160217154121_create_replies.rb │ ├── 20150208155537_create_topics.rb │ ├── 20160327113357_add_width_and_height_to_images.rb │ ├── 20150208150147_create_boards.rb │ ├── 20150516062535_disallow_null_on_foreign_keys.rb │ ├── 20150208160146_create_posts.rb │ ├── 20150421152549_create_view_fragments.rb │ ├── 20151006130931_add_options_to_posts.rb │ └── 20150715151825_add_pos_to_posts.rb ├── seeds │ └── development │ │ └── common.seeds.rb ├── seeds.rb └── schema.rb ├── Rakefile ├── README.md ├── .gitignore ├── MIT-LICENSE ├── Guardfile ├── Gemfile └── Gemfile.lock /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/partials/_global/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | —-warnings 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /config/initializers/sucker_punch.rb: -------------------------------------------------------------------------------- 1 | require 'sucker_punch/async_syntax' 2 | -------------------------------------------------------------------------------- /app/cells/topic/omitted.erb: -------------------------------------------------------------------------------- 1 | <%= render_cell_collection :post, :show, @posts %> 2 | -------------------------------------------------------------------------------- /app/views/topics/omitted.html.erb: -------------------------------------------------------------------------------- 1 | <%= render_cell :topic, :omitted, @topic %> 2 | -------------------------------------------------------------------------------- /app/models/view_fragment.rb: -------------------------------------------------------------------------------- 1 | class ViewFragment < ApplicationRecord 2 | belongs_to :board 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/factories/topics.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :topic do 3 | title "Lorem Ipsum" 4 | board 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/post/extension/sage.rb: -------------------------------------------------------------------------------- 1 | # Skip topic bump 2 | module Post::Extension::Sage 3 | def bump_topic 4 | # NOOP 5 | end 6 | end 7 | 8 | -------------------------------------------------------------------------------- /app/views/posts/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 | Hide 3 |
4 | <%= render_cell(:post, :form, @topic) %> 5 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | %w[ 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ].each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /lib/auto_html/filters/newline_to_br.rb: -------------------------------------------------------------------------------- 1 | AutoHtml.add_filter(:newline_to_br) do |text, options| 2 | text.gsub(/(?:\n\r?|\r\n?)/, "
\n") 3 | end 4 | -------------------------------------------------------------------------------- /spec/factories/posts.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :post do 3 | content "foo" 4 | pos 1 5 | email "foo@example.com" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/presenters/post_presenter.rb: -------------------------------------------------------------------------------- 1 | class PostPresenter < LulalalaPresenter::Base 2 | def dom_id 3 | "p-#{model.topic_id}-#{model.pos}" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_mei_session' 4 | -------------------------------------------------------------------------------- /spec/factories/boards.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :board do 3 | sequence(:name){|n| "Board #{n}" } 4 | sequence(:seo_name){|n| "board#{n}" } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/reply.rb: -------------------------------------------------------------------------------- 1 | class Reply < ApplicationRecord 2 | belongs_to :ancestor, class_name:'Post', optional: true 3 | belongs_to :descendant, class_name:'Post', optional: true 4 | end 5 | -------------------------------------------------------------------------------- /app/cells/post/images.erb: -------------------------------------------------------------------------------- 1 |
2 | <% @images.each do |image| %> 3 | <%= image.presenter.linked_display %> 4 | <% end %> 5 |
6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /db/migrate/20160319061350_allow_images_post_id_to_be_null.rb: -------------------------------------------------------------------------------- 1 | class AllowImagesPostIdToBeNull < ActiveRecord::Migration[4.2] 2 | def change 3 | change_column_null :images, :post_id, true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150419153856_add_bumped_at_to_topics.rb: -------------------------------------------------------------------------------- 1 | class AddBumpedAtToTopics < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :topics, :bumped_at, :datetime, comment:'topic bump time' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/jobs/topic_callback_job.rb: -------------------------------------------------------------------------------- 1 | # Contains all the topic related callbacks, 2 | class TopicCallbackJob < ActiveJob::Base 3 | queue_as :default 4 | 5 | def perform(topic) 6 | PruneJob.new.perform(topic.board) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20150512134821_add_config_to_boards.rb: -------------------------------------------------------------------------------- 1 | class AddConfigToBoards < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :boards, :config, :text, comment:'board-specific configuration in YAML' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/seeds/development/common.seeds.rb: -------------------------------------------------------------------------------- 1 | b = Board.first 2 | if b.topics.empty? 3 | topic = b.topics.create(title:'topic title goes here') 4 | 3.times do 5 | topic.posts.create(content:'post content goes here') 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /db/migrate/20150530134256_add_content_html_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddContentHtmlToPosts < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :posts, :content_html, :text, comment:'text content processed into html' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require 'bundler/setup' 8 | load Gem.bin_path('rspec-core', 'rspec') 9 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: mei_production 11 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /db/migrate/20170523114957_add_file_attachable_to_topics.rb: -------------------------------------------------------------------------------- 1 | class AddFileAttachableToTopics < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :topics, :file_attachable, :boolean, default: true, null: false, after: :locked 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/pagination.sass: -------------------------------------------------------------------------------- 1 | nav.pagination 2 | margin-bottom: 1em 3 | a, .current 4 | padding: 1em 5 | border: 1px solid #ccc 6 | display: inline-block 7 | 8 | .current 9 | background-color: #ccc 10 | color: #fff 11 | -------------------------------------------------------------------------------- /app/cells/post/image_upload_control.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= @form_field.file_field :images, autocomplete:'off', multiple:true, data:{url: images_path, behavior: 'upload'} %> 4 |
5 | -------------------------------------------------------------------------------- /db/migrate/20160405155046_add_lock_to_topics.rb: -------------------------------------------------------------------------------- 1 | class AddLockToTopics < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :topics, :locked, :boolean, default: false, null: false, after: :max_pos, comment:'prevent further replies' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/_init.sass: -------------------------------------------------------------------------------- 1 | // RWD 2 | $mobile-small: 240px 3 | $mobile-large: 320px 4 | $tablet-small: 480px 5 | $tablet-large: 768px 6 | $screen-small: 1024px 7 | $screen-large: 1280px 8 | 9 | // for small screens 10 | $small-screen-body-margin: 5px 11 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 5 | 6 | require_relative 'setting' 7 | -------------------------------------------------------------------------------- /app/cells/view_fragment_cell.rb: -------------------------------------------------------------------------------- 1 | class ViewFragmentCell < Cell::Rails 2 | def show(board, name) 3 | if view = ViewFragment.where(board:board, name:name).first 4 | render html: view.content.html_safe 5 | else 6 | nil 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20160320083647_add_remote_url_to_images.rb: -------------------------------------------------------------------------------- 1 | class AddRemoteUrlToImages < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :images, :remote_url, :string, after: :image, comment:'url of image fetched from' 4 | add_index :images, :remote_url 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/assets/stylesheets/topics.sass: -------------------------------------------------------------------------------- 1 | @import init 2 | 3 | section.topic 4 | border-bottom: 1px solid #ccc 5 | padding-bottom: 1em 6 | margin-bottom: 1em 7 | 8 | h1 9 | margin-bottom: 0.5em 10 | 11 | .actions 12 | display: inline 13 | 14 | .notice 15 | color: #999 16 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /lib/auto_html/filters/quote.rb: -------------------------------------------------------------------------------- 1 | AutoHtml.add_filter(:quote) do |text, options| 2 | text.lines.each_with_object([]) do |line, new_text| 3 | if line.start_with?(">") || line.start_with?(">") 4 | new_text << "#{line.chomp}\n" 5 | else 6 | new_text << line 7 | end 8 | end.join 9 | end 10 | -------------------------------------------------------------------------------- /app/presenters/board_presenter.rb: -------------------------------------------------------------------------------- 1 | class BoardPresenter < LulalalaPresenter::Base 2 | def representative_image_url 3 | model.config.dig(:seo, :image) 4 | end 5 | 6 | def og_image_tag 7 | if image = representative_image_url 8 | h.tag(:meta, property:'og:image', content:h.image_url(image)) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20150331150050_create_images.rb: -------------------------------------------------------------------------------- 1 | class CreateImages < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :images, comment:'image' do |t| 4 | t.references :post, index: true, foreign_key: true 5 | t.string :image, comment:'filename' 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/images_controller.rb: -------------------------------------------------------------------------------- 1 | class ImagesController < ApplicationController 2 | def create 3 | images = params[:images].map do |file| 4 | image = Image.create(image: file) 5 | {id: image.id} 6 | end 7 | 8 | respond_to do |format| 9 | format.json { render json: {images: images} } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | begin 5 | exec "yarnpkg", *ARGV 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20160217154121_create_replies.rb: -------------------------------------------------------------------------------- 1 | class CreateReplies < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :replies, comment:'replying relations between posts' do |t| 4 | t.integer :ancestor_id, comment:'post that is being replied to' 5 | t.integer :descendant_id, comment:'post that is the reply' 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | 4 | before_action :set_board 5 | def set_board 6 | @board = Board.find_by(seo_name:params[:board]) 7 | default_url_options[:board] = params[:board] 8 | end 9 | 10 | after_action :prepare_unobtrusive_flash 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20150208155537_create_topics.rb: -------------------------------------------------------------------------------- 1 | class CreateTopics < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :topics, comment:'topic of discussion, also called thread' do |t| 4 | t.string :title, comment:'title' 5 | t.references :board, index: true 6 | 7 | t.timestamps null: false 8 | end 9 | add_foreign_key :topics, :boards 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20160327113357_add_width_and_height_to_images.rb: -------------------------------------------------------------------------------- 1 | class AddWidthAndHeightToImages < ActiveRecord::Migration[4.2] 2 | def change 3 | change_table :images do |t| 4 | t.integer :width, after: :image 5 | t.integer :height, after: :width 6 | t.integer :thumb_width, after: :height 7 | t.integer :thumb_height, after: :thumb_width 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/assets/javascripts/fetch_omitted.coffee: -------------------------------------------------------------------------------- 1 | $(document).on 'click', '[data-behavior=view-omitted]', (e)-> 2 | e.preventDefault() 3 | 4 | $target = $(e.target) 5 | 6 | url = "#{$target.attr('href')}/omitted" 7 | request = $.ajax 8 | url:url, 9 | method:'GET' 10 | dataType:'html' 11 | 12 | request.done (msg)-> 13 | $target.parent().replaceWith(msg) 14 | PostHandle.apply() 15 | -------------------------------------------------------------------------------- /config/setting.rb: -------------------------------------------------------------------------------- 1 | require 'settei/loaders/simple_loader' 2 | require 'settei/base' 3 | require 'settei/extensions/host_url' 4 | 5 | if defined? Rails 6 | rails_env = Rails.env 7 | end 8 | 9 | loader= Settei::Loaders::SimpleLoader.new( 10 | dir: File.join(File.dirname(__FILE__), "environments") 11 | ) 12 | Setting = Settei::Base.new(loader.load(rails_env).as_hash) 13 | Setting.extend Settei::Extensions::HostUrl 14 | -------------------------------------------------------------------------------- /config/sitemap.rb: -------------------------------------------------------------------------------- 1 | SitemapGenerator::Sitemap.default_host = Setting.host() 2 | 3 | SitemapGenerator::Sitemap.create include_root:false do 4 | Board.find_each do |board| 5 | add board_path(board), lastmod:board.topics.maximum(:bumped_at), changefreq:'always' 6 | end 7 | 8 | Topic.find_each do |topic| 9 | add topic_path(board:topic.board.seo_name, id:topic), lastmod:topic.bumped_at, changefreq:'daily' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/pruner/sort_selector_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | describe Pruner::SortSelector do 3 | let(:board){ FactoryBot.create(:board) } 4 | 5 | subject { described_class.new(board, 3)} 6 | 7 | before do 8 | 5.times do 9 | FactoryBot.create(:topic, board: board) 10 | end 11 | end 12 | 13 | it "returns id" do 14 | subject.perform.should == Topic.order(id: :asc).limit(2).pluck(:id) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | scope '/:board' do 3 | get '/' => 'topics#index', as: :board 4 | resources :topics, only:[:show] do 5 | member do 6 | get :omitted 7 | end 8 | end 9 | resources :posts, only:[:create, :new] 10 | resources :images, only:[:create] 11 | end 12 | 13 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /db/migrate/20150208150147_create_boards.rb: -------------------------------------------------------------------------------- 1 | class CreateBoards < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :boards, comment:'board' do |t| 4 | t.string :seo_name, null:false, comment:'represent name in URL. Must be URL valid characters.' 5 | t.string :name, null:false, comment:'display name on top of page' 6 | 7 | t.timestamps null:false 8 | end 9 | add_index :boards, :seo_name, unique:true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/auto_html/filters/newline_to_br_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'auto_html' 3 | require 'auto_html/filters/newline_to_br' 4 | 5 | describe 'auto_html newline_to_br filter' do 6 | include AutoHtml 7 | it "convert > into quotes" do 8 | source = <<-SOURCE 9 | foo 10 | bar 11 | SOURCE 12 | 13 | result = auto_html(source){ newline_to_br } 14 | result.should == <<-OUTPUT 15 | foo
16 | bar
17 | OUTPUT 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/lib/pruner/destroy_remover.rb: -------------------------------------------------------------------------------- 1 | # Destroy topic with the provided ids 2 | module Pruner 3 | class DestroyRemover 4 | # @param board [Board] board to be pruned from 5 | # @param topic_ids [Array] ids of Topic to remove 6 | def initialize(board, topic_ids, **options) 7 | @board = board 8 | @topic_ids = topic_ids 9 | end 10 | 11 | def perform 12 | @board.topics.where(id: @topic_ids).destroy_all 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/assets/javascripts/image_load.coffee: -------------------------------------------------------------------------------- 1 | $(document).on 'click', 'article.post a.img', (e) -> 2 | e.preventDefault() 3 | 4 | a = $(this) 5 | fullImageUrl = this.href 6 | 7 | a.parents('article.post').addClass('full') 8 | $("", {src:fullImageUrl, class:'full'}).insertAfter(a) 9 | a.hide() 10 | 11 | $(document).on 'click', 'article.post img.full', (e) -> 12 | e.preventDefault() 13 | 14 | img = $(this) 15 | a = img.prev() 16 | 17 | img.remove() 18 | a.show() 19 | -------------------------------------------------------------------------------- /app/lib/board_config_serializer.rb: -------------------------------------------------------------------------------- 1 | # Serializing board config column. 2 | # 3 | # Any blank inputs will be converted to empty hash 4 | class BoardConfigSerializer 5 | def self.load(i) 6 | if i.blank? 7 | {} 8 | else 9 | YAML.load(i) 10 | end 11 | end 12 | 13 | def self.dump(i) 14 | i = {} if i.blank? 15 | 16 | if i.is_a?(String) # Allow assigning an YAML string as input 17 | i 18 | else 19 | YAML.dump(i) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/initializers/custom_path_helper.rb: -------------------------------------------------------------------------------- 1 | module CustomPathHelper 2 | def post_path(post, options = {}) 3 | options.merge!(anchor:post.presenter.dom_id) if post.pos != 1 4 | topic_path(post.topic, options) 5 | end 6 | 7 | def post_url(post, options = {}) 8 | options.merge!(anchor:post.presenter.dom_id) if post.pos != 1 9 | topic_utl(post.topic, options) 10 | end 11 | end 12 | 13 | Rails.application.routes.named_routes.url_helpers_module.send(:include, CustomPathHelper) 14 | -------------------------------------------------------------------------------- /db/migrate/20150516062535_disallow_null_on_foreign_keys.rb: -------------------------------------------------------------------------------- 1 | class DisallowNullOnForeignKeys < ActiveRecord::Migration[4.2] 2 | def up 3 | change_column_null :topics, :board_id, false 4 | change_column_null :posts, :topic_id, false 5 | change_column_null :images, :post_id, false 6 | end 7 | 8 | def down 9 | change_column_null :topics, :board_id, true 10 | change_column_null :posts, :topic_id, true 11 | change_column_null :images, :post_id, true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | Board.seed(:id) do |b| 9 | b.id = 1 10 | b.seo_name = 'i' 11 | b.name = 'imageboard' 12 | end 13 | -------------------------------------------------------------------------------- /spec/lib/auto_html/filters/quote_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'auto_html' 3 | require 'auto_html/filters/quote' 4 | 5 | describe 'auto_html quote filter' do 6 | include AutoHtml 7 | it "convert > into quotes" do 8 | source = <<-SOURCE 9 | > One 10 | >two 11 | three 12 | four 13 | SOURCE 14 | 15 | result = auto_html(source){ quote } 16 | result.should == <<-OUTPUT 17 | > One 18 | >two 19 | three 20 | four 21 | OUTPUT 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/20150208160146_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :posts, comment:'text content posted. New post or reply comments are all posts.' do |t| 4 | t.text :content, comment:'text content' 5 | t.string :author, comment:'author name' 6 | t.string :email, comment:'email' 7 | t.references :topic, index: true 8 | 9 | t.timestamps null: false 10 | end 11 | add_foreign_key :posts, :topics 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20150421152549_create_view_fragments.rb: -------------------------------------------------------------------------------- 1 | class CreateViewFragments < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :view_fragments, comment:'Custom HTML fragments to be displayed' do |t| 4 | t.references :board, foreign_key: true 5 | t.string :name, null:false, comment:'name for referencing' 6 | t.text :content, comment:'html fragment' 7 | 8 | t.timestamps null: false 9 | end 10 | add_index :view_fragments, [:board_id, :name], unique:true 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/assets/images/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Imageboard Mei 2 | ============== 3 | 4 | Mei is a Futaba-styled imageboard, allowing people to discuss and upload pictures. My aim is to improve the user interface to my liking, instead of mimicking the traditional Futaba's look & feel. 5 | 6 | **Visit the [wiki](https://github.com/lulalala/mei/wiki) to see more information! I try to minimize number of commits so documentations are all over there!** 7 | 8 | ### Highlights 9 | 10 | * Multi-image upload 11 | * Image upload using url 12 | * Better user interface 13 | * Multi-board support 14 | * Extensible -------------------------------------------------------------------------------- /app/presenters/image_presenter.rb: -------------------------------------------------------------------------------- 1 | class ImagePresenter < LulalalaPresenter::Base 2 | def linked_display 3 | h.link_to( 4 | h.image_tag(h.image_url(model.image.thumb.url), width:model.thumb_width, height:model.thumb_height), 5 | h.image_url(model.image.url), 6 | class:'img' 7 | ).html_safe 8 | end 9 | 10 | # Linked image in a container which clears floats, 11 | # so images will occupy entire row. 12 | def cleared_linked_display 13 | h.content_tag(:div, linked_display, class:'images single clear').html_safe 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /db/migrate/20151006130931_add_options_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddOptionsToPosts < ActiveRecord::Migration[4.2] 2 | def up 3 | add_column :posts, :options, :string, after: :author, comment:'array of options like sage' 4 | add_column :posts, :options_raw, :string, after: :options, comment:'user input for email and options' 5 | execute 'UPDATE posts SET options_raw = email, email = NULL' 6 | end 7 | 8 | def down 9 | execute 'UPDATE posts SET email = options_raw' 10 | remove_column :posts, :options 11 | remove_column :posts, :options_raw 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/cells/post_cell.rb: -------------------------------------------------------------------------------- 1 | class PostCell < Cell::Rails 2 | helper ApplicationHelper 3 | 4 | def show(post) 5 | @post = post 6 | @board = @post.topic.board 7 | render 8 | end 9 | 10 | def form(topic) 11 | return if topic.locked? 12 | 13 | @post_form = PostForm.new.from_topic(topic) 14 | render 15 | end 16 | 17 | def image_upload_control(form_field) 18 | @form_field = form_field 19 | render 20 | end 21 | 22 | def images(post) 23 | @images = post.images.where(remote_url:nil) 24 | render if @images.size > 0 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == "spring" } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/lib/omit.rb: -------------------------------------------------------------------------------- 1 | module Omit 2 | # @param total [Integer] total number of replies 3 | # @param n_recent_only [Integer] number of replies to show if omit occurs. 4 | # @param avoid_only_n_hidden [Integer] if after omit the omitted replies is less than or equal to this number, then do not omit the replies 5 | # @return [Boolean] whether omit should occur 6 | def self.omit?(total, n_recent_only, avoid_only_n_hidden = 0) 7 | if total - n_recent_only <= avoid_only_n_hidden 8 | return false 9 | elsif total > n_recent_only 10 | true 11 | else 12 | false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/post/extension/sage_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Post::Extension::Sage do 4 | let(:board) { FactoryBot.create(:board) } 5 | let(:topic) { FactoryBot.create(:topic, board:board).reload } 6 | let(:post) { FactoryBot.create(:post, topic:topic) } 7 | 8 | it 'does not bump' do 9 | old_bumped_at = topic.bumped_at 10 | 11 | Timecop.freeze(5.seconds.from_now) do 12 | new_post = topic.posts.build 13 | new_post.assign_attributes(content:'foo', options_raw:'sage') 14 | new_post.save 15 | 16 | topic.reload.bumped_at.should == old_bumped_at 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/views/topics/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :head do 2 | title = [@topic.presenter.title, @board.name].join(" - ") 3 | %> 4 | <%= title %> 5 | <%= tag(:link, property:'canonical', content:topic_url(@topic)) %> 6 | <%= tag(:meta, property:'og:title', content:title) %> 7 | <%= tag(:meta, property:'og:type', content:'website') %> 8 | <%= tag(:meta, property:'og:url', content:topic_url(@topic)) %> 9 | <%= @topic.presenter.og_image_tag %> 10 | <% end %> 11 | 12 |
13 | <%= render_cell :post, :form, @topic %> 14 | <%= render_custom_partial :after_form %> 15 |
16 | 17 | <%= render_cell :topic, :show, @topic %> 18 | -------------------------------------------------------------------------------- /app/cells/topic/show.erb: -------------------------------------------------------------------------------- 1 |
2 |

<%= @topic.title %>

3 | 4 | <% if @omit %> 5 | <% n_recent_post_only = @topic.board.reply_omit_condition[:n_recent_only] %> 6 | <%= render_cell :post, :show, @posts.first %> 7 | 8 | <%= @posts.size - 1 - n_recent_post_only %> posts omitted, 9 | <%= link_to 'view all', topic_path(@topic), data:{behavior:'view-omitted'} %> 10 | 11 | <%= render_cell_collection :post, :show, @posts.last(n_recent_post_only) %> 12 | <% else %> 13 | <%= render_cell_collection :post, :show, @posts %> 14 | <% end %> 15 |
16 | -------------------------------------------------------------------------------- /app/assets/javascripts/topics.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | 5 | $ -> 6 | $document = $(document) 7 | 8 | $document.on 'ajax:before', 'form#new_post', -> 9 | $('form img.loading').show() 10 | $document.on 'ajax:error', 'form#new_post', (event, data, status, xhr)-> 11 | $('form img.loading').hide() 12 | $document.on 'ajax:success', 'form#new_post', (event, data, status, xhr)-> 13 | if data['success'] 14 | location.reload() 15 | else 16 | $('form img.loading').hide() 17 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | Rails.application.config.assets.precompile += %w( jquery.js ) 15 | -------------------------------------------------------------------------------- /app/assets/stylesheets/reply.sass: -------------------------------------------------------------------------------- 1 | @import init 2 | 3 | div#reply-container 4 | right: 0 5 | top: 4em 6 | position: fixed 7 | width: 40% 8 | 9 | padding: 1em 2em 1em 1em 10 | 11 | background: #fff 12 | border: 1px solid #ccc 13 | 14 | @media screen and (max-width: $tablet-small) 15 | width: 100% 16 | height: 100% 17 | top: 0 18 | left: 0 19 | 20 | @media screen and (max-width: $tablet-large) and (min-width: $tablet-small) 21 | width: 60% 22 | 23 | textarea 24 | height: 10em 25 | 26 | .actions 27 | a.hide-link 28 | cursor: pointer 29 | color: #00f 30 | &:before 31 | content: "❌" 32 | 33 | form 34 | margin-top: 0.8em 35 | -------------------------------------------------------------------------------- /app/models/topic.rb: -------------------------------------------------------------------------------- 1 | class Topic < ApplicationRecord 2 | belongs_to :board, inverse_of: :topics 3 | has_many :posts, inverse_of: :topic, dependent: :destroy 4 | 5 | nilify_blanks 6 | 7 | before_create :set_bumped_at 8 | def set_bumped_at 9 | self.bumped_at = Time.now 10 | end 11 | 12 | after_commit :queue_after_commit_callback, on: :create 13 | def queue_after_commit_callback 14 | TopicCallbackJob.perform_later self 15 | end 16 | 17 | def bump 18 | now = Time.now 19 | update_columns( 20 | updated_at: now, 21 | bumped_at: now, 22 | ) 23 | end 24 | 25 | def increment_pos 26 | update_columns( 27 | max_pos: max_pos + 1 28 | ) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/lib/raw_options_analyzer.rb: -------------------------------------------------------------------------------- 1 | class RawOptionsAnalyzer 2 | # @param text [String] raw option text to be analyzed 3 | # @param available_options [Array] allowed options 4 | # @return [String, nil] 5 | def initialize(text, allowed_options) 6 | text ||= '' 7 | @text = text 8 | @allowed_options = allowed_options 9 | @tokens = text.split(' ') 10 | end 11 | 12 | # @return [String, nil] 13 | def email 14 | if @text.include?('@') # possible email 15 | email = @tokens.find do |token| 16 | token.match(/^\S+@\S+\.\S+$/) 17 | end 18 | end 19 | end 20 | 21 | # @return [Array] 22 | def options 23 | @tokens & @allowed_options 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/database.yml.example: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure your secret_key_base is kept private 11 | # if you're sharing your code publicly. 12 | 13 | Mei::Application.config.secret_token = Setting.dig(:rails, :secret_token) 14 | Mei::Application.config.secret_key_base = Setting.dig(:rails, :secret_key_base) 15 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Mei 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 5.2 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/presenters/topic_presenter.rb: -------------------------------------------------------------------------------- 1 | class TopicPresenter < LulalalaPresenter::Base 2 | def title 3 | if model.title.present? 4 | h.truncate(model.title, length: 50) 5 | elsif model.posts.first.content.present? 6 | h.truncate(model.posts.first.content, length: 50) 7 | else 8 | "##{model.id}" 9 | end 10 | end 11 | 12 | def representative_image_url 13 | post = model.posts.first 14 | if image = post.images.first 15 | image.image.url 16 | else 17 | model.board.presenter.representative_image_url 18 | end 19 | end 20 | 21 | def og_image_tag 22 | if image = representative_image_url 23 | h.tag(:meta, property:'og:image', content:h.image_url(image)) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/app_config.yml.example: -------------------------------------------------------------------------------- 1 | hosts: 2 | default: &default_host 3 | :protocol: 'http' 4 | :subdomain: null 5 | :domain: '' 6 | :port: null 7 | asset: 8 | <<: *default_host 9 | rails: 10 | secret_token: '' 11 | secret_key_base: '' 12 | carrierwave: 13 | watermark: 14 | font: 'Nimbus-Sans-Bold' 15 | 16 | # global board setting 17 | board: 18 | reply_omit_condition: 19 | n_recent_only: 5 20 | avoid_only_n_hidden: 2 21 | time_format: '%d %b %H:%M' 22 | pagination: 23 | per_page: 5 24 | max_page: 9 25 | prune: 26 | selector_class: SortSelector 27 | selector_options: 28 | remover_class: DestroyRemover 29 | remover_options: 30 | post: 31 | allowed_options: 32 | - sage 33 | seo: 34 | image: null 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | /node_modules 21 | /yarn-error.log 22 | 23 | .byebug_history 24 | 25 | /config/database.yml 26 | /config/app_config.yml 27 | 28 | /public/uploads 29 | 30 | /app/views/partials 31 | 32 | /config/environments/*.yml -------------------------------------------------------------------------------- /app/models/board.rb: -------------------------------------------------------------------------------- 1 | class Board < ApplicationRecord 2 | has_many :topics, inverse_of: :board 3 | 4 | serialize :config, BoardConfigSerializer 5 | def config 6 | @cascaded_config ||= Setting.dig_and_wrap(:board).tap do |c| 7 | if board_config = super 8 | c.merge(board_config) 9 | end 10 | end 11 | end 12 | 13 | def to_param 14 | seo_name 15 | end 16 | 17 | # Reply Omittion 18 | 19 | def reply_omit_condition 20 | Setting.dig(:board, :reply_omit_condition) 21 | end 22 | 23 | # @return total file size in bytes 24 | def file_size 25 | file_size = 0 26 | 27 | Image.joins(:post => :topic).where(:post => {:topic => {:board => self}}).find_each do |i| 28 | file_size += i.file_size 29 | end 30 | 31 | file_size 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def render_body_classes 3 | classes = [ params[:controller].tr('/','_'), params[:action] ] 4 | classes.join(' ') 5 | end 6 | 7 | # Renders custom HTML content. 8 | # 9 | # Order of precedence: 10 | # in app/views/partials/_global/ 11 | # in app/views/partials/{board seo name}/ 12 | # in view_fragments table in database 13 | def render_custom_partial(name) 14 | ["partials/#{@board.seo_name}/#{name}", "partials/_global/#{name}"].each do |path| 15 | if lookup_context.template_exists?(path, nil, true) 16 | return render(partial:path) 17 | end 18 | end 19 | 20 | render_cell(:view_fragment, :show, @board, name) 21 | end 22 | 23 | def current_page 24 | params[:page] || 1 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/views/topics/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :head do 2 | title = [@board.name, "Page #{current_page}"].join(" - ") 3 | %> 4 | <%= title %> 5 | <%= tag(:link, property:'canonical', content:board_url(@board)) %> 6 | <%= tag(:meta, property:'og:title', content:title) %> 7 | <%= tag(:meta, property:'og:type', content:'website') %> 8 | <%= tag(:meta, property:'og:url', content:board_url(@board)) %> 9 | <%= @board.presenter.og_image_tag %> 10 | <% end %> 11 | 12 |
13 | <%= render_cell(:post, :form, @board.topics.build) %> 14 | <%= render_custom_partial :after_form %> 15 |
16 | 17 |
18 | <%= render_cell_collection :topic, :show, @topics, omit:true %> 19 | <%= paginate @topics %> 20 |
21 | 22 | 24 | -------------------------------------------------------------------------------- /spec/jobs/prune_job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | describe PruneJob do 3 | let(:board) { FactoryBot.create(:board) } 4 | 5 | before do 6 | 5.times do 7 | FactoryBot.create(:topic, board: board) 8 | end 9 | 10 | board.stub(:config) { 11 | Settingslogic.new({ 12 | 'pagination' => { 13 | 'per_page' => 2, 14 | 'max_page' => 2 15 | }, 16 | 'prune' => { 17 | 'selector_class' => "SortSelector", 18 | 'selector_options' => {}, 19 | 'remover_class' => "DestroyRemover", 20 | 'remover_options' => {}, 21 | } 22 | }) 23 | } 24 | end 25 | 26 | it "prunes topics so there are only 2 pages left" do 27 | described_class.new.perform(board) 28 | board.topics.count.should == 4 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/cells/topic_cell.rb: -------------------------------------------------------------------------------- 1 | class TopicCell < Cell::Rails 2 | def show(topic, omit:false) 3 | @topic = topic 4 | @posts = @topic.posts.order(id: :asc).includes(:images) 5 | @omit = omit && topic_omittable?(topic) 6 | 7 | render 8 | end 9 | 10 | def omitted(topic) 11 | @topic = topic 12 | 13 | return if !topic_omittable?(topic) 14 | 15 | limit = @topic.posts.size - 1 - @topic.board.reply_omit_condition[:n_recent_only] 16 | @posts = @topic.posts.order(id: :asc).includes(:images).offset(1).limit(limit) 17 | 18 | render 19 | end 20 | 21 | private 22 | 23 | def topic_omittable?(topic) 24 | Omit.omit?( 25 | topic.posts.size - 1, 26 | topic.board.reply_omit_condition.dig(:n_recent_only), 27 | topic.board.reply_omit_condition.dig(:avoid_only_n_hidden) 28 | ) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/pruner/destroy_remover_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | describe Pruner::DestroyRemover do 3 | let(:board){ FactoryBot.create(:board) } 4 | let(:board2){ FactoryBot.create(:board) } 5 | 6 | before do 7 | 5.times do 8 | FactoryBot.create(:topic, board: board) 9 | end 10 | FactoryBot.create(:topic, board: board2) 11 | end 12 | 13 | it "destroys topic in provided ids" do 14 | ids = [board.topics.first.id, board.topics.last.id] 15 | described_class.new(board, ids).perform 16 | board.topics.pluck(:id).should_not include(ids) 17 | board.topics.count.should == 3 18 | end 19 | 20 | it "does not destroy topic not belonging to the board" do 21 | ids = board2.topics.pluck(:id) 22 | 23 | described_class.new(board, ids).perform 24 | 25 | board2.topics.pluck(:id).should == ids 26 | end 27 | end 28 | 29 | -------------------------------------------------------------------------------- /spec/lib/omit_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe Omit do 4 | describe ".omit?" do 5 | it "does not omit reply since only 2 can be potentially omitted, but we do no allow 3 or less omittions" do 6 | described_class.omit?(10, 8, 3).should == false 7 | end 8 | 9 | it "does not omit reply since only 2 can be potentially omitted, but we do no allow 2 or less omittions" do 10 | described_class.omit?(10, 8, 2).should == false 11 | end 12 | 13 | it "omits" do 14 | described_class.omit?(10, 7, 1).should == true 15 | end 16 | 17 | it "does not omit as total is equal to n_recent_only" do 18 | described_class.omit?(10, 10).should == false 19 | end 20 | 21 | it "does not omit as total is less than n_recent_only" do 22 | described_class.omit?(10, 20).should == false 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/topics_controller.rb: -------------------------------------------------------------------------------- 1 | class TopicsController < ApplicationController 2 | before_action :set_topic, only: [:show, :omitted, :destroy] 3 | 4 | # GET /topics 5 | def index 6 | @topics = @board.topics.order(bumped_at: :desc). 7 | page(params[:page]). 8 | per(@board.config.dig(:pagination, :per_page)) 9 | end 10 | 11 | # GET /topics/1 12 | def show 13 | end 14 | 15 | # DELETE /topics/1 16 | def destroy 17 | @topic.destroy 18 | respond_to do |format| 19 | format.html { redirect_to topics_url, notice: 'Topic was successfully destroyed.' } 20 | end 21 | end 22 | 23 | # GET /topics/1/omitted 24 | def omitted 25 | render layout:false 26 | end 27 | 28 | private 29 | 30 | # Use callbacks to share common setup or constraints between actions. 31 | def set_topic 32 | @topic = Topic.find(params[:id]) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/assets/javascripts/post_handle.coffee: -------------------------------------------------------------------------------- 1 | # A class that correspond to each post element in the document. 2 | # Filter can be registered, which will be run against each post element. 3 | class PostHandle 4 | # @container: post element 5 | constructor: (@container)-> 6 | $container = $(@container) 7 | for filter in @constructor.filters 8 | filter($container) 9 | 10 | @filters: [] 11 | 12 | # filter: Function, accepts an argument which is the post container wrapped in jQuery object 13 | @register: (filter) -> 14 | @filters.push filter 15 | 16 | # search through document for posts and initialize with a handle 17 | @apply: -> 18 | $('article.post').each (index, postEl)-> 19 | $postEl = $(postEl) 20 | if ! $postEl.data('handle') 21 | $postEl.data('handle', new PostHandle(postEl)) 22 | 23 | window.PostHandle = PostHandle 24 | 25 | $ -> 26 | PostHandle.apply() 27 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for :head %> 5 | 6 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 7 | <%= jquery_include_tag :google %> 8 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 9 | <%= csrf_meta_tags %> 10 | <%= render_custom_partial :bottom_of_head %> 11 | 12 | 13 |
14 | <%= render_custom_partial :top_of_header %> 15 |

<%= link_to @board.name, board_path %>

16 |
17 | 18 | <%= yield %> 19 | 20 |
21 | <%= link_to 'Board Index', board_path %> 22 | <%= render_custom_partial :bottom_of_footer %> 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Updating database ==" 24 | system! 'bin/rails db:migrate' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | puts "\n== Restarting application server ==" 30 | system! 'bin/rails restart' 31 | end 32 | -------------------------------------------------------------------------------- /app/lib/pruner/sort_selector.rb: -------------------------------------------------------------------------------- 1 | # Selects topics which should be pruned based on max number and sorting order. 2 | module Pruner 3 | class SortSelector 4 | # @param board [Board] board to be pruned from 5 | # @param max [Integer] maximum number of topics allowed to stay 6 | # @param sort_column [Symbol] the column to be ordered by, defaults to :id 7 | # @param sort_direction [Symbol] order direction: :desc or :asc, defaults to :desc 8 | def initialize(board, max, sort_column: :id, sort_direction: :desc) 9 | @board = board 10 | @max = max 11 | @sort_column = sort_column 12 | @sort_direction = sort_direction 13 | end 14 | 15 | # @return [Array] ids of Topics which need to be pruned 16 | def perform 17 | min = @board.topics.order(@sort_column => @sort_direction).limit(@max).pluck(@sort_column).min 18 | @board.topics.where("#{@sort_column} < ?", min).pluck(:id) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require normalize 14 | * 15 | *= require general 16 | *= require topics 17 | *= require posts 18 | *= require reply 19 | *= require pagination 20 | * 21 | *= require unobtrusive_flash_ui 22 | */ 23 | -------------------------------------------------------------------------------- /spec/models/board_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Board, type: :model do 4 | describe "#config" do 5 | it "uses global config as default" do 6 | Setting.dig('board')['foo'] = 1 7 | subject.config.dig(:foo).should == 1 8 | end 9 | it "uses board config with higher precedence" do 10 | Setting.dig('board')['foo'] = 1 11 | subject.config = {'foo' => 2} 12 | subject.config.dig(:foo).should == 2 13 | end 14 | it "does not interfere with other board configs" do 15 | Setting.dig('board')['foo'] = 1 16 | 17 | board1 = FactoryBot.create(:board, config: {'foo' => 7}) 18 | board2 = FactoryBot.create(:board) 19 | 20 | board2.config.dig(:foo).should == 1 21 | 22 | board3 = FactoryBot.create(:board, config: {'foo' => 9}) 23 | 24 | board3.config.dig(:foo).should == 9 25 | board2.config.dig(:foo).should == 1 26 | board1.config.dig(:foo).should == 7 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /db/migrate/20150715151825_add_pos_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddPosToPosts < ActiveRecord::Migration[4.2] 2 | def up 3 | add_column :posts, :pos, :integer, limit:2, after: :topic_id, comment:'position of post within topic' 4 | 5 | add_column :topics, :max_pos, :integer, limit:2, default:0, null:false, after: :board_id, comment:'current newest post pos, increment as posts are created' 6 | 7 | Post.reset_column_information 8 | Topic.all.each do |t| 9 | t.update_column(:max_pos, 0) 10 | t.posts.order(id: :asc).each_with_index do |p, i| 11 | p.update_column(:pos, i+1) 12 | end 13 | t.update_column(:max_pos, t.posts.size) 14 | end 15 | Post.reset_column_information 16 | 17 | change_column_null :posts, :pos, false 18 | add_index :posts, [:topic_id, :pos], unique:true 19 | end 20 | 21 | def down 22 | remove_index :posts, [:topic_id, :pos] 23 | remove_column :topics, :max_pos 24 | remove_column :posts, :pos 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/cells/post/form.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@post_form, format:'json', remote:true, data:{type: :json}) do |f| %> 2 | <% if @post_form.new_topic? %> 3 | <%= f.hidden_field :board_id %> 4 | <% else %> 5 | <%= f.hidden_field :topic_id %> 6 | <% end %> 7 | 8 | <% if @post_form.new_topic? %> 9 |
10 | <%= f.label :title %> 11 | <%= f.text_field :title, autocomplete:'off' %> 12 |
13 | <% end %> 14 |
15 | <%= f.label :author %> 16 | <%= f.text_field :author %> 17 |
18 |
19 | <%= f.label :options_raw %> 20 | <%= f.text_field :options_raw %> 21 |
22 | <% if @post_form.file_attachable? %> 23 | <%= render_cell :post, :image_upload_control, f %> 24 | <% end %> 25 | <%= f.label :content %> 26 | <%= f.text_area :content, autocomplete:'off' %> 27 |
28 | <%= f.submit data:{type: :json, disable_with:'Submitting...'} %> 29 | <%= image_tag 'loading.svg', class:'loading' %> 30 |
31 | <% end %> 32 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a starting point to setup your application. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:setup' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery_ujs 14 | //= require js-cookie 15 | // 16 | // File upload 17 | //= require blueimp-file-upload/vendor/jquery.ui.widget.js 18 | //= require blueimp-file-upload/jquery.iframe-transport.js 19 | //= require blueimp-file-upload/jquery.fileupload.js 20 | // 21 | //= require unobtrusive_flash 22 | //= require unobtrusive_flash_ui 23 | // 24 | //= require_tree . 25 | // 26 | -------------------------------------------------------------------------------- /app/models/image.rb: -------------------------------------------------------------------------------- 1 | class Image < ApplicationRecord 2 | belongs_to :post, inverse_of: :images, optional: true 3 | 4 | mount_uploader :image, ImageUploader 5 | 6 | validate :pre_upload_check 7 | def pre_upload_check 8 | if @invalid_reason.present? 9 | errors.add(:image, @invalid_reason) 10 | end 11 | end 12 | 13 | def remote_image_url=(url) 14 | self.remote_url = url 15 | 16 | # Check file type and size before Carrierwave fetches it. 17 | # Store invalid reason for Rails validation later on. 18 | checker = FastImage.new(url) 19 | @invalid_reason = nil 20 | 21 | if checker.content_length.present? && checker.content_length > 5.megabytes 22 | @invalid_reason = :file_size_too_big 23 | elsif checker.type.nil? 24 | @invalid_reason = :invalid_image_file 25 | end 26 | 27 | if @invalid_reason.nil? 28 | super 29 | else 30 | nil 31 | end 32 | end 33 | 34 | # @return file size in bytes 35 | def file_size 36 | result = `du -c -b #{Pathname.new(image.current_path).parent.to_s}` 37 | result.lines.last.split("\t").first.to_i 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 lulalala 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/cells/post/show.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | #<%= @post.pos %> 4 | <% if @post.options_raw %> 5 | <%= mail_to @post.options_raw do %><%= @post.author || "anonymous" %><% end %> 6 | <% else %> 7 | <%= @post.author %> 8 | <% end %> 9 | <%= link_to time_tag(@post.created_at, format:@board.config.dig(:time_format)), post_path(@post), rel:'bookmark' %> 10 |
11 | <% if !@post.topic.locked? %> 12 | <%= link_to 'Reply', topic_path(@post.topic), class:'reply' %> 13 | <% end %> 14 |
15 |
16 | <%= render_cell :post, :images, @post %> 17 |
18 | <%= @post.content_html %> 19 |
20 | <% if @post.children.exists? %> 21 |
22 | replied by: 23 | <% @post.children.each do |r| %> 24 | <%= link_to "> #{r.pos}", topic_path(@post.topic, anchor:r.presenter.dom_id) %> 25 | <% end %> 26 |
27 | <% end %> 28 |
29 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Report CSP violations to a specified URI 23 | # For further information see the following documentation: 24 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 25 | # Rails.application.config.content_security_policy_report_only = true 26 | -------------------------------------------------------------------------------- /spec/lib/raw_options_analyzer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe RawOptionsAnalyzer do 4 | describe '#email' do 5 | it "extracts single email" do 6 | described_class.new("foobar@example.com", []).email.should == "foobar@example.com" 7 | described_class.new("232323232323@BLUERADI.co.jp", []).email.should == "232323232323@BLUERADI.co.jp" 8 | described_class.new("administration@example.museum", []).email.should == "administration@example.museum" 9 | end 10 | 11 | it "extracts email from a few tokens" do 12 | described_class.new("sage akira@gmail.com fuge dage", []).email.should == "akira@gmail.com" 13 | end 14 | 15 | it "returns nil if no emails can be found" do 16 | described_class.new("this is a pen", []).email.should == nil 17 | end 18 | 19 | it "returns nil if options field is empty" do 20 | described_class.new(nil, []).email.should == nil 21 | described_class.new('', []).email.should == nil 22 | end 23 | end 24 | 25 | describe '#options' do 26 | it 'returns tokens matching available options' do 27 | described_class.new('sage foo@example.com', ['sage']).options.should == ['sage'] 28 | end 29 | 30 | it 'returns empty array if nothing in raw options matches' do 31 | described_class.new('sega', ['sage']).options.should == [] 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/assets/stylesheets/general.sass: -------------------------------------------------------------------------------- 1 | @import init 2 | 3 | html 4 | box-sizing: border-box 5 | *, *:before, *:after 6 | box-sizing: inherit 7 | 8 | body 9 | margin: 1em 10 | 11 | @media screen and (max-width: $tablet-large) 12 | margin: $small-screen-body-margin 13 | 14 | .header>form 15 | max-width: 600px 16 | .field, textarea 17 | margin-bottom: 5px 18 | 19 | .field 20 | display: flex 21 | label 22 | margin-right: 0.5em 23 | 24 | input[type=text], input[type=url] 25 | flex: 1 0 auto 26 | textarea 27 | width: 100% 28 | height: 5em 29 | input[type=file] 30 | width: 100% 31 | input[type=submit] 32 | width: 150px 33 | height: 3em 34 | font-size: 1em 35 | 36 | .file-section 37 | margin-bottom: 15px 38 | 39 | .field 40 | height: 1.6em 41 | 42 | a.add 43 | margin-right: 0.5em 44 | 45 | .actions 46 | display: inline 47 | line-height: 2em 48 | 49 | &.drag-over 50 | background-color: #fefefe 51 | border: 3px dashed #ccc 52 | 53 | img.loading 54 | display: none 55 | vertical-align: bottom 56 | 57 | .hide 58 | display: none 59 | 60 | .break-word 61 | -ms-word-break: break-all 62 | -ms-word-wrap: break-all 63 | -webkit-word-break: break-word 64 | -webkit-word-wrap: break-word 65 | word-break: break-word 66 | word-wrap: break-word 67 | -webkit-hyphens: auto 68 | -moz-hyphens: auto 69 | hyphens: auto 70 | -------------------------------------------------------------------------------- /app/assets/stylesheets/posts.sass: -------------------------------------------------------------------------------- 1 | @import init 2 | 3 | article.post 4 | width: auto 5 | min-width: 500px // TODO mobile incompatible 6 | display: table 7 | padding: 1em 8 | margin-top: 0.5em 9 | margin-left: 1em 10 | margin-bottom: 0.5em 11 | background-color: #F0E0D6 12 | 13 | @media screen and (max-width: $tablet-large) 14 | width: 100% 15 | min-width: initial 16 | margin-left: 0 17 | padding: $small-screen-body-margin 18 | 19 | &:first-of-type 20 | background-color: inherit 21 | margin-left: 0 22 | margin-bottom: 1em 23 | padding: 0 24 | 25 | header 26 | color: #999 27 | margin-bottom: 0.5em 28 | 29 | a[rel=bookmark] 30 | color: #999 31 | 32 | quote 33 | color: #789922 34 | 35 | div.reply 36 | color: #999 37 | 38 | // Images 39 | 40 | a.img 41 | float: left 42 | margin-right: 1em 43 | margin-bottom: 0.5em 44 | 45 | img.full 46 | max-width: 100% 47 | max-height: auto 48 | margin-bottom: 0.5em 49 | cursor: pointer 50 | display: block 51 | 52 | &.full 53 | display: block 54 | 55 | a.img 56 | float: none 57 | display: inline-block 58 | 59 | div.images.multi:after, div.images.clear:after 60 | content: "" 61 | display: table 62 | clear: both 63 | 64 | @media screen and (max-width: 600px) 65 | .video.youtube 66 | iframe 67 | max-width: 100% 68 | 69 | -------------------------------------------------------------------------------- /spec/lib/board_config_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe BoardConfigSerializer do 4 | describe '.load' do 5 | it 'returns hash if raw is blank' do 6 | described_class.load(nil).should == {} 7 | described_class.load('').should == {} 8 | end 9 | 10 | it 'loads YAML document' do 11 | yaml = <<-END.strip_heredoc 12 | --- 13 | pagination: 14 | per_page: 2 15 | END 16 | 17 | described_class.load(yaml).should == {'pagination' => {'per_page' => 2} } 18 | end 19 | end 20 | 21 | describe '.dump' do 22 | it 'returns hash if raw is blank' do 23 | described_class.load(nil).should == {} 24 | described_class.load('').should == {} 25 | end 26 | 27 | it 'loads YAML document' do 28 | yaml = <<-END.strip_heredoc 29 | --- 30 | pagination: 31 | per_page: 2 32 | END 33 | 34 | described_class.load(yaml).should == {'pagination' => {'per_page' => 2} } 35 | end 36 | end 37 | 38 | describe '.dump' do 39 | it 'defaults to empty hash if given blank value' do 40 | described_class.dump(nil).should == "--- {}\n" 41 | end 42 | 43 | it 'accepts a string, and pretend it is YAML text' do 44 | described_class.dump("--- {}\n").should == "--- {}\n" 45 | end 46 | 47 | it 'dumps hash into YAML' do 48 | described_class.dump({'a' => 1}).should == "---\na: 1\n" 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/jobs/prune_job.rb: -------------------------------------------------------------------------------- 1 | # Prunes a board. 2 | # The pruning process is divided into two parts, 3 | # the first part is the selection of topics to delete, 4 | # and the second part is the removal of these topics. 5 | # The strategies for these can be changed in the config file. 6 | # 7 | # Configuration: 8 | # prune: 9 | # selector_class: class name of the selector. 10 | # selector_options: hash of options to pass to the selector 11 | # remover_class: class name of the remover. 12 | # remover_options: hash of options to pass to the remover 13 | # pagination: 14 | # max_page: maximum number of pages allowed 15 | # per_page: number of topics to display per page 16 | class PruneJob < ActiveJob::Base 17 | queue_as :default 18 | 19 | # @param board [Board] the board to prune 20 | def perform(board) 21 | config = board.config 22 | 23 | prune = config.dig(:prune) 24 | return if prune['selector_class'].nil? || prune['remover_class'].nil? 25 | 26 | per_page = config.dig(:pagination, :per_page) 27 | max_page = config.dig(:pagination, :max_page) 28 | 29 | selector_class = ::Pruner.const_get(prune[:selector_class]) 30 | remover_class = ::Pruner.const_get(prune[:remover_class]) 31 | 32 | selector_options = prune[:selector_options].symbolize_keys! 33 | remover_options = prune[:remover_options].symbolize_keys! 34 | 35 | ids = selector_class.new(board, per_page * max_page, selector_options).perform 36 | remover_class.new(board, ids, remover_options).perform 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. 30 | # 31 | # preload_app! 32 | 33 | # Allow puma to be restarted by `rails restart` command. 34 | plugin :tmp_restart 35 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | activerecord: 34 | attributes: 35 | post: 36 | options_raw: Email 37 | errors: 38 | models: 39 | post: 40 | attributes: 41 | topic: 42 | locked: locked thus reply is not allowed 43 | 44 | adequate_errors: 45 | models: 46 | image: 47 | attributes: 48 | image: 49 | file_size_too_big: File size is too big. 50 | invalid_image_file: File is not an image. 51 | post: 52 | no_content: Image or content is required. 53 | attributes: 54 | topic: 55 | locked: '%{attribute} locked thus reply is not allowed' 56 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | before_action :set_post, only: [:destroy] 3 | 4 | # POST /posts 5 | # For creating new topic or replying 6 | def create 7 | @post_form = PostForm.new.from_params(params[:post]) 8 | 9 | respond_to do |format| 10 | if @post_form.save 11 | flash[:notice] = 'Successfully created.' 12 | 13 | format.json { 14 | render json: { 15 | success: true, 16 | redirect_to: topic_path(@post_form.topic) 17 | } 18 | } 19 | format.html { redirect_to @post_form.topic } 20 | else 21 | flash[:error] = @post_form.errors.adequate.messages.join('
').html_safe 22 | 23 | format.json { 24 | render json: { 25 | success: false 26 | } 27 | } 28 | format.html { redirect_to @post_form.topic } 29 | end 30 | end 31 | end 32 | 33 | # GET /posts for AJAX 34 | # For creating new topic or replying 35 | def new 36 | if params[:topic_id] 37 | @topic = Topic.find(params[:topic_id]) 38 | elsif params[:board_id] 39 | @topic = Board.find(params[:board_id]).topics.new 40 | elsif params[:post_id] 41 | @topic = Topic.joins(:posts).find_by('posts.id' => params[:post_id]) 42 | end 43 | 44 | render layout:false 45 | end 46 | 47 | # DELETE /posts/1 48 | def destroy 49 | @post.destroy 50 | respond_to do |format| 51 | format.html { redirect_to posts_url, notice: 'Post was successfully destroyed.' } 52 | end 53 | end 54 | 55 | private 56 | 57 | # Use callbacks to share common setup or constraints between actions. 58 | def set_post 59 | @post = Post.find(params[:id]) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/assets/javascripts/image_upload.coffee: -------------------------------------------------------------------------------- 1 | # Image upload control 2 | 3 | class @ImageUpload 4 | uploaded: 0 5 | failed: 0 6 | waitingForUpload: false 7 | # jQuery Object 8 | statusLabel: null 9 | 10 | # @form must be jQuery object 11 | constructor: (@form)-> 12 | @setupFileInputField() 13 | @setupForm() 14 | $(document).on("dragenter", -> 15 | $('.file-section').addClass('drag-over') 16 | ) 17 | 18 | getInput: -> 19 | @form.find('input[data-behavior~="upload"]') 20 | 21 | setupFileInputField: -> 22 | input = @getInput() 23 | 24 | @statusLabel = $('
').insertAfter(input) 25 | 26 | input.fileupload(dataType: 'json', paramName: "images[]", dropZone: @form.find(".file-section")).bind('fileuploaddone', (e, data) => 27 | for file, index in data.result.images 28 | if file.id != null 29 | $("").insertAfter(@statusLabel) 30 | @uploaded += 1 31 | else 32 | @failed += 1 33 | ).bind( 34 | 'fileuploadadd', @updateStatus 35 | ).bind( 36 | 'fileuploadalways', @updateStatus 37 | ).bind( 38 | 'fileuploadstop', @updateStatus 39 | ).bind( 40 | 'fileuploadstop', @submitForm 41 | ) 42 | 43 | setupForm: -> 44 | @form.on 'ajax:beforeSend', => 45 | if @getInput().fileupload('active') > 0 46 | @waitingForUpload = true 47 | return false 48 | 49 | submitForm: (e, data) => 50 | if @waitingForUpload && @getInput().fileupload('active') == 0 51 | @waitingForUpload = false 52 | @form.submit() 53 | 54 | updateStatus: (e, data) => 55 | @statusLabel.html("#{@getInput().fileupload('active')} uploading, #{@uploaded} uploaded, #{@failed} failed") 56 | 57 | $ -> 58 | $('form').each -> 59 | new ImageUpload($(this)) 60 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | config.action_mailer.perform_caching = false 32 | 33 | # Tell Action Mailer not to deliver emails to the real world. 34 | # The :test delivery method accumulates sent emails in the 35 | # ActionMailer::Base.deliveries array. 36 | config.action_mailer.delivery_method = :test 37 | 38 | # Print deprecation notices to the stderr. 39 | config.active_support.deprecation = :stderr 40 | 41 | # Raises error for missing translations 42 | # config.action_view.raise_on_missing_translations = true 43 | end 44 | -------------------------------------------------------------------------------- /app/assets/javascripts/reply.coffee: -------------------------------------------------------------------------------- 1 | #= require image_upload 2 | 3 | replyContainer = null 4 | currentTopicId = null 5 | 6 | $ -> 7 | $('.topic').on 'click', '.actions .reply', (e)-> 8 | e.preventDefault() 9 | 10 | topicId = $(e.delegateTarget).data('id') 11 | pos = $(e.target).parents('.post').data('pos') 12 | 13 | replyContainer = $('#reply-container') 14 | replyContainer.show() 15 | 16 | if replyContainer.length 17 | textArea = replyContainer.find('textarea') 18 | if currentTopicId == topicId 19 | if pos == 1 20 | textArea.focus() 21 | else 22 | insertRef(textArea, pos) 23 | else if textArea.val() == '' || confirm('You are about to switch thread which would cause currently unsaved reply to be cleared. Continue?') 24 | # clear current form 25 | replyContainer.remove() 26 | replyContainer = [] # empty 27 | 28 | # load form if none exists 29 | if ! replyContainer.length 30 | loadReplyForm(prepareUrl(e.target.getAttribute('href')), topicId, pos) 31 | 32 | # quote reference to post in textarea 33 | insertRef = (textarea, ref)-> 34 | t = textarea.val() 35 | if t.length > 0 36 | t = t + "\n" 37 | t = t + "> #{ref}\n" 38 | textarea.val(t).focus() 39 | textarea[0].scrollTop = textarea[0].scrollHeight 40 | 41 | loadReplyForm = (url, topicId, pos)-> 42 | $.get(url, topic_id:topicId).done (html)-> 43 | $('body>footer').before("
" + html + "
") 44 | if pos != 1 45 | insertRef($('#reply-container').find('textarea'), pos) 46 | currentTopicId = topicId 47 | 48 | new ImageUpload($('#reply-container form')) 49 | 50 | prepareHideLink() 51 | 52 | prepareHideLink = () -> 53 | replyContainer = $('#reply-container') 54 | replyContainer.find(".actions a.hide-link").click -> 55 | replyContainer.hide() 56 | 57 | prepareUrl = (path)-> 58 | path.replace(/topics.*/, 'posts/new') 59 | -------------------------------------------------------------------------------- /spec/models/post_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Post, type: :model do 4 | before do 5 | subject.stub(:board) { FactoryBot.create(:board) } 6 | end 7 | describe '#options_raw=' do 8 | it 'analyze value' do 9 | subject.options_raw = 'sage sg-epk@jtk93.x29.jp' 10 | subject.options.should include 'sage' 11 | subject.email.should include 'sg-epk@jtk93.x29.jp' 12 | end 13 | 14 | it 'can handle empty string' do 15 | subject.options_raw = '' 16 | expect(subject.options.empty?).to eq(true) 17 | end 18 | end 19 | 20 | describe 'reply' do 21 | let!(:board) { FactoryBot.create(:board) } 22 | let!(:topic) { FactoryBot.create(:topic, board:board)} 23 | let!(:post1) { FactoryBot.create(:post, topic:topic, content:'first post')} 24 | 25 | it do 26 | post2 = Post.create(topic: topic, content: "> 1 foo") 27 | post3 = Post.create(topic: topic, content: "> 1\n > 2 bar") 28 | 29 | expect(post2.parents).to contain_exactly(post1) 30 | expect(post3.parents).to contain_exactly(post1, post2) 31 | 32 | expect(post1.children).to contain_exactly(post2, post3) 33 | expect(post2.children).to contain_exactly(post3) 34 | end 35 | 36 | describe '#turn_reply_to_link' do 37 | it "convert reference to link" do 38 | post2 = Post.create(topic: topic, content: "> 1 foo") 39 | 40 | expect(post2.content_html).to include(%{> 1 foo}) 41 | 42 | # test for position over 10 43 | topic.update_column(:max_pos, 42) 44 | post2.update_column(:pos, 42) 45 | 46 | post3 = Post.create(topic: topic, content: "> 42 foo") 47 | 48 | expect(post3.content_html).to include(%{> 42 foo}) 49 | end 50 | end 51 | end 52 | 53 | describe 'locked topic' do 54 | let!(:topic) { FactoryBot.create(:topic, locked:true)} 55 | subject { FactoryBot.build(:post, topic: topic) } 56 | 57 | it 'can not reply to locked topic' do 58 | expect(subject.save).to eq(false) 59 | expect(subject.errors.added?(:topic, :locked)).to eq(true) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Don't care if the mailer can't send. 31 | config.action_mailer.raise_delivery_errors = false 32 | 33 | config.action_mailer.perform_caching = false 34 | 35 | # Print deprecation notices to the Rails logger. 36 | config.active_support.deprecation = :log 37 | 38 | # Raise an error on page load if there are pending migrations. 39 | config.active_record.migration_error = :page_load 40 | 41 | # Highlight code that triggered database queries in logs. 42 | config.active_record.verbose_query_logs = true 43 | 44 | # Debug mode disables concatenation and preprocessing of assets. 45 | # This option may cause significant delays in view rendering with a large 46 | # number of complex assets. 47 | config.assets.debug = true 48 | 49 | # Suppress logger output for asset requests. 50 | config.assets.quiet = true 51 | 52 | # Raises error for missing translations 53 | # config.action_view.raise_on_missing_translations = true 54 | 55 | # Use an evented file watcher to asynchronously detect changes in source code, 56 | # routes, locales, etc. This feature depends on the listen gem. 57 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 58 | end 59 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | require 'spec_helper' 4 | require File.expand_path('../../config/environment', __FILE__) 5 | require 'rspec/rails' 6 | # Add additional requires below this line. Rails is not loaded until this point! 7 | 8 | # Requires supporting ruby files with custom matchers and macros, etc, in 9 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 10 | # run as spec files by default. This means that files in spec/support that end 11 | # in _spec.rb will both be required and run as specs, causing the specs to be 12 | # run twice. It is recommended that you do not name files matching this glob to 13 | # end with _spec.rb. You can configure this pattern with the --pattern 14 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 15 | # 16 | # The following line is provided for convenience purposes. It has the downside 17 | # of increasing the boot-up time by auto-requiring all files in the support 18 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 19 | # require only the support files necessary. 20 | # 21 | # Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 22 | 23 | # Checks for pending migrations before tests are run. 24 | # If you are not using ActiveRecord, you can remove this line. 25 | ActiveRecord::Migration.maintain_test_schema! 26 | 27 | RSpec.configure do |config| 28 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 29 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 30 | 31 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 32 | # examples within a transaction, remove the following line or assign false 33 | # instead of true. 34 | config.use_transactional_fixtures = true 35 | 36 | # RSpec Rails can automatically mix in different behaviours to your tests 37 | # based on their file location, for example enabling you to call `get` and 38 | # `post` in specs under `spec/controllers`. 39 | # 40 | # You can disable this behaviour by removing the line below, and instead 41 | # explicitly tag your specs with their type, e.g.: 42 | # 43 | # RSpec.describe UsersController, :type => :controller do 44 | # # ... 45 | # end 46 | # 47 | # The different available types are documented in the features, such as in 48 | # https://relishapp.com/rspec/rspec-rails/docs 49 | config.infer_spec_type_from_file_location! 50 | end 51 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | ## Uncomment and set this to only include directories you want to watch 5 | # directories %w(app lib config test spec features) \ 6 | # .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")} 7 | 8 | ## Note: if you are using the `directories` clause above and you are not 9 | ## watching the project directory ('.'), then you will want to move 10 | ## the Guardfile to a watched dir and symlink it back, e.g. 11 | # 12 | # $ mkdir config 13 | # $ mv Guardfile config/ 14 | # $ ln -s config/Guardfile . 15 | # 16 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 17 | 18 | # Note: The cmd option is now required due to the increasing number of ways 19 | # rspec may be run, below are examples of the most common uses. 20 | # * bundler: 'bundle exec rspec' 21 | # * bundler binstubs: 'bin/rspec' 22 | # * spring: 'bin/rspec' (This will use spring if running and you have 23 | # installed the spring binstubs per the docs) 24 | # * zeus: 'zeus rspec' (requires the server to be started separately) 25 | # * 'just' rspec: 'rspec' 26 | 27 | guard :rspec, cmd: "bin/rspec" do 28 | require "guard/rspec/dsl" 29 | dsl = Guard::RSpec::Dsl.new(self) 30 | 31 | # Feel free to open issues for suggestions and improvements 32 | 33 | # RSpec files 34 | rspec = dsl.rspec 35 | watch(rspec.spec_helper) { rspec.spec_dir } 36 | watch(rspec.spec_support) { rspec.spec_dir } 37 | watch(rspec.spec_files) 38 | 39 | # Ruby files 40 | ruby = dsl.ruby 41 | dsl.watch_spec_files_for(ruby.lib_files) 42 | 43 | # Rails files 44 | rails = dsl.rails(view_extensions: %w(erb haml slim)) 45 | dsl.watch_spec_files_for(rails.app_files) 46 | dsl.watch_spec_files_for(rails.views) 47 | 48 | watch(rails.controllers) do |m| 49 | [ 50 | rspec.spec.("routing/#{m[1]}_routing"), 51 | rspec.spec.("controllers/#{m[1]}_controller"), 52 | rspec.spec.("acceptance/#{m[1]}") 53 | ] 54 | end 55 | 56 | # Rails config changes 57 | watch(rails.spec_helper) { rspec.spec_dir } 58 | watch(rails.routes) { "#{rspec.spec_dir}/routing" } 59 | watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } 60 | 61 | # Capybara features specs 62 | watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") } 63 | watch(rails.layouts) { |m| rspec.spec.("features/#{m[1]}") } 64 | 65 | # Turnip features and steps 66 | watch(%r{^spec/acceptance/(.+)\.feature$}) 67 | watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m| 68 | Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/services/post_form_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe PostForm do 4 | let(:board){ FactoryBot.create(:board) } 5 | 6 | let(:params){ 7 | ActionController::Parameters.new( 8 | board_id: board.id, 9 | content: "hi" 10 | ) 11 | } 12 | 13 | subject { 14 | subject = described_class.new.from_params(params) 15 | } 16 | 17 | describe "persistance" do 18 | context "opening new thread" do 19 | it "saves post with topic" do 20 | subject.save 21 | 22 | subject.post.persisted?.should == true 23 | subject.topic.persisted?.should == true 24 | end 25 | end 26 | 27 | context "opening new thread with image only" do 28 | let(:file){ 29 | Rack::Test::UploadedFile.new(File.join(ActionController::TestCase.fixture_path, 'files/sample.jpg'), 'image/jpeg') 30 | } 31 | let(:params){ 32 | ActionController::Parameters.new( 33 | board_id: board.id, 34 | images: [file] 35 | ) 36 | } 37 | 38 | it "saves post with topic" do 39 | subject.save 40 | 41 | subject.post.persisted?.should == true 42 | subject.post.images.size.should == 1 43 | subject.topic.persisted?.should == true 44 | end 45 | end 46 | end 47 | 48 | describe "#new_topic?" do 49 | it "returns false if not persisted yet" do 50 | subject.new_topic?.should == true 51 | end 52 | 53 | it "returns true if persisted" do 54 | subject.save 55 | subject.new_topic?.should == false 56 | end 57 | end 58 | 59 | describe ".valid?" do 60 | context "topic is invalid" do 61 | before do 62 | subject.topic.stub(:valid?).and_return(false) 63 | end 64 | it "returns false" do 65 | subject.valid?.should == false 66 | end 67 | end 68 | 69 | context "post is invalid" do 70 | let(:params){ 71 | ActionController::Parameters.new( 72 | board_id: board.id, 73 | content: "" # cause invalid 74 | ) 75 | } 76 | it "returns false" do 77 | subject.valid?.should == false 78 | subject.errors[:base].present?.should == true 79 | end 80 | end 81 | 82 | context "topic and post are valid" do 83 | it "returns false" do 84 | subject.valid?.should == true 85 | end 86 | end 87 | 88 | context "called twice" do 89 | it "clears error object when called again" do 90 | subject.post.content = "" 91 | subject.valid?.should == false 92 | subject.post.content = "foo" 93 | subject.valid?.should == true 94 | subject.errors.empty?.should == true 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /app/services/post_form.rb: -------------------------------------------------------------------------------- 1 | class PostForm 2 | include ActiveModel::Model 3 | 4 | attr_accessor :topic, :post 5 | delegate :title, :board_id, :file_attachable?, to: :topic 6 | delegate :author, :content, :options_raw, :topic_id, :images, :images_attributes=, to: :post 7 | 8 | # To be called right after new(). 9 | # For rendering empty form. 10 | def from_topic(topic) 11 | @topic = topic 12 | setup_post 13 | self 14 | end 15 | 16 | # To be called right after new(). 17 | # For params submitted from user. 18 | def from_params(params) 19 | if params[:topic_id].present? 20 | @topic = Topic.find(params[:topic_id]) 21 | else 22 | @topic = Board.find(params[:board_id]).topics.build 23 | end 24 | @post = @topic.posts.build 25 | attributes(params) 26 | self 27 | end 28 | 29 | def setup_post 30 | @post = @topic.posts.build 31 | if @post.images.empty? 32 | @post.images.build 33 | end 34 | end 35 | 36 | def attributes(params) 37 | @post.assign_attributes(post_params(params)) 38 | 39 | @post.images.concat build_images_from_params(params) 40 | 41 | if new_topic? 42 | if params[:title] 43 | @topic.title = params[:title] 44 | end 45 | if params[:board_id] 46 | @topic.board_id = params[:board_id] 47 | end 48 | end 49 | end 50 | 51 | def build_images_from_params(params) 52 | images = [] 53 | 54 | if params[:image_ids].present? 55 | images.concat Image.where(id: params[:image_ids], post_id: nil) 56 | end 57 | 58 | if params[:images].present? 59 | params[:images].each do |file| 60 | images << Image.new(image:file) 61 | end 62 | end 63 | 64 | images 65 | end 66 | 67 | def valid? 68 | validity = true 69 | errors.clear 70 | [post, topic].each do |object| 71 | if !object.valid? 72 | validity = false 73 | object.errors.adequate.each do |error| 74 | errors.adequate.import(error) 75 | end 76 | end 77 | end 78 | validity 79 | end 80 | 81 | def new_topic? 82 | !@topic.persisted? 83 | end 84 | 85 | def save 86 | return false if !valid? 87 | 88 | ActiveRecord::Base.transaction do 89 | @post.save! if @post.changed? 90 | @topic.save! if new_topic? 91 | end 92 | 93 | true 94 | rescue ActiveRecord::RecordInvalid => invalid 95 | false 96 | end 97 | 98 | def post_params(params) 99 | params.permit(:author, :content, :options_raw, :topic_id) 100 | end 101 | 102 | # Necessary code 103 | 104 | def self.model_name 105 | ActiveModel::Name.new(self, nil, 'Post') 106 | end 107 | 108 | class << self 109 | def i18n_scope 110 | :activerecord 111 | end 112 | end 113 | end 114 | 115 | -------------------------------------------------------------------------------- /app/uploaders/image_uploader.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | class ImageUploader < CarrierWave::Uploader::Base 4 | 5 | # Include RMagick or MiniMagick support: 6 | # include CarrierWave::RMagick 7 | include CarrierWave::MiniMagick 8 | 9 | # Choose what kind of storage to use for this uploader: 10 | storage :file 11 | # storage :fog 12 | 13 | # Override the directory where uploaded files will be stored. 14 | # This is a sensible default for uploaders that are meant to be mounted: 15 | def store_dir 16 | "uploads/#{model.id}" 17 | end 18 | 19 | # Provide a default URL as a default if there hasn't been a file uploaded: 20 | # def default_url 21 | # # For Rails 3.1+ asset pipeline compatibility: 22 | # # ActionController::Base.helpers.asset_path("fallback/" + [version_name, "default.png"].compact.join('_')) 23 | # 24 | # "/images/fallback/" + [version_name, "default.png"].compact.join('_') 25 | # end 26 | 27 | # Process files as they are uploaded: 28 | # process :scale => [200, 300] 29 | # 30 | # def scale(width, height) 31 | # # do something 32 | # end 33 | 34 | # Create different versions of your uploaded files: 35 | version :thumb do 36 | process :remove_animation 37 | process :resize_to_fit => [250, 250] 38 | process :watermark 39 | process :store_thumb_dimensions 40 | end 41 | 42 | process :store_dimensions 43 | 44 | # Add a white list of extensions which are allowed to be uploaded. 45 | # For images you might use something like this: 46 | def extension_white_list 47 | if model.remote_image_url.present? 48 | nil # extension check moved to Image model 49 | else 50 | %w(jpg jpeg gif png) 51 | end 52 | end 53 | 54 | # Override the filename of the uploaded files: 55 | # Avoid using model.id or version_name here, see uploader/store.rb for details. 56 | def filename 57 | if original_filename 58 | if model && model.read_attribute(mounted_as).present? 59 | model.read_attribute(mounted_as) 60 | else 61 | base = file.send(:split_extension, original_filename).first 62 | "#{Time.now.to_i}_#{base[0..16]}.#{get_extension}" 63 | end 64 | end 65 | end 66 | 67 | def get_extension 68 | FastImage.type(file.to_file) 69 | end 70 | 71 | def watermark 72 | manipulate! do |img| 73 | return img if !img.mime_type.include?('gif') 74 | 75 | img.combine_options do |cmd| 76 | cmd.gravity 'SouthEast' 77 | cmd.draw 'text 10,10 "GIF"' 78 | cmd.font Setting.dig(:carrierwave, :watermark, :font) 79 | cmd.pointsize '24' 80 | cmd.fill 'black' 81 | end 82 | end 83 | end 84 | 85 | def remove_animation 86 | manipulate! do |img| 87 | if img.mime_type.match /gif/ 88 | img.collapse! 89 | end 90 | img 91 | end 92 | end 93 | 94 | def store_dimensions 95 | if file && model 96 | model.width, model.height = ::MiniMagick::Image.open(file.file)[:dimensions] 97 | end 98 | end 99 | 100 | def store_thumb_dimensions 101 | if file && model 102 | model.thumb_width, model.thumb_height = ::MiniMagick::Image.open(file.file)[:dimensions] 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) do |repo_name| 4 | repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") 5 | "https://github.com/#{repo_name}.git" 6 | end 7 | 8 | gem 'bundler', '>= 1.8.4' 9 | 10 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 11 | gem 'rails', '5.2.0' 12 | # database 13 | gem 'pg', '~> 0.18.2' 14 | # Use Puma as the app server 15 | gem 'puma', '~> 3.11.2' 16 | # Use SCSS for stylesheets 17 | gem 'sass-rails', '~> 5.0.7' 18 | # Use Uglifier as compressor for JavaScript assets 19 | gem 'uglifier', '>= 1.3.0' 20 | # See https://github.com/sstephenson/execjs#readme for more supported runtimes 21 | # gem 'therubyracer', platforms: :ruby 22 | 23 | # Use jquery as the JavaScript library 24 | gem 'jquery-rails', '~> 4.3.1' 25 | gem 'jquery-rails-cdn', '~> 1.0.3' 26 | # Use CoffeeScript for .coffee assets and views 27 | gem 'coffee-rails', '~> 4.2.2' 28 | # Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks 29 | gem 'turbolinks' 30 | gem 'jquery-turbolinks', '~> 2.1.0' 31 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 32 | gem 'json', '~> 1.8.6' 33 | gem 'jbuilder', '~> 2.5' 34 | # Use Redis adapter to run Action Cable in production 35 | # gem 'redis', '~> 3.0' 36 | # bundle exec rake doc:rails generates the API under doc/api. 37 | gem 'sdoc', '~> 0.4.2', group: :doc 38 | 39 | # Use ActiveModel has_secure_password 40 | # gem 'bcrypt', '~> 3.1.7' 41 | 42 | # Use Capistrano for deployment 43 | # gem 'capistrano-rails', group: :development 44 | 45 | gem 'cells', '~> 3.11.3' 46 | gem 'cells-collection', '~> 0.0.2' 47 | gem 'kaminari', '~> 1.1.1' 48 | gem 'auto_html', '~> 1.6.4' 49 | gem 'lulalala_presenter', "~> 0.1.0" 50 | 51 | gem 'adequate_errors', '~> 0.1.1' 52 | gem 'nilify_blanks', '~> 1.2.1' 53 | 54 | gem 'migration_comments', '~> 0.4.1' 55 | gem 'seed-fu', '~> 2.3.6' 56 | gem 'seedbank', '~> 0.3' 57 | 58 | gem 'settingslogic', '~> 2.0' 59 | gem 'settei', '~> 0.1.3' 60 | 61 | gem 'unobtrusive_flash', '~> 3.3.1' 62 | 63 | gem 'sitemap_generator', '~> 5.0.5' 64 | 65 | # Image 66 | gem 'mini_magick', '~> 4.2.7' 67 | gem 'carrierwave', '~> 0.10.0' 68 | gem 'fastimage', '~> 1.7.0' 69 | 70 | # Assets 71 | source 'https://rails-assets.org' do 72 | gem 'rails-assets-js-cookie' 73 | gem 'rails-assets-normalize.css', '~> 3.0.3' 74 | gem 'rails-assets-blueimp-file-upload', '~> 9.28.0' 75 | end 76 | 77 | gem 'sucker_punch', '~> 2.0.1' 78 | 79 | # Reduces boot times through caching; required in config/boot.rb 80 | gem 'bootsnap', '>= 1.1.0', require: false 81 | 82 | group :development do 83 | # Access an IRB console on exception pages or by using <%= console %> anywhere in the code. 84 | gem 'web-console', '>= 3.3.0' 85 | gem 'listen', '>= 3.0.5', '< 3.2' 86 | end 87 | 88 | group :development, :test do 89 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 90 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 91 | # Adds support for Capybara system testing and selenium driver 92 | gem 'capybara', '~> 2.13' 93 | gem 'selenium-webdriver' 94 | 95 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 96 | gem 'spring' 97 | gem 'spring-watcher-listen', '~> 2.0.0' 98 | 99 | gem 'mysql2', '~> 0.5.1' # Sequel Pro is too good that I can't escape MySQL 100 | end 101 | 102 | group :test, :darwin do 103 | gem 'rspec-rails', '~> 3.7' 104 | gem 'spring-commands-rspec', '~> 1.0.4' 105 | gem 'guard-rspec', '~> 4.7' 106 | gem 'rb-fsevent', '~> 0.10.2' 107 | gem 'factory_bot_rails', '~> 4.8.2' 108 | gem 'timecop', '~> 0.9.1' 109 | end 110 | 111 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 112 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 113 | -------------------------------------------------------------------------------- /app/models/post.rb: -------------------------------------------------------------------------------- 1 | require 'auto_html/filters/quote' 2 | 3 | class Post < ApplicationRecord 4 | belongs_to :topic, inverse_of: :posts 5 | has_many :images, inverse_of: :post, dependent: :destroy 6 | 7 | # Replies 8 | has_many :links_as_parent, foreign_key: :ancestor_id, class_name:'Reply' 9 | has_many :links_as_child, foreign_key: :descendant_id, class_name:'Reply' 10 | has_many :parents, through: :links_as_child, source: :ancestor 11 | has_many :children, through: :links_as_parent, source: :descendant 12 | 13 | serialize :options, Array 14 | 15 | nilify_blanks 16 | 17 | auto_html_for :content do 18 | html_escape 19 | quote 20 | youtube 21 | link :target => "_blank", :rel => "nofollow" 22 | simple_format 23 | end 24 | 25 | delegate :board, to: :topic 26 | 27 | validate :validate_content 28 | def validate_content 29 | if images.blank? && content.blank? 30 | errors.add(:base, :no_content) 31 | end 32 | end 33 | 34 | validate :validate_topic_lock 35 | def validate_topic_lock 36 | if topic.locked? 37 | errors.add(:topic, :locked) 38 | end 39 | end 40 | 41 | def options_raw=(value) 42 | super 43 | 44 | analyzer = RawOptionsAnalyzer.new(options_raw, board.config.dig(:post, :allowed_options)) 45 | self.email = analyzer.email 46 | self.options = analyzer.options 47 | extend_options 48 | end 49 | 50 | before_create :set_pos 51 | def set_pos 52 | self.pos = topic.max_pos + 1 53 | end 54 | 55 | after_create :bump_topic 56 | def bump_topic 57 | topic.bump 58 | end 59 | 60 | after_create :increment_topic_pos 61 | def increment_topic_pos 62 | topic.increment_pos 63 | end 64 | 65 | private 66 | 67 | # Load post option modules to object's eigenclass. 68 | # This extends single object instance. 69 | def extend_options 70 | options.each do |option| 71 | klass = Post::Extension.const_get(option.camelize) 72 | self.extend klass 73 | end 74 | end 75 | 76 | before_save :assign_reply 77 | REPLY_PATTERN = /(\> ?(\d+))/ 78 | def assign_reply 79 | return if content_html.blank? 80 | 81 | pos_array = content_html.scan(REPLY_PATTERN).map{|m| m[1]} 82 | self.parents += Post.where(topic_id: topic_id, pos: pos_array) 83 | end 84 | 85 | before_save :extract_images 86 | def extract_images 87 | return if content_html.blank? 88 | return if !content_changed? 89 | 90 | content_html_dup = content_html.dup 91 | 92 | content.lines.each do |line| 93 | url = URI.extract(line) 94 | 95 | next if url.size != 1 96 | 97 | url = url.first 98 | next if (line.sub(url,'').strip).size > 0 99 | 100 | image = images.find_by(remote_image_url: url) 101 | if !image 102 | image = Image.create(remote_image_url: url) 103 | next if !image.valid? 104 | 105 | self.images << image 106 | end 107 | 108 | content_html_dup.gsub!(%r{}, image.presenter.cleared_linked_display) 109 | end 110 | 111 | self.content_html = content_html_dup 112 | end 113 | 114 | before_save :turn_reply_to_link 115 | def turn_reply_to_link 116 | return if content_html.blank? 117 | 118 | self.content_html = content_html.gsub(REPLY_PATTERN) do |match| 119 | pos = match[/\d+/].to_i 120 | r = parents.find {|parent| parent.pos == pos} 121 | if r 122 | url = Rails.application.routes.url_helpers.topic_path( 123 | board: r.topic.board, 124 | id: r.topic_id, 125 | anchor: r.presenter.dom_id 126 | ) 127 | ActionController::Base.helpers.link_to("> #{r.pos}", url) 128 | else 129 | match 130 | end 131 | end 132 | end 133 | 134 | # TODO: check in before_save to block uploading 135 | after_create :update_file_attachable 136 | def update_file_attachable 137 | if Image.joins(:post => :topic).where(:posts => {:topic => topic}).count > 100 138 | topic.update_columns(file_attachable: false) 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 33 | 34 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 35 | # config.action_controller.asset_host = 'http://assets.example.com' 36 | 37 | # Specifies the header that your server uses for sending files. 38 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 39 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 40 | 41 | # Mount Action Cable outside main process or domain 42 | # config.action_cable.mount_path = nil 43 | # config.action_cable.url = 'wss://example.com/cable' 44 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 45 | 46 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 47 | # config.force_ssl = true 48 | 49 | # Use the lowest log level to ensure availability of diagnostic information 50 | # when problems arise. 51 | config.log_level = :debug 52 | 53 | # Prepend all log lines with the following tags. 54 | config.log_tags = [ :request_id ] 55 | 56 | # Use a different cache store in production. 57 | # config.cache_store = :mem_cache_store 58 | 59 | # Use a real queuing backend for Active Job (and separate queues per environment) 60 | # config.active_job.queue_adapter = :resque 61 | # config.active_job.queue_name_prefix = "mei_#{Rails.env}" 62 | 63 | config.action_mailer.perform_caching = false 64 | 65 | # Ignore bad email addresses and do not raise email delivery errors. 66 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 67 | # config.action_mailer.raise_delivery_errors = false 68 | 69 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 70 | # the I18n.default_locale when a translation cannot be found). 71 | config.i18n.fallbacks = true 72 | 73 | # Send deprecation notices to registered listeners. 74 | config.active_support.deprecation = :notify 75 | 76 | # Use default logging formatter so that PID and timestamp are not suppressed. 77 | config.log_formatter = ::Logger::Formatter.new 78 | 79 | # Use a different logger for distributed setups. 80 | # require 'syslog/logger' 81 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 82 | 83 | if ENV["RAILS_LOG_TO_STDOUT"].present? 84 | logger = ActiveSupport::Logger.new(STDOUT) 85 | logger.formatter = config.log_formatter 86 | config.logger = ActiveSupport::TaggedLogging.new(logger) 87 | end 88 | 89 | # Do not dump schema after migrations. 90 | config.active_record.dump_schema_after_migration = false 91 | end 92 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | 33 | expectations.syntax = [:should, :expect] 34 | end 35 | 36 | # rspec-mocks config goes here. You can use an alternate test double 37 | # library (such as bogus or mocha) by changing the `mock_with` option here. 38 | config.mock_with :rspec do |mocks| 39 | # Prevents you from mocking or stubbing a method that does not exist on 40 | # a real object. This is generally recommended, and will default to 41 | # `true` in RSpec 4. 42 | mocks.verify_partial_doubles = true 43 | end 44 | 45 | # The settings below are suggested to provide a good initial experience 46 | # with RSpec, but feel free to customize to your heart's content. 47 | =begin 48 | # These two settings work together to allow you to limit a spec run 49 | # to individual examples or groups you care about by tagging them with 50 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 51 | # get run. 52 | config.filter_run :focus 53 | config.run_all_when_everything_filtered = true 54 | 55 | # Limits the available syntax to the non-monkey patched syntax that is 56 | # recommended. For more details, see: 57 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 58 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 59 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 60 | config.disable_monkey_patching! 61 | 62 | # Many RSpec users commonly either run the entire suite or an individual 63 | # file, and it's useful to allow more verbose output when running an 64 | # individual spec file. 65 | if config.files_to_run.one? 66 | # Use the documentation formatter for detailed output, 67 | # unless a formatter has already been configured 68 | # (e.g. via a command-line flag). 69 | config.default_formatter = 'doc' 70 | end 71 | 72 | # Print the 10 slowest examples and example groups at the 73 | # end of the spec run, to help surface which specs are running 74 | # particularly slow. 75 | config.profile_examples = 10 76 | 77 | # Run specs in random order to surface order dependencies. If you find an 78 | # order dependency and want to debug it, you can fix the order by providing 79 | # the seed, which is printed after each run. 80 | # --seed 1234 81 | config.order = :random 82 | 83 | # Seed global randomization in this process using the `--seed` CLI option. 84 | # Setting this allows you to use `--seed` to deterministically reproduce 85 | # test failures related to randomization by passing the same `--seed` value 86 | # as the one that triggered the failure. 87 | Kernel.srand config.seed 88 | =end 89 | end 90 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20170523114957) do 15 | 16 | create_table "boards", force: :cascade, comment: "board" do |t| 17 | t.string "seo_name", limit: 255, null: false, comment: "represent name in URL. Must be URL valid characters." 18 | t.string "name", limit: 255, null: false, comment: "display name on top of page" 19 | t.datetime "created_at", null: false 20 | t.datetime "updated_at", null: false 21 | t.text "config", limit: 65535, comment: "board-specific configuration in YAML" 22 | end 23 | 24 | add_index "boards", ["seo_name"], name: "index_boards_on_seo_name", unique: true, using: :btree 25 | 26 | create_table "images", force: :cascade, comment: "image" do |t| 27 | t.integer "post_id", limit: 4 28 | t.string "image", limit: 255, comment: "filename" 29 | t.integer "width", limit: 4 30 | t.integer "height", limit: 4 31 | t.integer "thumb_width", limit: 4 32 | t.integer "thumb_height", limit: 4 33 | t.string "remote_url", limit: 255, comment: "url of image fetched from" 34 | t.datetime "created_at", null: false 35 | t.datetime "updated_at", null: false 36 | end 37 | 38 | add_index "images", ["post_id"], name: "index_images_on_post_id", using: :btree 39 | add_index "images", ["remote_url"], name: "index_images_on_remote_url", using: :btree 40 | 41 | create_table "posts", force: :cascade, comment: "text content posted. New post or reply comments are all posts." do |t| 42 | t.text "content", limit: 65535, comment: "text content" 43 | t.string "author", limit: 255, comment: "author name" 44 | t.string "options", limit: 255, comment: "array of options like sage" 45 | t.string "options_raw", limit: 255, comment: "user input for email and options" 46 | t.string "email", limit: 255, comment: "email" 47 | t.integer "topic_id", limit: 4, null: false 48 | t.integer "pos", limit: 2, null: false, comment: "position of post within topic" 49 | t.datetime "created_at", null: false 50 | t.datetime "updated_at", null: false 51 | t.text "content_html", limit: 65535, comment: "text content processed into html" 52 | end 53 | 54 | add_index "posts", ["topic_id", "pos"], name: "index_posts_on_topic_id_and_pos", unique: true, using: :btree 55 | add_index "posts", ["topic_id"], name: "index_posts_on_topic_id", using: :btree 56 | 57 | create_table "replies", force: :cascade, comment: "replying relations between posts" do |t| 58 | t.integer "ancestor_id", limit: 4, comment: "post that is being replied to" 59 | t.integer "descendant_id", limit: 4, comment: "post that is the reply" 60 | end 61 | 62 | create_table "topics", force: :cascade, comment: "topic of discussion, also called thread" do |t| 63 | t.string "title", limit: 255, comment: "title" 64 | t.integer "board_id", limit: 4, null: false 65 | t.integer "max_pos", limit: 2, default: 0, null: false, comment: "current newest post pos, increment as posts are created" 66 | t.boolean "locked", default: false, null: false, comment: "prevent further replies" 67 | t.boolean "file_attachable", default: true, null: false 68 | t.datetime "created_at", null: false 69 | t.datetime "updated_at", null: false 70 | t.datetime "bumped_at", comment: "topic bump time" 71 | end 72 | 73 | add_index "topics", ["board_id"], name: "index_topics_on_board_id", using: :btree 74 | 75 | create_table "view_fragments", force: :cascade, comment: "Custom HTML fragments to be displayed" do |t| 76 | t.integer "board_id", limit: 4 77 | t.string "name", limit: 255, null: false, comment: "name for referencing" 78 | t.text "content", limit: 65535, comment: "html fragment" 79 | t.datetime "created_at", null: false 80 | t.datetime "updated_at", null: false 81 | end 82 | 83 | add_index "view_fragments", ["board_id", "name"], name: "index_view_fragments_on_board_id_and_name", unique: true, using: :btree 84 | 85 | add_foreign_key "images", "posts" 86 | add_foreign_key "posts", "topics" 87 | add_foreign_key "topics", "boards" 88 | add_foreign_key "view_fragments", "boards" 89 | end 90 | -------------------------------------------------------------------------------- /app/assets/javascripts/jquery.nested_attributes.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Authors: Nick Giancola (@patbenatar), Brendan Loudermilk (@bloudermilk) 3 | Homepage: https://github.com/patbenatar/jquery-nested_attributes 4 | ### 5 | $ = jQuery 6 | 7 | methods = 8 | init: (options) -> 9 | $el = $(@) 10 | throw "Can't initialize more than one item at a time" if $el.length > 1 11 | if $el.data("nestedAttributes") 12 | throw "Can't initialize on this element more than once" 13 | instance = new NestedAttributes($el, options) 14 | $el.data("nestedAttributes", instance) 15 | return $el 16 | add: -> 17 | $el = $(@) 18 | unless $el.data("nestedAttributes")? 19 | throw "You are trying to call instance methods without initializing first" 20 | $el.data("nestedAttributes").addItem() 21 | return $el 22 | 23 | $.fn.nestedAttributes = (method) -> 24 | if methods[method]? 25 | return methods[method].apply @, Array.prototype.slice.call(arguments, 1) 26 | else if typeof method == 'object' || !method 27 | return methods.init.apply(@, arguments) 28 | else 29 | $.error("Method #{method} does not exist on jQuery.nestedAttributes") 30 | 31 | class NestedAttributes 32 | 33 | RELEVANT_INPUTS_SELECTOR: ":input[name][name!=\"\"]" 34 | 35 | settings: 36 | collectionName: false # If not provided, we will autodetect 37 | bindAddTo: false # Required 38 | removeOnLoadIf: false 39 | collectIdAttributes: true 40 | beforeAdd: false 41 | afterAdd: false 42 | beforeMove: false 43 | afterMove: false 44 | beforeDestroy: false 45 | afterDestroy: false 46 | destroySelector: '.destroy' 47 | deepClone: true 48 | $clone: null 49 | 50 | ###################### 51 | ## ## 52 | ## Initialization ## 53 | ## ## 54 | ###################### 55 | 56 | constructor: ($el, options) -> 57 | 58 | # This plugin gets called on the container 59 | @$container = $el 60 | 61 | # Merge default options 62 | @options = $.extend({}, @settings, options) 63 | 64 | # If the user provided a jQuery object to bind the "Add" 65 | # bind it now or forever hold your peace. 66 | @options.bindAddTo.click(@addClick) if @options.bindAddTo 67 | 68 | # Cache all the items 69 | @$items = @$container.children() 70 | 71 | # If the user didn't provide a collectionName, autodetect it 72 | unless @options.collectionName 73 | @autodetectCollectionName() 74 | 75 | # Initialize existing items 76 | @$items.each (i, el) => 77 | $item = $(el) 78 | 79 | # If the user wants us to attempt to collect Rail's ID attributes, do it now 80 | # Using the default rails helpers, ID attributes will wind up right after their 81 | # propper containers in the form. 82 | if @options.collectIdAttributes and $item.is('input') 83 | # Move the _id field into its proper container 84 | $item.appendTo($item.prev()) 85 | # Remove it from the $items collection 86 | @$items = @$items.not($item) 87 | else 88 | # Try to find and bind the destroy link if the user wanted one 89 | @bindDestroy($item) 90 | 91 | # Now that we've collected ID attributes 92 | @hideIfAlreadyDestroyed $(item) for item in @$items 93 | 94 | # Remove any items on load if the client implements a check and the check passes 95 | if @options.removeOnLoadIf 96 | @$items.each (i, el) => 97 | $el = $(el) 98 | if $el.call(true, @options.removeOnLoadIf, i) 99 | $el.remove() 100 | 101 | 102 | ######################## 103 | ## ## 104 | ## Instance Methods ## 105 | ## ## 106 | ######################## 107 | 108 | autodetectCollectionName: -> 109 | pattern = /\[(.[^\]]*)_attributes\]/ 110 | try 111 | match = pattern.exec(@$items.first().find("#{@RELEVANT_INPUTS_SELECTOR}:first").attr('name'))[1] 112 | if match != null 113 | @options.collectionName = match 114 | else 115 | throw "Regex error" 116 | catch error 117 | console.log "Error detecting collection name", error 118 | 119 | addClick: (event) => 120 | 121 | @addItem() 122 | 123 | # Don't let the link do anything 124 | event.preventDefault() 125 | 126 | addItem: -> 127 | # Piece together an item 128 | newIndex = @$items.length 129 | $newClone = @applyIndexToItem(@extractClone(), newIndex) 130 | 131 | # Give the user a chance to make their own changes before we insert 132 | if (@options.beforeAdd) 133 | 134 | # Stop the add process if the callback returns false 135 | return false if !@options.beforeAdd.call(undefined, $newClone, newIndex) 136 | 137 | # Insert the new item after the last item 138 | @$container.append($newClone) 139 | 140 | # Give the user a chance to make their own changes after insertion 141 | @options.afterAdd.call(undefined, $newClone, newIndex) if (@options.afterAdd) 142 | 143 | # Add this item to the items list 144 | @refreshItems() 145 | 146 | extractClone: -> 147 | 148 | # Are we restoring from an already created clone? 149 | if @$restorableClone 150 | 151 | $record = @$restorableClone 152 | 153 | @$restorableClone = null 154 | 155 | else 156 | $record = @options.$clone || @$items.first() 157 | 158 | # Make a deep clone (bound events and data) 159 | $record = $record.clone(@options.deepClone) 160 | 161 | @bindDestroy($record) if @options.$clone or !@options.deepClone 162 | 163 | # Empty out the values of text inputs and selects 164 | $record.find(':text, :file, input[type=url], textarea, select').val('') 165 | 166 | # Reset checkboxes and radios 167 | $record.find(':checkbox, :radio').attr("checked", false) 168 | 169 | # Empty out any hidden [id] or [_destroy] fields 170 | $record.find('input[name$="\\[id\\]"]').remove() 171 | $record.find('input[name$="\\[_destroy\\]"]').remove() 172 | 173 | # Make sure it's not hidden as we return. 174 | # It would be hidden in the case where we're duplicating an 175 | # already removed item for its template. 176 | return $record # NOTE! SELF MODIFIED 177 | 178 | applyIndexToItem: ($item, index) -> 179 | collectionName = @options.collectionName 180 | 181 | $item.find(@RELEVANT_INPUTS_SELECTOR).each (i, el) => 182 | 183 | $el = $(el) 184 | 185 | idRegExp = new RegExp("_#{collectionName}_attributes_\\d+_") 186 | idReplacement = "_#{collectionName}_attributes_#{index}_" 187 | nameRegExp = new RegExp("\\[#{collectionName}_attributes\\]\\[\\d+\\]") 188 | nameReplacement = "[#{collectionName}_attributes][#{index}]" 189 | 190 | newID = $el.attr('id').replace(idRegExp, idReplacement) if $el.attr('id') 191 | newName = $el.attr('name').replace(nameRegExp, nameReplacement) 192 | 193 | $el.attr 194 | id: newID 195 | name: newName 196 | 197 | $item.find('label[for]').each (i, el) => 198 | $el = $(el) 199 | try 200 | forRegExp = new RegExp("_#{collectionName}_attributes_\\d+_") 201 | forReplacement = "_#{collectionName}_attributes_#{index}_" 202 | newFor = $el.attr('for').replace(forRegExp, forReplacement) 203 | $el.attr('for', newFor) 204 | catch error 205 | console.log "Error updating label", error 206 | 207 | return $item 208 | 209 | hideIfAlreadyDestroyed: ($item) -> 210 | $destroyField = $item.find("[name$='[_destroy]']") 211 | if $destroyField.length && $destroyField.val() == "true" 212 | @destroy $item 213 | 214 | # Hides a item from the user and marks it for deletion in the 215 | # DOM by setting _destroy to true if the record already exists. If it 216 | # is a new escalation, we simple delete the item 217 | destroyClick: (event) => 218 | event.preventDefault() 219 | @destroy $(event.target).parentsUntil(@$container).last() 220 | 221 | destroy: ($item) -> 222 | # If you're about to delete the last one, 223 | # cache a clone of it first so we have something to show 224 | # the next time user hits add 225 | @$restorableClone = @extractClone() unless @$items.length-1 226 | 227 | index = @indexForItem($item) 228 | itemIsNew = $item.find('input[name$="\\[id\\]"]').length == 0 229 | 230 | if (@options.beforeDestroy) 231 | 232 | # Stop the destroy process if the callback returns false 233 | return false if !@options.beforeDestroy.call(undefined, $item, index, itemIsNew) 234 | 235 | # Add a blank item row if none are visible after this deletion 236 | @addItem() unless @$items.filter(':visible').length-1 237 | 238 | if itemIsNew 239 | 240 | $item.remove() 241 | 242 | else 243 | 244 | # Hide the item 245 | $item.hide() 246 | 247 | # Add the _destroy field 248 | otherFieldName = $item.find(':input[name]:first').attr('name') 249 | attributePosition = otherFieldName.lastIndexOf('[') 250 | destroyFieldName = "#{otherFieldName.substring(0, attributePosition)}[_destroy]" 251 | # First look for an existing _destroy field 252 | $destroyField = $item.find("input[name='#{destroyFieldName}']") 253 | # If it doesn't exist, create it 254 | if $destroyField.length == 0 255 | $destroyField = $("") 256 | $item.append($destroyField) 257 | $destroyField.val(true).change() 258 | 259 | @options.afterDestroy.call($item, index, itemIsNew) if (@options.afterDestroy) 260 | 261 | # Remove this item from the items list 262 | @refreshItems() 263 | 264 | # Rename the remaining items 265 | @resetIndexes() 266 | 267 | indexForItem: ($item) -> 268 | regExp = new RegExp("\\[#{@options.collectionName}_attributes\\]\\[\\d+\\]") 269 | name = $item.find("#{@RELEVANT_INPUTS_SELECTOR}:first").attr('name') 270 | return parseInt(name.match(regExp)[0].split('][')[1].slice(0, -1), 10) 271 | 272 | refreshItems: -> 273 | @$items = @$container.children() 274 | 275 | # Sets the proper association indices and labels to all items 276 | # Used when removing items 277 | resetIndexes: -> 278 | @$items.each (i, el) => 279 | $el = $(el) 280 | 281 | # Make sure this is actually a new position 282 | oldIndex = @indexForItem($el) 283 | return true if (i == oldIndex) 284 | 285 | @options.beforeMove.call($el, i, oldIndex) if (@options.beforeMove) 286 | 287 | # Change the number to the new index 288 | @applyIndexToItem($el, i) 289 | 290 | @options.afterMove.call($el, i, oldIndex) if (@options.afterMove) 291 | 292 | bindDestroy: ($item) -> 293 | $item.find(@options.destroySelector).click(@destroyClick) if (@options.destroySelector) 294 | 295 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | remote: https://rails-assets.org/ 4 | specs: 5 | actioncable (5.2.0) 6 | actionpack (= 5.2.0) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailer (5.2.0) 10 | actionpack (= 5.2.0) 11 | actionview (= 5.2.0) 12 | activejob (= 5.2.0) 13 | mail (~> 2.5, >= 2.5.4) 14 | rails-dom-testing (~> 2.0) 15 | actionpack (5.2.0) 16 | actionview (= 5.2.0) 17 | activesupport (= 5.2.0) 18 | rack (~> 2.0) 19 | rack-test (>= 0.6.3) 20 | rails-dom-testing (~> 2.0) 21 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 22 | actionview (5.2.0) 23 | activesupport (= 5.2.0) 24 | builder (~> 3.1) 25 | erubi (~> 1.4) 26 | rails-dom-testing (~> 2.0) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 28 | activejob (5.2.0) 29 | activesupport (= 5.2.0) 30 | globalid (>= 0.3.6) 31 | activemodel (5.2.0) 32 | activesupport (= 5.2.0) 33 | activerecord (5.2.0) 34 | activemodel (= 5.2.0) 35 | activesupport (= 5.2.0) 36 | arel (>= 9.0) 37 | activestorage (5.2.0) 38 | actionpack (= 5.2.0) 39 | activerecord (= 5.2.0) 40 | marcel (~> 0.3.1) 41 | activesupport (5.2.0) 42 | concurrent-ruby (~> 1.0, >= 1.0.2) 43 | i18n (>= 0.7, < 2) 44 | minitest (~> 5.1) 45 | tzinfo (~> 1.1) 46 | addressable (2.5.2) 47 | public_suffix (>= 2.0.2, < 4.0) 48 | adequate_errors (0.1.1) 49 | activemodel 50 | arel (9.0.0) 51 | auto_html (1.6.4) 52 | redcarpet (~> 3.1) 53 | rinku (~> 1.5.0) 54 | bindex (0.5.0) 55 | bootsnap (1.3.0) 56 | msgpack (~> 1.0) 57 | builder (3.2.3) 58 | byebug (9.1.0) 59 | capybara (2.16.0) 60 | addressable 61 | mini_mime (>= 0.1.3) 62 | nokogiri (>= 1.3.3) 63 | rack (>= 1.0.0) 64 | rack-test (>= 0.5.4) 65 | xpath (~> 2.0) 66 | carrierwave (0.10.0) 67 | activemodel (>= 3.2.0) 68 | activesupport (>= 3.2.0) 69 | json (>= 1.7) 70 | mime-types (>= 1.16) 71 | cells (3.11.3) 72 | actionpack (>= 3.0) 73 | railties (>= 3.0) 74 | uber (~> 0.0.8) 75 | cells-collection (0.0.2) 76 | cells (~> 3.9) 77 | childprocess (0.8.0) 78 | ffi (~> 1.0, >= 1.0.11) 79 | coderay (1.1.2) 80 | coffee-rails (4.2.2) 81 | coffee-script (>= 2.2.0) 82 | railties (>= 4.0.0) 83 | coffee-script (2.4.1) 84 | coffee-script-source 85 | execjs 86 | coffee-script-source (1.12.2) 87 | concurrent-ruby (1.0.5) 88 | crass (1.0.4) 89 | diff-lcs (1.3) 90 | erubi (1.7.1) 91 | execjs (2.7.0) 92 | factory_bot (4.8.2) 93 | activesupport (>= 3.0.0) 94 | factory_bot_rails (4.8.2) 95 | factory_bot (~> 4.8.2) 96 | railties (>= 3.0.0) 97 | fastimage (1.7.0) 98 | addressable (~> 2.3, >= 2.3.5) 99 | ffi (1.9.18) 100 | formatador (0.2.5) 101 | globalid (0.4.1) 102 | activesupport (>= 4.2.0) 103 | guard (2.14.1) 104 | formatador (>= 0.2.4) 105 | listen (>= 2.7, < 4.0) 106 | lumberjack (~> 1.0) 107 | nenv (~> 0.1) 108 | notiffany (~> 0.0) 109 | pry (>= 0.9.12) 110 | shellany (~> 0.0) 111 | thor (>= 0.18.1) 112 | guard-compat (1.2.1) 113 | guard-rspec (4.7.3) 114 | guard (~> 2.1) 115 | guard-compat (~> 1.1) 116 | rspec (>= 2.99.0, < 4.0) 117 | i18n (1.0.0) 118 | concurrent-ruby (~> 1.0) 119 | jbuilder (2.7.0) 120 | activesupport (>= 4.2.0) 121 | multi_json (>= 1.2) 122 | jquery-rails (4.3.1) 123 | rails-dom-testing (>= 1, < 3) 124 | railties (>= 4.2.0) 125 | thor (>= 0.14, < 2.0) 126 | jquery-rails-cdn (1.0.3) 127 | jquery-rails 128 | jquery-turbolinks (2.1.0) 129 | railties (>= 3.1.0) 130 | turbolinks 131 | json (1.8.6) 132 | kaminari (1.1.1) 133 | activesupport (>= 4.1.0) 134 | kaminari-actionview (= 1.1.1) 135 | kaminari-activerecord (= 1.1.1) 136 | kaminari-core (= 1.1.1) 137 | kaminari-actionview (1.1.1) 138 | actionview 139 | kaminari-core (= 1.1.1) 140 | kaminari-activerecord (1.1.1) 141 | activerecord 142 | kaminari-core (= 1.1.1) 143 | kaminari-core (1.1.1) 144 | listen (3.0.8) 145 | rb-fsevent (~> 0.9, >= 0.9.4) 146 | rb-inotify (~> 0.9, >= 0.9.7) 147 | loofah (2.2.2) 148 | crass (~> 1.0.2) 149 | nokogiri (>= 1.5.9) 150 | lulalala_presenter (0.1.0) 151 | lumberjack (1.0.12) 152 | mail (2.7.0) 153 | mini_mime (>= 0.1.1) 154 | marcel (0.3.2) 155 | mimemagic (~> 0.3.2) 156 | method_source (0.9.0) 157 | migration_comments (0.4.1) 158 | activerecord (>= 4.2.0) 159 | mime-types (3.1) 160 | mime-types-data (~> 3.2015) 161 | mime-types-data (3.2016.0521) 162 | mimemagic (0.3.2) 163 | mini_magick (4.2.10) 164 | mini_mime (1.0.0) 165 | mini_portile2 (2.3.0) 166 | minitest (5.11.3) 167 | msgpack (1.2.4) 168 | multi_json (1.12.2) 169 | mysql2 (0.5.1) 170 | nenv (0.3.0) 171 | nilify_blanks (1.2.1) 172 | activerecord (>= 3.0.0) 173 | activesupport (>= 3.0.0) 174 | nio4r (2.3.0) 175 | nokogiri (1.8.2) 176 | mini_portile2 (~> 2.3.0) 177 | notiffany (0.1.1) 178 | nenv (~> 0.1) 179 | shellany (~> 0.0) 180 | pg (0.18.4) 181 | pry (0.11.3) 182 | coderay (~> 1.1.0) 183 | method_source (~> 0.9.0) 184 | public_suffix (3.0.1) 185 | puma (3.11.2) 186 | rack (2.0.4) 187 | rack-test (1.0.0) 188 | rack (>= 1.0, < 3) 189 | rails (5.2.0) 190 | actioncable (= 5.2.0) 191 | actionmailer (= 5.2.0) 192 | actionpack (= 5.2.0) 193 | actionview (= 5.2.0) 194 | activejob (= 5.2.0) 195 | activemodel (= 5.2.0) 196 | activerecord (= 5.2.0) 197 | activestorage (= 5.2.0) 198 | activesupport (= 5.2.0) 199 | bundler (>= 1.3.0) 200 | railties (= 5.2.0) 201 | sprockets-rails (>= 2.0.0) 202 | rails-assets-blueimp-canvas-to-blob (3.14.0) 203 | rails-assets-blueimp-file-upload (9.28.0) 204 | rails-assets-blueimp-canvas-to-blob (>= 2.1.1) 205 | rails-assets-blueimp-load-image (>= 1.13.0) 206 | rails-assets-blueimp-tmpl (>= 2.5.4) 207 | rails-assets-jquery (>= 1.6) 208 | rails-assets-blueimp-load-image (2.20.1) 209 | rails-assets-blueimp-tmpl (3.11.0) 210 | rails-assets-jquery (3.3.1) 211 | rails-assets-js-cookie (2.2.0) 212 | rails-assets-normalize.css (3.0.3) 213 | rails-dom-testing (2.0.3) 214 | activesupport (>= 4.2.0) 215 | nokogiri (>= 1.6) 216 | rails-html-sanitizer (1.0.4) 217 | loofah (~> 2.2, >= 2.2.2) 218 | railties (5.2.0) 219 | actionpack (= 5.2.0) 220 | activesupport (= 5.2.0) 221 | method_source 222 | rake (>= 0.8.7) 223 | thor (>= 0.18.1, < 2.0) 224 | rake (12.3.1) 225 | rb-fsevent (0.10.2) 226 | rb-inotify (0.9.10) 227 | ffi (>= 0.5.0, < 2) 228 | rdoc (4.3.0) 229 | redcarpet (3.4.0) 230 | rinku (1.5.1) 231 | rspec (3.7.0) 232 | rspec-core (~> 3.7.0) 233 | rspec-expectations (~> 3.7.0) 234 | rspec-mocks (~> 3.7.0) 235 | rspec-core (3.7.0) 236 | rspec-support (~> 3.7.0) 237 | rspec-expectations (3.7.0) 238 | diff-lcs (>= 1.2.0, < 2.0) 239 | rspec-support (~> 3.7.0) 240 | rspec-mocks (3.7.0) 241 | diff-lcs (>= 1.2.0, < 2.0) 242 | rspec-support (~> 3.7.0) 243 | rspec-rails (3.7.1) 244 | actionpack (>= 3.0) 245 | activesupport (>= 3.0) 246 | railties (>= 3.0) 247 | rspec-core (~> 3.7.0) 248 | rspec-expectations (~> 3.7.0) 249 | rspec-mocks (~> 3.7.0) 250 | rspec-support (~> 3.7.0) 251 | rspec-support (3.7.0) 252 | rubyzip (1.2.1) 253 | sass (3.5.3) 254 | sass-listen (~> 4.0.0) 255 | sass-listen (4.0.0) 256 | rb-fsevent (~> 0.9, >= 0.9.4) 257 | rb-inotify (~> 0.9, >= 0.9.7) 258 | sass-rails (5.0.7) 259 | railties (>= 4.0.0, < 6) 260 | sass (~> 3.1) 261 | sprockets (>= 2.8, < 4.0) 262 | sprockets-rails (>= 2.0, < 4.0) 263 | tilt (>= 1.1, < 3) 264 | sdoc (0.4.2) 265 | json (~> 1.7, >= 1.7.7) 266 | rdoc (~> 4.0) 267 | seed-fu (2.3.6) 268 | activerecord (>= 3.1) 269 | activesupport (>= 3.1) 270 | seedbank (0.4.0) 271 | selenium-webdriver (3.7.0) 272 | childprocess (~> 0.5) 273 | rubyzip (~> 1.0) 274 | settei (0.1.3) 275 | settingslogic (2.0.9) 276 | shellany (0.0.1) 277 | sitemap_generator (5.0.5) 278 | builder 279 | spring (2.0.2) 280 | activesupport (>= 4.2) 281 | spring-commands-rspec (1.0.4) 282 | spring (>= 0.9.1) 283 | spring-watcher-listen (2.0.1) 284 | listen (>= 2.7, < 4.0) 285 | spring (>= 1.2, < 3.0) 286 | sprockets (3.7.1) 287 | concurrent-ruby (~> 1.0) 288 | rack (> 1, < 3) 289 | sprockets-rails (3.2.1) 290 | actionpack (>= 4.0) 291 | activesupport (>= 4.0) 292 | sprockets (>= 3.0.0) 293 | sucker_punch (2.0.4) 294 | concurrent-ruby (~> 1.0.0) 295 | thor (0.20.0) 296 | thread_safe (0.3.6) 297 | tilt (2.0.8) 298 | timecop (0.9.1) 299 | turbolinks (5.0.1) 300 | turbolinks-source (~> 5) 301 | turbolinks-source (5.0.3) 302 | tzinfo (1.2.5) 303 | thread_safe (~> 0.1) 304 | uber (0.0.15) 305 | uglifier (3.2.0) 306 | execjs (>= 0.3.0, < 3) 307 | unobtrusive_flash (3.3.1) 308 | railties 309 | web-console (3.5.1) 310 | actionview (>= 5.0) 311 | activemodel (>= 5.0) 312 | bindex (>= 0.4.0) 313 | railties (>= 5.0) 314 | websocket-driver (0.7.0) 315 | websocket-extensions (>= 0.1.0) 316 | websocket-extensions (0.1.3) 317 | xpath (2.1.0) 318 | nokogiri (~> 1.3) 319 | 320 | PLATFORMS 321 | ruby 322 | 323 | DEPENDENCIES 324 | adequate_errors (~> 0.1.1) 325 | auto_html (~> 1.6.4) 326 | bootsnap (>= 1.1.0) 327 | bundler (>= 1.8.4) 328 | byebug 329 | capybara (~> 2.13) 330 | carrierwave (~> 0.10.0) 331 | cells (~> 3.11.3) 332 | cells-collection (~> 0.0.2) 333 | coffee-rails (~> 4.2.2) 334 | factory_bot_rails (~> 4.8.2) 335 | fastimage (~> 1.7.0) 336 | guard-rspec (~> 4.7) 337 | jbuilder (~> 2.5) 338 | jquery-rails (~> 4.3.1) 339 | jquery-rails-cdn (~> 1.0.3) 340 | jquery-turbolinks (~> 2.1.0) 341 | json (~> 1.8.6) 342 | kaminari (~> 1.1.1) 343 | listen (>= 3.0.5, < 3.2) 344 | lulalala_presenter (~> 0.1.0) 345 | migration_comments (~> 0.4.1) 346 | mini_magick (~> 4.2.7) 347 | mysql2 (~> 0.5.1) 348 | nilify_blanks (~> 1.2.1) 349 | pg (~> 0.18.2) 350 | puma (~> 3.11.2) 351 | rails (= 5.2.0) 352 | rails-assets-blueimp-file-upload (~> 9.28.0)! 353 | rails-assets-js-cookie! 354 | rails-assets-normalize.css (~> 3.0.3)! 355 | rb-fsevent (~> 0.10.2) 356 | rspec-rails (~> 3.7) 357 | sass-rails (~> 5.0.7) 358 | sdoc (~> 0.4.2) 359 | seed-fu (~> 2.3.6) 360 | seedbank (~> 0.3) 361 | selenium-webdriver 362 | settei (~> 0.1.3) 363 | settingslogic (~> 2.0) 364 | sitemap_generator (~> 5.0.5) 365 | spring 366 | spring-commands-rspec (~> 1.0.4) 367 | spring-watcher-listen (~> 2.0.0) 368 | sucker_punch (~> 2.0.1) 369 | timecop (~> 0.9.1) 370 | turbolinks 371 | tzinfo-data 372 | uglifier (>= 1.3.0) 373 | unobtrusive_flash (~> 3.3.1) 374 | web-console (>= 3.3.0) 375 | 376 | BUNDLED WITH 377 | 1.16.1 378 | --------------------------------------------------------------------------------