├── .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')
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/spec/reporting_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 | describe 'Reporting' do
15 | it 'lets users request post deletion' do
16 | get '/report/test/1/1'
17 | assert last_response.ok?, last_response.body
18 | end
19 |
20 | it 'creates reports for posts' do
21 | DB.transaction(:rollback => :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 |
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 |
--------------------------------------------------------------------------------
/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 |
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">Search
17 |
18 |
<% if @ip %>Bans for <%= @ip %><% else %>Recent entries<% end %>
19 |
20 |
21 | Active
22 | IP or Subnet
23 | Reason and Description
24 | Created by
25 | Created on
26 | Expires on
27 | Duration
28 |
29 |
30 |
31 | <% time_format = t(:time_format) %>
32 | <% @bans.each do |ban| %>
33 |
34 | <% if ban[:active] %>✓<% end %>
35 | <%= IPAddr.new_ntoh(ban[:ip]) %>
36 | <%= ban[:reason] %><% if ban[:info] %><%= ban[:info] %>
<% end %>
37 | <%= ban[:username] %>
38 | <%= Time.at(ban[:created_on]).utc.strftime(time_format) %>
39 | <% if ban[:duration] > 0 %><%= Time.at(ban[:expires_on]).utc.strftime(time_format) %><% end %>
40 | <%
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 | %>
46 | Edit
47 |
48 | <% end %>
49 |
50 |
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 |
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 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/views/manage_login.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <%= erb :manage_assets %>
6 | Login
7 |
8 |
9 |
10 |
11 | <%= 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 |
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 | <%= (post[:author] || anon) %> <% if post[:tripcode] %>!<%= post[:tripcode] %> <% end %>
28 | <%= Time.at(post[:created_on]).utc.strftime(time_format) %>
29 | <%= post[:title] %>(<%= post[:post_count] %>)
30 |
31 | <% if post[:comment] %>
32 |
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 |
<% end %>
43 |
44 |
Del <% if post[:file_hash] %>
DelFile <% end %>
">Ban Dismiss ">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 | User
19 | Group
20 |
21 |
22 |
23 | <% @users.each do |user| %>
24 |
25 | <%= user[:username] %>
26 | <%= t(:user_levels)[USER_GROUPS[user[:level]]] %>
27 | Edit
28 |
29 | <% end %>
30 |
31 |
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 |
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] %> <%= forced_anon ? anon : (post[:author] || anon) %> <% if post[:tripcode] && (!forced_anon || meta[:capcode]) %>
!<%= post[:tripcode] %> <% end %>
<%= Time.at(post[:created_on]).utc.strftime(time_format) %>
27 |
28 | <% if post[:comment] %>
29 |
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 |
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 |
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 |
--------------------------------------------------------------------------------