├── .jshintrc ├── CHANGELOG ├── Gemfile ├── README.md ├── Rakefile ├── config.ru ├── config ├── .gitignore ├── config.sample.rb └── db.sample.rb ├── helpers ├── admin_helpers.rb ├── ffmpeg_handler.rb ├── imagemagick_handler.rb └── misc_helpers.rb ├── hive.rb ├── i18n └── en.rb ├── log └── .gitignore ├── migrations ├── 1_init.rb ├── 2_create_reports.rb ├── 3_locked_pinned.rb └── 4_unique_thread_num.rb ├── public ├── css │ └── hive.css └── js │ ├── core.js │ ├── hive.js │ └── manage │ └── reports.js ├── spec ├── admin_spec.rb ├── ffmpeg_spec.rb ├── imagemagick_spec.rb ├── misc_test.rb ├── posting_spec.rb ├── reading_spec.rb ├── reporting_spec.rb └── spec_helper.rb ├── tmp └── .gitignore └── views ├── assets.erb ├── banned.erb ├── board.erb ├── error.erb ├── footer.erb ├── form.erb ├── index.erb ├── manage.erb ├── manage_assets.erb ├── manage_bans.erb ├── manage_bans_edit.erb ├── manage_boards.erb ├── manage_boards_edit.erb ├── manage_login.erb ├── manage_profile.erb ├── manage_reports.erb ├── manage_users.erb ├── manage_users_edit.erb ├── not_found.erb ├── post.erb ├── read.erb ├── report.erb └── success.erb /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "laxbreak": true, 3 | "boss": true, 4 | "expr": true, 5 | "sub": true, 6 | "browser": true, 7 | "devel": true, 8 | "strict": "implied", 9 | "multistr": true, 10 | "scripturl": true, 11 | "unused": "vars", 12 | "undef": true, 13 | "-W079": true, 14 | "esversion": 6, 15 | 16 | "globals": { 17 | "$": true, 18 | "Tegaki": true, 19 | "Tip": true, 20 | "ClickHandler": true, 21 | "Hive": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.5.3 - 2015-10-31 2 | * Fix boards with no threads returning 404 when pagination is enabled. 3 | 4 | 0.5.2 - 2015-10-28 5 | * Using a capcode now bypasses posting cooldowns. 6 | * Less race conditions when posting. 7 | 8 | 0.5.1 - 2015-10-26 9 | * Database schema changed. Run rake db:migrate to update. 10 | * Fix thread pruning deleting pinned threads. 11 | * hive_markup gem updated: 12 | - run `gem update hive_markup` to update 13 | - ascii art blocks now use
 tags.
14 |     - code blocks now have `code` as CSS class.
15 |   * Style pinned threads differently in the thread list.
16 |   * Change Pin/Lock buttons in the Post Menu:
17 |     - `Toggle Pin` is now `Pin` and `Change Pin`
18 |     - `Toggle Lock` is now `Lock` and `Unlock`
19 |   * Fix the `file_uploads` not preventing files from being uploaded.
20 | 
21 | 0.5.0 - 2015-10-07
22 |   * Database schema changed. Run rake db:migrate to update.
23 |   * It is now possible to delete user accounts.
24 |   * Temporary uploaded files are now cleaned up after each request.
25 |   * Thread pinning and locking.
26 |   * Post Menu: contains reporting and moderation buttons.
27 |   * The thread list can now be paginated:
28 |     - new config option: threads_per_page. nil disables pagination.
29 |   * Thread pruning can now be disabled by setting thread_limit to nil.
30 |   * config/config.rb is now config/config.sample.rb
31 |   * New rake tasks: jshint, puma:halt.
32 |   * rake puma:stop now stops puma gracefully so it exits with a 0 status.
33 | 
34 | 0.4.2 - 2015-03-02
35 |   * Proper /banned page
36 |   * Fixed report queue manager for postgresql
37 | 
38 | 0.4.1 - 2015-02-26
39 |   * Basic post reporting. Run rake db:migrate to update the database schema
40 |   * The db:migrate task now accepts an argument to specify a config file:
41 |     - ex: rake db:migrate[db_test] will use config/db_test.rb
42 |   * Fix replies not getting deleted properly when pruning overflowing threads
43 |   * Fix source map generation when building JS
44 | 
45 | 0.3.0 - 2015-02-01
46 |   * Captcha support
47 |   * Minify tegaki.css in rake build
48 | 
49 | 0.2.0 - 2015-01-27
50 |   * File spoilers
51 |   * File-only deletion for moderators
52 |   * Tegaki support
53 |   * Fix replies not being deleted from the database along with original posts
54 |   * Minify manage.js, hive.js, tegaki.js in rake build
55 | 
56 | 0.1.0 - 2014-10-06
57 |   * Added markdown-like comment formatting. This depends on the hive_markup gem.
58 |   * Fixed quotelinks not targeting posts when clicked.
59 |   * Added partial support for assets versioning:
60 |     - minified versions of hive.js and hive.css will be used in production.
61 |     - links to hive.js and hive.css will include their md5 as query string.
62 |   * Added rake tasks to minify hive.js and hive.css:
63 |     - rake build:js depends on the uglifier gem
64 |       and will minify, precompress and generate a source map for hive.js.
65 |     - rake build:css depends on the sass gem and will minify hive.css
66 | 
67 | 0.0.1 - 2014-09-21
68 |   * First public release.
69 | 


--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
 1 | source 'https://rubygems.org'
 2 | 
 3 | gem 'rake'
 4 | gem 'bcrypt'
 5 | gem 'erubis'
 6 | gem 'escape_utils'
 7 | gem 'sinatra'
 8 | gem 'sequel'
 9 | gem 'hive_markup'
10 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # HiveBBS
  2 | 
  3 | ***ENTERPRISE QUALITY BBS***
  4 | 
  5 | Notable features:
  6 | - Posting messages
  7 | - Reading messages
  8 | 
  9 | ## Installation
 10 | 
 11 | Make sure you have Ruby 1.9.3 at least. 2.0.0+ is recommended.
 12 | 
 13 | #### Get the source
 14 | 
 15 | Download and extract a release tarball or clone the git repository:
 16 | 
 17 | `git clone https://github.com/desuwa/hivebbs.git`
 18 | 
 19 | #### Install core dependencies
 20 | 
 21 | `gem install rake bcrypt erubis escape_utils sequel sinatra hive_markup`
 22 | 
 23 | #### Pick a database adapter
 24 | 
 25 | **For a MySQL database:**
 26 | 
 27 | Install the MySQL client dev package if you don't already have it:
 28 | 
 29 | `sudo apt-get install libmysqlclient-dev`
 30 | 
 31 | Then install the gem:
 32 | 
 33 | `gem install mysql2`
 34 | 
 35 | **For PostgreSQL:**
 36 | 
 37 | Install the PostgreSQL client dev package if you don't already have it:
 38 | 
 39 | `sudo apt-get install libpq-dev`
 40 | 
 41 | Then install the gem:
 42 | 
 43 | `gem install pg`
 44 | 
 45 | **For SQLite:**
 46 | 
 47 | Install the SQLite dev package if you don't already have it:
 48 | 
 49 | `sudo apt-get install libsqlite3-dev`
 50 | 
 51 | Then install the gem:
 52 | 
 53 | `gem install sqlite3`
 54 | 
 55 | #### Pick a webserver
 56 | 
 57 | Just get Puma:
 58 | 
 59 | `gem install puma`
 60 | 
 61 | or don't, and use some other Rack-compatible server, like *Unicorn* or *thin*.
 62 | 
 63 | #### Configuration
 64 | 
 65 | Assuming your database server is up and running and you have created a database for your HiveBBS installation (unless you are using SQLite).
 66 | 
 67 | Get into the directory where you extracted or cloned the sources.
 68 | 
 69 | **Configure the database**
 70 | 
 71 | Copy `config/db.sample.rb` to `config/db.rb` and edit it.
 72 | 
 73 | Set the `adapter` to `mysql2` for MySQL, `postgres` for PostgreSQL, `sqlite` for SQLite
 74 | 
 75 | `database` is the name of the database you created or the path to the SQLite database file.
 76 | 
 77 | **Migrate the database**
 78 | 
 79 | `rake db:migrate`
 80 | 
 81 | This will create the necessary tables. You can run it every time you need to upgrade the schema.
 82 | 
 83 | **Create the admin account**
 84 | 
 85 | `rake db:init`
 86 | or
 87 | `rake db:init[volunteer]`
 88 | if you want to use *volunteer* as username instead of the default *admin*.
 89 | 
 90 | **Generate the tripcode key file**
 91 | 
 92 | This will create a `config/trip.key` file containing a random key which will be used to generate tripcodes.
 93 | 
 94 | `rake gentripkey`
 95 | or
 96 | `rake gentripkey[512]`
 97 | if you want to change the size of the key in bytes. It's 256 by default.
 98 | 
 99 | **Copy the default configration**
100 | 
101 | Copy `config/config.sample.rb` to `config/config.rb` and edit it if needed.
102 | 
103 | At this point you should be able to start HiveBBS in development mode:
104 | 
105 | `./hive.rb`
106 | 
107 | It will be available at `http://localhost:4567`
108 | 
109 | The manager (admin) route is `/manage`
110 | 
111 | **Configure Puma**
112 | 
113 | You picked Puma, right?
114 | 
115 | `rake puma:init`
116 | 
117 | This will create a `puma-hive.rb` file
118 | 
119 | You might want to adjust the `threads` and `workers` parameters. Check the Puma [documentation](https://github.com/puma/puma) for that.
120 | 
121 | `rake puma:start` starts Puma.  
122 | `rake puma:reload` does a [phased-restart](https://github.com/puma/puma#normal-vs-hot-vs-phased-restart).  
123 | `rake puma:restart` restarts Puma.  
124 | `rake puma:stop` stops Puma.  
125 | 
126 | **Configure what's in front of Puma**
127 | 
128 | For nginx, you can add something like this to your site's .conf file:
129 | 
130 | ```
131 | upstream puma_hive {
132 |   server unix:///var/www/hivebbs/tmp/puma.sock;
133 | }
134 | ```
135 | 
136 | Replace `/var/www/hivebbs/tmp/puma.sock` with the path to the puma socket file.
137 | By default it's `{your_hivebbs_folder}/tmp/puma.sock`.
138 | 
139 | Then, inside the `server` block:
140 | 
141 | ```
142 |   try_files $uri @puma;
143 |   
144 |   location @puma {
145 |     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
146 |     proxy_set_header X-Forwarded-Proto $scheme;
147 |     proxy_set_header Host $http_host;
148 |     proxy_redirect off;
149 |     proxy_pass http://puma_hive;
150 |   }
151 | ```
152 | 
153 | #### File uploads
154 | 
155 | File uploads are disabled by default. You need [ImageMagick](http://www.imagemagick.org/script/install-source.php) to handle image files and [FFmpeg](https://trac.ffmpeg.org/wiki/CompilationGuide) for webm.
156 | 
157 | #### Additional dependencies
158 | 
159 | Gems:  
160 | `uglifier` to minify JavaScript (see [ExecJS](https://github.com/sstephenson/execjs) for a JavaScript runtime)  
161 | `sass` to minify CSS  
162 | `jshintrb` for JShint
163 | 
164 | #### The End
165 | 
166 | If your server isn't accessible through HTTPS, you'll need to set the `secure_cookies` option to `false` inside `config/config.rb`, otherwise you won't be able to log in.
167 | You really should use HTTPS, though.
168 | 
169 | ## Running tests
170 | 
171 | `gem install rack-test minitest`
172 | 
173 | Get the [sample data](https://github.com/desuwa/hivebbs_spec_data) if you want to test ImageMagick and FFmpeg. Put it inside the `spec` directory so to have the sample files inside `spec/data`.
174 | 
175 | Create `config/db_test.rb` and a new database.
176 | 
177 | `rake -T` to see all test tasks.
178 | 
179 | ## License
180 | 
181 | [MIT](http://www.opensource.org/licenses/MIT)
182 | 


--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
  1 | 
  2 | require 'rake/testtask'
  3 | 
  4 | Encoding.default_external = 'UTF-8'
  5 | 
  6 | include Rake
  7 | 
  8 | task :test => 'test:all'
  9 | 
 10 | namespace :test do
 11 |   TestTask.new(:all) do |t|
 12 |     t.description = 'Run all tests'
 13 |     t.warning = false
 14 |     t.test_files = FileList['spec/*_spec.rb', 'spec/*_test.rb']
 15 |   end
 16 |   
 17 |   TestTask.new(:nomedia) do |t|
 18 |     t.description = 'Skip all upload handlers tests'
 19 |     t.warning = false
 20 |     t.test_files = Dir['spec/*_spec.rb', 'spec/*_test.rb'].reject do |f|
 21 |       /imagemagic|ffmpeg/ =~f
 22 |     end
 23 |   end
 24 |   
 25 |   TestTask.new(:noffmpeg) do |t|
 26 |     t.description = 'Skip FFmpeg handler tests'
 27 |     t.warning = false
 28 |     t.test_files = Dir['spec/*_spec.rb', 'spec/*_test.rb'].reject do |f|
 29 |       f.include?('ffmpeg')
 30 |     end
 31 |   end
 32 |   
 33 |   TestTask.new(:nomagick) do |t|
 34 |     t.description = 'Skip ImageMagick handler tests'
 35 |     t.warning = false
 36 |     t.test_files = Dir['spec/*_spec.rb', 'spec/*_test.rb'].reject do |f|
 37 |       f.include?('magick')
 38 |     end
 39 |   end
 40 | end
 41 | 
 42 | task :build => 'build:all'
 43 | 
 44 | namespace :build do
 45 |   desc 'Remove minified and precompressed files'
 46 |   task :clean do
 47 |     root = 'public/js'
 48 |     ['core', 'hive', 'tegaki', 'manage/reports'].each do |basename|
 49 |       ['.js', '.js.map', '.js.gz'].each do |suf|
 50 |         src = "#{root}/#{basename}.min#{suf}"
 51 |         if File.exist?(src)
 52 |           puts "Removing #{src}"
 53 |           FileUtils.rm(src)
 54 |         end
 55 |       end
 56 |     end
 57 |     
 58 |     root = 'public/css'
 59 |     ['hive', 'tegaki'].each do |basename|
 60 |       ['.css', '.css.gz'].each do |suf|
 61 |         src = "#{root}/#{basename}.min#{suf}"
 62 |         if File.exist?(src)
 63 |           puts "Removing #{src}"
 64 |           FileUtils.rm(src)
 65 |         end
 66 |       end
 67 |     end 
 68 |   end
 69 |   
 70 |   desc 'Minify and precompress everything'
 71 |   task :all do
 72 |     Rake::Task['build:js'].invoke
 73 |     Rake::Task['build:css'].invoke
 74 |   end
 75 |   
 76 |   desc 'Minify and precompress JavaScript'
 77 |   task :js do
 78 |     require 'zlib'
 79 |     require 'uglifier'
 80 |     
 81 |     root = 'public/js'
 82 |     ['core', 'hive', 'tegaki', 'manage/reports'].each do |basename|
 83 |       next unless File.exist?("#{root}/#{basename}.js")
 84 |       
 85 |       puts "Building #{basename}.js"
 86 |       
 87 |       u = Uglifier.new(
 88 |         :harmony => true,
 89 |         :source_map => {
 90 |           :filename => "#{basename}.js",
 91 |           :map_url => "#{basename}.min.js.map",
 92 |         },
 93 |       )
 94 |       
 95 |       js, sm = u.compile_with_map(File.read("#{root}/#{basename}.js"))
 96 |       
 97 |       Zlib::GzipWriter.open("#{root}/#{basename}.min.js.gz") do |gz|
 98 |         gz.write(js)
 99 |       end
100 |       
101 |       File.open("#{root}/#{basename}.min.js", 'w') { |f| f.write js }
102 |       File.open("#{root}/#{basename}.min.js.map", 'w') { |f| f.write sm }
103 |     end
104 |   end
105 |   
106 |   desc 'Minify and precompress CSS'
107 |   task :css do
108 |     require 'zlib'
109 |     require 'sass'
110 |     
111 |     root = 'public/css'
112 |     
113 |     ['hive', 'tegaki'].each do |basename|
114 |       next unless File.exist?("#{root}/#{basename}.css")
115 |       
116 |       puts "Building #{basename}.css"
117 |       
118 |       sass = Sass::Engine.new(
119 |         File.read("#{root}/#{basename}.css"),
120 |         style: :compressed, cache: false, syntax: :scss
121 |       )
122 |       
123 |       css = sass.render
124 |       
125 |       Zlib::GzipWriter.open("#{root}/#{basename}.min.css.gz") do |gz|
126 |         gz.write(css)
127 |       end
128 |       
129 |       File.open("#{root}/#{basename}.min.css", 'w') { |f| f.write css }
130 |     end
131 |   end
132 | end
133 | 
134 | desc 'Run JShint'
135 | task :jshint do |t|
136 |   require 'open3'
137 |   
138 |   root = 'public/js'
139 |   
140 |   ['core', 'hive', 'manage/reports'].each do |basename|
141 |     f = "#{root}/#{basename}.js"
142 |     
143 |     next unless File.exist?(f)
144 |     
145 |     output, outerr, status = Open3.capture3('jshint', f)
146 |     
147 |     if outerr != ''
148 |       puts outerr
149 |       abort
150 |     else
151 |       puts output
152 |     end
153 |   end
154 | end
155 | 
156 | namespace :db do
157 |   cfg = 'config/db.rb'
158 |   
159 |   desc 'Create admin account using the provided username'
160 |   task :init, [:username] do |t, args|
161 |     require 'bcrypt'
162 |     require 'sequel'
163 |     
164 |     username = args.username || 'admin'
165 |     
166 |     Sequel.connect(eval(File.open(cfg, 'r') { |f| f.read }))[:users].insert({
167 |       username: username,
168 |       password: BCrypt::Password.create('admin'),
169 |       level: 99,
170 |       created_on: Time.now.utc.to_i,
171 |     })
172 |     
173 |     puts "Added admin account (username: #{username} / password: admin)"
174 |   end
175 |   
176 |   desc 'Run migrations'
177 |   task :migrate, [:cfg] do |t, args|
178 |     require 'sequel'
179 |     
180 |     cfg = "config/#{args.cfg}.rb" if args.cfg
181 |     
182 |     DB = Sequel.connect(eval(File.open(cfg, 'r') { |f| f.read }))
183 |     
184 |     Sequel.extension :migration
185 |     Sequel::Migrator.run(DB, 'migrations')
186 |     
187 |     puts 'Done migrating'
188 |   end
189 | end
190 | 
191 | namespace :puma do
192 |   cfg = 'puma-hive.rb'
193 |   
194 |   desc 'Create Puma config file'
195 |   task :init do |t|
196 |     if File.exist?(cfg)
197 |       puts 'Puma configuration file already exists. Aborting'
198 |       next
199 |     end
200 |     
201 |     File.open(cfg, 'w') do |f|
202 |       f.write <<-'PUMA'.gsub(/^[ \t]+/, '')
203 |         #!/usr/bin/env puma
204 |         
205 |         cwd = File.expand_path(File.dirname(__FILE__))
206 |         
207 |         environment 'production'
208 |         rackup "#{cwd}/config.ru"
209 |         bind "unix://#{cwd}/tmp/puma.sock"
210 |         pidfile "#{cwd}/tmp/puma.pid"
211 |         stdout_redirect "#{cwd}/log/stdout.log", "#{cwd}/log/stderr.log"
212 |         quiet
213 |         threads 0, 8
214 |         workers 2
215 |         daemonize
216 |       PUMA
217 |     end
218 |     
219 |     puts "Done generating #{cfg}"
220 |   end
221 |   
222 |   desc 'Start Puma'
223 |   task :start do |t|
224 |     system('puma -C puma-hive.rb')
225 |   end
226 |   
227 |   desc 'Reload Puma (phased restart)'
228 |   task :reload do |t|
229 |     system('pumactl -F puma-hive.rb phased-restart')
230 |   end
231 |   
232 |   desc 'Restart Puma'
233 |   task :restart do |t|
234 |     system('pumactl -F puma-hive.rb restart')
235 |   end
236 |   
237 |   desc 'Stop Puma'
238 |   task :stop do |t|
239 |     system('pumactl -F puma-hive.rb stop')
240 |   end
241 |   
242 |   desc 'Immediately stop Puma'
243 |   task :halt do |t|
244 |     system('pumactl -F puma-hive.rb halt')
245 |   end
246 | end
247 | 
248 | desc 'Generate the tripcode pepper file'
249 | task :gentripkey, [:size] do |t, args|
250 |   key_file = 'config/trip.key'
251 |   
252 |   if File.exist?(key_file)
253 |     puts 'config/trip.key already exists. Aborting'
254 |     next
255 |   end
256 |   
257 |   size = args.size.to_i
258 |   size = 256 if size.zero?
259 |   
260 |   system("openssl rand -out #{key_file} #{size}")
261 | end
262 | 


--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | 
3 | require File.expand_path(File.dirname(__FILE__) + '/hive.rb')
4 | 
5 | Hive::BBS::DB.disconnect
6 | 
7 | run Hive::BBS
8 | 


--------------------------------------------------------------------------------
/config/.gitignore:
--------------------------------------------------------------------------------
1 | *.rb
2 | *.key
3 | !*.sample.rb
4 | 


--------------------------------------------------------------------------------
/config/config.sample.rb:
--------------------------------------------------------------------------------
 1 | {
 2 |   locale: 'en',
 3 |   
 4 |   app_name: 'hiveBBS',
 5 |   
 6 |   anon: 'Anonymous',
 7 |   forced_anon: false,
 8 |   
 9 |   auth_idle: 5 * 86400,
10 |   auth_ttl: 30 * 86400,
11 |   secure_cookies: settings.production?,
12 |   
13 |   thread_limit: 500,
14 |   post_limit: 500,
15 |   threads_per_page: 50,
16 |   
17 |   author_length: 50,
18 |   title_length: 100,
19 |   comment_length: 5000,
20 |   comment_lines: 100,
21 |   
22 |   delay_auth: 30,
23 |   delay_thread: 60,
24 |   delay_reply: 15,
25 |   
26 |   thumb_dimensions: 100,
27 |   thumb_quality: 80,
28 |   
29 |   captcha: false,
30 |   captcha_public_key: nil,
31 |   captcha_private_key: nil,
32 |   
33 |   file_uploads: false,
34 |   
35 |   tegaki: false,
36 |   tegaki_width: 380,
37 |   tegaki_height: 380,
38 |   tegaki_data_limit: 1 * 1048576,
39 |   
40 |   file_types: [ 'jpg', 'png', 'gif', 'webm' ],
41 |   
42 |   file_limits: {
43 |     image: {
44 |       file_size: 5 * 1048576,
45 |       dimensions: 5120,
46 |     },
47 |     
48 |     video: {
49 |       file_size: 5 * 1048576,
50 |       dimensions: 2048,
51 |       duration: 5 * 3600,
52 |       allow_audio: false
53 |     }
54 |   },
55 |   
56 |   post_reporting: false,
57 |   reporting_captcha: false,
58 |   delay_report: 15,
59 |   
60 |   report_categories: {
61 |     #'rule' => 1,
62 |     #'illegal' => 100
63 |   }
64 | }
65 | 


--------------------------------------------------------------------------------
/config/db.sample.rb:
--------------------------------------------------------------------------------
1 | {
2 |   adapter: 'mysql2',
3 |   host: 'localhost',
4 |   #socket: '/var/run/mysqld/mysqld.sock',
5 |   database: 'hivebbs',
6 |   user: 'hivebbs',
7 |   password: 'hivebbs',
8 | }
9 | 


--------------------------------------------------------------------------------
/helpers/admin_helpers.rb:
--------------------------------------------------------------------------------
  1 | module Hive
  2 | 
  3 | class BBS < Sinatra::Base
  4 |   
  5 |   MAX_BAN = 2147483647
  6 |   
  7 |   USER_LEVELS = {
  8 |     :admin => 99,
  9 |     :mod => 50,
 10 |   }
 11 |   
 12 |   USER_GROUPS = USER_LEVELS.invert
 13 |   
 14 |   THREAD_LOCKED = 1
 15 |   
 16 |   def resolve_name(ip)
 17 |     Resolv.getname(ip)
 18 |   rescue Resolv::ResolvError, Resolv::ResolvTimeout
 19 |     nil
 20 |   end
 21 |   
 22 |   def csrf_tag(value, name = 'csrf')
 23 |     ""
 24 |   end
 25 |   
 26 |   def validate_csrf_token(name = 'csrf')
 27 |     csrf_cookie = request.cookies[name].to_s
 28 |     csrf_param = params[name].to_s
 29 |     
 30 |     if !csrf_cookie.empty? && !csrf_param.empty? && csrf_cookie == csrf_param
 31 |       return
 32 |     end
 33 |     
 34 |     bad_request 
 35 |   end
 36 |   
 37 |   def validate_referrer
 38 |     ref = request.referrer.to_s
 39 |     return if ref.empty? || URI.parse(ref).host == request.host
 40 |     bad_request
 41 |   rescue URI::InvalidURIError
 42 |   end
 43 |   
 44 |   def validate_honeypot
 45 |     halt if params['email'] && !params['email'].empty?
 46 |   end
 47 |   
 48 |   def validate_cooldowns(is_new_thread)
 49 |     if is_new_thread
 50 |       failure t(:fast_post) if DB[:posts].select(1).reverse_order(:id)
 51 |         .first(Sequel.lit('ip = ? AND num = 1 AND created_on > ?',
 52 |           request.ip, Time.now.utc.to_i - cfg(:delay_thread, @board_cfg))
 53 |         )
 54 |     else
 55 |       failure t(:fast_post) if DB[:posts].select(1).reverse_order(:id)
 56 |         .first(Sequel.lit('ip = ? AND created_on > ?',
 57 |           request.ip, Time.now.utc.to_i - cfg(:delay_reply, @board_cfg))
 58 |         )
 59 |     end
 60 |   end
 61 |   
 62 |   def verify_captcha
 63 |     resp_token = params['g-recaptcha-response'.freeze]
 64 |     
 65 |     failure t(:captcha_empty_error) if !resp_token || resp_token.empty?
 66 |     
 67 |     data = {
 68 |       'secret'.freeze => cfg(:captcha_private_key),
 69 |       'response'.freeze => resp_token
 70 |     }
 71 |     
 72 |     resp = nil
 73 |     
 74 |     Timeout::timeout(3) do
 75 |       http = Net::HTTP.new('www.google.com'.freeze, 443)
 76 |       http.use_ssl = true
 77 |       http.verify_mode = OpenSSL::SSL::VERIFY_NONE
 78 |       resp = http.get("/recaptcha/api/siteverify?#{URI.encode_www_form(data)}")
 79 |     end
 80 |     
 81 |     captcha = JSON.parse(resp.body)
 82 |     
 83 |     if captcha['success'.freeze] != true
 84 |       failure t(:captcha_invalid_error)
 85 |     end
 86 |   rescue
 87 |     failure t(:captcha_generic_error)
 88 |   end
 89 |   
 90 |   def ban_duration_ts(created_on, duration)
 91 |     if duration < 0
 92 |       expires_on = MAX_BAN
 93 |     elsif duration == 0
 94 |       expires_on = 0
 95 |     else
 96 |       expires_on = created_on + duration * 3600
 97 |       expires_on = MAX_BAN if expires_on > MAX_BAN
 98 |     end
 99 |     expires_on
100 |   end
101 |   
102 |   def hash_password(plain_pwd)
103 |     BCrypt::Password.create(plain_pwd)
104 |   end
105 |   
106 |   def password_valid?(plain_pwd, hashed_pwd)
107 |     BCrypt::Password.new(hashed_pwd) == plain_pwd
108 |   end
109 |   
110 |   def hash_session_id(sid)
111 |     OpenSSL::Digest::SHA256.hexdigest(sid)
112 |   end
113 |   
114 |   def set_session_cookies(sid)
115 |     expires = Time.now + cfg(:auth_ttl)
116 |     
117 |     response.set_cookie('sid',
118 |       value: sid,
119 |       path: '/manage',
120 |       httponly: true,
121 |       secure: cfg(:secure_cookies),
122 |       expires: expires
123 |     )
124 |     
125 |     response.set_cookie('sid',
126 |       value: sid,
127 |       path: '/post',
128 |       httponly: true,
129 |       secure: cfg(:secure_cookies),
130 |       expires: expires
131 |     )
132 |     
133 |     response.set_cookie('csrf',
134 |       value: random_base64bytes(16),
135 |       path: '/',
136 |       secure: cfg(:secure_cookies),
137 |       expires: expires
138 |     )
139 |   end
140 |   
141 |   def clear_session_cookies
142 |     response.delete_cookie('sid',
143 |       path: '/manage',
144 |       httponly: true,
145 |       secure: cfg(:secure_cookies)
146 |     )
147 |     
148 |     response.delete_cookie('sid',
149 |       path: '/post',
150 |       httponly: true,
151 |       secure: cfg(:secure_cookies)
152 |     )
153 |     response.delete_cookie('csrf',
154 |       path: '/',
155 |       secure: cfg(:secure_cookies)
156 |     )
157 |   end
158 |   
159 |   def get_user_session
160 |     sid = request.cookies['sid'].to_s
161 |     
162 |     return nil if sid.empty?
163 |     
164 |     sid = hash_session_id(sid)
165 |     
166 |     session = DB[:sessions].first(:sid => sid)
167 |     
168 |     unless session
169 |       clear_session_cookies
170 |       return nil
171 |     end
172 |     
173 |     now = Time.now.utc.to_i
174 |     
175 |     if session[:created_on] <= now - cfg(:auth_ttl) ||
176 |         session[:updated_on] <= now - cfg(:auth_idle)
177 |       clear_session_cookies
178 |       DB[:sessions].where(:sid => sid).delete
179 |       return nil
180 |     end
181 |     
182 |     user = DB[:users].first(:id => session[:user_id])
183 |     
184 |     unless user
185 |       clear_session_cookies
186 |       return nil
187 |     end
188 |     
189 |     DB[:sessions].where(:id => session[:id]).update(:updated_on => now)
190 |     
191 |     user
192 |   end
193 |   
194 |   def authenticate_user(username, password)
195 |     if username.empty? || password.empty?
196 |       forbidden
197 |     end
198 |     
199 |     now = Time.now.utc.to_i
200 |     
201 |     DB[:auth_fails].where(Sequel.lit('created_on < ?', now - cfg(:delay_auth))).delete
202 |     
203 |     if DB[:auth_fails].first(:ip => request.ip)
204 |       failure t(:fast_auth), 403
205 |     end
206 |     
207 |     user = DB[:users].first(:username => username)
208 |     
209 |     if user && password_valid?(password, user[:password])
210 |       return user
211 |     end
212 |     
213 |     DB[:auth_fails].insert(:ip => request.ip, :created_on => now)
214 |     
215 |     forbidden
216 |   end
217 |   
218 |   def get_post_by_path(slug, thread_num, post_num)
219 |     board = DB[:boards].select(:id).first(:slug => slug)
220 |     return nil unless board
221 |     
222 |     thread = DB[:threads]
223 |       .select(:id)
224 |       .first(:board_id => board[:id], :num => thread_num)
225 |     return nil unless thread
226 |     
227 |     DB[:posts].first(:thread_id => thread[:id], :num => post_num)
228 |   end
229 |   
230 |   def delete_threads(threads)
231 |     ids = []
232 |     paths = []
233 |     
234 |     root = settings.files_dir
235 |     
236 |     threads.each do |thread|
237 |       ids << thread[:id]
238 |       paths << "#{root}/#{thread[:board_id]}/#{thread[:id]}"
239 |     end
240 |     
241 |     DB[:threads].where(:id => ids).delete
242 |     DB[:posts].where(:thread_id => ids).delete
243 |     
244 |     dismiss_reports(:thread_id => ids) if cfg(:post_reporting)
245 |     
246 |     FileUtils.rm_rf(paths)
247 |   end
248 |   
249 |   def delete_replies(posts, file_only = false)
250 |     ids = []
251 |     paths = []
252 |     
253 |     files_dir = settings.files_dir
254 |     
255 |     posts.each do |post|
256 |       next if !file_only && post[:num] == 1
257 |       
258 |       ids << post[:id]
259 |       
260 |       if post[:file_hash]
261 |         meta = JSON.parse(post[:meta])['file']
262 |         root = "#{files_dir}/#{post[:board_id]}/#{post[:thread_id]}"
263 |         paths << "#{root}/#{post[:file_hash]}.#{meta['ext']}"
264 |         paths << "#{root}/t_#{post[:file_hash]}.jpg"
265 |       end
266 |     end
267 |     
268 |     if file_only
269 |       DB[:posts].where(:id => ids).update(:file_hash => nil)
270 |     else
271 |       DB[:posts].where(:id => ids).delete
272 |     end
273 |     
274 |     dismiss_reports(:post_id => ids) if cfg(:post_reporting)
275 |     
276 |     FileUtils.rm_f(paths)
277 |   end
278 |   
279 |   def user_has_level?(user, level)
280 |     USER_LEVELS[level] <= user[:level]
281 |   end
282 |   
283 |   def dismiss_reports(by)
284 |     DB[:reports].where(by).delete
285 |   end
286 | end
287 | 
288 | end
289 | 


--------------------------------------------------------------------------------
/helpers/ffmpeg_handler.rb:
--------------------------------------------------------------------------------
  1 | module Hive
  2 | 
  3 | class BBS < Sinatra::Base
  4 |   
  5 |   def process_file_ffmpeg(file, dest)
  6 |     limits = cfg(:file_limits, @board_cfg)[:video]
  7 |     
  8 |     if file.size > limits[:file_size]
  9 |       failure t(:file_size_too_big)
 10 |     end
 11 |     
 12 |     cmd = "ffprobe -i \"#{file.path}\" -v quiet -show_streams -show_format -of json"
 13 |     
 14 |     status, output = run_cmd(cmd, 10)
 15 |     
 16 |     if status != 0
 17 |       if /^{\s+}$/ =~ output.strip
 18 |         failure t(:bad_file_format)
 19 |       else
 20 |         logger.error "FFprobe failed: #{output.strip}"
 21 |         failure t(:server_error)
 22 |       end
 23 |     end
 24 |     
 25 |     info = JSON.parse(output)
 26 |     
 27 |     if info['format']['format_name'] != 'matroska,webm'
 28 |       failure t(:bad_file_format)
 29 |     end
 30 |     
 31 |     duration = info['format']['duration'].to_f
 32 |     
 33 |     if duration > limits[:duration]
 34 |       failure t(:duration_too_long)
 35 |     end
 36 |     
 37 |     sar = width = height = nil
 38 |     has_audio = false
 39 |     has_video = false
 40 |     
 41 |     info['streams'].each do |stream|
 42 |       type = stream['codec_type']
 43 |       
 44 |       if type == 'audio'
 45 |         failure t(:webm_audio_disabled) if !limits[:allow_audio]
 46 |         failure t(:too_many_audio) if has_audio
 47 |         failure t(:invalid_audio) if stream['codec_name'] != 'vorbis'
 48 |         has_audio = true
 49 |       elsif type == 'video'
 50 |         failure t(:too_many_video) if has_video
 51 |         failure t(:invalid_video) if stream['codec_name'] != 'vp8'
 52 |         
 53 |         has_video = true
 54 |         
 55 |         width = stream['width'].to_i
 56 |         height = stream['height'].to_i
 57 |         
 58 |         if width > limits[:dimensions] || height > limits[:dimensions]
 59 |           failure t(:dimensions_too_large)
 60 |         end
 61 |         
 62 |         if tmp_sar = stream['sample_aspect_ratio']
 63 |           tmp_sar = tmp_sar.split(':').map { |x| x.to_i }
 64 |           
 65 |           if tmp_sar[1] && tmp_sar[0] != tmp_sar[1]
 66 |             tmp_sar = tmp_sar[0].to_f / tmp_sar[1]
 67 |             
 68 |             if tmp_sar < 2 && tmp_sar > 0.5
 69 |               sar = tmp_sar
 70 |             end
 71 |           end
 72 |         end
 73 |       else
 74 |         failure t(:invalid_stream)
 75 |       end
 76 |     end
 77 |     
 78 |     if !has_video
 79 |       failure t(:no_video_streams)
 80 |     end
 81 |     
 82 |     thumb_dims = cfg(:thumb_dimensions, @board_cfg)
 83 |     
 84 |     if sar
 85 |       if sar > 1.0
 86 |         image_width = width
 87 |         image_height = (height / sar).ceil
 88 |       else
 89 |         image_width = (width * sar).ceil
 90 |         image_height = height
 91 |       end
 92 |     else
 93 |       image_width = width
 94 |       image_height = height
 95 |     end
 96 |     
 97 |     th_width = image_width
 98 |     th_height = image_height
 99 |     
100 |     if image_width > thumb_dims || image_height > thumb_dims
101 |       ratio = image_width.to_f / image_height
102 |       
103 |       if ratio > 1
104 |         th_width = thumb_dims
105 |         th_height = (thumb_dims / ratio).ceil
106 |       else
107 |         th_height = thumb_dims
108 |         th_width = (thumb_dims * ratio).ceil
109 |       end
110 |     end
111 |     
112 |     q = 1 + ((100 - cfg(:thumb_quality)) * 0.15).floor
113 |     
114 |     cmd = "ffmpeg -i \"#{file.path}\" -vframes 1 "  <<
115 |       "-s #{th_width}x#{th_height} -qscale #{q} -an -y \"#{dest}\" 2>&1"
116 |     
117 |     status, output = run_cmd(cmd, 10)
118 |     
119 |     if status != 0
120 |       logger.error "FFmpeg failed: #{output.strip}"
121 |       failure t(:server_error)
122 |     end
123 |     
124 |     meta = {
125 |       ext: 'webm',
126 |       w: width,
127 |       h: height,
128 |       th_w: th_width,
129 |       th_h: th_height,
130 |       size: file.size
131 |     }
132 |     
133 |     if has_audio
134 |       meta[:has_audio] = true
135 |     end
136 |     
137 |     meta
138 |   end
139 |   
140 | end
141 | 
142 | end
143 | 


--------------------------------------------------------------------------------
/helpers/imagemagick_handler.rb:
--------------------------------------------------------------------------------
 1 | module Hive
 2 | 
 3 | class BBS < Sinatra::Base
 4 |   
 5 |   def process_file_imagemagick(file, dest)
 6 |     limits = cfg(:file_limits, @board_cfg)[:image]
 7 |     
 8 |     if file.size > limits[:file_size]
 9 |       failure t(:file_size_too_big)
10 |     end
11 |     
12 |     cmd = "identify -quiet -ping -format \"%m %w %h\" \"#{file.path}[0]\""
13 |     
14 |     status, output = run_cmd(cmd, 10)
15 |     
16 |     if status != 0
17 |       #logger.error "identify failed: #{output.strip}"
18 |       failure t(:bad_file_format)
19 |     end
20 |     
21 |     image_info = output.strip.split(' ')
22 |     image_format = image_info[0].downcase
23 |     image_width = image_info[1].to_i
24 |     image_height = image_info[2].to_i
25 |     
26 |     if image_width > limits[:dimensions] || image_height > limits[:dimensions]
27 |       failure t(:dimensions_too_large)
28 |     end
29 |     
30 |     if image_format == 'jpeg'
31 |       image_format = 'jpg'
32 |     end
33 |     
34 |     if !cfg(:file_types, @board_cfg).include?(image_format)
35 |       failure t(:bad_file_format)
36 |     end
37 |     
38 |     thumb_dims = cfg(:thumb_dimensions, @board_cfg)
39 |     
40 |     th_width = image_width
41 |     th_height = image_height
42 |     
43 |     if image_width > thumb_dims || image_height > thumb_dims
44 |       ratio = image_width.to_f / image_height
45 |       
46 |       if ratio > 1
47 |         th_width = thumb_dims
48 |         th_height = (thumb_dims / ratio).ceil
49 |       else
50 |         th_height = thumb_dims
51 |         th_width = (thumb_dims * ratio).ceil
52 |       end
53 |     end
54 |     
55 |     cmd = "convert \"#{file.path}[0]\" -format jpg " <<
56 |       "-resize #{th_width}x#{th_height}! -background \"#FFFAFA\" " <<
57 |       "-alpha remove -strip -quality #{cfg(:thumb_quality).to_i} \"#{dest}\""
58 |     
59 |     status, output = run_cmd(cmd, 10)
60 |     
61 |     if status != 0
62 |       logger.error "convert failed: #{output.strip}"
63 |       FileUtils.rm_f(dest) if File.exist?(dest)
64 |       failure t(:server_error)
65 |     end
66 |     
67 |     return {
68 |       ext: image_format,
69 |       w: image_width,
70 |       h: image_height,
71 |       th_w: th_width,
72 |       th_h: th_height,
73 |       size: file.size
74 |     }
75 |   end
76 |   
77 | end
78 | 
79 | end
80 | 


--------------------------------------------------------------------------------
/helpers/misc_helpers.rb:
--------------------------------------------------------------------------------
  1 | module Hive
  2 | 
  3 | class BBS < Sinatra::Base
  4 |   
  5 |   def t(id)
  6 |     STRINGS[id]
  7 |   end
  8 |   
  9 |   def cfg(id, board_cfg = nil)
 10 |     if board_cfg && board_cfg[id]
 11 |       board_cfg[id]
 12 |     else
 13 |       CONFIG[id]
 14 |     end
 15 |   end
 16 |   
 17 |   def logger
 18 |     LOGGER
 19 |   end
 20 |   
 21 |   def failure(msg, code = nil)
 22 |     status code if code
 23 |     @msg, @code = msg, code
 24 |     halt erb :error
 25 |   end
 26 |   
 27 |   def success(msg, redirect = nil)
 28 |     @msg, @redirect = msg, redirect
 29 |     halt erb :success
 30 |   end
 31 |   
 32 |   def forbidden
 33 |     failure t(:denied), 403
 34 |   end
 35 |   
 36 |   def bad_request
 37 |     failure t(:bad_request), 400
 38 |   end
 39 |   
 40 |   def asset(src)
 41 |     if a = ASSETS[src]
 42 |       return a
 43 |     end
 44 |     
 45 |     min_src = src.sub(/\.([a-z]+)$/, '.min.\1'.freeze)
 46 |     min_path = "#{settings.public_dir}#{min_src}".freeze
 47 |     
 48 |     if settings.production? && File.exist?(min_path)
 49 |       path = min_path
 50 |       src = min_src
 51 |     else
 52 |       path = "#{settings.public_dir}#{src}".freeze
 53 |     end
 54 |     
 55 |     v = OpenSSL::Digest::MD5.file(path).hexdigest
 56 |     
 57 |     ASSETS[src] = "#{src}?#{v[0, 8]}".freeze # query strings, for now...
 58 |   end
 59 |   
 60 |   def paginate_html(page, total, count = 5)
 61 |     return if total < 2
 62 |     
 63 |     buffer = count / 2
 64 |     
 65 |     stop = page + buffer
 66 |     stop = count if stop < count
 67 |     stop = total if stop > total
 68 |     
 69 |     start = stop - count + 1
 70 |     start = 1 if start < 1
 71 |     
 72 |     if block_given?
 73 |       yield :first if start > 1
 74 |       yield :previous if page > 1
 75 |       (start..stop).each { |i| yield i }
 76 |       yield :next if total > page
 77 |     else
 78 |       start..stop
 79 |     end
 80 |   end
 81 |   
 82 |   def decode_tegaki_upload(data)
 83 |     ext = data.scan(/^data:image\/([a-z0-9]+);base64,/).flatten.join
 84 |     
 85 |     start = data.index(','.freeze)
 86 |     
 87 |     failure t(:bad_file_format) if !start
 88 |     
 89 |     data = Base64.decode64(data[start, (data.size - start)])
 90 |     
 91 |     tmp = Tempfile.new('hive'.freeze)
 92 |     tmp.binmode
 93 |     tmp.write data
 94 |     tmp.close
 95 |     
 96 |     {
 97 |       tempfile: tmp,
 98 |       filename: "raw.#{ext}".freeze
 99 |     }
100 |   end
101 |   
102 |   def run_cmd(cmd, timeout = nil)
103 |     rpipe, wpipe = IO.pipe
104 |     
105 |     pid = Process.spawn(
106 |       { 'LANG' => 'C' },
107 |       cmd,
108 |       { STDERR => wpipe , STDOUT => wpipe }
109 |     )
110 |     
111 |     wpipe.close
112 |     
113 |     status = nil
114 |     output = ''
115 |     
116 |     begin
117 |       if timeout
118 |         Timeout.timeout(timeout) do
119 |           Process.waitpid(pid, Process::WUNTRACED)
120 |         end
121 |       else
122 |         Process.waitpid(pid, Process::WUNTRACED)
123 |       end
124 |       
125 |       status = $?.exitstatus
126 |       output = rpipe.readlines.join('')
127 |     rescue Errno::ECHILD
128 |     rescue Timeout::Error
129 |       Process.kill(9, pid) rescue Errno::ESRCH
130 |       Process.waitpid(pid, Process::WUNTRACED) rescue Errno::ECHILD
131 |       logger.warn "run_cmd timed out: #{cmd}"
132 |     end
133 |     
134 |     rpipe.close
135 |     
136 |     [status, output]
137 |   end
138 |   
139 |   def get_board_config(board)
140 |     if board[:config]
141 |       JSON.parse(board[:config], symbolize_names: true)
142 |     else
143 |       nil
144 |     end
145 |   end
146 |   
147 |   def pretty_bytesize(size)
148 |     if size < 1024
149 |       return "#{size} B"
150 |     end
151 |     
152 |     size = size.to_f
153 |     
154 |     if size < 1048576
155 |       size /= 1024
156 |       return "#{size.round} KiB"
157 |     end
158 |     
159 |     size /= 1048576
160 |     return "#{size.round(2)} MiB"
161 |   end
162 |   
163 |   def make_tripcode(data)
164 |     digest = OpenSSL::Digest.new('sha1')
165 |     [OpenSSL::HMAC.digest(digest, TRIP_KEY, data)[0, 9]].pack('m0')
166 |   end
167 |   
168 |   
169 |   def random_base64bytes(length)
170 |     [OpenSSL::Random.random_bytes(length)].pack("m0").tr('+/', '-_').delete('=')
171 |   end
172 |   
173 | end
174 | 
175 | end
176 | 


--------------------------------------------------------------------------------
/hive.rb:
--------------------------------------------------------------------------------
   1 | #!/usr/bin/env ruby
   2 | 
   3 | require 'base64'
   4 | require 'bcrypt'
   5 | require 'erubis'
   6 | require 'escape_utils'
   7 | require 'fileutils'
   8 | require 'hive_markup'
   9 | require 'ipaddr'
  10 | require 'json'
  11 | require 'logger'
  12 | require 'net/https'
  13 | require 'openssl'
  14 | require 'resolv'
  15 | require 'sequel'
  16 | require 'tilt/erubis'
  17 | require 'sinatra/base'
  18 | require 'timeout'
  19 | 
  20 | Encoding.default_external = 'UTF-8'
  21 | 
  22 | module Hive
  23 | 
  24 | class BBS < Sinatra::Base
  25 |   VERSION = '0.5.3'
  26 |   
  27 |   Dir.glob("#{settings.root}/helpers/*.rb").each { |f| require f }
  28 |   
  29 |   if settings.test?
  30 |     env_sfx = '_test'
  31 |   else
  32 |     env_sfx = ''
  33 |   end
  34 |   
  35 |   DB = Sequel.connect(eval(
  36 |       File.open("#{settings.root}/config/db#{env_sfx}.rb", 'r') { |f| f.read }
  37 |     )
  38 |   )
  39 |   
  40 |   if settings.development?
  41 |     DB.logger = Logger.new($stdout)
  42 |   end
  43 |   
  44 |   CONFIG = eval(
  45 |     File.open("#{settings.root}/config/config.rb", 'r') { |f| f.read }
  46 |   )
  47 |   
  48 |   STRINGS = eval(
  49 |     File.open("#{settings.root}/i18n/#{CONFIG[:locale]}.rb", 'r') { |f| f.read }
  50 |   )
  51 |   
  52 |   TRIP_KEY = File.binread("#{settings.root}/config/trip.key")
  53 |   
  54 |   LOGGER = Logger.new("#{settings.root}/log/error.log", 524288)
  55 |   
  56 |   set :erb, :engine_class => Erubis::FastEruby
  57 |   
  58 |   set :files_dir, "#{settings.public_folder}/files#{env_sfx}"
  59 |   
  60 |   set :tmp_dir, "#{settings.root}/tmp#{env_sfx}"
  61 |   
  62 |   set :protection, false
  63 |   
  64 |   ASSETS = {}
  65 |   
  66 |   RACK_TEMPFILES = 'rack.tempfiles'.freeze
  67 |   
  68 |   after '/post' do
  69 |     if env[RACK_TEMPFILES]
  70 |       env[RACK_TEMPFILES].each do |tmp|
  71 |         tmp.close! if tmp
  72 |       end
  73 |     end
  74 |   end
  75 |   
  76 |   get '/' do
  77 |     @boards = DB[:boards].all
  78 |     erb :index
  79 |   end
  80 |   
  81 |   get %r{/([-_0-9a-z]+)/([0-9]+)?} do |slug, page|
  82 |     @board = DB[:boards].where(:slug => slug).first
  83 |     
  84 |     halt 404 if !@board
  85 |     
  86 |     @board_cfg = get_board_config(@board)
  87 |     
  88 |     threads_per_page = cfg(:threads_per_page, @board_cfg)
  89 |     
  90 |     offset = nil
  91 |     @current_page = nil
  92 |     @total_pages = nil
  93 |     
  94 |     if threads_per_page
  95 |       thread_count = DB[:threads].where(:board_id => @board[:id]).count
  96 |       
  97 |       thread_count = 1 if thread_count == 0
  98 |       
  99 |       if !page
 100 |         page = 1
 101 |       else
 102 |         page = page.to_i
 103 |         
 104 |         if page > 1
 105 |           offset = (page - 1) * threads_per_page
 106 |         elsif page == 1
 107 |           redirect to("/#{@board[:slug]}/"), 301
 108 |         else
 109 |           halt 404
 110 |         end
 111 |       end
 112 |       
 113 |       @total_pages = (thread_count / threads_per_page.to_f).ceil
 114 |       
 115 |       halt 404 if page > @total_pages
 116 |       
 117 |       @current_page = page
 118 |     elsif page
 119 |       halt 404
 120 |     end
 121 |     
 122 |     @threads = DB[:threads]
 123 |       .reverse_order(:pinned, :updated_on)
 124 |       .where(:board_id => @board[:id])
 125 |       .limit(threads_per_page)
 126 |       .offset(offset)
 127 |       .all
 128 |     
 129 |     erb :board
 130 |   end
 131 |   
 132 |   get %r{/([-_0-9a-z]+)/read/([0-9]+)} do |slug, num|
 133 |     @board = DB[:boards].first(:slug => slug)
 134 |     
 135 |     halt 404 if !@board
 136 |     
 137 |     @thread = DB[:threads].first(:board_id => @board[:id], :num => num.to_i)
 138 |     
 139 |     halt 404 if !@thread
 140 |     
 141 |     @posts = DB[:posts].where(:thread_id => @thread[:id]).all
 142 |     
 143 |     @board_cfg = get_board_config(@board)
 144 |     
 145 |     erb :read
 146 |   end
 147 |   
 148 |   post '/markup' do
 149 |     content_type :json
 150 |     
 151 |     comment = params['comment'].to_s
 152 |     
 153 |     data = if comment.empty?
 154 |       comment
 155 |     else
 156 |       Markup.render(comment)
 157 |     end
 158 |     
 159 |     { status: 'success', data: data }.to_json
 160 |   end
 161 |   
 162 |   post '/post' do
 163 |     validate_referrer
 164 |     
 165 |     validate_honeypot
 166 |     
 167 |     board = DB[:boards].where(:slug => params['board'].to_s).first
 168 |     
 169 |     failure t(:bad_board) if !board
 170 |     
 171 |     @board_cfg = get_board_config(board)
 172 |     
 173 |     verify_captcha if cfg(:captcha, @board_cfg)
 174 |     
 175 |     now = Time.now.utc.to_i
 176 |     
 177 |     thread_num = params['thread'].to_i
 178 |     
 179 |     is_new_thread = thread_num == 0
 180 |     
 181 |     #
 182 |     # Ban checks
 183 |     #
 184 |     ip_addr = IPAddr.new(request.ip)
 185 |     
 186 |     if ip_addr.ipv6?
 187 |       if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped?
 188 |         ip_addr = ip_addr.native
 189 |       else
 190 |         ip_addr = ip_addr.mask(64)
 191 |       end
 192 |     end
 193 |     
 194 |     ip_addr = Sequel::SQL::Blob.new(ip_addr.hton)
 195 |     
 196 |     if DB[:bans].first(Sequel.lit('ip = ? AND active = ?', ip_addr, true))
 197 |       redirect '/banned'
 198 |     end
 199 |     
 200 |     #
 201 |     # Author
 202 |     #
 203 |     author = params['author'].to_s
 204 |     
 205 |     tripcode = command = nil
 206 |     
 207 |     if author.empty?
 208 |       author = nil
 209 |     else
 210 |       if author.include?('#')
 211 |         author, tripcode, command = author.split('#', 3)
 212 |       end
 213 |       
 214 |       author.gsub!(/[^[:print:]]/u, '')
 215 |       author.strip!
 216 |       
 217 |       if !author.empty? && author != cfg(:anon, @board_cfg)
 218 |         if /[^\u0000-\uffff]/u =~ author
 219 |           failure t(:invalid_chars)
 220 |         end
 221 |         
 222 |         if author.length > cfg(:author_length)
 223 |           failure t(:name_too_long) 
 224 |         end
 225 |         
 226 |         author = EscapeUtils.escape_html(author)
 227 |       else
 228 |         author = nil
 229 |       end
 230 |     end
 231 |     
 232 |     if tripcode
 233 |       if tripcode.empty?
 234 |         tripcode = nil
 235 |       else
 236 |         tripcode = make_tripcode(tripcode)
 237 |       end
 238 |     end
 239 |     
 240 |     #
 241 |     # Comment
 242 |     #
 243 |     comment = params['comment'].to_s
 244 |     
 245 |     if /[^\u0000-\uffff]/u =~ comment
 246 |       failure t(:invalid_chars)
 247 |     end
 248 |     
 249 |     if comment.lines.count > cfg(:comment_lines)
 250 |       failure t(:comment_too_long) 
 251 |     end
 252 |     
 253 |     if comment.length > cfg(:comment_length)
 254 |       failure t(:comment_too_long)
 255 |     end
 256 |     
 257 |     comment = Markup.render(comment)
 258 |     #comment.gsub!(/[^[:print:]]/u, '')
 259 |     
 260 |     if comment.empty? || !(/[[:graph:]]/u =~ comment)
 261 |       comment = nil
 262 |     end
 263 |     
 264 |     #
 265 |     # Title
 266 |     #
 267 |     if is_new_thread
 268 |       title = params['title'].to_s
 269 |       title.strip!
 270 |       title.gsub!(/[^[:print:]]/u, '')
 271 |       
 272 |       if title.empty?
 273 |         failure t(:title_empty)
 274 |       end
 275 |       
 276 |       if /[^\u0000-\uffff]/u =~ title
 277 |         failure t(:invalid_chars)
 278 |       end
 279 |       
 280 |       if title.length > cfg(:title_length)
 281 |         failure t(:title_too_long)
 282 |       end
 283 |       
 284 |       title = EscapeUtils.escape_html(title)
 285 |     else
 286 |       thread = DB[:threads].first(:board_id => board[:id], :num => thread_num)
 287 |       
 288 |       if !thread
 289 |         failure t(:bad_thread)
 290 |       end
 291 |     end
 292 |     
 293 |     #
 294 |     # Files
 295 |     #
 296 |     if cfg(:file_uploads, @board_cfg)
 297 |       if file = params['tegaki']
 298 |         file = file.to_s
 299 |         
 300 |         if file.size > cfg(:tegaki_data_limit, @board_cfg)
 301 |           failure t(:file_size_too_big)
 302 |         end
 303 |         
 304 |         file = decode_tegaki_upload(file)
 305 |       else
 306 |         file = params['file']
 307 |       end
 308 |     else
 309 |       file = nil
 310 |     end
 311 |     
 312 |     if has_file = file.is_a?(Hash)
 313 |       tmp_thumb_path = nil
 314 |       
 315 |       file_hash = OpenSSL::Digest::MD5.file(file[:tempfile].path).hexdigest
 316 |       
 317 |       if is_new_thread
 318 |         if DB[:posts].first(:file_hash => file_hash, :num => 1)
 319 |           failure t(:dup_file_thread)
 320 |         end
 321 |       else
 322 |         failure t(:dup_file_reply) if DB[:posts].select(1)
 323 |           .first(:file_hash => file_hash, :thread_id => thread[:id])
 324 |       end
 325 |       
 326 |       file_ext = file[:filename].scan(/\.([a-z0-9]+$)/i).flatten.join.downcase
 327 |       
 328 |       tmp_thumb_path =
 329 |         "#{settings.tmp_dir}/#{board[:id]}_#{thread_num}_#{file_hash}.jpg"
 330 |       
 331 |       file_meta =
 332 |         if file_ext == 'webm'
 333 |           process_file_ffmpeg(file[:tempfile], tmp_thumb_path)
 334 |         else
 335 |           process_file_imagemagick(file[:tempfile], tmp_thumb_path)
 336 |         end
 337 |     elsif !comment
 338 |       failure t(:comment_empty)
 339 |     end
 340 |     
 341 |     begin
 342 |       capcode = nil
 343 |       
 344 |       if command && !command.empty?
 345 |         command.sub!(/^capcode_/, '')
 346 |         
 347 |         if capcode = t(:user_trips)[command]
 348 |           user = get_user_session
 349 |           
 350 |           unless user && user_has_level?(user, command.to_sym)
 351 |             failure t(:cmd_forbidden)
 352 |           end
 353 |           
 354 |           author = nil
 355 |           tripcode = capcode
 356 |         end
 357 |       end
 358 |       
 359 |       if !capcode
 360 |         if cfg(:forced_anon, @board_cfg)
 361 |           author = tripcode = nil
 362 |         end
 363 |         
 364 |         validate_cooldowns(is_new_thread)
 365 |       end
 366 |       
 367 |       DB.transaction do
 368 |         if is_new_thread
 369 |           board = DB[:boards].for_update.where(id: board[:id]).first
 370 | 
 371 |           DB[:boards]
 372 |             .where(:id => board[:id])
 373 |             .update(:thread_count => Sequel.+(:thread_count, 1))
 374 |           
 375 |           thread = {}
 376 |           thread[:board_id] = board[:id]
 377 |           thread[:num] = board[:thread_count] + 1
 378 |           thread[:created_on] = now
 379 |           thread[:updated_on] = now
 380 |           thread[:title] = title
 381 |           
 382 |           thread[:id] = DB[:threads].insert(thread)
 383 |           
 384 |           thread[:post_count] = 1
 385 |         else
 386 |           thread = DB[:threads].for_update.where(id: thread[:id]).first
 387 |           
 388 |           if thread[:locked] > 0
 389 |             failure t(:thread_locked)
 390 |           end
 391 |           
 392 |           if thread[:post_count] >= cfg(:post_limit, @board_cfg)
 393 |             failure t(:thread_full)
 394 |           end
 395 |           
 396 |           new_vals = {
 397 |             :post_count => Sequel.+(:post_count, 1)
 398 |           }
 399 |           
 400 |           if !params['sage']
 401 |             new_vals[:updated_on] = now
 402 |           end
 403 |           
 404 |           DB[:threads].where(:id => thread[:id]).update(new_vals)
 405 |           
 406 |           thread[:post_count] += 1
 407 |         end
 408 |         
 409 |         post = {}
 410 |         post[:board_id] = board[:id]
 411 |         post[:thread_id] = thread[:id]
 412 |         post[:num] = thread[:post_count]
 413 |         post[:created_on] = now
 414 |         post[:author] = author
 415 |         post[:tripcode] = tripcode
 416 |         post[:ip] = request.ip
 417 |         post[:comment] = comment
 418 |         
 419 |         meta = {}
 420 |         
 421 |         if file
 422 |           post[:file_hash] = file_hash
 423 |           file_meta[:spoiler] = true if params['spoiler']
 424 |           meta[:file] = file_meta
 425 |         end
 426 |         
 427 |         if capcode
 428 |           meta[:capcode] = true
 429 |         end
 430 |         
 431 |         post[:meta] = meta.to_json unless meta.empty?
 432 |         
 433 |         DB[:posts].insert(post)
 434 |         
 435 |         files_dest_dir = "#{settings.files_dir}/#{board[:id]}/#{thread[:id]}"
 436 |         
 437 |         if is_new_thread
 438 |           FileUtils.mkdir(files_dest_dir)
 439 |         end
 440 |         
 441 |         if has_file
 442 |           dest_file = "#{files_dest_dir}/#{file_hash}.#{file_meta[:ext]}"
 443 |           
 444 |           FileUtils.mv(
 445 |             tmp_thumb_path,
 446 |             "#{files_dest_dir}/t_#{file_hash}.jpg"
 447 |           )
 448 |           
 449 |           tmp_thumb_path = nil
 450 |           
 451 |           FileUtils.cp(
 452 |             file[:tempfile].path,
 453 |             dest_file
 454 |           )
 455 |           
 456 |           File.chmod(0644, dest_file)
 457 |         end
 458 |       end
 459 |     ensure
 460 |       FileUtils.rm_f(tmp_thumb_path) if tmp_thumb_path
 461 |     end
 462 |     
 463 |     thread_limit = cfg(:thread_limit, @board_cfg)
 464 |     
 465 |     if is_new_thread && thread_limit &&
 466 |       DB[:threads].where(board_id: board[:id]).count > thread_limit
 467 |       
 468 |       overflow = DB[:threads]
 469 |         .select(:id, :board_id, :num)
 470 |         .where(:board_id => board[:id])
 471 |         .reverse_order(:pinned, :updated_on)
 472 |         .limit(nil, cfg(:thread_limit, @board_cfg))
 473 |         .all
 474 |       
 475 |       delete_threads(overflow) unless overflow.empty?
 476 |     end
 477 |     
 478 |     @thread = thread
 479 |     @board = board
 480 |     
 481 |     erb :post
 482 |   end
 483 |   
 484 |   get '/report/:slug/:thread_num/:post_num' do
 485 |     failure t(:cannot_report) unless cfg(:post_reporting)
 486 |     
 487 |     now = Time.now.utc.to_i
 488 |     throttle = now - cfg(:delay_report)
 489 |     
 490 |     ip_addr = request.ip
 491 |     
 492 |     if DB[:reports].select(1).reverse_order(:id)
 493 |       .first(Sequel.lit('ip = ? AND created_on > ?', ip_addr, throttle))
 494 |       failure t(:fast_report)
 495 |     end
 496 |     
 497 |     erb :report
 498 |   end
 499 |   
 500 |   post '/report/:slug/:thread_num/:post_num' do
 501 |     failure t(:cannot_report) unless cfg(:post_reporting)
 502 |     
 503 |     validate_referrer
 504 |     
 505 |     validate_honeypot
 506 |     
 507 |     verify_captcha if cfg(:reporting_captcha)
 508 |     
 509 |     slug = params[:slug].to_s
 510 |     thread_num = params[:thread_num].to_i
 511 |     post_num = params[:post_num].to_i
 512 |     cat = params['category'].to_s
 513 |     
 514 |     post = get_post_by_path(slug, thread_num, post_num)
 515 |     
 516 |     failure t(:bad_post) unless post
 517 |     
 518 |     if !cfg(:report_categories).empty?
 519 |       failure t(:bad_report_cat) unless score = cfg(:report_categories)[cat]
 520 |     else
 521 |       cat = ''
 522 |       score = 1
 523 |     end
 524 |     
 525 |     ip_addr = request.ip
 526 |     
 527 |     if DB[:reports].first(Sequel.lit('ip = ? AND post_id = ?', ip_addr, post[:id]))
 528 |       failure t(:duplicate_report)
 529 |     end
 530 |     
 531 |     now = Time.now.utc.to_i
 532 |     throttle = now - cfg(:delay_report)
 533 |     
 534 |     if DB[:reports].select(1).reverse_order(:id)
 535 |       .first(Sequel.lit('ip = ? AND created_on > ?', ip_addr, throttle))
 536 |       failure t(:fast_report)
 537 |     end
 538 |     
 539 |     DB[:reports].insert({
 540 |       board_id: post[:board_id],
 541 |       thread_id: post[:thread_id],
 542 |       post_id: post[:id],
 543 |       created_on: now,
 544 |       ip: ip_addr,
 545 |       score: score,
 546 |       category: cat
 547 |     })
 548 |     
 549 |     success t(:done)
 550 |   end
 551 |   
 552 |   post '/manage/posts/delete' do
 553 |     validate_csrf_token
 554 |     
 555 |     forbidden unless user = get_user_session
 556 |     forbidden unless user_has_level?(user, :mod)
 557 |     
 558 |     slug = params['board'].to_s
 559 |     thread_num = params['thread'].to_i
 560 |     post_num = params['post'].to_i
 561 |     file_only = !!params['file_only']
 562 |     
 563 |     if slug.empty? || thread_num.zero? || post_num.zero?
 564 |       bad_request
 565 |     end
 566 |     
 567 |     board = DB[:boards].first(:slug => slug)
 568 |     failure t(:bad_board) unless board
 569 |     
 570 |     thread = DB[:threads].first(:board_id => board[:id], :num => thread_num)
 571 |     failure t(:bad_thread) unless thread
 572 |     
 573 |     if post_num == 1 && !file_only
 574 |       delete_threads([thread])
 575 |     else
 576 |       post = DB[:posts]
 577 |         .select(:id, :board_id, :thread_id, :num, :file_hash, :meta)
 578 |         .first(:thread_id => thread[:id], :num => post_num)
 579 |       
 580 |       failure t(:bad_post) unless post
 581 |       
 582 |       delete_replies([post], file_only)
 583 |     end
 584 |     
 585 |     success t(:done), "#{board[:slug]}/read/#{thread[:num]}"
 586 |   end
 587 |   
 588 |   post '/manage/threads/flags' do
 589 |     validate_csrf_token
 590 |     
 591 |     forbidden unless user = get_user_session
 592 |     forbidden unless user_has_level?(user, :mod)
 593 |     
 594 |     slug = params['board'].to_s
 595 |     thread_num = params['thread'].to_i
 596 |     flag = params['flag'].to_s
 597 |     value = params['value'].to_i
 598 |     
 599 |     if slug.empty? || thread_num.zero? || flag.empty?
 600 |       bad_request
 601 |     end
 602 |     
 603 |     if !['pinned', 'locked'].include?(flag) || value < 0
 604 |       bad_request
 605 |     end
 606 |     
 607 |     board = DB[:boards].first(:slug => slug)
 608 |     failure t(:bad_board) unless board
 609 |     
 610 |     thread = DB[:threads].first(:board_id => board[:id], :num => thread_num)
 611 |     failure t(:bad_thread) unless thread
 612 |     
 613 |     DB[:threads].where(:id => thread[:id]).update({ flag.to_sym => value })
 614 |     
 615 |     success t(:done), "#{board[:slug]}/read/#{thread[:num]}"
 616 |   end
 617 |   
 618 |   get '/banned' do
 619 |     ip_addr = IPAddr.new(request.ip)
 620 |     
 621 |     if ip_addr.ipv6?
 622 |       if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped?
 623 |         ip_addr = ip_addr.native
 624 |       else
 625 |         ip_addr = ip_addr.mask(64)
 626 |       end
 627 |     end
 628 |     
 629 |     ip_addr = Sequel::SQL::Blob.new(ip_addr.hton)
 630 |     now = Time.now.utc.to_i
 631 |     
 632 |     @bans = DB[:bans]
 633 |       .where(:ip => ip_addr, :active => true)
 634 |       .reverse_order(:id)
 635 |       .all
 636 |     
 637 |     if !@bans.empty?
 638 |       DB[:bans]
 639 |         .where(Sequel.lit('ip = ? AND active = ? AND expires_on <= ?', ip_addr, true, now))
 640 |         .update(:active => false)
 641 |     end
 642 |     
 643 |     erb :banned
 644 |   end
 645 |   
 646 |   get '/manage/bans/create/:slug/:thread_num/:post_num' do
 647 |     forbidden unless user = get_user_session
 648 |     forbidden unless user_has_level?(user, :mod)
 649 |     
 650 |     @slug = params[:slug].to_s
 651 |     @thread_num = params[:thread_num].to_i
 652 |     @post_num = params[:post_num].to_i
 653 |     
 654 |     @post = get_post_by_path(@slug, @thread_num, @post_num)
 655 |     
 656 |     failure t(:bad_post) unless @post
 657 |     
 658 |     erb :manage_bans_edit
 659 |   end
 660 |   
 661 |   get '/manage/bans/update/:id' do
 662 |     forbidden unless user = get_user_session
 663 |     forbidden unless user_has_level?(user, :mod)
 664 |     
 665 |     @ban = DB[:bans]
 666 |       .select_all(:bans)
 667 |       .select_append(Sequel[:c][:username].as(:creator_name), Sequel[:u][:username].as(:updater_name))
 668 |       .left_join(Sequel.as(:users, :c), :id => Sequel[:bans][:created_by])
 669 |       .left_join(Sequel.as(:users, :u), :id => Sequel[:bans][:updated_by])
 670 |       .first(Sequel[:bans][:id] => params[:id].to_i)
 671 |     
 672 |     halt 404 if !@ban
 673 |     
 674 |     erb :manage_bans_edit
 675 |   end
 676 |   
 677 |   post '/manage/bans/update' do
 678 |     validate_csrf_token
 679 |     
 680 |     forbidden unless user = get_user_session
 681 |     forbidden unless user_has_level?(user, :mod)
 682 |     
 683 |     now = Time.now.utc.to_i
 684 |     
 685 |     # Expiration
 686 |     duration = params['duration'].to_i
 687 |     
 688 |     # Reason
 689 |     reason = params['reason'].to_s.strip
 690 |     
 691 |     failure t(:empty_ban_reason) if reason.empty?
 692 |     
 693 |     reason = EscapeUtils.escape_html(reason)
 694 |     
 695 |     # Info
 696 |     info = params['info'].to_s.strip
 697 |     
 698 |     if info.empty?
 699 |       info = nil
 700 |     else
 701 |       info = EscapeUtils.escape_html(info)
 702 |     end
 703 |     
 704 |     ban = {
 705 |       :duration => duration,
 706 |       :reason => reason,
 707 |       :info => info,
 708 |     }
 709 |     
 710 |     if params['id']
 711 |       ban_id = params['id'].to_i
 712 |       
 713 |       target_ban = DB[:bans].select(:created_on, :active).first(:id => ban_id)
 714 |       
 715 |       failure t(:invalid_ban_id) unless target_ban 
 716 |       
 717 |       active = !!params['active']
 718 |       
 719 |       if target_ban[:active] != active
 720 |         ban[:active] = active
 721 |       end
 722 | 
 723 |       ban[:expires_on] = ban_duration_ts(target_ban[:created_on], duration)
 724 |       ban[:updated_by] = user[:id]
 725 |       
 726 |       DB[:bans].where(:id => ban_id).update(ban)
 727 |     else
 728 |       slug = params['board'].to_s
 729 |       thread_num = params['thread'].to_i
 730 |       post_num = params['post'].to_i
 731 |       
 732 |       post = get_post_by_path(slug, thread_num, post_num)
 733 |       failure t(:bad_post) unless post
 734 |       
 735 |       ip_addr = IPAddr.new(post[:ip])
 736 |       
 737 |       if ip_addr.ipv6?
 738 |         if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped?
 739 |           ip_addr = ip_addr.native
 740 |         else
 741 |           ip_addr = ip_addr.mask(64)
 742 |         end
 743 |       end
 744 |       
 745 |       post[:slug] = slug
 746 |       post[:thread_num] = thread_num
 747 |       
 748 |       if post_num == 1
 749 |         post[:title] = DB[:threads]
 750 |           .where(:id => post[:thread_id])
 751 |           .get(:title)
 752 |       end
 753 |       
 754 |       ban[:ip] = Sequel::SQL::Blob.new(ip_addr.hton)
 755 |       ban[:post] = post.to_json
 756 |       ban[:created_by] = user[:id]
 757 |       ban[:created_on] = now
 758 |       ban[:expires_on] = ban_duration_ts(now, duration)
 759 |       
 760 |       ban_id = DB[:bans].insert(ban)
 761 |     end
 762 |     
 763 |     success(t(:done), request.path + "/#{ban_id}")
 764 |   end
 765 |   
 766 |   get '/manage/bans' do
 767 |     forbidden unless user = get_user_session
 768 |     forbidden unless user_has_level?(user, :mod)
 769 |     
 770 |     dataset = DB[:bans]
 771 |       .select_all(:bans)
 772 |       .select_append(:username)
 773 |       .left_join(:users, :id => :id)
 774 |       .reverse_order(Sequel[:bans][:id])
 775 |     
 776 |     if params['q']
 777 |       @ip = params['q'].to_s
 778 |       
 779 |       begin
 780 |         ip_addr = IPAddr.new(@ip)
 781 |       rescue
 782 |         failure t(:invalid_ip)
 783 |       end
 784 |       
 785 |       if ip_addr.ipv6?
 786 |         if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped?
 787 |           ip_addr = ip_addr.native
 788 |         else
 789 |           ip_addr = ip_addr.mask(64)
 790 |         end
 791 |       end
 792 |       
 793 |       ip_addr = Sequel::SQL::Blob.new(ip_addr.hton)
 794 |       
 795 |       dataset = dataset.where(:ip => ip_addr)
 796 |     else
 797 |       @ip = nil
 798 |       dataset = dataset.limit(50)
 799 |     end
 800 |     
 801 |     @bans = dataset.all
 802 |     
 803 |     erb :manage_bans
 804 |   end
 805 |   
 806 |   get '/manage' do
 807 |     if (@user = get_user_session) && user_has_level?(@user, :mod)
 808 |       erb :manage
 809 |     else
 810 |       redirect '/manage/auth'
 811 |     end
 812 |   end
 813 |   
 814 |   get '/manage/auth' do
 815 |     @csrf = random_base64bytes(8)
 816 |     
 817 |     response.set_cookie('auth_csrf',
 818 |       value: @csrf,
 819 |       path: '/manage/auth',
 820 |       secure: cfg(:secure_cookies)
 821 |     )
 822 |     
 823 |     erb :manage_login
 824 |   end
 825 |   
 826 |   post '/manage/auth' do
 827 |     validate_csrf_token('auth_csrf')
 828 |     
 829 |     user = authenticate_user(params['username'], params['password'])
 830 |     
 831 |     old_sid = request.cookies['sid'].to_s
 832 |     new_sid = random_base64bytes(64)
 833 |     
 834 |     if !old_sid.empty?
 835 |       DB[:sessions].where(:sid => old_sid).delete
 836 |     end
 837 |     
 838 |     now = Time.now.utc.to_i
 839 |     
 840 |     DB[:sessions].where(Sequel.lit('created_on <= ?', now - cfg(:auth_ttl))).delete
 841 |     
 842 |     DB[:sessions].where(Sequel.lit('updated_on <= ?', now - cfg(:auth_idle))).delete
 843 |     
 844 |     DB[:sessions].insert({
 845 |       sid: hash_session_id(new_sid),
 846 |       user_id: user[:id],
 847 |       ip: request.ip,
 848 |       created_on: now,
 849 |       updated_on: now
 850 |     })
 851 |     
 852 |     set_session_cookies(new_sid)
 853 |     
 854 |     redirect '/manage'
 855 |   end
 856 |   
 857 |   post '/manage/logout' do
 858 |     validate_csrf_token
 859 |     
 860 |     sid = request.cookies['sid'].to_s
 861 |     
 862 |     redirect '/' if sid.empty?
 863 |     
 864 |     DB[:sessions].where(:sid => sid).delete
 865 |     
 866 |     clear_session_cookies
 867 |     
 868 |     redirect '/'
 869 |   end
 870 |   
 871 |   get '/manage/reports' do
 872 |     forbidden unless user = get_user_session
 873 |     forbidden unless user_has_level?(user, :mod)
 874 |     
 875 |     @posts = DB[:reports]
 876 |       .select(
 877 |         :post_id,
 878 |         Sequel.function(:count, :score).as(:total),
 879 |         Sequel.function(:sum, :score).as(:score)
 880 |       )
 881 |       .group(:post_id)
 882 |       .reverse_order(:score)
 883 |       .from_self(:alias => :reports)
 884 |       .select_all(:reports, :posts)
 885 |       .select_append(
 886 |         Sequel[:threads][:title],
 887 |         Sequel[:threads][:post_count],
 888 |         Sequel[:threads][:num].as(:thread_num),
 889 |         Sequel[:boards][:slug]
 890 |       )
 891 |       .inner_join(:posts, :id => :post_id)
 892 |       .inner_join(:threads, :id => :thread_id)
 893 |       .inner_join(:boards, :id => :board_id)
 894 |       .all 
 895 |     
 896 |     erb :manage_reports
 897 |   end
 898 |   
 899 |   post '/manage/reports/delete' do
 900 |     validate_csrf_token
 901 |     
 902 |     forbidden unless user = get_user_session
 903 |     forbidden unless user_has_level?(user, :mod)
 904 |     
 905 |     post_id = params['post_id'].to_i
 906 |     
 907 |     failure t(:bad_request) if post_id.zero?
 908 |     
 909 |     DB[:reports].where(:post_id => post_id).delete
 910 |     
 911 |     success t(:done)
 912 |   end
 913 |   
 914 |   get '/manage/boards' do
 915 |     forbidden unless user = get_user_session
 916 |     forbidden unless user_has_level?(user, :admin)
 917 |     
 918 |     @boards = DB[:boards].all
 919 |     
 920 |     erb :manage_boards
 921 |   end
 922 |   
 923 |   get %r{/manage/boards/(create|update)(?:/([0-9]+))?} do |action, board_id|
 924 |     forbidden unless user = get_user_session
 925 |     forbidden unless user_has_level?(user, :admin)
 926 |     
 927 |     if action == 'update'
 928 |       @board = DB[:boards].first(:id => board_id.to_i)
 929 |       failure t(:bad_board) unless @board
 930 |     end
 931 |     
 932 |     erb :manage_boards_edit
 933 |   end
 934 |   
 935 |   post %r{/manage/boards/(create|update)} do |action|
 936 |     validate_csrf_token
 937 |     
 938 |     forbidden unless user = get_user_session
 939 |     forbidden unless user_has_level?(user, :admin)
 940 |     
 941 |     slug = params['slug'].to_s
 942 |     title = params['title'].to_s
 943 |     config = params['config'].to_s
 944 |     
 945 |     failure t(:bad_slug) unless /\A[-_a-z0-9]+\z/ =~ slug
 946 |     failure t(:bad_title) if title.empty?
 947 |     
 948 |     if config.empty?
 949 |       config = nil
 950 |     else
 951 |       begin
 952 |         config = JSON.parse(config).to_json
 953 |       rescue JSON::JSONError => e
 954 |         failure t(:bad_config_json)
 955 |       end
 956 |     end
 957 |     
 958 |     board = {
 959 |       slug: slug,
 960 |       title: EscapeUtils.escape_html(title),
 961 |       config: config
 962 |     }
 963 |     
 964 |     if action == 'update'
 965 |       affected = DB[:boards].where(:id => params['id'].to_i).update(board)
 966 |       failure t(:bad_board) unless affected > 0
 967 |     else
 968 |       board[:created_on] = Time.now.utc.to_i
 969 |       
 970 |       DB.transaction do
 971 |         board_id = DB[:boards].insert(board)
 972 |         
 973 |         unless File.directory?(settings.files_dir)
 974 |           FileUtils.mkdir settings.files_dir
 975 |         end
 976 |         
 977 |         FileUtils.mkdir "#{settings.files_dir}/#{board_id}"
 978 |       end
 979 |     end
 980 |     
 981 |     success t(:done), '/manage/boards'
 982 |   end
 983 |   
 984 |   post '/manage/boards/delete' do
 985 |     validate_csrf_token
 986 |     
 987 |     forbidden unless user = get_user_session
 988 |     forbidden unless user_has_level?(user, :admin)
 989 |     
 990 |     conf = params['confirm_keyword'].to_s
 991 |     
 992 |     if conf.empty? || conf != t(:confirm_keyword)
 993 |       failure t(:bad_confirm_keyword)
 994 |     end
 995 |     
 996 |     board = DB[:boards].first(:id => params['id'].to_i)
 997 |     
 998 |     failure t(:bad_board) unless board
 999 |     
1000 |     DB.transaction(:rollback => :reraise) do
1001 |       DB[:boards].where(:id => board[:id]).delete
1002 |       DB[:threads].where(:board_id => board[:id]).delete
1003 |       DB[:posts].where(:board_id => board[:id]).delete
1004 |     end
1005 |     
1006 |     begin
1007 |       path = "#{settings.files_dir}/#{board[:id]}"
1008 |       FileUtils.rm_r(path)
1009 |     rescue => e
1010 |       logger.error "Failed to delete '#{path}' (#{e.message})"
1011 |     end
1012 |     
1013 |     success t(:done), '/manage/boards'
1014 |   end
1015 |   
1016 |   get '/manage/users' do
1017 |     forbidden unless user = get_user_session
1018 |     forbidden unless user_has_level?(user, :admin)
1019 |     
1020 |     @users = DB[:users].all
1021 |     
1022 |     erb :manage_users
1023 |   end
1024 |   
1025 |   get %r{/manage/users/(create|update)(?:/([0-9]+))?} do |action, user_id|
1026 |     forbidden unless user = get_user_session
1027 |     forbidden unless user_has_level?(user, :admin)
1028 |     
1029 |     if action == 'update'
1030 |       @user = DB[:users].first(:id => user_id.to_i)
1031 |       failure t(:bad_user_id) unless @user
1032 |     end
1033 |     
1034 |     erb :manage_users_edit
1035 |   end
1036 |   
1037 |   post %r{/manage/users/(create|update)} do |action|
1038 |     validate_csrf_token
1039 |     
1040 |     forbidden unless user = get_user_session
1041 |     forbidden unless user_has_level?(user, :admin)
1042 |     
1043 |     username = params['username'].to_s
1044 |     level = params['level'].to_i
1045 |     
1046 |     failure t(:bad_user_name) unless /\A[_a-z0-9]+\z/i =~ username
1047 |     failure t(:bad_user_level) unless USER_GROUPS[level]
1048 |     
1049 |     new_user = {
1050 |       username: username,
1051 |       level: level
1052 |     }
1053 |     
1054 |     if action == 'create' || params['reset_password']
1055 |       new_plain_pwd = random_base64bytes(16)
1056 |       
1057 |       new_user[:password] = hash_password(new_plain_pwd)
1058 |     end
1059 |     
1060 |     if action == 'update'
1061 |       affected = DB[:users].where(:id => params['id'].to_i).update(new_user)
1062 |       
1063 |       failure t(:bad_user_id) unless affected > 0
1064 |     else
1065 |       new_user[:created_on] = Time.now.utc.to_i
1066 |       
1067 |       DB[:users].insert(new_user)
1068 |     end
1069 |     
1070 |     if new_user[:password]
1071 |       success t(:new_passwd) % new_plain_pwd
1072 |     else
1073 |       success t(:done), '/manage/users'
1074 |     end
1075 |   end
1076 | 
1077 |   post '/manage/users/delete' do
1078 |     validate_csrf_token
1079 |     
1080 |     forbidden unless user = get_user_session
1081 |     forbidden unless user_has_level?(user, :admin)
1082 |     
1083 |     conf = params['confirm_keyword'].to_s
1084 |     
1085 |     if conf.empty? || conf != t(:confirm_keyword)
1086 |       failure t(:bad_confirm_keyword)
1087 |     end
1088 |     
1089 |     user = DB[:users].first(:id => params['id'].to_i)
1090 |     
1091 |     failure t(:bad_user_id) unless user
1092 |     
1093 |     DB[:users].where(:id => user[:id]).delete
1094 |     
1095 |     success t(:done), '/manage/users'
1096 |   end
1097 | 
1098 |   get '/manage/profile' do
1099 |     forbidden unless @user = get_user_session
1100 |     forbidden unless user_has_level?(@user, :mod)
1101 |     
1102 |     erb :manage_profile
1103 |   end
1104 |   
1105 |   post '/manage/profile' do
1106 |     validate_csrf_token
1107 |     
1108 |     forbidden unless user = get_user_session
1109 |     forbidden unless user_has_level?(user, :mod)
1110 |     
1111 |     old_pwd = params['old_pwd'].to_s
1112 |     new_pwd = params['new_pwd'].to_s
1113 |     new_pwd_again = params['new_pwd_again'].to_s
1114 |     
1115 |     if old_pwd.empty?
1116 |       forbidden
1117 |     end
1118 |     
1119 |     if new_pwd.length < 8
1120 |       failure t(:passwd_too_short)
1121 |     end
1122 |     
1123 |     if new_pwd != new_pwd_again
1124 |       failure t(:passwd_mismatch)
1125 |     end
1126 |     
1127 |     now = Time.now.utc.to_i
1128 |     
1129 |     if !password_valid?(old_pwd, user[:password])
1130 |       DB[:auth_fails].insert(:ip => request.ip, :created_on => now)
1131 |       forbidden
1132 |     end
1133 |     
1134 |     new_hashed_pwd = hash_password(new_pwd)
1135 |     
1136 |     DB[:users].where(:id => user[:id]).update(:password => new_hashed_pwd)
1137 |     
1138 |     success t(:done), '/manage/profile'
1139 |   end
1140 |   
1141 |   not_found do
1142 |     erb :not_found
1143 |   end
1144 | 
1145 |   error do
1146 |     LOGGER.error(env['sinatra.error'].message)
1147 |     failure 'Internal Server Error', 500
1148 |   end
1149 |   
1150 |   run! if File.expand_path(app_file) == File.expand_path($0)
1151 | end
1152 | 
1153 | end
1154 | 


--------------------------------------------------------------------------------
/i18n/en.rb:
--------------------------------------------------------------------------------
 1 | {
 2 |   # Misc
 3 |   time_format: '%d/%m/%Y %R',
 4 |   denied: 'Denied.',
 5 |   done: 'Done.',
 6 |   no_results: 'Nothing found.',
 7 |   db_error: 'Database error.',
 8 |   server_error: 'Internal Server Error',
 9 |   bad_request: 'Bad request.',
10 |   not_implemented: 'Not implemented.',
11 |   
12 |   # Posting validation
13 |   name_too_long: 'Name is too long.',
14 |   title_too_long: 'Title is too long.',
15 |   comment_too_long: 'Comment is too long.',
16 |   comment_empty: 'Comment cannot be empty.',
17 |   title_empty: 'Title cannot be empty.',
18 |   invalid_chars: 'Your post contains invalid characters.',
19 |   fast_post: 'You have to wait a while before making another post.',
20 |   honeypot: 'Do not fill in this field.',
21 |   thread_full: 'Reply limit reached.',
22 |   thread_locked: 'This thread is locked.',
23 |   cmd_forbidden: 'You cannot use this command.',
24 |   captcha_generic_error: "Couldn't verify the captcha.",
25 |   captcha_invalid_error: 'You seem to have mistyped the captcha.',
26 |   captcha_empty_error: 'You forgot to solve the captcha.',
27 |   
28 |   # File validation
29 |   file_size_too_big: 'File size too big.',
30 |   dimensions_too_large: 'Image dimensions too large.',
31 |   dup_file_thread: 'A thread with the same file already exists.',
32 |   dup_file_reply: 'This file was already posted in this thread.',
33 |   bad_file_format: 'Invalid file format.',
34 |   duration_too_long: 'Duration too long.',
35 |   webm_audio_disabled: 'Audio streams are not allowed.',
36 |   invalid_audio: 'Invalid audio stream.',
37 |   invalid_video: 'Invalid video stream.',
38 |   invalid_stream: 'Invalid stream.',
39 |   no_video_streams: 'File doesn\'t have any video streams.',
40 |   too_many_video: 'Only one video stream is allowed.',
41 |   too_many_audio: 'Only one audio stream is allowed.',
42 |   
43 |   # Path validation
44 |   bad_board: 'Invalid board.',
45 |   bad_thread: 'Invalid thread.',
46 |   bad_post: 'Invalid post.',
47 |   
48 |   # Reporting
49 |   cannot_report: 'You cannot report this post.',
50 |   bad_report_cat: 'Invalid report category.',
51 |   duplicate_report: 'You have already reported this post.',
52 |   fast_report: 'You have to wait a while before reporting another post.',
53 |   
54 |   report_categories: {
55 |     'rule' => 'Rule violation',
56 |     'illegal' => 'Illegal content'
57 |   },
58 |   
59 |   # Admin
60 |   nope: "Can't let you do that.",
61 |   fast_auth: 'You have to wait a while before trying to log in again.',
62 |   confirm_keyword: 'YES',
63 |   bad_confirm_keyword: 'Bad confirmation keyword.',
64 |   bad_slug: 'Invalid slug.',
65 |   bad_title: 'Invalid title.',
66 |   bad_config_json: 'Invalid config.',
67 |   
68 |   passwd_too_short: 'Password is too short (minimum is 8 characters).',
69 |   passwd_mismatch: 'Password doesn\'t match the confirmation.',
70 |   
71 |   invalid_ip: 'Invalid IP address.',
72 |   invalid_ban_id: 'Invalid ban id.',
73 |   cannot_edit_expired_ban: 'You cannot edit expired bans.',
74 |   empty_ban_reason: 'Ban reason cannot be empty.',
75 |   
76 |   not_banned: 'You are not banned.',
77 |   
78 |   bad_user_id: 'Cannot find this user.',
79 |   bad_user_name: 'Invalid user name.',
80 |   bad_user_level: 'Invalid user level.',
81 |   new_passwd: 'Generated password is %s',
82 |   user_levels: {
83 |     admin: 'Administrator',
84 |     mod: 'Moderator'
85 |   },
86 |   
87 |   # Capcodes
88 |   user_trips: {
89 |     'admin' => 'Admin',
90 |     'mod' => 'Mod'
91 |   }
92 | }
93 | 


--------------------------------------------------------------------------------
/log/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 | 


--------------------------------------------------------------------------------
/migrations/1_init.rb:
--------------------------------------------------------------------------------
  1 | opts = {
  2 |   engine: 'InnoDB',
  3 |   charset: 'utf8',
  4 |   collate: 'utf8_general_ci'
  5 | }
  6 | 
  7 | case DB.database_type
  8 | when :mysql
  9 |   collate_bin = 'ascii_bin'
 10 |   type_ip = 'varbinary(16)'
 11 | when :sqlite
 12 |   collate_bin = 'binary'
 13 |   type_ip = 'varbinary(16)'
 14 | when :postgres
 15 |   collate_bin = nil
 16 |   type_ip = 'bytea'
 17 | else
 18 |   collate_bin = nil
 19 |   type_ip = 'varbinary(16)'
 20 | end
 21 | 
 22 | Sequel.migration do
 23 |   up do
 24 |     create_table :boards, opts do
 25 |       primary_key :id
 26 |       
 27 |       column :slug,         'varchar',  :null => false
 28 |       column :title,        'varchar',  :null => false
 29 |       column :created_on,   'int',      :null => false
 30 |       column :thread_count, 'int',      :null => false, :default => 0
 31 |       column :config,       'text'
 32 |       
 33 |       index :slug, :unique => true
 34 |     end
 35 |     
 36 |     create_table :threads, opts do
 37 |       primary_key :id
 38 |       
 39 |       column :board_id,     'int',      :null => false
 40 |       column :num,          'int',      :null => false
 41 |       column :created_on,   'int',      :null => false
 42 |       column :updated_on,   'int',      :null => false
 43 |       column :title,        'varchar',  :null => false
 44 |       column :post_count,   'int',      :null => false, :default => 1
 45 |       
 46 |       index [:board_id, :num], :unique => true
 47 |       index :updated_on
 48 |     end
 49 |     
 50 |     create_table :posts, opts do
 51 |       primary_key :id
 52 |       
 53 |       column :board_id,   'int',      :null => false
 54 |       column :thread_id,  'int',      :null => false
 55 |       column :num,        'int',      :null => false, :default => 1
 56 |       column :created_on, 'int',      :null => false
 57 |       column :author,     'varchar'
 58 |       column :tripcode,   'varchar'
 59 |       column :ip,         'varchar',  :null => false, :collate => collate_bin
 60 |       column :comment,    'text'
 61 |       column :file_hash,  'varchar',  :collate => collate_bin
 62 |       column :meta,       'text'
 63 |       
 64 |       index :board_id
 65 |       index :thread_id
 66 |       index [:thread_id, :num]
 67 |       index :ip
 68 |       index :file_hash
 69 |     end
 70 |     
 71 |     create_table :users, opts do
 72 |       primary_key :id
 73 |       
 74 |       column :username,   'varchar',  :null => false, :collate => collate_bin
 75 |       column :password,   'varchar',  :null => false, :collate => collate_bin
 76 |       column :level,      'smallint', :null => false, :default => 0
 77 |       column :created_on, 'int',      :null => false
 78 |       
 79 |       index :username, :unique => true
 80 |     end
 81 |     
 82 |     create_table :sessions, opts do
 83 |       primary_key :id
 84 |       
 85 |       column :sid,        'varchar',  :null => false, :collate => collate_bin
 86 |       column :user_id,    'int',      :null => false
 87 |       column :ip,         'varchar',  :null => false, :collate => collate_bin
 88 |       column :created_on, 'int',      :null => false
 89 |       column :updated_on, 'int',      :null => false
 90 |       
 91 |       index :sid, :unique => true
 92 |     end
 93 |     
 94 |     create_table :auth_fails, opts do
 95 |       primary_key :id
 96 |       
 97 |       column :ip,         'varchar',  :null => false, :collate => collate_bin
 98 |       column :created_on, 'int',      :null => false
 99 |       
100 |       index :ip
101 |       index :created_on
102 |     end
103 |     
104 |     create_table :bans, opts do
105 |       primary_key :id
106 |       
107 |       column :ip,         type_ip,    :null => false
108 |       column :active,     'boolean',  :null => false, :default => true
109 |       column :created_on, 'int',      :null => false
110 |       column :expires_on, 'int'
111 |       column :duration,   'int'
112 |       column :reason,     'text',     :null => false
113 |       column :info,       'text'
114 |       column :post,       'text'
115 |       column :created_by, 'int',      :null => false
116 |       column :updated_by, 'int'
117 |       
118 |       index [:ip, :active]
119 |       index :expires_on
120 |       index :created_by
121 |     end
122 |   end
123 | end
124 | 


--------------------------------------------------------------------------------
/migrations/2_create_reports.rb:
--------------------------------------------------------------------------------
 1 | opts = {
 2 |   engine: 'InnoDB',
 3 |   charset: 'utf8',
 4 |   collate: 'utf8_general_ci'
 5 | }
 6 | 
 7 | case DB.database_type
 8 | when :mysql
 9 |   collate_bin = 'ascii_bin'
10 | when :sqlite
11 |   collate_bin = 'binary'
12 | when :postgres
13 |   collate_bin = nil
14 | else
15 |   collate_bin = nil
16 | end
17 | 
18 | Sequel.migration do
19 |   up do
20 |     create_table :reports, opts do
21 |       primary_key :id
22 |       
23 |       column :board_id,     'int',      :null => false
24 |       column :thread_id,    'int',      :null => false
25 |       column :post_id,      'int',      :null => false
26 |       column :created_on,   'int',      :null => false
27 |       column :ip,           'varchar',  :null => false, :collate => collate_bin
28 |       column :score,        'int',      :null => false
29 |       column :category,     'varchar',  :null => false, :collate => collate_bin
30 |       
31 |       index :board_id
32 |       index :thread_id
33 |       index :post_id
34 |       index [:ip, :post_id]
35 |     end
36 |   end
37 |   
38 |   down do
39 |     drop_table(:reports)
40 |   end
41 | end
42 | 


--------------------------------------------------------------------------------
/migrations/3_locked_pinned.rb:
--------------------------------------------------------------------------------
 1 | Sequel.migration do
 2 |   up do
 3 |     add_column :threads, :locked, 'smallint', :null => false, :default => 0
 4 |     add_column :threads, :pinned, 'smallint', :null => false, :default => 0
 5 |   end
 6 |   
 7 |   down do
 8 |     drop_column :threads, :locked
 9 |     drop_column :threads, :pinned
10 |   end
11 | end
12 | 


--------------------------------------------------------------------------------
/migrations/4_unique_thread_num.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 |   up do
3 |     alter_table(:posts) do
4 |       drop_index [:thread_id, :num]
5 |       add_index [:thread_id, :num], :unique => true
6 |     end
7 |   end
8 | end
9 | 


--------------------------------------------------------------------------------
/public/css/hive.css:
--------------------------------------------------------------------------------
  1 | /*! YUI Reset */
  2 | html{color:#000;background:#FFF}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td{margin:0;padding:0}table{border-collapse:collapse;border-spacing:0}fieldset,img{border:0}address,caption,cite,dfn,th,var{font-style:normal;font-weight:normal}ol,ul{list-style:none}caption,th{text-align:left}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal}abbr,acronym{border:0;font-variant:normal}sup{vertical-align:text-top}sub{vertical-align:text-bottom}input,textarea,select{font-family:inherit;font-size:inherit;font-weight:inherit}input,textarea,legend{color:#000}
  3 | 
  4 | body, html {
  5 |   background: #FFFAFA;
  6 | }
  7 | 
  8 | html {
  9 |   position: relative;
 10 |   min-height: 100%;
 11 | }
 12 | 
 13 | body {
 14 |   font: 16px
 15 |     Sylfaen,
 16 |     'Palatino Linotype',
 17 |     Palatino,
 18 |     'Century Schoolbook L',
 19 |     'Times New Roman',
 20 |     serif;
 21 |   color: #232c33;
 22 |   margin-bottom: 60px;
 23 | }
 24 | 
 25 | pre {
 26 |   font: 12px
 27 |     'DejaVu Sans Mono',
 28 |     'Consolas',
 29 |     'Andale Mono',
 30 |     'Lucida Console',
 31 |     monospace;
 32 |   padding: 10px;
 33 |   margin: 10px 0;
 34 |   border: 1px solid #eee;
 35 |   background-color: #fff;
 36 | }
 37 | 
 38 | a,
 39 | .link-span {
 40 |   cursor: pointer;
 41 |   color: #007FFF;
 42 |   text-decoration: none;
 43 | }
 44 | 
 45 | a:hover,
 46 | .link-span:hover {
 47 |   color: #C41E3A;
 48 |   text-decoration: underline;
 49 | }
 50 | 
 51 | a:focus,
 52 | .link-span:focus {
 53 |   color: #C41E3A;
 54 |   outline: none;
 55 | }
 56 | 
 57 | .q { color: #708090 }
 58 | 
 59 | h2, h3, h4, h5, h6 {
 60 |   font-weight: bold;
 61 |   margin: 0.6em 0 0.6em;
 62 | }
 63 | 
 64 | h1 {
 65 |   font-size: 32px;
 66 |   font-weight: bold;
 67 |   word-wrap: break-word;
 68 | }
 69 | 
 70 | h2 { font-size: 25px; }
 71 | h3 { font-size: 18px; }
 72 | 
 73 | form, p, ul, ol, table { margin-bottom: 1em; }
 74 | 
 75 | .disabled { opacity: 0.5; }
 76 | .hidden { display: none !important; }
 77 | .invisible { visibility: hidden; }
 78 | .tainted:after { content: '*'; }
 79 | 
 80 | .aa {
 81 |   white-space: pre;
 82 |   font: 16px/17px 'IPAMonaPGothic', 'Mona', 'MS PGothic', monospace;
 83 |   overflow: auto;
 84 |   padding: 0;
 85 |   margin: 0;
 86 |   border: 0;
 87 |   background-color: transparent;
 88 | }
 89 | 
 90 | .table  {
 91 |   border-left: 1px solid rgba(0, 0, 0, 0.2);
 92 |   border-top: 1px solid rgba(0, 0, 0, 0.2);
 93 |   font-size: 14px;
 94 | }
 95 | 
 96 | .table th,
 97 | .table td {
 98 |   padding: 4px 6px;
 99 |   text-align: center;
100 |   border-right: 1px solid rgba(0, 0, 0, 0.2);
101 |   border-bottom: 1px solid rgba(0, 0, 0, 0.2);
102 |   height: 1em;
103 |   overflow: hidden;
104 | }
105 | 
106 | .table th {
107 |   font-weight: bold;
108 | }
109 | 
110 | .pagination li {
111 |   display: inline;
112 |   font-size: 14px;
113 | }
114 | 
115 | .pagination li a,
116 | .pagination li span {
117 |   display: inline-block;
118 |   padding: 1px 6px;
119 | }
120 | 
121 | .pagination .pgn-ctrl {
122 |   font-size: 1.25em;
123 | }
124 | 
125 | blockquote {
126 |   padding-left: 10px;
127 |   border-left: 3px solid #DDD;
128 |   color: #777;
129 | }
130 | 
131 | td, th {
132 |   padding: 2px 5px 2px 0;
133 | }
134 | 
135 | input {
136 |   margin-right: 5px;
137 |   vertical-align: middle;
138 | }
139 | 
140 | .error {
141 |   text-align: center;
142 |   color: #C41E3A;
143 | }
144 | 
145 | .success {
146 |   text-align: center;
147 |   color: #00A550;
148 | }
149 | 
150 | .error,
151 | .success {
152 |   margin-top: 40px;
153 | }
154 | 
155 | .s {
156 |   color: transparent;
157 |   text-decoration: none;
158 |   background-color: #708090;
159 | }
160 | 
161 | .s:hover {
162 |   background-color: inherit;
163 |   color: inherit;
164 | }
165 | 
166 | .s a { visibility: hidden; }
167 | .s:hover a { visibility: visible; }
168 | 
169 | #app-title {
170 |   margin-top: 20px;
171 | }
172 | 
173 | .post {
174 |   margin-bottom: 30px;
175 |   overflow: hidden;
176 | }
177 | 
178 | .post-num {
179 |   font-weight: bold;
180 |   color: #232c33;
181 | }
182 | 
183 | .post-num,
184 | .post-date {
185 |   white-space: nowrap;
186 | }
187 | 
188 | .post-author,
189 | .post-date {
190 |   margin-left: 0.25em;
191 |   vertical-align: bottom;
192 |   display: inline-block;
193 | }
194 | 
195 | .post-author {
196 |   color: #00A550;
197 |   font-weight: bold;
198 |   word-wrap: break-word;
199 | }
200 | 
201 | .post-tripcode {
202 |   color: #74C365;
203 |   font-style: italic;
204 | }
205 | 
206 | .post-comment {
207 |   display: inline-block;
208 |   margin-left: 15px;
209 |   margin-top: 10px;
210 |   max-width: 100%;
211 |   word-wrap: break-word;
212 | }
213 | 
214 | .post-menu-btn {
215 |   -moz-user-select: none;
216 |   -webkit-user-select: none;
217 |   -ms-user-select: none;
218 |   border: 0;
219 |   margin: 0;
220 |   padding: 0;
221 |   background: none;
222 |   cursor: pointer;
223 |   color: #708090;
224 |   text-decoration: inherit;
225 |   outline: none;
226 |   font-size: 16px;
227 |   width: 16px;
228 |   height: 16px;
229 |   line-height: 1;
230 |   display: inline-block;
231 |   margin-left: 0.5em;
232 |   text-align: center;
233 | }
234 | 
235 | .post-menu-btn:before {
236 |   content: '…';
237 |   position: relative;
238 |   bottom: 4px;
239 | }
240 | 
241 | .post-menu-btn::-moz-focus-inner { 
242 |   border: 0;
243 | }
244 | 
245 | .no-post-menu .post-menu-btn {
246 |   display: none;
247 | }
248 | 
249 | #post-menu {
250 |   position: absolute;
251 |   text-align: center;
252 |   font-size: 12px;
253 |   padding: 4px;
254 |   background-color: inherit;
255 |   -moz-box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
256 |   box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
257 | }
258 | 
259 | #post-menu li {
260 |   line-height: 18px;
261 | }
262 | 
263 | .post-comment p { margin-bottom: 15px; }
264 | .post-comment p:last-child { margin-bottom: 0 }
265 | 
266 | .post-file {
267 |   font-size: 14px;
268 |   margin-left: 15px;
269 |   margin-top: 10px;
270 | }
271 | 
272 | .post-file-meta {
273 |   margin-bottom: 5px;
274 | }
275 | 
276 | .post-file-thumb {
277 |   display: inline-block;
278 |   line-height: 0;
279 | }
280 | 
281 | .post-file-thumb img {
282 |   -moz-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
283 |   box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
284 | }
285 | 
286 | .post-file,
287 | .post-date {
288 |   color: #708090;
289 | }
290 | 
291 | .file-spoiler {
292 |   background-color: #708090;
293 |   transition: background-color .5s .25s;
294 | }
295 | 
296 | .file-spoiler:hover {
297 |   background-color: transparent;
298 | }
299 | 
300 | .file-spoiler img {
301 |   opacity: 0;
302 |   transition: opacity .5s .25s;
303 | }
304 | 
305 | .file-spoiler:hover img {
306 |   opacity: 1;
307 | }
308 | 
309 | .manage-ctrl {
310 |   font-size: 12px;
311 | }
312 | 
313 | .manage-ctrl a {
314 |   margin-left: 8px;
315 | }
316 | 
317 | #threads {
318 |   color: #708090;
319 |   width: 100%;
320 | }
321 | 
322 | .section {
323 |   margin-top: 20px;
324 | }
325 | 
326 | .list li {
327 |   overflow: hidden;
328 |   white-space: nowrap;
329 |   text-overflow: ellipsis;
330 |   padding-bottom: 5px;
331 |   line-height: 1.3em;
332 | }
333 | 
334 | header {
335 |   margin: 10px 40px 0 40px;
336 | }
337 | 
338 | #content {
339 |   margin: 0 40px;
340 | }
341 | 
342 | .rc {
343 |   color: #232c33;
344 |   width: 10px;
345 |   display: inline-block;
346 |   text-align: center;
347 | }
348 | 
349 | .tce { color: #B3B3B3; }
350 | 
351 | footer {
352 |   text-align: center;
353 |   font-size: 12px;
354 |   line-height: 1.75em;
355 |   position: absolute;
356 |   left: 0;
357 |   bottom: 0;
358 |   height: 30px;
359 |   width: 100%;
360 | }
361 | 
362 | #post-form table {
363 |   width: 380px;
364 | }
365 | 
366 | #post-form th {
367 |   width: 40px;
368 | }
369 | 
370 | #post-form tfoot { text-align: right; }
371 | 
372 | .captcha-loading {
373 |   height: 74px;
374 |   width: 300px;
375 | }
376 | 
377 | #captcha-toggle { margin-right: 10px }
378 | 
379 | #post-form .g-recaptcha > div{
380 |   float: right;
381 |   margin: 5px 0;
382 | }
383 | 
384 | #captcha-fallback {
385 |   float: right;
386 |   margin: 5px 0;
387 | }
388 | 
389 | #captcha-fallback,
390 | #captcha-fallback #cfb-wrap,
391 | #captcha-fallback #cfb-icnt,
392 | #captcha-fallback iframe {
393 |   width: 302px;
394 |   height: 352px;
395 | }
396 | 
397 | #captcha-fallback #cfb-wrap { position: relative }
398 | #captcha-fallback #cfb-icnt { position: absolute }
399 | #captcha-fallback iframe { border-style: none }
400 | #captcha-fallback #g-recaptcha-response {
401 |   width: 250px;
402 |   height: 80px;
403 |   border: 1px solid #c1c1c1;
404 |   margin: 0px;
405 |   padding: 0px;
406 |   resize: none;
407 | }
408 | 
409 | #captcha-fallback #cfb-tcnt {
410 |   width: 250px;
411 |   height: 80px;
412 |   position: absolute;
413 |   border-style: none;
414 |   bottom: 21px;
415 |   left: 25px;
416 |   margin: 0px;
417 |   padding: 0px;
418 |   right: 25px;
419 | }
420 | 
421 | #email-field { display: none; }
422 | 
423 | #author-field,
424 | #file-field,
425 | #title-field { width: 100%; }
426 | 
427 | #comment-field {
428 |   display: block;
429 |   margin-top: 5px;
430 |   min-width: 100%;
431 |   width: 100%;
432 |   height: 120px;
433 |   min-height: 120px;
434 | }
435 | 
436 | #comment-preview {
437 |   overflow: auto;
438 |   margin-top: 5px;
439 |   box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);
440 | }
441 | 
442 | #tegaki-btn,
443 | #comment-preview-btn {
444 |   font-size: 12px;
445 |   margin: 0 5px;
446 | }
447 | 
448 | .mini-type {
449 |   font-size: 12px;
450 | }
451 | 
452 | #file-field {
453 |   font-size: 14px;
454 |   max-width: 210px;
455 | }
456 | 
457 | #sage-label,
458 | #spoiler-label {
459 |   font-size: 14px;
460 |   float: right;
461 | }
462 | 
463 | #nav {
464 |   list-style: none;
465 |   text-indent: 0;
466 |   padding: 0;
467 |   margin: 10px 0;
468 | }
469 | 
470 | #nav li {
471 |   margin: 0 3px;
472 |   display: inline;
473 | }
474 | 
475 | #confirm-destroy {
476 |   margin-top: 50px;
477 | }
478 | 
479 | #confirm-destroy input {
480 |   width: 150px;
481 | }
482 | 
483 | #confirm-destroy h3 {
484 |   color: #C41E3A;
485 | }
486 | 
487 | #confirm-destroy label {
488 |   display: block;
489 |   margin-bottom: 5px;
490 | }
491 | 
492 | .form {
493 |   width: 280px;
494 | }
495 | 
496 | .form label {
497 |   display: block;
498 |   margin: 10px 0 2px 0;
499 | }
500 | 
501 | .form button {
502 |   margin-top: 20px;
503 |   display: block;
504 | }
505 | 
506 | .form input[type=text],
507 | .form select,
508 | .form textarea {
509 |   min-width: 100%;
510 | }
511 | 
512 | input[type="checkbox"] {
513 |   margin-bottom: 3px;
514 | }
515 | 
516 | .form .protip {
517 |   margin-top: 5px;
518 |   font-size: 90%;
519 | }
520 | 
521 | .link-form {
522 |   display: inline;
523 | }
524 | 
525 | .link-form button {
526 |   border: 0;
527 |   margin: 0;
528 |   padding: 0;
529 |   line-height: 1;
530 |   background: none;
531 |   font: inherit;
532 |   cursor: pointer;
533 |   color: inherit;
534 |   text-decoration: inherit;
535 |   display: inline;
536 |   outline: none;
537 | }
538 | 
539 | .link-form button::-moz-focus-inner {
540 |   border: none;
541 |   padding: 0;
542 | }
543 | 
544 | #backdrop {
545 |   position: fixed;
546 |   width: 100%;
547 |   height: 100%;
548 |   top: 0;
549 |   left: 0;
550 |   background-color: rgba(128, 128, 128, 0.5);
551 |   text-align: center;
552 | }
553 | 
554 | #backdrop:before {
555 |   content: ' ';
556 |   display: inline-block;
557 |   vertical-align: middle;
558 |   height: 100%;
559 | }
560 | 
561 | #expanded-file {
562 |   vertical-align: middle;
563 | }
564 | 
565 | .fit-to-screen {
566 |   max-width: 100%;
567 |   max-height: 100%;
568 | }
569 | 
570 | .has-backdrop {
571 |   overflow: hidden;
572 | }
573 | 
574 | #dummy-fcon {
575 |   height: 50%;
576 |   left: 0;
577 |   position: absolute;
578 |   top: 0;
579 |   width: 100%;
580 | }
581 | 
582 | .quote-preview {
583 |   position: absolute;
584 |   background-color: inherit;
585 |   -moz-box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
586 |   box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
587 |   padding: 0 5px;
588 |   overflow: hidden;
589 | }
590 | 
591 | .quote-preview .post {
592 |   margin-bottom: 15px;
593 | }
594 | 
595 | .ban-head {
596 |   color: #708090;
597 |   margin-top: 20px;
598 | }
599 | 
600 | .banned-status {
601 |   padding-bottom: 25px;
602 |   border-bottom: 3px solid rgba(0, 0, 0, 0.2);
603 | }
604 | 
605 | .banned-status:last-child {
606 |   border: none;
607 | }
608 | 
609 | .ban-reason {
610 |   white-space: pre;
611 | }
612 | 
613 | #ban-list {
614 |   width: 100%;
615 | }
616 | 
617 | .table .col-xs { width: 50px; }
618 | .table .col-s { width: 80px; }
619 | .table .col-m { width: 150px ;}
620 | .table .col-txt { text-align: left; }
621 | 
622 | .note {
623 |   color: #708090;
624 |   font-size: 0.8em;
625 | }
626 | 
627 | #post-report-form #captcha-cnt {
628 |   margin-top: 15px;
629 | }
630 | 
631 | .post-board {
632 |   font-weight: bold;
633 | }
634 | 
635 | .post-report {
636 |   background-color: #fff;
637 |   margin-bottom: 18px;
638 |   box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
639 |   overflow: hidden;
640 |   vertical-align: top;
641 | }
642 | 
643 | .post-report .rc {
644 |   margin-left: 0.25em;
645 | }
646 | 
647 | .post-report .rc:before {
648 |   content: '»';
649 | }
650 | 
651 | .post-report .pc {
652 |   color: #708090;
653 | }
654 | 
655 | .post-report-meta {
656 |   margin-bottom: 4px;
657 | }
658 | 
659 | .post-title {
660 |   display: inline-block;
661 | }
662 | 
663 | .is-reply .rc:before {
664 |   content: '↪';
665 |   font-size: 11px;
666 | }
667 | 
668 | .post-report .post-head {
669 |   padding: 4px 6px 0 6px;
670 | }
671 | 
672 | .post-report .post-comment {
673 |   overflow: auto;
674 |   padding-right: 8px;
675 | }
676 | 
677 | .post-report-score {
678 |   font-size: 12px;
679 |   float: right;
680 |   color: #708090;
681 |   cursor: default;
682 | }
683 | 
684 | .post-report-ctrl {
685 |   width: 100%;
686 |   box-shadow: inset 0 1px 1px -1px rgba(0, 0, 0, 0.3);
687 | }
688 | 
689 | .post-report-ctrl a,
690 | .post-report-ctrl .link-span {
691 |   font-size: 10px;
692 |   display: inline-block;
693 |   margin: 0 6px 6px 6px;
694 |   text-transform: uppercase;
695 | }
696 | 
697 | #tooltip,
698 | .post-menu-btn,
699 | #post-menu,
700 | .post-report-score,
701 | .post-report-ctrl a,
702 | .post-report-ctrl .link-span {
703 |   font-family: 'Segoe UI', 'Helvetica Neue', Optima, Calibri, Arial, sans-serif;
704 | }
705 | 
706 | #tooltip {
707 |   position: absolute;
708 |   background-color: #181f24;
709 |   border-radius: 3px;
710 |   font-size: 12px;
711 |   padding: 5px 8px 4px 8px;
712 |   z-index: 100000;
713 |   word-wrap: break-word;
714 |   white-space: pre-line;
715 |   max-width: 400px;
716 |   color: #dedede;
717 |   text-align: center;
718 | }
719 | 
720 | @media screen and (max-width: 480px) {
721 |   #post-form table {
722 |     width: 100%;
723 |   }
724 |   
725 |   #content {
726 |     margin: 0 10px;
727 |   }
728 | }
729 | 


--------------------------------------------------------------------------------
/public/js/core.js:
--------------------------------------------------------------------------------
  1 | 'use strict';
  2 | 
  3 | var $ = {
  4 |   docEl: document.documentElement,
  5 |   
  6 |   body: document.body,
  7 |   
  8 |   id: function(id) {
  9 |     return document.getElementById(id);
 10 |   },
 11 |   
 12 |   cls: function(klass, root) {
 13 |     return (root || document).getElementsByClassName(klass);
 14 |   },
 15 |   
 16 |   tag:  function(tag, root) {
 17 |     return (root || document).getElementsByTagName(tag);
 18 |   },
 19 |   
 20 |   qs: function(selector, root) {
 21 |     return (root || document).querySelector(selector);
 22 |   },
 23 | 
 24 |   qsa: function(selector, root) {
 25 |     return (root || document).querySelectorAll(selector);
 26 |   },
 27 |   
 28 |   on: function(o, e, h) {
 29 |     o.addEventListener(e, h, false);
 30 |   },
 31 |   
 32 |   off: function(o, e, h) {
 33 |     o.removeEventListener(e, h, false);
 34 |   },
 35 |   
 36 |   xhr: function(method, url, attrs, data, headers) {
 37 |     var h, key, xhr, form;
 38 |     
 39 |     xhr = new XMLHttpRequest();
 40 |     
 41 |     xhr.open(method, url, true);
 42 |     
 43 |     if (attrs) {
 44 |       for (key in attrs) {
 45 |         xhr[key] = attrs[key];
 46 |       }
 47 |     }
 48 |     
 49 |     if (headers) {
 50 |       for (h in headers) {
 51 |         xhr.setRequestHeader(h, headers[h]);
 52 |       }
 53 |     }
 54 |     
 55 |     if (data) {
 56 |       if (typeof data === 'string') {
 57 |         xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
 58 |       }
 59 |       else {
 60 |         form = new FormData();
 61 |         for (key in data) {
 62 |           form.append(key, data[key]);
 63 |         }
 64 |         data = form;
 65 |       }
 66 |     }
 67 |     else {
 68 |       data = null;
 69 |     }
 70 |     
 71 |     xhr.send(data);
 72 |     
 73 |     return xhr;
 74 |   },
 75 |   
 76 |   postFromNode: function(el) {
 77 |     var root = $.docEl;
 78 |     
 79 |     while (el !== root) {
 80 |      if (el.classList.contains('post') || el.classList.contains('post-report')) {
 81 |         break;
 82 |       }
 83 |       
 84 |       el = el.parentNode;
 85 |     }
 86 |     
 87 |     if (el.id) {
 88 |       return el;
 89 |     }
 90 |     
 91 |     return null;
 92 |   },
 93 |   
 94 |   el: function(name) {
 95 |     return document.createElement(name);
 96 |   },
 97 |   
 98 |   frag: function() {
 99 |     return document.createDocumentFragment();
100 |   },
101 |   
102 |   getItem: function(key) {
103 |     return localStorage.getItem(key);
104 |   },
105 |   
106 |   setItem: function(key, value) {
107 |     return localStorage.setItem(key, value);
108 |   },
109 |   
110 |   removeItem: function(key) {
111 |     return localStorage.removeItem(key);
112 |   },
113 |   
114 |   getCookie: function(name) {
115 |     var i, c, ca, key;
116 |     
117 |     key = name + '=';
118 |     ca = document.cookie.split(';');
119 |     
120 |     for (i = 0; c = ca[i]; ++i) {
121 |       while (c.charAt(0) == ' ') {
122 |         c = c.substring(1, c.length);
123 |       }
124 |       if (c.indexOf(key) === 0) {
125 |         return decodeURIComponent(c.substring(key.length, c.length));
126 |       }
127 |     }
128 |     
129 |     return null;
130 |   },
131 |   
132 |   setCookie: function(name, value, days, path, domain) {
133 |     var date, vars = [];
134 |     
135 |     vars.push(name + '=' + value);
136 |     
137 |     if (days) {
138 |       date = new Date();
139 |       date = date.setTime(date.getTime() + (days * 86400000)).toGMTString();
140 |       vars.push('expire=' + date);
141 |     }
142 |     
143 |     if (path) {
144 |       vars.push('path=' + domain);
145 |     }
146 |     
147 |     if (domain) {
148 |       vars.push('domain=' + domain);
149 |     }
150 |     
151 |     document.cookie = vars.join('; ');
152 |   },
153 |   
154 |   removeCookie: function(name, path, domain) {
155 |     var vars = [];
156 |     
157 |     vars.push(name + '=');
158 |     
159 |     vars.push('expires=Thu, 01 Jan 1970 00:00:01 GMT');
160 |     
161 |     if (path) {
162 |       vars.push('path=' + domain);
163 |     }
164 |     
165 |     if (domain) {
166 |       vars.push('domain=' + domain);
167 |     }
168 |     
169 |     document.cookie = vars.join('; ');
170 |   }
171 | };
172 | 
173 | var ClickHandler = {
174 |   commands: {},
175 |   
176 |   init: function() {
177 |     $.on(document, 'click', this.onClick);
178 |   },
179 |   
180 |   onClick: function(e) {
181 |     var t, cmd, cb;
182 |     
183 |     t = e.target;
184 |     
185 |     if (t === document || !t.hasAttribute('data-cmd')) {
186 |       return;
187 |     }
188 |     
189 |     cmd = t.getAttribute('data-cmd');
190 |     
191 |     if (cmd && e.which === 1 && (cb = ClickHandler.commands[cmd])) {
192 |       if (cb(t, e) !== false) {
193 |         e.preventDefault();
194 |       }
195 |     }
196 |   }
197 | };
198 | 
199 | var Tip = {
200 |   node: null,
201 |   
202 |   timeout: null,
203 |   
204 |   cbRoot: window,
205 |   
206 |   delay: 150,
207 |   
208 |   init: function() {
209 |     document.addEventListener('mouseover', this.onMouseOver, false);
210 |     document.addEventListener('mouseout', this.onMouseOut, false);
211 |   },
212 |   
213 |   onMouseOver: function(e) {
214 |     var cb, data, t;
215 |     
216 |     t = e.target;
217 |     
218 |     if (Tip.timeout) {
219 |       clearTimeout(Tip.timeout);
220 |       Tip.timeout = null;
221 |     }
222 |     
223 |     if (t.hasAttribute('data-tip')) {
224 |       data = t.getAttribute('data-tip');
225 |       
226 |       if (data[0] === '.') {
227 |         cb = data.slice(1);
228 |         
229 |         if (!Tip.cbRoot[cb]) {
230 |           return;
231 |         }
232 |         
233 |         data = Tip.cbRoot[cb](t);
234 |         
235 |         if (data === false) {
236 |           return;
237 |         }
238 |       }
239 | 
240 |       Tip.timeout = setTimeout(Tip.show, Tip.delay, t, data);
241 |     }
242 |   },
243 |   
244 |   onMouseOut: function(e) {
245 |     if (Tip.timeout) {
246 |       clearTimeout(Tip.timeout);
247 |       Tip.timeout = null;
248 |     }
249 |     
250 |     Tip.hide();
251 |   },
252 |   
253 |   show: function(t, data) {
254 |     var el, anchor, style, left, top, margin;
255 |     
256 |     margin = 4;
257 |     
258 |     el = document.createElement('div');
259 |     el.id = 'tooltip';
260 |     el.innerHTML = data;
261 |     document.body.appendChild(el);
262 |     
263 |     anchor = t.getBoundingClientRect();
264 |     
265 |     top = anchor.top - el.offsetHeight - margin;
266 |     
267 |     if (top < 0) {
268 |       top = anchor.top + anchor.height + margin;
269 |     }
270 |     
271 |     left = anchor.left - (el.offsetWidth - anchor.width) / 2;
272 |     
273 |     if (left < 0) {
274 |       left = margin;
275 |     }
276 |     else if (left + el.offsetWidth > $.docEl.clientWidth) {
277 |       left = $.docEl - el.offsetWidth - margin;
278 |     }
279 |     
280 |     style = el.style;
281 |     style.display = 'none';
282 |     style.top = (top + window.pageYOffset) + 'px';
283 |     style.left = (left + window.pageXOffset) + 'px';
284 |     style.display = '';
285 |     
286 |     Tip.node = el;
287 |   },
288 |   
289 |   hide: function() {
290 |     if (Tip.node) {
291 |       document.body.removeChild(Tip.node);
292 |       Tip.node = null;
293 |     }
294 |   }
295 | };
296 | 
297 | Tip.init();
298 | 


--------------------------------------------------------------------------------
/public/js/hive.js:
--------------------------------------------------------------------------------
  1 | 'use strict';
  2 | 
  3 | var QuotePreviews = {
  4 |   frozen: false,
  5 |   
  6 |   hasPreviews: false,
  7 |   
  8 |   timeout: null,
  9 |   
 10 |   delay: 75,
 11 |   
 12 |   init: function() {
 13 |     $.on(document, 'mouseover', QuotePreviews.onMouseOver);
 14 |   },
 15 |   
 16 |   onMouseOver: function(e) {
 17 |     var t = e.target;
 18 |     
 19 |     if (QuotePreviews.frozen) {
 20 |       if (t.classList.contains('quote-preview') || t.classList.contains('ql')) {
 21 |         QuotePreviews.frozen = false;
 22 |       }
 23 |       else {
 24 |         return;
 25 |       }
 26 |     }
 27 |     
 28 |     if ($.body.classList.contains('has-backdrop') || PostMenu.node) {
 29 |       QuotePreviews.frozen = true;
 30 |       return;
 31 |     }
 32 |     
 33 |     if (QuotePreviews.timeout) {
 34 |       clearTimeout(QuotePreviews.timeout);
 35 |       QuotePreviews.timeout = null;
 36 |     }
 37 |     
 38 |     if (t.classList.contains('ql')) {
 39 |       QuotePreviews.show(t);
 40 |     }
 41 |     else if (QuotePreviews.hasPreviews) {
 42 |       QuotePreviews.timeout =
 43 |         setTimeout(QuotePreviews.detach, QuotePreviews.delay, t);
 44 |     }
 45 |   },
 46 |   
 47 |   show: function(t) {
 48 |     var postId, postEl, el, cnt, aabb, s, left;
 49 |     
 50 |     postId = t.href.split('#').pop();
 51 |     postEl = $.id(postId);
 52 |     
 53 |     QuotePreviews.detach(t);
 54 |     
 55 |     if (!postEl || $.id('preview-' + postEl.id)) {
 56 |       return;
 57 |     }
 58 |     
 59 |     el = postEl.cloneNode(true);
 60 |     el.id = 'preview-' + el.id;
 61 |     
 62 |     cnt = $.el('div');
 63 |     cnt.className = 'quote-preview';
 64 |     cnt.appendChild(el);
 65 |     
 66 |     s = cnt.style;
 67 |     
 68 |     aabb = t.getBoundingClientRect();
 69 |     
 70 |     $.body.appendChild(cnt);
 71 |     
 72 |     if (aabb.right / $.docEl.clientWidth > 0.7) {
 73 |       s.maxWidth = (aabb.left - 20) + 'px';
 74 |       s.left = (aabb.left - cnt.offsetWidth - 5) + 'px';
 75 |     }
 76 |     else {
 77 |       s.maxWidth = ($.docEl.clientWidth - aabb.right - 20) + 'px';
 78 |       s.left = (aabb.right + window.pageXOffset + 5) + 'px';
 79 |     }
 80 |     
 81 |     s.top = (aabb.top + window.pageYOffset + 5) + 'px';
 82 |     
 83 |     QuotePreviews.hasPreviews = true;
 84 |   },
 85 |   
 86 |   detach: function(t) {
 87 |     var i, el, nodes, root;
 88 |     
 89 |     root = $.docEl;
 90 |     
 91 |     while (t !== root) {
 92 |      if (t.classList.contains('quote-preview')) {
 93 |         break;
 94 |       }
 95 |       
 96 |       t = t.parentNode;
 97 |     }
 98 |     
 99 |     nodes = $.cls('quote-preview');
100 |     
101 |     for (i = nodes.length - 1; i > -1; i--) {
102 |       el = nodes[i];
103 |       
104 |       if (el === t) {
105 |         break;
106 |       }
107 |       
108 |       $.body.removeChild(el);
109 |     }
110 |     
111 |     QuotePreviews.hasPreviews = !!nodes[0];
112 |   }
113 | };
114 | 
115 | var PostMenu = {
116 |   btn: null,
117 |   node: null,
118 |   items: [],
119 |   
120 |   init: function(items) {
121 |     if (this.items.length) {
122 |       ClickHandler.commands['p-m'] = this.onClick;
123 |     }
124 |     else {
125 |       $.body.classList.add('no-post-menu');
126 |     }
127 |   },
128 |   
129 |   onClick: function(btn) {
130 |     var i, item, items, len, cnt, el, baseEl;
131 |     
132 |     if (PostMenu.node) {
133 |       PostMenu.close();
134 |       
135 |       if (PostMenu.btn === btn) {
136 |         return;
137 |       }
138 |     }
139 |     
140 |     items = PostMenu.items;
141 |     
142 |     cnt = $.el('ul');
143 |     cnt.id = 'post-menu';
144 |     cnt.setAttribute('data-pid', $.postFromNode(btn).id.split('-').pop());
145 |     
146 |     for (i = 0, len = items.length; i < len; ++i) {
147 |       item = items[i];
148 |       
149 |       if (typeof item === 'function') {
150 |         item = item(btn);
151 |         
152 |         if (item === false) {
153 |           continue;
154 |         }
155 |       }
156 |       
157 |       cnt.appendChild(item);
158 |     }
159 |     
160 |     PostMenu.node = cnt;
161 |     PostMenu.btn = btn;
162 |     
163 |     $.on(document, 'click', PostMenu.close);
164 |     
165 |     $.body.appendChild(cnt);
166 |     
167 |     PostMenu.adjustPos(btn, cnt);
168 |   },
169 |   
170 |   adjustPos: function(btn, el) {
171 |     var anchor, top, left, margin, style;
172 |     
173 |     margin = 4;
174 |     
175 |     anchor = btn.getBoundingClientRect();
176 |     
177 |     top = anchor.top + btn.offsetHeight + margin;
178 |     left = anchor.left - el.offsetWidth / 2 + btn.offsetWidth / 2;
179 |     
180 |     if (left + el.offsetWidth > $.docEl.clientWidth) {
181 |       left = $.docEl.clientWidth - el.offsetWidth - margin;
182 |     }
183 |     
184 |     style = el.style;
185 |     style.display = 'none';
186 |     style.top = (top + window.pageYOffset) + 'px';
187 |     style.left = (left + window.pageXOffset) + 'px';
188 |     style.display = '';
189 |   },
190 |   
191 |   close: function() {
192 |     $.off(document, 'click', PostMenu.close);
193 |     
194 |     if (PostMenu.node) {
195 |       PostMenu.node.parentNode.removeChild(PostMenu.node);
196 |       PostMenu.node = null;
197 |     }
198 |   }
199 | };
200 | 
201 | var Hive = {
202 |   xhr: {},
203 |   
204 |   init: function() {
205 |     $.on(document, 'DOMContentLoaded', Hive.run);
206 |     
207 |     ClickHandler.commands = {
208 |       'q': Hive.onPostNumClick,
209 |       'fexp': Hive.onFileClick,
210 |       'fcon': Hive.closeGallery,
211 |       'markup': Hive.onMarkupClick,
212 |       'captcha': Hive.onDisplayCaptchaClick,
213 |       'tegaki': Hive.onTegakiClick,
214 |       'report-post': Hive.onReportClick,
215 |       'delete-post': Hive.onDeletePostClick,
216 |       'pin-thread': Hive.onPinThreadClick,
217 |       'lock-thread': Hive.onLockThreadClick
218 |     };
219 |     
220 |     ClickHandler.init();
221 |     QuotePreviews.init();
222 |   },
223 |   
224 |   run: function() {
225 |     var page;
226 |     
227 |     $.off(document, 'DOMContentLoaded', Hive.run);
228 |     
229 |     page = $.body.getAttribute('data-page');
230 |     
231 |     if (page === 'read') {
232 |       if ($.body.hasAttribute('data-reporting')) {
233 |         Hive.initReportCtrl();
234 |       }
235 |       
236 |       if ($.getCookie('csrf')) {
237 |         Hive.initModCtrl();
238 |       }
239 |     }
240 |     else if (page === 'report') {
241 |       Hive.loadReCaptcha();
242 |     }
243 |     
244 |     PostMenu.init();
245 |     
246 |     window.prettyPrint && window.prettyPrint();
247 |   },
248 |   
249 |   initReportCtrl: function() {
250 |     var el = $.el('li');
251 |     
252 |     el.innerHTML =
253 |       'Report';
254 |       
255 |     PostMenu.items.push(el);
256 |   },
257 |   
258 |   onReportClick: function(t) {
259 |     var board, thread, pid, src;
260 |     
261 |     board = $.body.getAttribute('data-board');
262 |     thread = $.body.getAttribute('data-thread');
263 |     pid = PostMenu.node.getAttribute('data-pid');
264 |     
265 |     src = '/report/' + board + '/' + thread
266 |         + '/' + pid;
267 |     
268 |     window.open(src);
269 |   },
270 |   
271 |   onDisplayCaptchaClick: function(t) {
272 |     Hive.loadReCaptcha();
273 |     t.classList.add('hidden');
274 |   },
275 |   
276 |   loadReCaptcha: function() {
277 |     var el = $.el('script');
278 |     el.src = 'https://www.google.com/recaptcha/api.js';
279 |     document.head.appendChild(el);
280 |   },
281 |   
282 |   abortXhr: function(id) {
283 |     if (Hive.xhr[id]) {
284 |       Hive.xhr[id].abort();
285 |       delete Hive.xhr[id];
286 |     }
287 |   },
288 |   
289 |   onTegakiClick: function(t) {
290 |     if (t.hasAttribute('data-active')) {
291 |       Hive.closeTegaki(t);
292 |     }
293 |     else {
294 |       Hive.showTegaki(t);
295 |     }
296 |   },
297 |   
298 |   showTegaki: function(t) {
299 |     Tegaki.open({
300 |       onDone: Hive.onTegakiDone,
301 |       onCancel: Hive.onTegakiCancel,
302 |       canvasOptions: Hive.buildTegakiCanvasList,
303 |       width: +t.getAttribute('data-width'),
304 |       height: +t.getAttribute('data-height')
305 |     });
306 |   },
307 |   
308 |   buildTegakiCanvasList: function(el) {
309 |     var i, a, opt, nodes = $.cls('post-file-thumb');
310 |     
311 |     for (i = 0; a = nodes[i]; ++i) {
312 |       if (/\.(png|jpg)$/.test(a.href)) {
313 |         opt = $.el('option');
314 |         opt.value = a.href;
315 |         opt.textContent = '>>' + a.parentNode.parentNode.id;
316 |         el.appendChild(opt);
317 |       }
318 |     }
319 |   },
320 |   
321 |   onTegakiDone: function() {
322 |     var input, data, thres, limit;
323 |     
324 |     input = $.id('tegaki-data');
325 |     
326 |     if (!input) {
327 |       input = $.el('input');
328 |       input.id = 'tegaki-data';
329 |       input.name = 'tegaki';
330 |       input.type = 'hidden';
331 |       
332 |       $.id('post-form').appendChild(input);
333 |       
334 |       $.id('file-field').classList.add('invisible');
335 |       $.id('tegaki-btn').classList.add('tainted');
336 |     }
337 |     
338 |     limit = +$.id('tegaki-btn').getAttribute('data-limit');
339 |     thres = Tegaki.canvas.width * Tegaki.canvas.height;
340 |     
341 |     if (thres > limit) {
342 |       data = Tegaki.flatten().toDataURL('image/jpeg', 0.9);
343 |     }
344 |     else {
345 |       data = Tegaki.flatten().toDataURL('image/png');
346 |     }
347 |     
348 |     if (data.length > limit) {
349 |       alert('The resulting file size is too big.');
350 |     }
351 |     else {
352 |       input.value = data;
353 |     }
354 |   },
355 |   
356 |   onTegakiCancel: function() {
357 |     var input;
358 |     
359 |     if (input = $.id('tegaki-data')) {
360 |       input.parentNode.removeChild(input);
361 |       $.id('file-field').classList.remove('invisible');
362 |       $.id('tegaki-btn').classList.remove('tainted');
363 |     }
364 |   },
365 |   
366 |   onFileClick: function(t) {
367 |     var bg, el, href;
368 |     
369 |     bg = $.el('div');
370 |     bg.id = 'backdrop';
371 |     bg.setAttribute('data-cmd', 'fcon');
372 |     
373 |     href = t.parentNode.href;
374 |     
375 |     if (/\.webm$/.test(href)) {
376 |       el = $.el('video');
377 |       el.muted = true;
378 |       el.controls = true;
379 |       el.loop = true;
380 |       el.autoplay = true;
381 |       
382 |       bg.innerHTML = '
'; 383 | } 384 | else { 385 | el = $.el('img'); 386 | el.alt = ''; 387 | el.setAttribute('data-cmd', 'fcon'); 388 | } 389 | 390 | el.id = 'expanded-file'; 391 | el.className = 'fit-to-screen'; 392 | el.src = href; 393 | 394 | bg.insertBefore(el, bg.firstElementChild); 395 | 396 | $.body.classList.add('has-backdrop'); 397 | 398 | $.body.appendChild(bg); 399 | }, 400 | 401 | closeGallery: function() { 402 | var el; 403 | 404 | if (el = $.id('backdrop')) { 405 | $.body.removeChild(el); 406 | $.body.classList.remove('has-backdrop'); 407 | } 408 | }, 409 | 410 | onMarkupClick: function(t) { 411 | if (t.hasAttribute('data-active')) { 412 | Hive.hideMarkupPreview(t); 413 | } 414 | else { 415 | Hive.showMarkupPreview(t); 416 | } 417 | }, 418 | 419 | showMarkupPreview: function(t) { 420 | var el, com; 421 | 422 | Hive.abortXhr('markup'); 423 | 424 | if (!(com = $.id('comment-field'))) { 425 | return; 426 | } 427 | 428 | t.setAttribute('data-active', '1'); 429 | t.textContent = 'Loading…'; 430 | 431 | Hive.xhr.markup = $.xhr('POST', '/markup', 432 | { 433 | onload: Hive.onMarkupLoaded 434 | }, 435 | { 436 | comment: com.value 437 | } 438 | ); 439 | }, 440 | 441 | onMarkupLoaded: function() { 442 | var resp, el, com; 443 | 444 | Hive.xhr.markup = null; 445 | 446 | resp = JSON.parse(this.responseText); 447 | 448 | if (!(com = $.id('comment-field'))) { 449 | return; 450 | } 451 | 452 | if ((el = $.id('comment-preview'))) { 453 | el.parentNode.removeChild(el); 454 | } 455 | 456 | el = $.el('div'); 457 | el.id = 'comment-preview'; 458 | el.style.width = (com.offsetWidth - 2) + 'px'; 459 | el.style.height = com.offsetHeight + 'px'; 460 | 461 | el.innerHTML = resp.data; 462 | 463 | com.parentNode.appendChild(el); 464 | com.classList.add('hidden'); 465 | 466 | $.id('comment-preview-btn').textContent = 'Write'; 467 | }, 468 | 469 | hideMarkupPreview: function(t) { 470 | var el, com; 471 | 472 | if (el = $.id('comment-preview')) { 473 | el.parentNode.removeChild(el); 474 | } 475 | 476 | if (com = $.id('comment-field')) { 477 | com.classList.remove('hidden'); 478 | } 479 | 480 | t.removeAttribute('data-active'); 481 | t.textContent = 'Preview'; 482 | }, 483 | 484 | onPostNumClick: function(t) { 485 | Hive.quotePost(+t.textContent); 486 | }, 487 | 488 | quotePost: function(postId) { 489 | var txt, pos, sel, com; 490 | 491 | com = $.id('comment-field'); 492 | 493 | pos = com.selectionStart; 494 | 495 | sel = window.getSelection().toString(); 496 | 497 | if (postId) { 498 | txt = '>>' + postId + '\n'; 499 | } 500 | else { 501 | txt = ''; 502 | } 503 | 504 | if (sel) { 505 | txt += '>' + sel.trim().replace(/[\r\n]+/g, '\n>') + '\n'; 506 | } 507 | 508 | if (com.value) { 509 | com.value = com.value.slice(0, pos) 510 | + txt + com.value.slice(com.selectionEnd); 511 | } 512 | else { 513 | com.value = txt; 514 | } 515 | 516 | com.selectionStart = com.selectionEnd = pos + txt.length; 517 | 518 | if (com.selectionStart == com.value.length) { 519 | com.scrollTop = com.scrollHeight; 520 | } 521 | 522 | com.focus(); 523 | }, 524 | 525 | onPostDeleted: function() { 526 | var el = $.id(this.hivePostId); 527 | el && el.classList.add('disabled'); 528 | }, 529 | 530 | deletePost: function(id, slug, thread, post, file_only) { 531 | var params; 532 | 533 | params = []; 534 | params.push('board=' + slug); 535 | params.push('thread=' + thread); 536 | params.push('post=' + post); 537 | params.push('csrf=' + $.getCookie('csrf')); 538 | 539 | if (file_only) { 540 | params.push('file_only=1'); 541 | } 542 | 543 | $.xhr('POST', '/manage/posts/delete', 544 | { 545 | onload: Hive.onPostDeleted, 546 | hivePostId: id 547 | }, 548 | params.join('&') 549 | ); 550 | }, 551 | 552 | onDeletePostClick: function(t) { 553 | var board, thread, pid, file_only; 554 | 555 | board = $.body.getAttribute('data-board'); 556 | thread = $.body.getAttribute('data-thread'); 557 | pid = PostMenu.node.getAttribute('data-pid'); 558 | file_only = t.hasAttribute('data-delfile'); 559 | 560 | Hive.deletePost(pid, board, thread, pid, file_only); 561 | }, 562 | 563 | onLockThreadClick: function(btn) { 564 | var board, thread, params, value; 565 | 566 | board = $.body.getAttribute('data-board'); 567 | thread = $.body.getAttribute('data-thread'); 568 | value = +!$.body.hasAttribute('data-locked'); 569 | 570 | params = [ 571 | 'board=' + board, 572 | 'thread=' + thread, 573 | 'flag=locked', 574 | 'value=' + value, 575 | 'csrf=' + $.getCookie('csrf') 576 | ]; 577 | 578 | $.xhr('POST', '/manage/threads/flags', 579 | { 580 | onload: Hive.onLockThreadLoaded 581 | }, 582 | params.join('&') 583 | ); 584 | }, 585 | 586 | onLockThreadLoaded: function() { 587 | if ($.body.hasAttribute('data-locked')) { 588 | $.body.removeAttribute('data-locked'); 589 | } 590 | else { 591 | $.body.setAttribute('data-locked', '1'); 592 | } 593 | }, 594 | 595 | onPinThreadClick: function(btn) { 596 | var board, thread, params, value, current_value; 597 | 598 | board = $.body.getAttribute('data-board'); 599 | thread = $.body.getAttribute('data-thread'); 600 | current_value = +$.body.getAttribute('data-pinned') || 1; 601 | 602 | value = prompt('Order (0 to unpin)', current_value); 603 | 604 | if (value === null) { 605 | return; 606 | } 607 | 608 | params = [ 609 | 'board=' + board, 610 | 'thread=' + thread, 611 | 'flag=pinned', 612 | 'value=' + value, 613 | 'csrf=' + $.getCookie('csrf') 614 | ]; 615 | 616 | $.xhr('POST', '/manage/threads/flags', 617 | { 618 | onload: Hive.onPinThreadLoaded, 619 | hiveValue: +value 620 | }, 621 | params.join('&') 622 | ); 623 | }, 624 | 625 | onPinThreadLoaded: function() { 626 | if (this.hiveValue === 0) { 627 | $.body.removeAttribute('data-pinned'); 628 | } 629 | else { 630 | $.body.setAttribute('data-pinned', this.hiveValue); 631 | } 632 | }, 633 | 634 | initModCtrl: function() { 635 | PostMenu.items.push(Hive.buildModCtrl); 636 | }, 637 | 638 | buildModCtrl: function(el) { 639 | var label, cnt, ctrl, path, board, thread, post_id; 640 | 641 | board = $.body.getAttribute('data-board'); 642 | thread = $.body.getAttribute('data-thread'); 643 | 644 | path = '/manage/bans/create/' + board + '/' + thread + '/'; 645 | 646 | el = $.postFromNode(el); 647 | 648 | post_id = el.id.split('-').pop(); 649 | 650 | cnt = $.frag(); 651 | 652 | ctrl = $.el('li'); 653 | ctrl.innerHTML = 654 | 'Delete'; 655 | cnt.appendChild(ctrl); 656 | 657 | if ($.cls('post-file-thumb', el)[0]) { 658 | ctrl = $.el('li'); 659 | ctrl.innerHTML = 660 | 'Delete File'; 662 | cnt.appendChild(ctrl); 663 | } 664 | 665 | ctrl = $.el('li'); 666 | ctrl.innerHTML = 667 | 'Ban'; 668 | cnt.appendChild(ctrl); 669 | 670 | if (post_id === '1') { 671 | label = $.body.hasAttribute('data-pinned') ? 'Change Pin' : 'Pin'; 672 | ctrl = $.el('li'); 673 | ctrl.innerHTML = 674 | '' + label + ''; 675 | cnt.appendChild(ctrl); 676 | 677 | label = $.body.hasAttribute('data-locked') ? 'Unlock' : 'Lock'; 678 | ctrl = $.el('li'); 679 | ctrl.innerHTML = 680 | '' + label + ''; 681 | cnt.appendChild(ctrl); 682 | } 683 | 684 | return cnt; 685 | } 686 | }; 687 | 688 | Hive.init(); 689 | -------------------------------------------------------------------------------- /public/js/manage/reports.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Reports = { 4 | init: function() { 5 | ClickHandler.commands['delete-post'] = Reports.onDeletePostClick; 6 | ClickHandler.commands['dismiss-report'] = Reports.onDismissReportClick; 7 | }, 8 | 9 | onDeletePostClick: function(t) { 10 | var el, path, board, thread, pid, file_only; 11 | 12 | el = $.postFromNode(t); 13 | 14 | if (!el) { 15 | return; 16 | } 17 | 18 | path = el.id.split('-'); 19 | 20 | board = path[0]; 21 | thread = path[1]; 22 | pid = path[2]; 23 | file_only = t.hasAttribute('data-delfile'); 24 | 25 | Hive.deletePost(el.id, board, thread, pid, file_only); 26 | }, 27 | 28 | onDismissReportClick: function(t) { 29 | var post_id, params, el; 30 | 31 | el = $.postFromNode(t); 32 | 33 | if (!el) { 34 | return; 35 | } 36 | 37 | post_id = el.getAttribute('data-post-id'); 38 | 39 | params = 'post_id=' + post_id + '&csrf=' + $.getCookie('csrf'); 40 | 41 | $.xhr('POST', '/manage/reports/delete', 42 | { 43 | onload: Reports.onPostDeleted, 44 | hivePostId: el.id 45 | }, 46 | params 47 | ); 48 | }, 49 | 50 | onPostDeleted: function() { 51 | var el = $.id(this.hivePostId); 52 | el && el.classList.add('disabled'); 53 | }, 54 | }; 55 | 56 | Reports.init(); 57 | -------------------------------------------------------------------------------- /spec/admin_spec.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'spec_helper.rb' 4 | 5 | class HiveSpec < MiniTest::Spec 6 | self.reset_db 7 | self.reset_dirs 8 | 9 | CONFIG[:delay_auth] = 0 10 | 11 | REQUIRED_LEVELS = { 12 | :admin => { 13 | :get => [ 14 | '/boards', '/boards/create', '/boards/update/1', 15 | '/users', '/users/create', '/users/update/1' 16 | ], 17 | :post => [ 18 | '/boards/create', '/boards/update', '/boards/delete', 19 | '/users/create', '/users/update', '/users/delete' 20 | ] 21 | }, 22 | :mod => { 23 | :get => [ 24 | '', '/bans', '/bans/create/test/1/1', '/boards/update/1', '/reports', 25 | '/profile' 26 | ], 27 | :post => [ 28 | '/bans/update', '/profile', 29 | '/posts/delete', '/reports/delete', '/threads/flags' 30 | ] 31 | } 32 | } 33 | 34 | def setup 35 | HiveSpec.reset_board_dir 36 | HiveSpec.reset_config 37 | end 38 | 39 | def auth(u, p = nil, csrf = '123') 40 | set_cookie "auth_csrf=#{csrf}" if csrf 41 | 42 | DB.transaction(:rollback => :always) do 43 | post '/manage/auth', { 44 | 'username' => u, 45 | 'password' => p || u, 46 | 'auth_csrf' => csrf 47 | } 48 | end 49 | end 50 | 51 | describe '/manage/auth' do 52 | it 'shows the login form' do 53 | get '/manage/auth' 54 | assert last_response.ok? 55 | end 56 | 57 | it 'logs users in' do 58 | auth('admin') 59 | last_response.location.must_include '/manage' 60 | end 61 | 62 | it 'uses case-sensitive credentials' do 63 | auth('Admin') 64 | assert last_response.forbidden? 65 | 66 | auth('admin', 'Admin') 67 | assert last_response.forbidden? 68 | end 69 | 70 | it 'validates csrf tokens' do 71 | auth('admin', nil, nil) 72 | assert last_response.bad_request? 73 | end 74 | end 75 | 76 | describe '/manage' do 77 | it 'enforces access levels' do 78 | sorted_levels = 79 | BBS::USER_LEVELS.to_a.sort { |a, b| b[1] <=> a[1] }.map { |a| a[0] } 80 | 81 | sorted_levels << :none 82 | 83 | REQUIRED_LEVELS.each do |group, methods| 84 | above = sorted_levels[sorted_levels.index(group) + 1] 85 | 86 | sid_as(above) 87 | 88 | methods.each do |method, paths| 89 | paths.each do |path| 90 | path = "/manage#{path}" 91 | 92 | DB.transaction(:rollback => :always) do 93 | if method == :post 94 | post path, { 'csrf' => 'ok' } 95 | else 96 | get path 97 | end 98 | end 99 | 100 | if path == '/manage' 101 | assert last_response.location.include?('/manage'), '/manage' 102 | else 103 | assert last_response.forbidden?, "#{group} #{method} #{path}" 104 | end 105 | end 106 | end 107 | end 108 | end 109 | 110 | it 'validates csrf tokens' do 111 | REQUIRED_LEVELS.each do |group, methods| 112 | next unless methods[:post] 113 | 114 | methods[:post].each do |path| 115 | DB.transaction(:rollback => :always) do 116 | post "/manage#{path}" 117 | end 118 | 119 | assert last_response.bad_request?, "#{group} /manage#{path}" 120 | end 121 | end 122 | end 123 | 124 | it 'destroys the session after logging out' do 125 | sid_as('mod') 126 | 127 | post '/manage/logout', { 'csrf' => 'ok' } 128 | 129 | assert last_response.redirect?, last_response.body 130 | assert_empty rack_mock_session.cookie_jar['sid'] 131 | assert_empty rack_mock_session.cookie_jar['csrf'] 132 | end 133 | end 134 | 135 | describe 'Capcodes' do 136 | it 'uses the capcode_{level} posting command' do 137 | REQUIRED_LEVELS.each_key do |level| 138 | HiveSpec.reset_board_dir 139 | 140 | sid_as(level) 141 | 142 | DB.transaction(:rollback => :always) do 143 | make_post({ 144 | 'title' => 'test', 'comment' => 'test', 145 | 'author' => "##capcode_#{level}" 146 | }) 147 | end 148 | 149 | assert last_response.body.include?('http-equiv="Refresh"') 150 | end 151 | end 152 | end 153 | 154 | describe '/manage/users' do 155 | it 'allows to edit user profiles' do 156 | sid_as('admin') 157 | get '/manage/users/update/1' 158 | assert last_response.body.include?(DB[:users].where(id: 1).get(:username)) 159 | end 160 | 161 | it 'creates users' do 162 | sid_as('admin') 163 | 164 | DB.transaction(:rollback => :always) do 165 | post '/manage/users/create', { 166 | 'username' => 'testuser', 167 | 'level' => BBS::USER_LEVELS[:mod], 168 | 'csrf' => 'ok' 169 | } 170 | assert DB[:users].first(:username => 'testuser') != nil 171 | end 172 | end 173 | 174 | it 'updates users' do 175 | sid_as('admin') 176 | 177 | DB.transaction(:rollback => :always) do 178 | user_id = DB[:users].insert({ 179 | username: 'testupdateuser', 180 | password: 'testdeluser', 181 | level: BBS::USER_LEVELS[:mod], 182 | created_on: Time.now.utc.to_i 183 | }) 184 | 185 | post "/manage/users/update", { 186 | 'username' => 'testupdateuser2', 187 | 'level' => BBS::USER_LEVELS[:mod], 'id' => user_id, 188 | 'csrf' => 'ok' 189 | } 190 | 191 | assert DB[:users].first(:id => user_id)[:username] == 'testupdateuser2' 192 | end 193 | end 194 | 195 | it 'deletes users' do 196 | sid_as('admin') 197 | 198 | DB.transaction(:rollback => :always) do 199 | user_id = DB[:users].insert({ 200 | username: 'testdeluser', 201 | password: 'testdeluser', 202 | level: BBS::USER_LEVELS[:mod], 203 | created_on: Time.now.utc.to_i 204 | }) 205 | 206 | post "/manage/users/delete", { 207 | 'confirm_keyword' => t(:confirm_keyword), 208 | 'id' => user_id, 'csrf' => 'ok' 209 | } 210 | 211 | assert_nil DB[:users].first(:id => user_id) 212 | end 213 | end 214 | end 215 | 216 | describe '/manage/boards' do 217 | it 'creates boards' do 218 | HiveSpec.reset_dirs 219 | 220 | sid_as('admin') 221 | 222 | DB.transaction(:rollback => :always) do 223 | post '/manage/boards/create', { 224 | 'slug' => 'test2', 'title' => 'Test', 'config' => '', 'csrf' => 'ok' 225 | } 226 | assert DB[:boards].first(:slug => 'test2') != nil 227 | end 228 | end 229 | 230 | it 'updates boards' do 231 | sid_as('admin') 232 | 233 | DB.transaction(:rollback => :always) do 234 | board_id = DB[:boards].insert({ 235 | slug: 'test2', 236 | title: 'Test', 237 | config: '', 238 | created_on: Time.now.utc.to_i 239 | }) 240 | 241 | post "/manage/boards/update", { 242 | 'slug' => 'testing', 'title' => 'Test', 'config' => '{}', 243 | 'id' => board_id, 'csrf' => 'ok', 244 | } 245 | 246 | board = DB[:boards].first(:id => board_id, :slug => 'testing') 247 | assert board, last_response.body 248 | end 249 | end 250 | 251 | it 'deletes boards' do 252 | HiveSpec.reset_dirs 253 | 254 | sid_as('admin') 255 | 256 | DB.transaction(:rollback => :always) do 257 | board_id = DB[:boards].insert({ 258 | slug: 'test2', 259 | title: 'Test', 260 | config: '', 261 | created_on: Time.now.utc.to_i 262 | }) 263 | 264 | board_dir = "#{app.settings.files_dir}/#{board_id}" 265 | 266 | FileUtils.mkdir(board_dir) 267 | 268 | post "/manage/boards/delete", { 269 | 'confirm_keyword' => t(:confirm_keyword), 270 | 'id' => board_id, 'csrf' => 'ok' 271 | } 272 | 273 | assert_nil DB[:boards].first(:id => board_id) 274 | assert File.directory?(board_dir) == false 275 | end 276 | end 277 | end 278 | 279 | describe '/manage/posts/delete' do 280 | def prepare_thread 281 | make_post({ 'title' => 'test', 'comment' => 'test' }) 282 | 283 | tid, tnum = DB[:threads].select(:id, :num).reverse_order(:id).first.values 284 | 285 | 2.times do 286 | make_post({ 'thread' => tnum, 'comment' => 'test' }) 287 | end 288 | 289 | dir = "#{app.settings.files_dir}/1/#{tid}" 290 | meta = { file: { ext: 'jpg' } }.to_json 291 | 292 | 3.times do |i| 293 | i += 1 294 | DB[:posts].where(:thread_id => tid, :num => i).update({ 295 | :file_hash => "dead#{i}", 296 | :meta => meta 297 | }) 298 | 299 | FileUtils.touch("#{dir}/dead#{i}.jpg") 300 | FileUtils.touch("#{dir}/t_dead#{i}.jpg") 301 | end 302 | 303 | [tid, tnum] 304 | end 305 | 306 | it 'deletes posts' do 307 | DB.transaction(:rollback => :always) do 308 | tid, tnum = prepare_thread 309 | 310 | sid_as('admin') 311 | 312 | dir = "#{app.settings.files_dir}/1/#{tid}" 313 | 314 | # Delete last reply 315 | post '/manage/posts/delete', { 316 | 'board' => 'test', 'thread' => tnum, 'post' => '3', 'csrf' => 'ok' 317 | } 318 | 319 | assert last_response.body.include?(t(:done)), last_response.body 320 | 321 | File.exist?("#{dir}/dead3.jpg").must_equal false, 'file 3' 322 | File.exist?("#{dir}/t_dead3.jpg").must_equal false, 'thumb 3' 323 | 324 | # Delete whole thread 325 | post '/manage/posts/delete', { 326 | 'board' => 'test', 'thread' => tnum, 'post' => '1', 'csrf' => 'ok' 327 | } 328 | 329 | assert last_response.body.include?(t(:done)), last_response.body 330 | 331 | 2.times do |i| 332 | i += 1 333 | File.exist?("#{dir}/dead#{i}.jpg").must_equal false, "file #{i}" 334 | File.exist?("#{dir}/t_dead#{i}.jpg").must_equal false, "thumb #{i}" 335 | end 336 | 337 | assert_nil DB[:threads].first(:id => tid), 'thread' 338 | assert_empty DB[:posts].where(:thread_id => tid).all, 'replies' 339 | end 340 | end 341 | 342 | it 'deletes files only' do 343 | DB.transaction(:rollback => :always) do 344 | tid, tnum = prepare_thread 345 | 346 | sid_as('admin') 347 | 348 | dir = "#{app.settings.files_dir}/1/#{tid}" 349 | 350 | post '/manage/posts/delete', { 351 | 'board' => 'test', 'thread' => tnum, 'post' => '3', 352 | 'csrf' => 'ok', 'file_only' => '1' 353 | } 354 | 355 | assert last_response.body.include?(t(:done)), last_response.body 356 | 357 | refute_nil DB[:threads].first(:id => tid), 'thread' 358 | refute_empty DB[:posts].where(:thread_id => tid).all, 'replies' 359 | 360 | File.exist?("#{dir}/dead3.jpg").must_equal false, 'file 3' 361 | File.exist?("#{dir}/t_dead3.jpg").must_equal false, 'thumb 3' 362 | end 363 | end 364 | end 365 | 366 | describe '/manage/reports' do 367 | def prepare_report 368 | DB[:reports].insert({ 369 | board_id: 1, 370 | thread_id: 1, 371 | post_id: 1, 372 | created_on: Time.now.utc.to_i, 373 | ip: '127.0.0.1', 374 | score: 1, 375 | category: '' 376 | }) 377 | end 378 | 379 | it 'shows reported posts' do 380 | sid_as('mod') 381 | get '/manage/reports' 382 | assert last_response.ok?, last_response.body 383 | end 384 | 385 | it 'clears reports by post id' do 386 | DB.transaction(:rollback => :always) do 387 | prepare_report 388 | 389 | sid_as('mod') 390 | 391 | post '/manage/reports/delete', { 'post_id' => '1', 'csrf' => 'ok' } 392 | 393 | count = DB[:reports].count 394 | 395 | assert_equal(0, count) 396 | end 397 | end 398 | 399 | it 'clears reports when a post is deleted' do 400 | DB.transaction(:rollback => :always) do 401 | prepare_report 402 | 403 | sid_as('mod') 404 | 405 | post '/manage/posts/delete', { 406 | 'board' => 'test', 'thread' => '1', 'post' => '1', 'csrf' => 'ok' 407 | } 408 | 409 | assert_equal(0, DB[:reports].count) 410 | end 411 | end 412 | 413 | it 'clears reports when a file is deleted' do 414 | DB.transaction(:rollback => :always) do 415 | prepare_report 416 | 417 | sid_as('mod') 418 | 419 | post '/manage/posts/delete', { 420 | 'board' => 'test', 'thread' => '1', 'post' => '1', 421 | 'file_only' => '1', 'csrf' => 'ok' 422 | } 423 | 424 | assert_equal(0, DB[:reports].count) 425 | end 426 | end 427 | end 428 | 429 | describe '/manage/bans' do 430 | # The duration is expressed in hours 431 | 432 | it 'allows to create bans from posts' do 433 | sid_as('mod') 434 | get '/manage/bans/create/test/1/1' 435 | assert last_response.ok?, last_response.body 436 | end 437 | 438 | it 'shows a list of most recent bans' do 439 | sid_as('mod') 440 | get '/manage/bans' 441 | assert last_response.ok?, last_response.body 442 | end 443 | 444 | it 'allows to search bans by IP' do 445 | sid_as('mod') 446 | 447 | [ 448 | '192.0.2.1', 449 | '2001:db8:1:1::1', 450 | '::ffff:192.0.2.1', 451 | '::192.0.2.1' 452 | ].each do |ip| 453 | DB.transaction(:rollback => :always) do 454 | ban_id = prepare_ban(ip, 24) 455 | get '/manage/bans', { 'q' => ip } 456 | assert last_response.body.include?(ip), last_response.body 457 | end 458 | end 459 | end 460 | 461 | it 'creates bans from posts' do 462 | sid_as('mod') 463 | 464 | [ 465 | '192.0.2.1', 466 | '2001:db8:1:1::1', 467 | '::ffff:192.0.2.1', 468 | '::192.0.2.1' 469 | ].each do |ip| 470 | DB.transaction(:rollback => :always) do 471 | tid = insert_thread { |t, p| p[:ip] = ip } 472 | 473 | thread = DB[:threads].where(id: tid).get(:num) 474 | 475 | post '/manage/bans/update', { 476 | 'board' => 'test', 'thread' => thread, 'post' => 1, 477 | 'duration' => 24, 'reason' => 'test', 'csrf' => 'ok' 478 | } 479 | 480 | assert last_response.body.include?(t(:done)), last_response.body 481 | 482 | ip_addr = IPAddr.new(ip) 483 | 484 | if ip_addr.ipv6? 485 | if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped? 486 | ip_addr = ip_addr.native 487 | else 488 | ip_addr = ip_addr.mask(64) 489 | end 490 | end 491 | 492 | bin_ip = Sequel::SQL::Blob.new(ip_addr.hton) 493 | assert DB[:bans].first(ip: bin_ip), ip 494 | end 495 | end 496 | end 497 | 498 | it 'creates a warning if the duration is zero' do 499 | sid_as('mod') 500 | 501 | DB.transaction(:rollback => :always) do 502 | post '/manage/bans/update', { 503 | 'board' => 'test', 'thread' => 1, 'post' => 1, 504 | 'duration' => 0, 'reason' => 'test', 'csrf' => 'ok' 505 | } 506 | assert_equal(1, DB[:bans].count) 507 | end 508 | end 509 | 510 | it 'creates a permanent ban if duration < 0 or expires_on > MAX_BAN' do 511 | sid_as('mod') 512 | 513 | DB.transaction(:rollback => :always) do 514 | post '/manage/bans/update', { 515 | 'board' => 'test', 'thread' => 1, 'post' => 1, 516 | 'duration' => -1, 'reason' => 'test', 'csrf' => 'ok' 517 | } 518 | assert_equal(1, DB[:bans].count) 519 | assert_equal(BBS::MAX_BAN, DB[:bans].first[:expires_on]) 520 | end 521 | 522 | DB.transaction(:rollback => :always) do 523 | post '/manage/bans/update', { 524 | 'board' => 'test', 'thread' => 1, 'post' => 1, 525 | 'duration' => Time.now.to_i, 'reason' => 'test', 'csrf' => 'ok' 526 | } 527 | assert_equal(1, DB[:bans].count) 528 | assert_equal(BBS::MAX_BAN, DB[:bans].first[:expires_on]) 529 | end 530 | end 531 | 532 | it 'requires a public reason' do 533 | sid_as('mod') 534 | 535 | DB.transaction(:rollback => :always) do 536 | post '/manage/bans/update', { 537 | 'duration' => 24, 'reason' => '', 'csrf' => 'ok' 538 | } 539 | assert_equal(0, DB[:bans].count) 540 | assert last_response.body.include?(t(:empty_ban_reason)) 541 | end 542 | end 543 | 544 | it 'allows to edit existing bans' do 545 | sid_as('mod') 546 | DB.transaction(:rollback => :always) do 547 | ban_id = prepare_ban('192.0.2.1', 24) 548 | get "/manage/bans/update/#{ban_id}" 549 | assert last_response.ok?, last_response.body 550 | end 551 | end 552 | 553 | it 'updates existing bans' do 554 | sid_as('mod') 555 | 556 | DB.transaction(:rollback => :always) do 557 | reason = Time.now.to_s 558 | ban_id = prepare_ban('192.0.2.1', 24) 559 | post "/manage/bans/update", { 560 | 'id' => ban_id, 'duration' => 24, 'reason' => reason, 'csrf' => 'ok' 561 | } 562 | assert_equal(1, DB[:bans].count) 563 | assert_equal(reason, DB[:bans].first[:reason]) 564 | end 565 | end 566 | end 567 | 568 | describe '/banned' do 569 | it 'shows when an IPv4 or an IPv6 /64 block is banned or warned' do 570 | cases = [ 571 | ['192.0.2.1', 3600, '192.0.2.1', 'user-banned'], 572 | ['192.0.2.1', 0, '192.0.2.1', 'user-warned'], 573 | ['2001:db8:1:1::1', 3600, '2001:db8:1:1::2', 'user-banned'], 574 | ['2001:db8:1:1::1', 0, '2001:db8:1:1::2', 'user-warned'], 575 | ['192.0.2.1', 3600, '192.0.2.2', t(:not_banned)], 576 | ['2001:db8:1:1::1', 3600, '2001:db8:1:2::1', t(:not_banned)], 577 | ['::ffff:192.0.2.1', 3600, '::ffff:192.0.2.1', 'user-banned'], 578 | ['::192.0.2.1', 3600, '::192.0.2.1', 'user-banned'], 579 | ] 580 | 581 | cases.each do |p| 582 | DB.transaction(:rollback => :always) do 583 | prepare_ban(p[0], p[1]) 584 | get '/banned', {}, { 'REMOTE_ADDR' => p[2] } 585 | assert last_response.body.include?(p[3]), "#{p[0]}/#{p[3]} failed" 586 | end 587 | end 588 | end 589 | 590 | it 'handles ban expiration' do 591 | DB.transaction(:rollback => :always) do 592 | prepare_ban('192.0.2.1', -3600) 593 | get '/banned', {}, { 'REMOTE_ADDR' => '192.0.2.1' } 594 | assert_equal(false, DB[:bans].first[:active]) 595 | end 596 | end 597 | end 598 | 599 | describe 'Thread pinning' do 600 | it 'can pin threads to the top of the list' do 601 | sid_as('mod') 602 | 603 | DB.transaction(:rollback => :always) do 604 | post '/manage/threads/flags', { 605 | 'board' => 'test', 606 | 'thread' => '1', 607 | 'flag' => 'pinned', 608 | 'value' => '1', 609 | 'csrf' => 'ok' 610 | } 611 | 612 | assert last_response.body.include?(t(:done)), last_response.body 613 | assert_equal 1, DB[:threads].first(:id => 1)[:pinned] 614 | end 615 | end 616 | 617 | it 'respects pinning order' do 618 | sid_as('mod') 619 | 620 | DB.transaction(:rollback => :always) do 621 | title_bottom = 'BottomPin' 622 | title_top = 'TopPin' 623 | 624 | insert_thread do |t, p| 625 | t[:board_id] = 1 626 | t[:title] = title_bottom 627 | t[:pinned] = 1 628 | end 629 | 630 | insert_thread do |t, p| 631 | t[:board_id] = 1 632 | t[:title] = title_top 633 | t[:pinned] = 2 634 | end 635 | 636 | get '/test/' 637 | body = last_response.body 638 | 639 | assert body.index(title_top) < body.index(title_bottom) 640 | end 641 | end 642 | end 643 | 644 | describe 'Thread locking' do 645 | it 'makes threads unable to receive new replies' do 646 | sid_as('mod') 647 | 648 | DB.transaction(:rollback => :always) do 649 | post '/manage/threads/flags', { 650 | 'board' => 'test', 651 | 'thread' => '1', 652 | 'flag' => 'locked', 653 | 'value' => BBS::THREAD_LOCKED, 654 | 'csrf' => 'ok' 655 | } 656 | 657 | assert last_response.body.include?(t(:done)), last_response.body 658 | assert_equal BBS::THREAD_LOCKED, DB[:threads].first(:id => 1)[:locked] 659 | end 660 | end 661 | end 662 | 663 | describe '/manage/profile' do 664 | it 'shows the profile of the currently logged in user' do 665 | sid_as('mod') 666 | get '/manage/profile' 667 | assert last_response.ok? 668 | end 669 | 670 | it 'lets users change their password' do 671 | sid_as('mod') 672 | new_pwd = 'newmod1337' 673 | DB.transaction(:rollback => :always) do 674 | post '/manage/profile', { 675 | 'old_pwd' => 'mod', 676 | 'new_pwd' => new_pwd, 677 | 'new_pwd_again' => new_pwd, 678 | 'csrf' => 'ok' 679 | } 680 | assert last_response.body.include?(t(:done)), last_response.body 681 | user = DB[:users].first(:username => 'mod') 682 | assert app.password_valid?(new_pwd, user[:password]) 683 | end 684 | end 685 | 686 | it 'validates password complexity' do 687 | sid_as('mod') 688 | new_pwd = '1' 689 | DB.transaction(:rollback => :always) do 690 | post '/manage/profile', { 691 | 'old_pwd' => 'mod', 692 | 'new_pwd' => new_pwd, 693 | 'new_pwd_again' => new_pwd, 694 | 'csrf' => 'ok' 695 | } 696 | body = last_response.body 697 | assert body.include?(t(:passwd_too_short)), body 698 | end 699 | end 700 | 701 | it 'validates password confirmation' do 702 | sid_as('mod') 703 | new_pwd = 'newmod1337' 704 | DB.transaction(:rollback => :always) do 705 | post '/manage/profile', { 706 | 'old_pwd' => 'mod', 707 | 'new_pwd' => new_pwd, 708 | 'new_pwd_again' => 'nope', 709 | 'csrf' => 'ok' 710 | } 711 | body = last_response.body 712 | assert body.include?(t(:passwd_mismatch)), body 713 | end 714 | end 715 | 716 | it 'validates old password' do 717 | sid_as('mod') 718 | new_pwd = 'newmod1337' 719 | DB.transaction(:rollback => :always) do 720 | post '/manage/profile', { 721 | 'old_pwd' => 'nope', 722 | 'new_pwd' => new_pwd, 723 | 'new_pwd_again' => new_pwd, 724 | 'csrf' => 'ok' 725 | } 726 | assert last_response.forbidden?, last_response.body 727 | end 728 | end 729 | end 730 | end 731 | -------------------------------------------------------------------------------- /spec/ffmpeg_spec.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'spec_helper.rb' 4 | 5 | class HiveSpec < MiniTest::Spec 6 | self.reset_db 7 | self.reset_dirs 8 | 9 | def setup 10 | HiveSpec.reset_board_dir 11 | HiveSpec.reset_config 12 | end 13 | 14 | def post_file(file) 15 | skip "Missing sample data: #{file}" unless File.exist?("#{DATA}/#{file}") 16 | DB.transaction(:rollback => :always) do 17 | make_post({ 'title' => 'Test', 'file' => file }) 18 | end 19 | end 20 | 21 | describe 'FFmpeg file handler' do 22 | it 'validates and renders thumbnails for video files' do 23 | post_file('test.webm') 24 | assert last_response.body.include?('http-equiv="Refresh"') 25 | end 26 | 27 | it 'fails if the file is not matroska' do 28 | post_file('test_png.webm') 29 | assert last_response.body.include?(t(:bad_file_format)) 30 | end 31 | 32 | it 'enforces duration limits' do 33 | CONFIG[:file_limits][:video][:duration] = 0 34 | post_file('test.webm') 35 | assert last_response.body.include?(t(:duration_too_long)) 36 | end 37 | 38 | it 'enforces dimensions limits' do 39 | CONFIG[:file_limits][:video][:dimensions] = 0 40 | post_file('test.webm') 41 | assert last_response.body.include?(t(:dimensions_too_large)) 42 | end 43 | 44 | it 'enforces file size limits' do 45 | CONFIG[:file_limits][:video][:file_size] = 0 46 | post_file('test.webm') 47 | assert last_response.body.include?(t(:file_size_too_big)) 48 | end 49 | 50 | it 'rejects files with audio streams when allow_audio is false' do 51 | CONFIG[:file_limits][:video][:allow_audio] = false 52 | post_file('test_audio.webm') 53 | assert last_response.body.include?(t(:webm_audio_disabled)) 54 | end 55 | 56 | it 'accepts files with audio streams when allow_audio is true' do 57 | CONFIG[:file_limits][:video][:allow_audio] = true 58 | post_file('test_audio.webm') 59 | assert last_response.body.include?('http-equiv="Refresh"') 60 | end 61 | 62 | it 'only accepts VP8 video streams' do 63 | post_file('test_vp9.webm') 64 | assert last_response.body.include?(t(:invalid_video)) 65 | end 66 | 67 | it 'rejects files with multiple video streams' do 68 | post_file('test_multi_v.webm') 69 | assert last_response.body.include?(t(:too_many_video)) 70 | end 71 | 72 | it 'rejects files with multiple audio streams' do 73 | CONFIG[:file_limits][:video][:allow_audio] = true 74 | post_file('test_multi_a.webm') 75 | assert last_response.body.include?(t(:too_many_audio)) 76 | end 77 | 78 | it 'rejects files without a video stream' do 79 | CONFIG[:file_limits][:video][:allow_audio] = true 80 | post_file('test_no_v.webm') 81 | assert last_response.body.include?(t(:no_video_streams)) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/imagemagick_spec.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'spec_helper.rb' 4 | 5 | class HiveSpec < MiniTest::Spec 6 | self.reset_db 7 | self.reset_dirs 8 | 9 | def setup 10 | HiveSpec.reset_board_dir 11 | HiveSpec.reset_config 12 | end 13 | 14 | def post_file(file) 15 | skip "Missing sample data: #{file}" unless File.exist?("#{DATA}/#{file}") 16 | DB.transaction(:rollback => :always) do 17 | make_post({ 'title' => 'Test', 'file' => file }) 18 | end 19 | end 20 | 21 | def post_base64(file) 22 | skip "Missing sample data: #{file}" unless File.exist?("#{DATA}/#{file}") 23 | ext = File.extname(file)[1..-1] 24 | DB.transaction(:rollback => :always) do 25 | make_post({ 'title' => 'Test', 'tegaki' => "data:image/#{ext};base64," + 26 | Base64.encode64(File.binread("#{DATA}/#{file}"))}) 27 | end 28 | end 29 | 30 | describe 'ImageMagick file handler' do 31 | it 'validates and renders thumbnails for image files' do 32 | post_file('test.png') 33 | assert last_response.body.include?('http-equiv="Refresh"') 34 | end 35 | 36 | it 'fails if the file is not a valid image' do 37 | post_file('test_blank.png') 38 | assert last_response.body.include?(t(:bad_file_format)) 39 | end 40 | 41 | it 'enforces dimensions limits' do 42 | CONFIG[:file_limits][:image][:dimensions] = 0 43 | post_file('test.png') 44 | assert last_response.body.include?(t(:dimensions_too_large)) 45 | end 46 | 47 | it 'enforces file size limits' do 48 | CONFIG[:file_limits][:image][:file_size] = 0 49 | post_file('test.png') 50 | assert last_response.body.include?(t(:file_size_too_big)) 51 | end 52 | 53 | it 'only accepts allowed file types' do 54 | CONFIG[:file_types] = ['png'] 55 | post_file('test_gif.png') 56 | assert last_response.body.include?(t(:bad_file_format)) 57 | end 58 | 59 | it 'allows base64 uploads' do 60 | post_base64('test.png') 61 | assert last_response.body.include?('http-equiv="Refresh"') 62 | end 63 | 64 | it 'enforces base64 size limits' do 65 | CONFIG[:tegaki_data_limit] = 0 66 | post_base64('test.png') 67 | assert last_response.body.include?(t(:file_size_too_big)) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/misc_test.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'spec_helper.rb' 4 | 5 | class HiveTest < MiniTest::Test 6 | include Hive 7 | 8 | APP = BBS.new! 9 | 10 | def pages_as_ary(page, total, count) 11 | pages = [] 12 | APP.paginate_html(page, total, count) do |p| 13 | pages << p 14 | end 15 | pages 16 | end 17 | 18 | def test_html_pagination 19 | assert_nil APP.paginate_html(1, 1, 5) 20 | 21 | assert_equal(1..5, APP.paginate_html(1, 5, 5)) 22 | 23 | assert_equal([1, 2, 3, :next], pages_as_ary(1, 3, 5)) 24 | 25 | assert_equal([:previous, 1, 2, 3, :next], pages_as_ary(2, 3, 5)) 26 | 27 | assert_equal([:previous, 1, 2, 3], pages_as_ary(3, 3, 5)) 28 | 29 | assert_equal([:first, :previous, 2, 3, :next], pages_as_ary(2, 3, 2)) 30 | 31 | assert_equal([:first, :previous, 2, 3], pages_as_ary(3, 3, 2)) 32 | end 33 | 34 | def test_tripcodes 35 | assert APP.make_tripcode('test') 36 | end 37 | 38 | def test_pretty_bytesize 39 | { 40 | 1023 => '1023 B', 41 | 1024 => '1 KiB', 42 | 10250 => '10 KiB', 43 | 1048576 => '1.0 MiB', 44 | 10485770 => '10.0 MiB' 45 | }.each { |k, v| assert_equal v, APP.pretty_bytesize(k) } 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/posting_spec.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: utf-8 3 | 4 | require_relative 'spec_helper.rb' 5 | 6 | class HiveSpec < MiniTest::Spec 7 | self.reset_db 8 | self.reset_dirs 9 | 10 | def setup 11 | HiveSpec.reset_board_dir 12 | HiveSpec.reset_config 13 | end 14 | 15 | describe 'Posting' do 16 | it 'allows to post threads' do 17 | DB.transaction(:rollback => :always) do 18 | make_post({ 'title' => 'test', 'comment' => 'test' }) 19 | end 20 | assert last_response.body.include?('http-equiv="Refresh"') 21 | end 22 | 23 | it 'allows to post replies' do 24 | DB.transaction(:rollback => :always) do 25 | make_post({ 'thread' => '1', 'comment' => 'test' }) 26 | end 27 | assert last_response.body.include?('http-equiv="Refresh"') 28 | end 29 | 30 | it 'fails if the thread has no title' do 31 | DB.transaction(:rollback => :always) do 32 | make_post({ 'comment' => 'test' }) 33 | end 34 | assert last_response.body.include?(t(:title_empty)) 35 | end 36 | 37 | it 'fails if both the comment and the file fields are empty' do 38 | DB.transaction(:rollback => :always) do 39 | make_post({ 'title' => 'test' }) 40 | end 41 | assert last_response.body.include?(t(:comment_empty)) 42 | end 43 | 44 | it 'fails if the title field is too long' do 45 | DB.transaction(:rollback => :always) do 46 | make_post({ 47 | 'title' => 'W' * (CONFIG[:title_length] + 1), 48 | 'comment' => 'test' 49 | }) 50 | end 51 | assert last_response.body.include?(t(:title_too_long)) 52 | end 53 | 54 | it 'fails if the name field is too long' do 55 | DB.transaction(:rollback => :always) do 56 | make_post({ 57 | 'title' => 'test', 58 | 'comment' => 'test', 59 | 'author' => 'W' * (CONFIG[:author_length] + 1) 60 | }) 61 | end 62 | assert last_response.body.include?(t(:name_too_long)) 63 | end 64 | 65 | it 'fails if the comment field is too long' do 66 | DB.transaction(:rollback => :always) do 67 | make_post({ 68 | 'title' => 'test', 69 | 'comment' => 'W' * (CONFIG[:comment_length] + 1), 70 | }) 71 | end 72 | assert last_response.body.include?(t(:comment_too_long)) 73 | end 74 | 75 | it 'fails if the comment field has too many lines' do 76 | DB.transaction(:rollback => :always) do 77 | make_post({ 78 | 'title' => 'test', 79 | 'comment' => "W\n" * (CONFIG[:comment_lines] + 1), 80 | }) 81 | end 82 | assert last_response.body.include?(t(:comment_too_long)) 83 | end 84 | 85 | it 'fails if a field contains utf8 characters outside of the BMP' do 86 | DB.transaction(:rollback => :always) do 87 | ['title', 'comment', 'author'].each do |field| 88 | p = { 'title' => 'test', 'author' => 'test', 'comment' => 'test' } 89 | p[field] = '🙊' 90 | make_post(p) 91 | assert last_response.body.include?(t(:invalid_chars)), field 92 | end 93 | end 94 | end 95 | 96 | it 'enforces cooldowns between new threads' do 97 | DB.transaction(:rollback => :always) do 98 | make_post({ 'title' => 'test', 'comment' => 'test' }) 99 | CONFIG[:delay_thread] = 100 100 | make_post({ 'title' => 'test', 'comment' => 'test' }) 101 | assert last_response.body.include?(t(:fast_post)) 102 | end 103 | end 104 | 105 | it 'enforces cooldowns between new replies' do 106 | DB.transaction(:rollback => :always) do 107 | make_post({ 'thread' => '1', 'comment' => 'test' }) 108 | CONFIG[:delay_reply] = 100 109 | make_post({ 'thread' => '1', 'comment' => 'test' }) 110 | assert last_response.body.include?(t(:fast_post)) 111 | end 112 | end 113 | 114 | it 'lets capcoded posts bypass cooldowns' do 115 | sid_as('admin') 116 | 117 | DB.transaction(:rollback => :always) do 118 | make_post({ 'thread' => '1', 'comment' => 'test' }) 119 | CONFIG[:delay_reply] = 100 120 | make_post({ 121 | 'thread' => '1', 'comment' => 'test', 'author' => '##capcode_admin' 122 | }) 123 | assert last_response.body.include?('http-equiv="Refresh"') 124 | end 125 | end 126 | 127 | it 'fails if the honeypot field is not empty' do 128 | DB.transaction(:rollback => :always) do 129 | make_post({ 'title' => 'test', 'email' => '1', 'comment' => 'test' }) 130 | last_response.body.size.must_equal 0 131 | end 132 | end 133 | 134 | it 'limits the number of replies per thread' do 135 | CONFIG[:post_limit] = 1 136 | DB.transaction(:rollback => :always) do 137 | make_post({ 'thread' => '1', 'comment' => 'test' }) 138 | assert last_response.body.include?(t(:thread_full)) 139 | end 140 | end 141 | 142 | it "doesn't allow to reply to locked threads" do 143 | DB.transaction(:rollback => :always) do 144 | DB[:threads].where(:id => 1).update({ :locked => BBS::THREAD_LOCKED }) 145 | make_post({ 'thread' => '1', 'comment' => 'test' }) 146 | refute last_response.body.include?('http-equiv="Refresh"') 147 | end 148 | end 149 | 150 | it 'prunes inactive threads when a new thread is made' do 151 | CONFIG[:thread_limit] = 2 152 | DB.transaction(:rollback => :always) do 153 | t1 = insert_thread do |t, p| 154 | t[:pinned] = 1 155 | t[:updated_on] = Time.now.utc.to_i - 99999 156 | end 157 | 158 | make_post({ 'title' => 'test', 'comment' => 'test' }) 159 | 160 | assert_equal(1, DB[:threads].where(:id => t1).count) 161 | assert_equal(2, DB[:threads].where(:board_id => 1).count) 162 | 163 | File.exist?("#{app.settings.files_dir}/1/1").must_equal false, 'files' 164 | end 165 | end 166 | 167 | it 'disables pruning if thread_limit is nil' do 168 | CONFIG[:thread_limit] = nil 169 | DB.transaction(:rollback => :always) do 170 | tid = make_post({ 'title' => 'test', 'comment' => 'test' }) 171 | assert last_response.body.include?('http-equiv="Refresh"') 172 | end 173 | end 174 | 175 | it 'understands sage' do 176 | DB.transaction(:rollback => :always) do 177 | th = DB[:threads].first(:id => 1) 178 | post_count = th[:post_count] 179 | updated_on = th[:updated_on] 180 | make_post({ 'thread' => '1', 'comment' => 'test', 'sage' => '1' }) 181 | th = DB[:threads].first(:id => 1) 182 | th[:post_count].must_equal(post_count + 1, "Post didn't go through") 183 | th[:updated_on].must_equal updated_on 184 | end 185 | end 186 | 187 | describe 'Posting from banned IPs' do 188 | it 'disallows posting from a banned IPv4' do 189 | DB.transaction(:rollback => :always) do 190 | prepare_ban('192.0.2.1') 191 | make_post( 192 | { 'thread' => '1', 'comment' => 'test' }, 193 | { 'REMOTE_ADDR' => '192.0.2.1' } 194 | ) 195 | assert last_response.redirection? 196 | end 197 | end 198 | 199 | it 'disallows posting from a banned IPv6 /64 block' do 200 | DB.transaction(:rollback => :always) do 201 | prepare_ban('2001:db8:1:1::1') 202 | make_post( 203 | { 'thread' => '1', 'comment' => 'test' }, 204 | { 'REMOTE_ADDR' => '2001:db8:1:1::2' } 205 | ) 206 | assert last_response.redirection? 207 | end 208 | end 209 | 210 | it 'handles IPv4-mapped IPv6 addresses' do 211 | DB.transaction(:rollback => :always) do 212 | prepare_ban('::ffff:192.0.2.1') 213 | make_post( 214 | { 'thread' => '1', 'comment' => 'test' }, 215 | { 'REMOTE_ADDR' => '::ffff:192.0.2.1' } 216 | ) 217 | assert last_response.redirection?, 'IPv4-mapped IPv6' 218 | end 219 | 220 | DB.transaction(:rollback => :always) do 221 | prepare_ban('::192.0.2.1') 222 | make_post( 223 | { 'thread' => '1', 'comment' => 'test' }, 224 | { 'REMOTE_ADDR' => '::192.0.2.1' } 225 | ) 226 | assert last_response.redirection?, 'IPv4-compatible IPv6' 227 | end 228 | end 229 | end 230 | 231 | describe 'Captcha' do 232 | it 'validates reCaptcha v2' do 233 | CONFIG[:captcha] = true 234 | 235 | stub_instance(Net::HTTP, :get, Net::HTTPResponse.new(1.0, 200, 'OK')) do 236 | # Empty captcha 237 | stub_instance(Net::HTTPResponse, :body, '{"success":true}') do 238 | DB.transaction(:rollback => :always) do 239 | make_post({ 'title' => 'test', 'comment' => 'test' }) 240 | end 241 | end 242 | assert last_response.body.include?(t(:captcha_empty_error)), 'Empty' 243 | 244 | # Bad captcha 245 | stub_instance(Net::HTTPResponse, :body, '{"success":false}') do 246 | DB.transaction(:rollback => :always) do 247 | make_post({ 'title' => 'test', 'comment' => 'test', 248 | 'g-recaptcha-response' => 'x' }) 249 | end 250 | end 251 | assert last_response.body.include?(t(:captcha_invalid_error)), 'Bad' 252 | 253 | # Good captcha 254 | stub_instance(Net::HTTPResponse, :body, '{"success":true}') do 255 | DB.transaction(:rollback => :always) do 256 | make_post({ 'title' => 'test', 'comment' => 'test', 257 | 'g-recaptcha-response' => 'x' }) 258 | end 259 | end 260 | assert last_response.body.include?('http-equiv="Refresh"'), 'Good' 261 | end 262 | end 263 | end 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /spec/reading_spec.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative 'spec_helper.rb' 4 | 5 | class HiveSpec < MiniTest::Spec 6 | self.reset_db 7 | self.reset_dirs 8 | 9 | def setup 10 | HiveSpec.reset_config 11 | end 12 | 13 | describe 'Index page' do 14 | it 'lists boards' do 15 | get '/' 16 | assert last_response.ok? 17 | end 18 | end 19 | 20 | describe 'Board page' do 21 | it 'lists threads' do 22 | get '/test/' 23 | assert last_response.ok? 24 | end 25 | 26 | it 'redirects to the board root when the page number is 1' do 27 | get '/test/1' 28 | assert last_response.redirect? 29 | assert last_response['Location'].end_with?('/test/') 30 | end 31 | 32 | it 'returns 404 for out of bounds page numbers' do 33 | get '/test/0' 34 | assert last_response.not_found?, "Page 0 didn't 404" 35 | get '/test/2' 36 | assert last_response.not_found?, "Out of bounds page didn't 404" 37 | CONFIG[:threads_per_page] = nil 38 | get '/test/1' 39 | assert last_response.not_found?, "Pagination disabled" 40 | end 41 | end 42 | 43 | describe 'Thread page' do 44 | it 'displays the thread' do 45 | get '/test/read/1' 46 | assert last_response.ok? 47 | end 48 | end 49 | 50 | describe '/markup' do 51 | it 'generates comment previews' do 52 | post '/markup', { 'comment' => '*test*' } 53 | assert last_response.body.include?('>test :always) do 22 | post '/report/test/1/1' 23 | count = DB[:reports].count 24 | assert_equal(1, count) 25 | end 26 | end 27 | 28 | it 'can be disabled' do 29 | CONFIG[:post_reporting] = false 30 | DB.transaction(:rollback => :always) do 31 | post '/report/test/1/1' 32 | count = DB[:reports].count 33 | assert_equal(0, count) 34 | assert last_response.body.include?(t(:cannot_report)) 35 | end 36 | end 37 | 38 | it 'supports categories and priorities' do 39 | CONFIG[:report_categories] = { 40 | 'rule' => 1, 41 | 'illegal' => 100 42 | } 43 | 44 | DB.transaction(:rollback => :always) do 45 | post '/report/test/1/1' 46 | count = DB[:reports].count 47 | assert_equal(0, count) 48 | assert last_response.body.include?(t(:bad_report_cat)) 49 | end 50 | 51 | DB.transaction(:rollback => :always) do 52 | post '/report/test/1/1', { 'category' => 'rule' } 53 | count = DB[:reports].count 54 | assert_equal(1, count) 55 | end 56 | end 57 | 58 | it 'only allows one report per user per post' do 59 | DB.transaction(:rollback => :always) do 60 | post '/report/test/1/1' 61 | post '/report/test/1/1' 62 | count = DB[:reports].count 63 | assert_equal(1, count) 64 | assert last_response.body.include?(t(:duplicate_report)) 65 | end 66 | end 67 | 68 | it 'enforces cooldowns' do 69 | CONFIG[:delay_report] = 9999 70 | DB.transaction(:rollback => :always) do 71 | post '/report/test/1/1' 72 | make_post({ 'thread' => '1', 'comment' => 'test' }) 73 | post '/report/test/1/2' 74 | count = DB[:reports].count 75 | assert_equal(1, count) 76 | assert last_response.body.include?(t(:fast_report)) 77 | end 78 | end 79 | 80 | it 'fails if the honeypot field is not empty' do 81 | DB.transaction(:rollback => :always) do 82 | post '/report/test/1/1', { 'email' => '1' } 83 | last_response.body.size.must_equal 0 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | 3 | if ENV['COVERAGE'] 4 | require 'simplecov' 5 | SimpleCov.start { add_filter '/spec/' } 6 | end 7 | 8 | require_relative '../hive.rb' 9 | require 'minitest' 10 | require 'minitest/autorun' 11 | require 'rack/test' 12 | 13 | class HiveSpec < MiniTest::Spec 14 | 15 | include Rack::Test::Methods 16 | 17 | include Hive 18 | 19 | DATA = File.expand_path(File.dirname(__FILE__)) + '/data' 20 | 21 | DB = BBS::DB 22 | 23 | CONFIG = BBS::CONFIG 24 | 25 | CONFIG[:delay_thread] = 0 26 | CONFIG[:delay_reply] = 0 27 | CONFIG[:delay_report] = 0 28 | CONFIG[:file_uploads] = true 29 | CONFIG[:post_reporting] = true 30 | CONFIG[:reporting_captcha] = false 31 | 32 | CONFIG_CLEAN = Marshal.load(Marshal.dump(CONFIG)) 33 | 34 | DIRS = [:files_dir, :tmp_dir] 35 | 36 | def app 37 | BBS.new! 38 | end 39 | 40 | def t(str) 41 | BBS::STRINGS[str] 42 | end 43 | 44 | def sid_as(group) 45 | set_cookie "sid=#{group}" 46 | set_cookie "csrf=ok" 47 | end 48 | 49 | def make_post(fields = {}, rack_env = {}) 50 | fields['board'] = 'test' 51 | 52 | if fields['file'] 53 | fields['file'] = Rack::Test::UploadedFile.new( 54 | "#{DATA}/#{fields['file']}", 'application/octet-stream', true 55 | ) 56 | end 57 | 58 | post '/post', fields, rack_env 59 | end 60 | 61 | def insert_thread 62 | now = Time.now.utc.to_i 63 | 64 | thread_opts = { 65 | board_id: 1, 66 | created_on: now, 67 | updated_on: now, 68 | post_count: 1 69 | } 70 | 71 | post_opts = { 72 | board_id: thread_opts[:board_id], 73 | num: 1, 74 | created_on: thread_opts[:created_on], 75 | ip: '127.0.0.1', 76 | comment: "Test" 77 | } 78 | 79 | yield thread_opts, post_opts if block_given? 80 | 81 | board_id = thread_opts[:board_id] 82 | 83 | thread_count = DB[:boards].where(id: board_id).get(:thread_count) 84 | thread_num = thread_count + 1 85 | 86 | DB[:boards].where(id: board_id).update({ 87 | thread_count: thread_num 88 | }) 89 | 90 | thread_opts[:num] = thread_num 91 | thread_opts[:title] ||= "Test #{thread_num}" 92 | 93 | tid = DB[:threads].insert(thread_opts) 94 | 95 | post_opts[:thread_id] = tid 96 | 97 | DB[:posts].insert(post_opts) 98 | 99 | return tid 100 | end 101 | 102 | def prepare_ban(ip_str, seconds = nil, active = true) 103 | now = Time.now.utc.to_i 104 | 105 | if !seconds 106 | expires_on = BBS::MAX_BAN 107 | else 108 | expires_on = now + seconds 109 | end 110 | 111 | ip_addr = IPAddr.new(ip_str) 112 | 113 | if ip_addr.ipv6? 114 | if ip_addr.ipv4_compat? || ip_addr.ipv4_mapped? 115 | ip_addr = ip_addr.native 116 | else 117 | ip_addr = ip_addr.mask(64) 118 | end 119 | end 120 | 121 | post = DB[:posts].first 122 | 123 | post[:slug] = 'test' 124 | 125 | DB[:bans].insert({ 126 | :active => active, 127 | :created_on => now, 128 | :expires_on => expires_on, 129 | :duration => seconds.to_i / 3600, 130 | :reason => 'test reason', 131 | :info => 'test description', 132 | :ip => Sequel::SQL::Blob.new(ip_addr.hton), 133 | :created_by => 1, 134 | :post => post.to_json 135 | }) 136 | end 137 | 138 | def self.reset_config 139 | CONFIG.merge!(Marshal.load(Marshal.dump(CONFIG_CLEAN))) 140 | end 141 | 142 | def self.reset_board_dir 143 | FileUtils.rm_rf(BBS.settings.files_dir + '/1') 144 | FileUtils.mkdir_p(BBS.settings.files_dir + '/1/1') 145 | end 146 | 147 | def self.reset_dirs 148 | DIRS.each do |dir| 149 | path = BBS.settings.send(dir) 150 | 151 | FileUtils.rm_rf(path) if File.directory?(path) 152 | FileUtils.mkdir_p(path) 153 | end 154 | 155 | self.reset_board_dir 156 | end 157 | 158 | def self.reset_db 159 | now = Time.now.utc.to_i - 1 160 | 161 | trunc = 'TRUNCATE TABLE %s' 162 | 163 | if DB.database_type == :sqlite 164 | sql = "SELECT name FROM sqlite_master WHERE type = 'table'" 165 | DB.fetch(sql).each do |row| 166 | table = row.values.first 167 | next if table == 'schema_info' 168 | DB.run("DELETE FROM #{table}") 169 | end 170 | #DB.run("DROP TABLE IF EXISTS sqlite_sequence") 171 | elsif DB.database_type == :postgres 172 | sql = "SELECT table_name FROM information_schema.tables " << 173 | "WHERE table_schema='public'" 174 | DB.fetch(sql).each do |row| 175 | table = row.values.first 176 | next if table == 'schema_info' 177 | DB.run("TRUNCATE TABLE #{table} RESTART IDENTITY") 178 | end 179 | trunc << ' RESTART IDENTITY' 180 | else 181 | DB.fetch('SHOW TABLES').each do |row| 182 | table = row.values.first 183 | next if table == 'schema_info' 184 | DB[table.to_sym].truncate 185 | end 186 | end 187 | 188 | DB[:boards].insert({ 189 | slug: 'test', 190 | title: 'Test', 191 | created_on: now, 192 | thread_count: 1 193 | }) 194 | 195 | DB[:threads].insert({ 196 | board_id: 1, 197 | num: 1, 198 | title: 'Test', 199 | created_on: now, 200 | updated_on: now, 201 | post_count: 1 202 | }) 203 | 204 | DB[:posts].insert({ 205 | board_id: 1, 206 | thread_id: 1, 207 | num: 1, 208 | created_on: now, 209 | ip: '127.0.0.1', 210 | comment: 'Test' 211 | }) 212 | 213 | BBS::USER_LEVELS.merge({ 'none' => 0 }).each do |username, level| 214 | DB[:users].insert({ 215 | username: username.to_s, 216 | password: BCrypt::Password.create(username.to_s), 217 | level: level, 218 | created_on: now, 219 | }) 220 | end 221 | 222 | BBS::USER_LEVELS.merge({ 'none' => 0 }).keys.each_with_index do |user, id| 223 | DB[:sessions].insert({ 224 | sid: BBS.new!.hash_session_id(user.to_s), 225 | user_id: id + 1, 226 | ip: '127.0.0.1', 227 | created_on: now, 228 | updated_on: now 229 | }) 230 | end 231 | end 232 | 233 | def stub_instance(klass, method, ret) 234 | tmp_method = "__hive_#{method}" 235 | 236 | klass.class_eval do 237 | alias_method tmp_method, method 238 | 239 | define_method(method) do |*args| 240 | if ret.respond_to? :call 241 | ret.call(*args) 242 | else 243 | ret 244 | end 245 | end 246 | end 247 | 248 | yield 249 | ensure 250 | klass.class_eval do 251 | undef_method method 252 | alias_method method, tmp_method 253 | undef_method tmp_method 254 | end 255 | end 256 | 257 | Minitest.after_run do 258 | DIRS.each do |dir| 259 | path = BBS.settings.send(dir) 260 | 261 | if File.directory?(path) 262 | FileUtils.rm_rf(path) 263 | end 264 | end 265 | end 266 | 267 | end 268 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /views/assets.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% if cfg(:tegaki, @board_cfg) %> 6 | <% end %> -------------------------------------------------------------------------------- /views/banned.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | Ban status 7 | 8 | 9 |
10 | 13 | <% if @bans.empty? %> 14 |

<%= t(:not_banned) %>

15 | <% else %> 16 | <% @bans.each do |ban| %> 17 |
18 |

You <%= ban[:duration] == 0 ? 'have been warned' : 'are banned' %>

19 |
Reason:
20 |
<%= ban[:reason] %>
21 | <% if ban[:duration] != 0 %> 22 |
Expires on:
23 |
<%= ban[:duration] < 0 ? 'Never' : Time.at(ban[:expires_on]).utc.strftime(t(:time_format)) %>
24 | <% end %> 25 | <% if ban[:post]; post = JSON.parse(ban[:post], symbolize_names: true) %> 26 |
27 |
Your message was posted on /<%= post[:slug] %>/ and contained:
28 | <% if post[:comment] %> 29 |
<%= post[:comment] %>
30 | <% end %> 31 | <% if post[:file_hash]; 32 | file_meta = JSON.parse(post[:meta], symbolize_names: true)[:file] %> 33 |
File:
34 |
<%= file_meta[:ext] %> <%= file_meta[:w] %>×<%= file_meta[:h] %> <%= pretty_bytesize(file_meta[:size]) %> <%= post[:file_hash] %>
35 | <% end %> 36 |
37 | <% end %> 38 |
39 | <% end %> 40 | <% end %> 41 |
42 | <%= erb :footer %> 43 | 44 | 45 | -------------------------------------------------------------------------------- /views/board.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | /<%= @board[:slug] %>/ - <%= @board[:title] %> 7 | 8 | 9 |
10 | 16 | <% 17 | forced_anon = false 18 | anon = cfg(:anon) 19 | time_format = t(:time_format) 20 | %> 21 |

<%= @board[:title] %>

22 | 31 | <% if @current_page %> 32 | 41 | <% end %> 42 | <%= erb :form %> 43 |
44 | <%= erb :footer %> 45 | 46 | 47 | -------------------------------------------------------------------------------- /views/error.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | Error 7 | 8 | 9 |
10 |

<%= @msg %>

11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /views/footer.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/form.erb: -------------------------------------------------------------------------------- 1 |

<%= @thread ? 'Reply' : 'New thread' %>

2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <% if !@thread %> 11 | 12 | 13 | 14 | 15 | <% end %> 16 | 17 | 18 | 19 | <% if cfg(:file_uploads, @board_cfg) %> 20 | 21 | 22 | <% end %> 23 | 24 | 25 | <% if cfg(:captcha, @board_cfg) %> 26 | <% end %> 32 | 33 | 34 | 35 | 36 |
Preview?<% if @thread %><% end %>
<% if cfg(:tegaki, @board_cfg) %>Draw<% end %>
27 | 31 |
<% if cfg(:captcha, @board_cfg) %>Display Captcha<% end %>
37 | 38 | <% if @thread %><% end %> 39 |
-------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | <%= cfg(:app_name) %> 7 | 8 | 9 |
10 |

<%= cfg(:app_name) %>

11 | 16 |
17 | <%= erb :footer %> 18 | 19 | 20 | -------------------------------------------------------------------------------- /views/manage.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | Manage 7 | 8 | 9 |
10 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /views/manage_assets.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /views/manage_bans.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | Manage bans 7 | 8 | 9 |
10 | 13 |

Manage bans

14 |

Search

15 |
16 | IP: value="<%= @ip %>" <% end %>name="q"> 17 |
18 |

<% if @ip %>Bans for <%= @ip %><% else %>Recent entries<% end %>

19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | <% time_format = t(:time_format) %> 32 | <% @bans.each do |ban| %> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 48 | <% end %> 49 | 50 |
ActiveIP or SubnetReason and DescriptionCreated byCreated onExpires onDuration
<% if ban[:active] %>✓<% end %><%= IPAddr.new_ntoh(ban[:ip]) %><%= ban[:reason] %><% if ban[:info] %>
<%= ban[:info] %>
<% end %>
<%= ban[:username] %><%= Time.at(ban[:created_on]).utc.strftime(time_format) %><% if ban[:duration] > 0 %><%= Time.at(ban[:expires_on]).utc.strftime(time_format) %><% end %><% 41 | if ban[:duration] < 0 %>Permanent<% 42 | elsif ban[:duration] == 0 %>Warning<% 43 | else %><%= ban[:duration] %> hour<%= 's' if ban[:duration] > 1 %><% 44 | end 45 | %>Edit
51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /views/manage_bans_edit.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | <%= @post ? 'Ban' : 'Update ban' %> 7 | 8 | 9 |
10 | 13 |

<%= @post ? 'Ban' : 'Update ban' %>

14 | 15 | <% post = @post || JSON.parse(@ban[:post], symbolize_names: true) %> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | <% if @ban %> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | <% if post[:title] %> 42 | 43 | 44 | 45 | 46 | <% end %> 47 | <% if post[:comment] %> 48 | 49 | 50 | 51 | 52 | <% end %> 53 | <% if post[:file_hash]; file_meta = JSON.parse(post[:meta], symbolize_names: true)[:file] %> 54 | 55 | 56 | 57 | 58 | <% end %> 59 | <% end %> 60 |
IP<%= post[:ip] %>
Reverse<%= resolve_name post[:ip] %>
Banned on<%= Time.at(@ban[:created_on]).utc.strftime(t(:time_format)) %><% if @ban[:creator_name] %> by <%= @ban[:creator_name] %><% end %><% if @ban[:updater_name] %>, updated by <%= @ban[:updater_name] %><% end %>
Post ID/<%= (post[:slug] || post[:board_num]) %>/<%= post[:thread_num] %>/<%= post[:num] %>
Name<%= (post[:author] || cfg(:anon)) %><% if post[:tripcode] %>!<%= post[:tripcode] %><% end %>
Date<%= Time.at(post[:created_on]).utc.strftime(t(:time_format)) %>
Title<%= post[:title] %>
Comment<%= post[:comment] %>
File<%= file_meta[:ext] %> <%= file_meta[:w] %>×<%= file_meta[:h] %> <%= pretty_bytesize(file_meta[:size]) %> <%= post[:file_hash] %>
61 |
62 | <% if @ban %> 63 | 64 | 65 | <% else %> 66 | 67 | 68 | 69 | <% end %> 70 | 71 | 72 | 73 | 74 | 75 | 76 |
    77 |
  • Null duration results in a warning.
  • 78 |
  • Negative duration results in a permaban.
  • 79 |
80 | 81 | 82 |
83 |
84 | 85 | 86 | -------------------------------------------------------------------------------- /views/manage_boards.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | Manage boards 7 | 8 | 9 |
10 | 15 |

Manage boards

16 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /views/manage_boards_edit.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | <%= @board ? 'Edit' : 'Add' %> board 7 | 8 | 9 |
10 | 13 |

<%= @board ? 'Edit' : 'Add' %> board

14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | <%= csrf_tag(request.cookies['csrf']) %> 22 | <% if @board %><% end %> 23 | 24 |
25 | <% if @board %> 26 |
27 |

Delete this board

28 |
29 | <%= csrf_tag(request.cookies['csrf']) %> 30 | 31 | 32 | 33 |
34 |
35 | <% end %> 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /views/manage_login.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | Login 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
<%= csrf_tag(@csrf, 'auth_csrf') %> 24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /views/manage_profile.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | Manage profile 7 | 8 | 9 |
10 | 13 |

Profile

14 |

Change password

15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /views/manage_reports.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | 7 | 8 | Reports 9 | 10 | 11 |
12 | 15 |

Reports

16 |
17 | <% 18 | anon = cfg(:anon, @board_cfg) 19 | time_format = t(:time_format) 20 | @posts.each do |post| 21 | meta = post[:meta] ? JSON.parse(post[:meta], symbolize_names: true) : {} %> 22 |
" class="post-report<% if post[:num] > 1 %> is-reply<% end %>" data-post-id="<%= post[:id] %>"> 23 |
24 |
25 | ×<%= post[:total].to_i %> 26 | /<%= post[:slug] %>/ 27 | <% if post[:tripcode] %>!<%= post[:tripcode] %><% end %> 28 | 29 | <%= post[:title] %>(<%= post[:post_count] %>) 30 |
31 | <% if post[:comment] %> 32 |
33 | <%= post[:comment] %> 34 |
35 | <% end %> 36 | <% if post[:file_hash]; 37 | file_meta = JSON.parse(post[:meta], symbolize_names: true)[:file]; 38 | file_href = "/files/#{post[:board_id]}/#{post[:thread_id]}/#{post[:file_hash]}.#{file_meta[:ext]}" %> 39 |
40 |
<%= file_meta[:ext] %> <%= file_meta[:w] %>×<%= file_meta[:h] %> <%= pretty_bytesize(file_meta[:size]) %>
41 | .jpg" data-cmd="fexp" width="<%= file_meta[:th_w] %>" height="<%= file_meta[:th_h] %>" alt=""> 42 |
<% end %> 43 |
44 |
Del<% if post[:file_hash] %>DelFile<% end %>">BanDismiss">Open
45 |
46 | <% end %> 47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /views/manage_users.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | Manage users 7 | 8 | 9 |
10 | 15 |

Manage users

16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <% @users.each do |user| %> 24 | 25 | 26 | 27 | 28 | 29 | <% end %> 30 | 31 |
UserGroup
<%= user[:username] %><%= t(:user_levels)[USER_GROUPS[user[:level]]] %>Edit
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /views/manage_users_edit.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :manage_assets %> 6 | <%= @user ? 'Edit' : 'Add' %> user 7 | 8 | 9 |
10 | 13 |

<%= @user ? 'Edit' : 'Add' %> user

14 |
15 | 16 | 17 | 18 | 24 | <% if @user %><% end %> 25 | <%= csrf_tag(request.cookies['csrf']) %> 26 | <% if @user %><% end %> 27 | 28 |
29 | <% if @user %> 30 |
31 |

Delete this user

32 |
33 | <%= csrf_tag(request.cookies['csrf']) %> 34 | 35 | <% if @user %><% end %> 36 | 37 |
38 |
39 | <% end %> 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /views/not_found.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | 404 Not Found 7 | 8 | 9 |
10 |

404 Not Found

11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /views/post.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | " /> 6 | <%= erb :assets %> 7 | Great success! 8 | 9 | 10 |
11 |

Great success!

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /views/read.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | /<%= @board[:slug] %>/ - <%= @thread[:title] %> 7 | 8 | 0 %> data-locked<% end %><% if @thread[:pinned] > 0 %> data-pinned="<%= @thread[:pinned] %>"<% end %><% if cfg(:post_reporting) %> data-reporting<% end %>> 9 |
10 | 15 |

<%= @thread[:title] %>

16 |
17 | <% 18 | forced_anon = cfg(:forced_anon, @board_cfg) 19 | anon = cfg(:anon, @board_cfg) 20 | time_format = t(:time_format) 21 | @posts.each do |post| 22 | meta = post[:meta] ? JSON.parse(post[:meta], symbolize_names: true) : {} 23 | %> 24 |
25 |
26 | <%= post[:num] %> <% if post[:tripcode] && (!forced_anon || meta[:capcode]) %>!<%= post[:tripcode] %><% end %> 27 |
28 | <% if post[:comment] %> 29 |
30 | <%= post[:comment] %> 31 |
32 | <% end %> 33 | <% if post[:file_hash]; 34 | file_meta = JSON.parse(post[:meta], symbolize_names: true)[:file]; 35 | file_href = "/files/#{@board[:id]}/#{post[:thread_id]}/#{post[:file_hash]}.#{file_meta[:ext]}" %> 36 |
37 |
<%= file_meta[:ext] %> <%= file_meta[:w] %>×<%= file_meta[:h] %> <%= pretty_bytesize(file_meta[:size]) %>
38 | .jpg" data-cmd="fexp" width="<%= file_meta[:th_w] %>" height="<%= file_meta[:th_h] %>" alt=""> 39 |
40 | <% end %> 41 |
42 | <% end %> 43 |
44 | <% if @thread[:post_count] >= cfg(:post_limit) %> 45 |

<%= t(:thread_full) %>

46 | <% elsif @thread[:locked] > 0 %> 47 |

<%= t(:thread_locked) %>

48 | <% else @_out_buf << (erb :form) end %> 49 |
50 | <%= erb :footer %> 51 | 52 | 53 | -------------------------------------------------------------------------------- /views/report.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= erb :assets %> 6 | <%= cfg(:app_name) %> 7 | 8 | 9 |
10 |

Reporting

11 |
12 | 13 | <% if !cfg(:report_categories).empty?; cfg(:report_categories).each_key do |cat| %> 14 | 15 | <% end; end %> 16 | <% if cfg(:reporting_captcha) %> 17 |
18 | 21 | <% end %> 22 | 23 |
24 |
25 | <%= erb :footer %> 26 | 27 | 28 | -------------------------------------------------------------------------------- /views/success.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% if @redirect %><% end %> 6 | <%= erb :assets %> 7 | Success 8 | 9 | 10 |
11 |

<%= @msg %>

12 |
13 | 14 | 15 | --------------------------------------------------------------------------------