diff --git a/.gitignore b/.gitignore
index 0abd85ad..6a1cc1be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,24 +1,9 @@
-*.gem
-*.rbc
-.bundle
.config
-coverage
-InstalledFiles
-lib/bundler/man
-pkg
-rdoc
-spec/reports
-test/tmp
-test/version_tmp
-tmp
-# YARD artifacts
-.yardoc
-_yardoc
-doc/
tests/coverage
config.yml
.DS_Store
public/css/neo.css
+public/css/neo.css.map
public/site_thumbnails
public/sites
public/site_screenshots
@@ -26,12 +11,16 @@ public/site_screenshots_test
public/site_thumbnails_test
*.swp
files/map.txt
+files/supporter-map.txt
+files/maps
.sass-cache
.sass-cache/*
files/sslsites.zip
.tm_properties
-./black_box.rb
.vagrant
public/banned_sites
public/deleted_sites
-tests/stat_logs/*
+files/disposable_email_blacklist.conf
+files/letsencrypt.key
+files/tor.txt
+.bundle
diff --git a/.travis.yml b/.travis.yml
index e029b7a2..8d082fd8 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,9 @@
language: ruby
rvm:
- - "2.1.1"
+ - "2.3.0"
addons:
postgresql: "9.3"
before_script:
- psql -c 'create database travis_ci_test;' -U postgres
+sudo: false
+bundler_args: --jobs=1
diff --git a/Gemfile b/Gemfile
index dcaf606a..1104711a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,6 +2,7 @@ source 'https://rubygems.org'
gem 'sinatra'
gem 'redis'
+gem 'redis-namespace'
gem 'sequel', '4.8.0'
gem 'bcrypt'
gem 'sinatra-flash', require: 'sinatra/flash'
@@ -9,14 +10,13 @@ gem 'sinatra-xsendfile', require: 'sinatra/xsendfile'
gem 'puma', require: nil
gem 'rack-recaptcha', require: 'rack/recaptcha'
gem 'rmagick', require: nil
-gem 'sidekiq'
+gem 'sidekiq', '~> 4.1.2'
gem 'ago'
gem 'mail'
-gem 'google-api-client', require: 'google/api_client'
gem 'tilt'
gem 'erubis'
-gem 'stripe' #, source: 'https://code.stripe.com/'
-gem 'screencap'
+gem 'stripe', '1.15.0' #, source: 'https://code.stripe.com/'
+#gem 'screencap', '~> 0.1.4'
gem 'cocaine'
gem 'zipruby'
gem 'sass', require: nil
@@ -25,18 +25,37 @@ gem 'filesize'
gem 'thread'
gem 'scrypt'
gem 'rack-cache'
-gem 'rest-client'
+gem 'rest-client', require: 'rest_client'
+gem 'addressable', require: 'addressable/uri'
+gem 'paypal-recurring', require: 'paypal/recurring'
gem 'geoip'
gem 'io-extra', require: 'io/extra'
gem 'rye'
-gem 'dnsruby'
+gem 'base32'
+gem 'coveralls', require: false
+gem 'sanitize'
+gem 'will_paginate'
+gem 'simpleidn'
+gem 'gandi'
+gem 'hoe', '3.14.2', require: nil
+gem 'msgpack'
+gem 'json-jwt', {
+ git: 'https://github.com/neocities/json-jwt.git',
+ branch: 'drop_activesupport'
+}
+gem 'acme-client', {
+ git: 'https://github.com/jhass/acme-client.git',
+ branch: 'no_activesupport'
+}
+gem 'http'
+gem 'htmlentities'
platform :mri, :rbx do
gem 'magic' # sudo apt-get install file, For OSX: brew install libmagic
gem 'pg'
gem 'sequel_pg', require: nil
gem 'hiredis'
- gem 'rainbows', require: nil
+ gem 'posix-spawn'
group :development, :test do
gem 'pry'
@@ -61,6 +80,7 @@ end
group :development do
gem 'shotgun', require: nil
+ gem 'certified'
end
group :test do
@@ -73,10 +93,12 @@ group :test do
gem 'rake', require: nil
gem 'poltergeist'
gem 'capybara_minitest_spec'
+ gem 'capybara', '2.6.2', require: nil
gem 'rack_session_access', require: nil
gem 'webmock', require: nil
- gem 'stripe-ruby-mock', '~> 2.0.1', require: 'stripe_mock'
+ gem 'stripe-ruby-mock', '2.0.1', require: 'stripe_mock'
gem 'timecop'
+ gem 'mock_redis'
platform :mri, :rbx do
gem 'simplecov', require: nil
diff --git a/Gemfile.lock b/Gemfile.lock
index 8ec16f93..968bb56e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,28 +1,46 @@
+GIT
+ remote: https://github.com/jhass/acme-client.git
+ revision: d0ced992bfe42908bb1fc25ac549ae3318386c97
+ branch: no_activesupport
+ specs:
+ acme-client (0.2.2)
+ faraday (~> 0.9, >= 0.9.1)
+ json-jwt (~> 1.2, >= 1.2.3)
+
+GIT
+ remote: https://github.com/neocities/json-jwt.git
+ revision: 00d9c3d34e6bfbab866a4a0405897182a4ed3833
+ branch: drop_activesupport
+ specs:
+ json-jwt (1.5.2)
+ bindata
+ hashery (~> 2.0)
+ multi_json (>= 1.3)
+ securecompare
+ url_safe_base64
+
GEM
remote: https://rubygems.org/
specs:
- activesupport (4.1.4)
- i18n (~> 0.6, >= 0.6.9)
+ activesupport (4.2.6)
+ i18n (~> 0.7)
json (~> 1.7, >= 1.7.7)
minitest (~> 5.1)
- thread_safe (~> 0.1)
+ thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
- addressable (2.3.7)
+ addressable (2.4.0)
ago (0.1.5)
annoy (0.5.6)
highline (>= 1.5.0)
- ansi (1.4.3)
- autoparse (0.3.3)
- addressable (>= 2.3.1)
- extlib (>= 0.9.15)
- multi_json (>= 1.0.0)
- bcrypt (3.1.7)
+ ansi (1.5.0)
+ base32 (0.3.2)
+ bcrypt (3.1.11)
+ bindata (2.3.1)
blankslate (3.1.3)
builder (3.2.2)
- byebug (2.7.0)
- columnize (~> 0.3)
- debugger-linecache (~> 1.2)
- capybara (2.4.4)
+ byebug (8.2.4)
+ capybara (2.6.2)
+ addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
@@ -31,123 +49,123 @@ GEM
capybara_minitest_spec (1.0.5)
capybara (>= 2)
minitest (>= 4)
- celluloid (0.15.2)
- timers (~> 1.1.0)
+ certified (1.0.0)
climate_control (0.0.3)
activesupport (>= 3.0)
cliver (0.3.2)
- cocaine (0.5.4)
+ cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
- coderay (1.1.0)
- columnize (0.8.9)
- connection_pool (2.0.0)
- crack (0.4.2)
+ coderay (1.1.1)
+ concurrent-ruby (1.0.2)
+ connection_pool (2.2.0)
+ coveralls (0.8.13)
+ json (~> 1.8)
+ simplecov (~> 0.11.0)
+ term-ansicolor (~> 1.3)
+ thor (~> 0.19.1)
+ tins (~> 1.6.0)
+ crack (0.4.3)
safe_yaml (~> 1.0.0)
+ crass (1.0.2)
dante (0.2.0)
dav4rack (0.3.0)
nokogiri (>= 1.4.2)
rack (>= 1.1.0)
uuidtools (~> 2.1.1)
- debugger-linecache (1.2.0)
- dnsruby (1.58.0)
- docile (1.1.3)
- domain_name (0.5.23)
+ docile (1.1.5)
+ domain_name (0.5.20160310)
unf (>= 0.0.5, < 1.0.0)
drydock (0.6.9)
erubis (2.7.0)
- extlib (0.9.16)
- fabrication (2.11.0)
- faker (1.3.0)
+ fabrication (2.15.0)
+ faker (1.6.3)
i18n (~> 0.5)
- faraday (0.9.0)
+ faraday (0.9.2)
multipart-post (>= 1.2, < 3)
- ffi (1.9.6)
+ ffi (1.9.10)
ffi-compiler (0.1.3)
ffi (>= 1.0.0)
rake
- filesize (0.0.3)
- geoip (1.5.0)
- google-api-client (0.7.1)
- addressable (>= 2.3.2)
- autoparse (>= 0.3.3)
- extlib (>= 0.9.15)
- faraday (>= 0.9.0)
- jwt (>= 0.1.5)
- launchy (>= 2.1.1)
- multi_json (>= 1.0.0)
- retriable (>= 1.4)
- signet (>= 0.5.0)
- uuidtools (>= 2.1.0)
- hashie (2.0.5)
- highline (1.7.2)
- hiredis (0.5.0)
+ filesize (0.1.1)
+ gandi (2.1.3)
+ hashie
+ geoip (1.6.1)
+ hashdiff (0.3.0)
+ hashery (2.1.2)
+ hashie (3.4.3)
+ highline (1.7.8)
+ hiredis (0.6.1)
+ hoe (3.14.2)
+ rake (>= 0.8, < 11.0)
+ htmlentities (4.3.4)
+ http (2.0.1)
+ addressable (~> 2.3)
+ http-cookie (~> 1.0)
+ http-form_data (~> 1.0.1)
+ http_parser.rb (~> 0.6.0)
http-cookie (1.0.2)
domain_name (~> 0.5)
- i18n (0.6.9)
+ http-form_data (1.0.1)
+ http_parser.rb (0.6.0)
+ i18n (0.7.0)
io-extra (1.2.8)
jimson-temp (0.9.5)
blankslate (>= 3.1.2)
multi_json (~> 1.0)
rack (~> 1.4)
rest-client (~> 1.0)
- json (1.8.1)
- jwt (0.1.11)
- multi_json (>= 1.5)
- kgio (2.9.2)
- launchy (2.4.2)
- addressable (~> 2.3)
- m (1.3.4)
+ json (1.8.3)
+ m (1.4.2)
method_source (>= 0.6.7)
rake (>= 0.9.2.2)
- magic (0.2.6)
+ magic (0.2.9)
ffi (>= 0.6.3)
- mail (2.5.4)
- mime-types (~> 1.16)
- treetop (~> 1.4.8)
+ mail (2.6.4)
+ mime-types (>= 1.16, < 4)
metaclass (0.0.4)
method_source (0.8.2)
- mime-types (1.25.1)
- mini_portile (0.6.2)
- minitest (5.6.1)
- minitest-reporters (1.0.2)
+ mime-types (2.99.1)
+ mini_portile2 (2.0.0)
+ minitest (5.8.4)
+ minitest-reporters (1.1.8)
ansi
builder
minitest (>= 5.0)
- powerbar
- mocha (1.0.0)
+ ruby-progressbar
+ mocha (1.1.0)
metaclass (~> 0.0.1)
- multi_json (1.11.0)
+ mock_redis (0.16.1)
+ msgpack (0.7.5)
+ multi_json (1.11.2)
multipart-post (2.0.0)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
- net-ssh (2.9.2)
- netrc (0.10.3)
- nokogiri (1.6.6.2)
- mini_portile (~> 0.6.0)
- pg (0.17.1)
- phantomjs (1.9.7.1)
- poltergeist (1.6.0)
+ net-ssh (3.1.1)
+ netrc (0.11.0)
+ nokogiri (1.6.7.2)
+ mini_portile2 (~> 2.0.0.rc2)
+ nokogumbo (1.4.7)
+ nokogiri
+ paypal-recurring (1.1.0)
+ pg (0.18.4)
+ poltergeist (1.9.0)
capybara (~> 2.1)
cliver (~> 0.3.1)
multi_json (~> 1.0)
websocket-driver (>= 0.2.0)
- polyglot (0.3.4)
- powerbar (1.0.11)
- ansi (~> 1.4.0)
- hashie (>= 1.1.0)
- pry (0.9.12.6)
- coderay (~> 1.0)
- method_source (~> 0.8)
+ posix-spawn (0.3.11)
+ pry (0.10.3)
+ coderay (~> 1.1.0)
+ method_source (~> 0.8.1)
slop (~> 3.4)
- pry-byebug (1.3.2)
- byebug (~> 2.7)
- pry (~> 0.9.12)
- puma (2.8.1)
- rack (>= 1.1, < 2.0)
- rack (1.6.0)
- rack-cache (1.2)
+ pry-byebug (3.3.0)
+ byebug (~> 8.0)
+ pry (~> 0.10)
+ puma (3.4.0)
+ rack (1.6.4)
+ rack-cache (1.6.1)
rack (>= 0.4)
- rack-protection (1.5.2)
+ rack-protection (1.5.3)
rack
rack-recaptcha (0.6.6)
json
@@ -156,21 +174,16 @@ GEM
rack_session_access (0.1.1)
builder (>= 2.0.0)
rack (>= 1.0.0)
- rainbows (4.6.1)
- kgio (~> 2.5)
- rack (~> 1.1)
- unicorn (~> 4.8)
- raindrops (0.13.0)
- rake (10.3.2)
- redis (3.0.7)
- redis-namespace (1.4.1)
- redis (~> 3.0.4)
+ rake (10.5.0)
+ redis (3.2.2)
+ redis-namespace (1.5.2)
+ redis (~> 3.0, >= 3.0.4)
rest-client (1.8.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 3.0)
netrc (~> 0.7)
- retriable (1.4.1)
- rmagick (2.15.0)
+ rmagick (2.15.4)
+ ruby-progressbar (1.7.5)
rye (0.9.13)
annoy
docile (>= 1.0.1)
@@ -179,38 +192,35 @@ GEM
net-ssh (>= 2.0.13)
sysinfo (>= 0.8.1)
safe_yaml (1.0.4)
- sass (3.3.8)
- screencap (0.1.1)
- phantomjs
- scrypt (2.0.0)
+ sanitize (4.0.1)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.4.4)
+ nokogumbo (~> 1.4.1)
+ sass (3.4.22)
+ scrypt (2.1.1)
ffi-compiler (>= 0.0.2)
rake
+ securecompare (1.0.0)
sequel (4.8.0)
- sequel_pg (1.6.9)
+ sequel_pg (1.6.16)
pg (>= 0.8.0)
- sequel (>= 3.39.0)
- shotgun (0.9)
+ sequel (>= 4.0.0)
+ shotgun (0.9.1)
rack (>= 1.0)
- sidekiq (3.0.0)
- celluloid (>= 0.15.2)
- connection_pool (>= 2.0.0)
- json
- redis (>= 3.0.6)
- redis-namespace (>= 1.3.1)
- signet (0.5.0)
- addressable (>= 2.2.3)
- faraday (>= 0.9.0.rc5)
- jwt (>= 0.1.5)
- multi_json (>= 1.0.0)
- simplecov (0.8.2)
+ sidekiq (4.1.2)
+ concurrent-ruby (~> 1.0)
+ connection_pool (~> 2.2, >= 2.2.0)
+ redis (~> 3.2, >= 3.2.1)
+ simplecov (0.11.2)
docile (~> 1.1.0)
- multi_json
- simplecov-html (~> 0.8.0)
- simplecov-html (0.8.0)
- sinatra (1.4.4)
- rack (~> 1.4)
+ json (~> 1.8)
+ simplecov-html (~> 0.10.0)
+ simplecov-html (0.10.0)
+ simpleidn (0.0.6)
+ sinatra (1.4.7)
+ rack (~> 1.5)
rack-protection (~> 1.4)
- tilt (~> 1.3, >= 1.3.4)
+ tilt (>= 1.3, < 3)
sinatra-flash (0.3.0)
sinatra (>= 1.0.0)
sinatra-xsendfile (0.4.2)
@@ -228,30 +238,29 @@ GEM
sysinfo (0.8.1)
drydock
storable
- thread (0.1.4)
- thread_safe (0.3.4)
- tilt (1.4.1)
- timecop (0.7.4)
- timers (1.1.0)
- treetop (1.4.15)
- polyglot
- polyglot (>= 0.3.1)
+ term-ansicolor (1.3.2)
+ tins (~> 1.0)
+ thor (0.19.1)
+ thread (0.2.2)
+ thread_safe (0.3.5)
+ tilt (2.0.2)
+ timecop (0.8.1)
+ tins (1.6.0)
tzinfo (1.2.2)
thread_safe (~> 0.1)
unf (0.1.4)
unf_ext
- unf_ext (0.0.6)
- unicorn (4.8.2)
- kgio (~> 2.6)
- rack
- raindrops (~> 0.7)
- uuidtools (2.1.4)
- webmock (1.17.4)
- addressable (>= 2.2.7)
+ unf_ext (0.0.7.2)
+ url_safe_base64 (0.2.2)
+ uuidtools (2.1.5)
+ webmock (1.24.2)
+ addressable (>= 2.3.6)
crack (>= 0.3.2)
- websocket-driver (0.5.4)
+ hashdiff
+ websocket-driver (0.6.3)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
+ will_paginate (3.1.0)
xpath (2.0.0)
nokogiri (~> 1.3)
zipruby (0.3.6)
@@ -260,31 +269,44 @@ PLATFORMS
ruby
DEPENDENCIES
+ acme-client!
+ addressable
ago
+ base32
bcrypt
+ capybara (= 2.6.2)
capybara_minitest_spec
+ certified
cocaine
+ coveralls
dav4rack
- dnsruby
erubis
fabrication
faker
filesize
+ gandi
geoip
- google-api-client
hiredis
+ hoe (= 3.14.2)
+ htmlentities
+ http
io-extra
jdbc-postgres
jruby-openssl
json
+ json-jwt!
m
magic
mail
minitest
minitest-reporters
mocha
+ mock_redis
+ msgpack
+ paypal-recurring
pg
poltergeist
+ posix-spawn
pry
pry-byebug
puma
@@ -292,31 +314,33 @@ DEPENDENCIES
rack-recaptcha
rack-test
rack_session_access
- rainbows
rake
redis
+ redis-namespace
rest-client
rmagick
ruby-debug
rye
+ sanitize
sass
- screencap
scrypt
sequel (= 4.8.0)
sequel_pg
shotgun
- sidekiq
+ sidekiq (~> 4.1.2)
simplecov
+ simpleidn
sinatra
sinatra-flash
sinatra-xsendfile
- stripe
- stripe-ruby-mock (~> 2.0.1)
+ stripe (= 1.15.0)
+ stripe-ruby-mock (= 2.0.1)
thread
tilt
timecop
webmock
+ will_paginate
zipruby
BUNDLED WITH
- 1.10.2
+ 1.12.1
diff --git a/README.md b/README.md
index d69f7d66..e5fc7bbf 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# Neocities.org
[](https://travis-ci.org/neocities/neocities)
+[](https://coveralls.io/github/neocities/neocities?branch=master)
The web site for Neocities! It's open source. Want a feature on the site? Send a pull request!
diff --git a/Rakefile b/Rakefile
index 4ac5badd..db3c10ef 100644
--- a/Rakefile
+++ b/Rakefile
@@ -38,10 +38,17 @@ task :parse_logs => [:environment] do
Stat.parse_logfiles $config['logs_path']
end
+desc 'Update disposable email blacklist'
+task :update_disposable_email_blacklist => [:environment] do
+ uri = URI.parse('https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blacklist.conf')
+
+ File.write(Site::DISPOSABLE_EMAIL_BLACKLIST_PATH, Net::HTTP.get(uri))
+end
+
desc 'Update banned IPs list'
task :update_blocked_ips => [:environment] do
uri = URI.parse('http://www.stopforumspam.com/downloads/listed_ip_90.zip')
- blocked_ips_zip = Tempfile.new('blockedipszip', Dir.tmpdir, 'wb')
+ blocked_ips_zip = Tempfile.new('blockedipszip', Dir.tmpdir)
blocked_ips_zip.binmode
Net::HTTP.start(uri.host, uri.port) do |http|
@@ -65,13 +72,65 @@ task :update_blocked_ips => [:environment] do
end
end
-desc 'Compile domain map for nginx'
-task :compile_domain_map => [:environment] do
- File.open('./files/map.txt', 'w') do |file|
- Site.exclude(domain: nil).exclude(domain: '').select(:username,:domain).all.collect do |site|
- file.write ".#{site.domain} #{site.username};\n"
+desc 'parse tor exits'
+task :parse_tor_exits => [:environment] do
+ exit_ips = Net::HTTP.get(URI.parse('https://check.torproject.org/exit-addresses'))
+
+ exit_ips.split("\n").collect {|line|
+ line.match(/ExitAddress (\d+\.\d+\.\d+\.\d+)/)&.captures&.first
+ }.compact
+
+ # ^^ Array of ip addresses of known exit nodes
+end
+
+desc 'Compile nginx mapfiles'
+task :compile_nginx_mapfiles => [:environment] do
+ FileUtils.mkdir_p './files/maps'
+
+ File.open('./files/maps/domains.txt', 'w') do |file|
+ Site.exclude(domain: nil).exclude(domain: '').select(:username,:domain).all.each do |site|
+ file.write ".#{site.values[:domain]} #{site.username};\n"
end
end
+
+ File.open('./files/maps/supporters.txt', 'w') do |file|
+ Site.select(:username, :domain).exclude(plan_type: 'free').exclude(plan_type: nil).all.each do |parent_site|
+ sites = [parent_site] + parent_site.children
+ sites.each do |site|
+ file.write "#{site.username}.neocities.org 1;\n"
+ unless site.host.match(/\.neocities\.org$/)
+ file.write ".#{site.values[:domain]} 1;\n"
+ end
+ end
+ end
+ end
+
+ File.open('./files/maps/sandboxed.txt', 'w') do |file|
+ usernames = DB["select username from sites where created_at > ? and (plan_type is null or plan_type='free')", 1.week.ago].all.collect {|s| s[:username]}.each {|username| file.write "#{username} 1;\n"}
+ end
+
+ # Compile letsencrypt ssl keys
+ sites = DB[%{select username,ssl_key,ssl_cert,domain from sites where ssl_cert is not null and ssl_key is not null and (domain is not null or domain != '') and is_banned != 't' and is_deleted != 't'}].all
+
+ ssl_path = './files/maps/ssl'
+
+ FileUtils.mkdir_p ssl_path
+
+ sites.each do |site|
+ [site[:domain], "www.#{site[:domain]}"].each do |domain|
+ begin
+ key = OpenSSL::PKey::RSA.new site[:ssl_key]
+ crt = OpenSSL::X509::Certificate.new site[:ssl_cert]
+ rescue => e
+ puts "SSL ERROR: #{e.class} #{e.inspect}"
+ next
+ end
+
+ File.open(File.join(ssl_path, "#{domain}.key"), 'wb') {|f| f.write key.to_der}
+ File.open(File.join(ssl_path, "#{domain}.crt"), 'wb') {|f| f.write site[:ssl_cert]}
+ end
+ end
+
end
desc 'Produce SSL config package for proxy'
@@ -216,6 +275,73 @@ task :hash_ips => [:environment] do
end
end
+desc 'prime_site_files'
+task :prime_site_files => [:environment] do
+ Site.where(is_banned: false).where(is_deleted: false).select(:id, :username).all.each do |site|
+ Dir.glob(File.join(site.files_path, '**/*')).each do |file|
+ path = file.gsub(site.base_files_path, '').sub(/^\//, '')
+
+ site_file = site.site_files_dataset[path: path]
+
+ if site_file.nil?
+ mtime = File.mtime file
+
+ site_file_opts = {
+ path: path,
+ updated_at: mtime,
+ created_at: mtime
+ }
+
+ if File.directory? file
+ site_file_opts.merge! is_directory: true
+ else
+ site_file_opts.merge!(
+ size: File.size(file),
+ sha1_hash: Digest::SHA1.file(file).hexdigest
+ )
+ end
+
+ site.add_site_file site_file_opts
+ end
+ end
+ end
+end
+
+desc 'dedupe_follows'
+task :dedupe_follows => [:environment] do
+ follows = Follow.all
+ deduped_follows = Follow.all.uniq {|f| "#{f.site_id}_#{f.actioning_site_id}"}
+
+ follows.each do |follow|
+ unless deduped_follows.include?(follow)
+ puts "deleting dedupe: #{follow.inspect}"
+ follow.delete
+ end
+ end
+end
+
+desc 'flush_empty_index_sites'
+task :flush_empty_index_sites => [:environment] do
+ sites = Site.select(:id).all
+
+ counter = 0
+
+ sites.each do |site|
+ if site.empty_index?
+ counter += 1
+ site.site_changed = false
+ site.save_changes validate: false
+ end
+ end
+
+ puts "#{counter} sites set to not changed."
+end
+
+desc 'compute_scores'
+task :compute_scores => [:environment] do
+ Site.compute_scores
+end
+
=begin
desc 'Update screenshots'
task :update_screenshots => [:environment] do
@@ -224,3 +350,56 @@ task :update_screenshots => [:environment] do
}
end
=end
+
+desc 'prime_classifier'
+task :prime_classifier => [:environment] do
+ Site.select(:id, :username).where(is_banned: false, is_deleted: false).all.each do |site|
+ next if site.site_files_dataset.where(classifier: 'spam').count > 0
+ html_files = site.site_files_dataset.where(path: /\.html$/).all
+
+ html_files.each do |html_file|
+ print "training #{site.username}/#{html_file.path}..."
+ site.train html_file.path
+ print "done.\n"
+ end
+ end
+end
+
+desc 'train_spam'
+task :train_spam => [:environment] do
+ paths = File.read('./spam.txt')
+
+ paths.split("\n").each do |path|
+ username, site_file_path = path.match(/^([a-zA-Z0-9_\-]+)\/(.+)$/i).captures
+ site = Site[username: username]
+ next if site.nil?
+ site_file = site.site_files_dataset.where(path: site_file_path).first
+ next if site_file.nil?
+ site.train site_file_path, :spam
+ site.ban!
+ puts "Deleted #{site_file_path}, banned #{site.username}"
+ end
+end
+
+desc 'regenerate_ssl_certs'
+task :regenerate_ssl_certs => [:environment] do
+ sites = DB[%{select id from sites where (domain is not null or domain != '') and is_banned != 't' and is_deleted != 't'}].all
+
+ seconds = 2
+
+ sites.each do |site|
+ LetsEncryptWorker.perform_in seconds, site[:id]
+ seconds += 10
+ end
+
+ puts "#{sites.length.to_s} records are primed"
+end
+
+desc 'renew_ssl_certs'
+task :renew_ssl_certs => [:environment] do
+ delay = 0
+ DB[%{select id from sites where (domain is not null or domain != '') and is_banned != 't' and is_deleted != 't' and (cert_updated_at is null or cert_updated_at < ?)}, 60.days.ago].all.each do |site|
+ LetsEncryptWorker.perform_in delay.seconds, site[:id]
+ delay += 10
+ end
+end
diff --git a/app.rb b/app.rb
index 27a7034b..d73ba5d1 100644
--- a/app.rb
+++ b/app.rb
@@ -27,8 +27,10 @@ before do
if request.path.match /^\/api\//i
@api = true
content_type :json
- elsif request.path.match /^\/stripe_webhook$/
- # Skips the CSRF check for stripe web hooks
+ elsif request.path.match /^\/webhooks\//
+ # Skips the CSRF/validation check for stripe web hooks
+ elsif email_not_validated? && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/signout|^\/welcome|^\/plan/)
+ redirect "/site/#{current_site.username}/confirm_email"
else
content_type :html, 'charset' => 'utf-8'
redirect '/' if request.post? && !csrf_safe?
diff --git a/app/admin.rb b/app/admin.rb
index 5f2303af..2eda7fa6 100644
--- a/app/admin.rb
+++ b/app/admin.rb
@@ -11,11 +11,166 @@ get '/admin/reports' do
erb :'admin/reports'
end
+get '/admin/site/:username' do |username|
+ require_admin
+ @site = Site[username: username]
+ not_found if @site.nil?
+ @title = "Site Inspector - #{@site.username}"
+ erb :'admin/site'
+end
+
+post '/admin/reports' do
+
+end
+
+post '/admin/site_files/train' do
+ require_admin
+ site = Site[params[:site_id]]
+ site_file = site.site_files_dataset.where(path: params[:path]).first
+ not_found if site_file.nil?
+ site.untrain site_file.path
+ site.train site_file.path, params[:classifier]
+ 'ok'
+end
+
+get '/admin/usage' do
+ require_admin
+ today = Date.today
+ current_month = Date.new today.year, today.month, 1
+
+ @monthly_stats = []
+
+ month = current_month
+
+ until month.year == 2016 && month.month == 2 do
+
+ stats = DB["select sum(views) as views, sum(hits) as hits,sum(bandwidth) as bandwidth from daily_site_stats where created_at::text LIKE '#{month.year}-#{month.strftime('%m')}-%'"].first
+
+ stats.keys.each do |key|
+ stats[key] ||= 0
+ end
+
+ stats.collect {|s| s == 0}.uniq
+
+ if stats[:views] != 0 && stats[:hits] != 0 && stats[:bandwidth] != 0
+ popular_sites = DB[
+ 'select sum(bandwidth) as bandwidth,username from stats left join sites on sites.id=stats.site_id where stats.created_at >= ? and stats.created_at < ? group by username order by bandwidth desc limit 50',
+ month,
+ month.next_month
+ ].all
+
+ @monthly_stats.push stats.merge(date: month).merge(popular_sites: popular_sites)
+ end
+
+ month = month.prev_month
+ end
+
+ erb :'admin/usage'
+end
+
get '/admin/email' do
require_admin
erb :'admin/email'
end
+get '/admin/stats' do
+ require_admin
+ # expires 14400, :public, :must_revalidate if self.class.production? # 4 hours
+
+ @stats = {
+ total_hosted_site_hits: DB['SELECT SUM(hits) FROM sites'].first[:sum],
+ total_hosted_site_views: DB['SELECT SUM(views) FROM sites'].first[:sum],
+ total_site_changes: DB['select max(changed_count) from sites'].first[:max],
+ total_sites: Site.count
+ }
+
+ # Start with the date of the first created site
+
+ start = Site.select(:created_at).
+ exclude(created_at: nil).
+ order(:created_at).
+ first[:created_at].to_date
+
+ runner = start
+
+ monthly_stats = []
+
+ now = Date.today
+
+ while Date.new(runner.year, runner.month, 1) <= Date.new(now.year, now.month, 1)
+ monthly_stats.push(
+ date: runner,
+ sites_created: Site.where(created_at: runner..runner.next_month).count,
+ total_from_start: Site.where(created_at: start..runner.next_month).count,
+ supporters: Site.where(created_at: start..runner.next_month).exclude(stripe_customer_id: nil).count,
+ )
+
+ runner = runner.next_month
+ end
+
+ @stats[:monthly_stats] = monthly_stats
+
+ if $stripe_cache && Time.now < $stripe_cache[:time] + 14400
+ customers = $stripe_cache[:customers]
+ else
+ customers = Stripe::Customer.all limit: 100000
+ $stripe_cache = {
+ customers: customers,
+ time: Time.now
+ }
+ end
+
+ @stats[:monthly_revenue] = 0.0
+
+ subscriptions = []
+ @stats[:cancelled_subscriptions] = 0
+
+ customers.each do |customer|
+ sub = {created_at: Time.at(customer.created)}
+
+ if customer[:subscriptions][:data].empty?
+ @stats[:cancelled_subscriptions] += 1
+ next
+ end
+
+ next if customer[:subscriptions][:data].first[:plan][:amount] == 0
+
+ sub[:status] = 'active'
+ plan = customer[:subscriptions][:data].first[:plan]
+
+ sub[:amount_without_fees] = (plan[:amount] / 100.0).round(2)
+ sub[:percentage_fee] = (sub[:amount_without_fees]/(100/2.9)).ceil_to(2)
+ sub[:fixed_fee] = 0.30
+ sub[:amount] = sub[:amount_without_fees] - sub[:percentage_fee] - sub[:fixed_fee]
+
+ if(plan[:interval] == 'year')
+ sub[:amount] = (sub[:amount] / 12).round(2)
+ end
+
+ @stats[:monthly_revenue] += sub[:amount]
+
+ subscriptions.push sub
+ end
+
+ @stats[:subscriptions] = subscriptions
+
+ # Hotwired for now
+ @stats[:expenses] = 300.0 #/mo
+ @stats[:percent_until_profit] = (
+ (@stats[:monthly_revenue].to_f / @stats[:expenses]) * 100
+ )
+
+ @stats[:poverty_threshold] = 11_945
+ @stats[:poverty_threshold_percent] = (@stats[:monthly_revenue].to_f / ((@stats[:poverty_threshold]/12) + @stats[:expenses])) * 100
+
+ # http://en.wikipedia.org/wiki/Poverty_threshold
+
+ @stats[:average_developer_salary] = 93_280.00 # google "average developer salary"
+ @stats[:percent_until_developer_salary] = (@stats[:monthly_revenue].to_f / ((@stats[:average_developer_salary]/12) + @stats[:expenses])) * 100
+
+ erb :'admin/stats'
+end
+
post '/admin/email' do
require_admin
@@ -55,43 +210,43 @@ post '/admin/email' do
redirect '/'
end
-post '/admin/banip' do
- require_admin
- site = Site[username: params[:username]]
-
- if site.nil?
- flash[:error] = 'User not found'
- redirect '/admin'
- end
-
- if site.ip.nil? || site.ip.empty?
- flash[:error] = 'IP is blank, cannot continue'
- redirect '/admin'
- end
- sites = Site.filter(ip: site.ip, is_banned: false).all
- sites.each {|s| s.ban!}
- flash[:error] = "#{sites.length} sites have been banned."
- redirect '/admin'
-end
-
post '/admin/banhammer' do
require_admin
- site = Site[username: params[:username]]
-
- if site.nil?
- flash[:error] = 'User not found'
+ if params[:usernames].empty?
+ flash[:error] = 'no usernames provided'
redirect '/admin'
end
- if site.is_banned
- flash[:error] = 'User is already banned'
- redirect '/admin'
+ usernames = params[:usernames].split("\n").collect {|u| u.strip}
+
+ deleted_count = 0
+ ip_deleted_count = 0
+
+ usernames.each do |username|
+ next if username == ''
+ site = Site[username: username]
+ next if site.nil? || site.is_banned
+
+ if !params[:classifier].empty?
+ site.untrain 'index.html'
+ site.train 'index.html', params[:classifier]
+ end
+
+ site.ban!
+ deleted_count += 1
+
+ if !params[:ban_using_ips].empty? && !site.ip.empty?
+ sites = Site.filter(ip: site.ip, is_banned: false).all
+ sites.each do |s|
+ next if usernames.include?(s.username)
+ s.ban!
+ end
+ ip_deleted_count += 1
+ end
end
- site.ban!
-
- flash[:success] = 'MISSION ACCOMPLISHED'
+ flash[:success] = "#{ip_deleted_count + deleted_count} sites have been banned, including #{ip_deleted_count} matching IPs."
redirect '/admin'
end
@@ -105,6 +260,7 @@ post '/admin/mark_nsfw' do
end
site.is_nsfw = true
+ site.admin_nsfw = true
site.save_changes validate: false
flash[:success] = 'MISSION ACCOMPLISHED'
@@ -126,6 +282,14 @@ post '/admin/feature' do
redirect '/admin'
end
+get '/admin/masquerade/:username' do
+ require_admin
+ site = Site[username: params[:username]]
+ not_found if site.nil?
+ session[:id] = site.id
+ redirect '/'
+end
+
def require_admin
redirect '/' unless signed_in? && current_site.is_admin
end
diff --git a/app/api.rb b/app/api.rb
index 1387c659..02352ad9 100644
--- a/app/api.rb
+++ b/app/api.rb
@@ -5,6 +5,31 @@ get '/api' do
erb :'api'
end
+get '/api/list' do
+ require_api_credentials
+
+ files = []
+
+ if params[:path].nil? || params[:path].empty?
+ file_list = current_site.site_files
+ else
+ file_list = current_site.file_list params[:path]
+ end
+
+ file_list.each do |file|
+ new_file = {}
+ new_file[:path] = file[:path]
+ new_file[:is_directory] = file[:is_directory]
+ new_file[:size] = file[:size] unless file[:is_directory]
+ new_file[:updated_at] = file[:updated_at].rfc2822
+ files << new_file
+ end
+
+ files.each {|f| f[:path].sub!(/^\//, '')}
+
+ api_success files: files
+end
+
post '/api/upload' do
require_api_credentials
@@ -77,7 +102,6 @@ end
get '/api/info' do
if params[:sitename]
site = Site[username: params[:sitename]]
-
api_error 400, 'site_not_found', "could not find site #{params[:sitename]}" if site.nil? || site.is_banned
api_success api_info_for(site)
else
@@ -95,7 +119,8 @@ def api_info_for(site)
created_at: site.created_at.rfc2822,
last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil,
domain: site.domain,
- tags: site.tags.collect {|t| t.name}
+ tags: site.tags.collect {|t| t.name},
+ latest_ipfs_hash: site.latest_archive ? site.latest_archive.ipfs_hash : nil
}
}
end
@@ -113,6 +138,7 @@ end
def require_api_credentials
if !request.env['HTTP_AUTHORIZATION'].nil?
init_api_credentials
+ api_error(403, 'email_not_validated', 'you need to validate your email address before using the API') if email_not_validated?
else
api_error_invalid_auth
end
diff --git a/app/blog.rb b/app/blog.rb
index 6fa8e715..c2e4df8e 100644
--- a/app/blog.rb
+++ b/app/blog.rb
@@ -1,22 +1,7 @@
-require 'net/http'
-require 'uri'
-
get '/blog/?' do
- expires 60, :public, :must_revalidate
- return Net::HTTP.get_response(URI('http://blog.neocities.org')).body
+ redirect 'https://blog.neocities.org', 301
end
get '/blog/:article' do |article|
- expires 60, :public, :must_revalidate
-
- attempted = false
-
- begin
- return Net::HTTP.get_response(URI("http://blog.neocities.org/#{article}.html")).body
- rescue => e
- raise e if attempted
- attempted = true
- article = article.match(/^[a-zA-Z0-9-]+/).to_s
- retry
- end
+ redirect "https://blog.neocities.org/#{article}.html", 301
end
diff --git a/app/browse.rb b/app/browse.rb
index ba0c2704..3a11c4aa 100644
--- a/app/browse.rb
+++ b/app/browse.rb
@@ -1,7 +1,7 @@
get '/browse/?' do
- @current_page = params[:current_page]
- @current_page = @current_page.to_i
- @current_page = 1 if @current_page == 0
+ @surfmode = false
+ @page = params[:page].to_i
+ @page = 1 if @page == 0
params.delete 'tag' if params[:tag].nil? || params[:tag].strip.empty?
@@ -11,12 +11,14 @@ get '/browse/?' do
site_dataset = browse_sites_dataset
end
- site_dataset = site_dataset.paginate @current_page, Site::BROWSE_PAGINATION_LENGTH
- @page_count = site_dataset.page_count || 1
+ site_dataset = site_dataset.paginate @page, Site::BROWSE_PAGINATION_LENGTH
+ @pagination_dataset = site_dataset
@sites = site_dataset.all
+
if params[:tag]
@title = "Sites tagged #{params[:tag]}"
end
+
erb :browse
end
@@ -28,9 +30,12 @@ def education_sites_dataset
end
def browse_sites_dataset
- site_dataset = Site.filter(is_deleted: false, is_banned: false, is_crashing: false).filter(site_changed: true)
+
+ site_dataset = Site.browse_dataset
if current_site
+ site_dataset.or! sites__id: current_site.id
+
if !current_site.blocking_site_ids.empty?
site_dataset.where!(Sequel.~(Sequel.qualify(:sites, :id) => current_site.blocking_site_ids))
end
@@ -43,6 +48,9 @@ def browse_sites_dataset
end
case params[:sort_by]
+ when 'special_sauce'
+ site_dataset.exclude! score: nil
+ site_dataset.order! :score.desc
when 'followers'
site_dataset = site_dataset.association_left_join :follows
site_dataset.select_all! :sites
@@ -75,16 +83,12 @@ def browse_sites_dataset
params[:sort_by] = 'last_updated'
site_dataset.order!(:site_updated_at.desc, :views.desc)
else
- if params[:tag]
- params[:sort_by] = 'views'
- site_dataset.order!(:views.desc, :site_updated_at.desc)
- else
- site_dataset = site_dataset.association_left_join :follows
- site_dataset.select_all! :sites
- site_dataset.select_append! Sequel.lit("count(follows.site_id) AS follow_count")
- site_dataset.group! :sites__id
- site_dataset.order! :follow_count.desc, :updated_at.desc
- end
+ params[:sort_by] = 'followers'
+ site_dataset = site_dataset.association_left_join :follows
+ site_dataset.select_all! :sites
+ site_dataset.select_append! Sequel.lit("count(follows.site_id) AS follow_count")
+ site_dataset.group! :sites__id
+ site_dataset.order! :follow_count.desc, :views.desc, :updated_at.desc
end
site_dataset.where! ['sites.is_nsfw = ?', (params[:is_nsfw] == 'true' ? true : false)]
diff --git a/app/create.rb b/app/create.rb
index d0af94c0..b3f0d3d8 100644
--- a/app/create.rb
+++ b/app/create.rb
@@ -1,5 +1,5 @@
def new_recaptcha_valid?
- return session[:captcha_valid] = true if ENV['RACK_ENV'] == 'test'
+ return session[:captcha_valid] = true if ENV['RACK_ENV'] == 'test' || ENV['TRAVIS']
resp = Net::HTTP.get URI(
'https://www.google.com/recaptcha/api/siteverify?'+
Rack::Utils.build_query(
diff --git a/app/dashboard.rb b/app/dashboard.rb
index f9a96fe2..f7616472 100644
--- a/app/dashboard.rb
+++ b/app/dashboard.rb
@@ -1,6 +1,13 @@
get '/dashboard' do
require_login
dashboard_init
+ dont_browser_cache
+
+ unless current_site.dashboard_accessed
+ current_site.dashboard_accessed = true
+ current_site.save_changes validate: false
+ end
+
erb :'dashboard'
end
@@ -22,4 +29,4 @@ def dashboard_init
@dir = params[:dir]
@file_list = current_site.file_list @dir
-end
\ No newline at end of file
+end
diff --git a/app/dmca.rb b/app/dmca.rb
index ae838708..de118f0a 100644
--- a/app/dmca.rb
+++ b/app/dmca.rb
@@ -30,7 +30,7 @@ post '/dmca/contact' do
no_footer: true
})
- flash[:success] = 'Your DCMA notification has been sent.'
+ flash[:success] = 'Your DMCA notification has been sent.'
redirect '/'
end
end
diff --git a/app/domain.rb b/app/domain.rb
new file mode 100644
index 00000000..6aaaa784
--- /dev/null
+++ b/app/domain.rb
@@ -0,0 +1,29 @@
+get '/domain/new' do
+ require_login
+ @title = 'Register a Domain'
+
+ erb :'domain/new'
+end
+
+post '/domain/check_availability.json' do
+ require_login
+ content_type :json
+
+ timer = Time.now.to_i
+
+ while true
+ if (Time.now.to_i - timer) > 60
+ api_error 200, :contact_fail, 'Error contacting domain server, please try again.'
+ end
+
+ begin
+ res = $gandi.domain.available([params[:domain]])[params[:domain]]
+ rescue => Gandi::DataError
+ api_error 200, :invalid_domain, 'Domain name was invalid, please try another.'
+ end
+
+ api_success res unless res == 'pending'
+ sleep 0.2
+ end
+
+end
diff --git a/app/index.rb b/app/index.rb
index 17e2ee85..a047a56f 100644
--- a/app/index.rb
+++ b/app/index.rb
@@ -6,23 +6,23 @@ get '/?' do
@suggestions = current_site.suggestions
- @current_page = params[:current_page].to_i
- @current_page = 1 if @current_page == 0
+ @page = params[:page].to_i
+ @page = 1 if @page == 0
if params[:activity] == 'mine'
- events_dataset = current_site.latest_events(@current_page, 10)
+ events_dataset = current_site.latest_events(@page, 10)
elsif params[:event_id]
event = Event.select(:id).where(id: params[:event_id]).first
not_found if event.nil?
not_found if event.is_deleted
events_dataset = Event.where(id: params[:event_id]).paginate(1, 1)
elsif params[:activity] == 'global'
- events_dataset = Event.global_dataset @current_page
+ events_dataset = Event.global_dataset @page
else
- events_dataset = current_site.news_feed(@current_page, 10)
+ events_dataset = current_site.news_feed(@page, 10)
end
- @page_count = events_dataset.page_count || 1
+ @pagination_dataset = events_dataset
@events = events_dataset.all
current_site.events_dataset.update notification_seen: true
@@ -50,10 +50,6 @@ get '/education' do
erb :education, layout: :index_layout
end
-get '/tutorials' do
- erb :'tutorials'
-end
-
get '/donate' do
erb :'donate'
end
@@ -82,3 +78,46 @@ end
get '/permanent-web' do
erb :'permanent_web'
end
+
+get '/thankyou' do
+ erb :'thankyou'
+end
+
+get '/forgot_username' do
+ erb :'forgot_username'
+end
+
+post '/forgot_username' do
+ if params[:email].blank?
+ flash[:error] = 'Cannot use an empty email address!'
+ redirect '/forgot_username'
+ end
+
+ sites = Site.where(email: params[:email]).all
+
+ sites.each do |site|
+ body = <<-EOT
+Hello! This is the Neocities cat, and I have received a username lookup request using this email address.
+
+Your username is #{site.username}
+
+If you didn't request this, you can ignore it. Or hide under a bed. Or take a nap. Your call.
+
+Meow,
+the Neocities Cat
+ EOT
+
+ body.strip!
+
+ EmailWorker.perform_async({
+ from: 'web@neocities.org',
+ to: params[:email],
+ subject: '[Neocities] Username lookup',
+ body: body
+ })
+
+ end
+
+ flash[:success] = 'If your email was valid, the Neocities Cat will send an e-mail with your username in it.'
+ redirect '/'
+end
diff --git a/app/mockup.rb b/app/mockup.rb
index 3017a565..5aab40b2 100644
--- a/app/mockup.rb
+++ b/app/mockup.rb
@@ -29,4 +29,9 @@ get '/stats_mockup' do
require_login
erb :'stats_mockup', locals: {site: current_site}
end
+
+get '/tutorial_mockup_c1p2' do
+ require_login
+ erb :'tutorial_mockup_c1p2', locals: {site: current_site}
+end
# :nocov:
\ No newline at end of file
diff --git a/app/password_reset.rb b/app/password_reset.rb
index 9fd20299..863811fb 100644
--- a/app/password_reset.rb
+++ b/app/password_reset.rb
@@ -1,39 +1,43 @@
get '/password_reset' do
+ redirect '/' if signed_in?
erb :'password_reset'
end
post '/send_password_reset' do
+ if params[:email].blank?
+ flash[:error] = 'You must enter a valid email address.'
+ redirect '/password_reset'
+ end
+
sites = Site.filter(email: params[:email]).all
if sites.length > 0
token = SecureRandom.uuid.gsub('-', '')
sites.each do |site|
+ next unless site.parent?
site.update password_reset_token: token
- end
- body = <<-EOT
-Hello! This is the Neocities cat, and I have received a password reset request for your e-mail address. Purrrr.
+ body = <<-EOT
+Hello! This is the Neocities cat, and I have received a password reset request for your e-mail address.
-Go to this URL to reset your password: http://neocities.org/password_reset_confirm?token=#{token}
+Go to this URL to reset your password: http://neocities.org/password_reset_confirm?username=#{Rack::Utils.escape(site.username)}&token=#{token}
-After clicking on this link, your password for all the sites registered to this email address will be changed to this token.
-
-Token: #{token}
-
-If you didn't request this reset, you can ignore it. Or hide under a bed. Or take a nap. Your call.
+If you didn't request this password reset, you can ignore it. Or hide under a bed. Or take a nap. Your call.
Meow,
the Neocities Cat
EOT
- body.strip!
+ body.strip!
- EmailWorker.perform_async({
- from: 'web@neocities.org',
- to: params[:email],
- subject: '[Neocities] Password Reset',
- body: body
- })
+ EmailWorker.perform_async({
+ from: 'web@neocities.org',
+ to: params[:email],
+ subject: '[Neocities] Password Reset',
+ body: body
+ })
+
+ end
end
flash[:success] = 'If your email was valid (and used by a site), the Neocities Cat will send an e-mail to your account with password reset instructions.'
@@ -42,29 +46,22 @@ end
get '/password_reset_confirm' do
if params[:token].nil? || params[:token].strip.empty?
- flash[:error] = 'Could not find a site with this token.'
+ flash[:error] = 'Token cannot be empty.'
redirect '/'
end
- reset_site = Site[password_reset_token: params[:token]]
+ reset_site = Site.where(username: params[:username], password_reset_token: params[:token]).first
if reset_site.nil?
- flash[:error] = 'Could not find a site with this token.'
+ flash[:error] = 'Could not find a site with this username and token.'
redirect '/'
end
- sites = Site.filter(email: reset_site.email).all
+ reset_site.password_reset_token = nil
+ reset_site.password_reset_confirmed = true
+ reset_site.save_changes
- if sites.length > 0
- sites.each do |site|
- site.password = reset_site.password_reset_token
- site.save_changes
- end
+ session[:id] = reset_site.id
- flash[:success] = 'Your password for all sites with your email address has been changed to the token sent in your e-mail. Please login and change your password as soon as possible.'
- else
- flash[:error] = 'Could not find a site with this token.'
- end
-
- redirect '/'
+ redirect '/settings#password'
end
diff --git a/app/plan.rb b/app/plan.rb
index ee225d81..b75c6825 100644
--- a/app/plan.rb
+++ b/app/plan.rb
@@ -100,11 +100,103 @@ get '/plan/thanks' do
erb :'plan/thanks'
end
+get '/plan/bitcoin/?' do
+ erb :'plan/bitcoin'
+end
+
+get '/plan/alternate/?' do
+ redirect '/plan/bitcoin'
+end
+
+def paypal_recurring_hash
+ {
+ ipn_url: "https://neocities.org/webhooks/paypal",
+ description: 'Neocities Supporter - Monthly',
+ amount: Site::PLAN_FEATURES[:supporter][:price].to_s,
+ currency: 'USD'
+ }
+end
+
+def paypal_recurring_authorization_hash
+ paypal_recurring_hash.merge(
+ return_url: "https://neocities.org/plan/paypal/return",
+ cancel_url: "https://neocities.org/plan",
+ ipn_url: "https://neocities.org/webhooks/paypal"
+ )
+end
+
+get '/plan/paypal' do
+ require_login
+ redirect '/plan' if parent_site.supporter?
+
+ hash = paypal_recurring_authorization_hash
+
+ if current_site.paypal_token
+ hash.merge! token: current_site.paypal_token
+ end
+
+ ppr = PayPal::Recurring.new hash
+
+ paypal_response = ppr.checkout
+
+ redirect paypal_response.checkout_url if paypal_response.valid?
+end
+
+get '/plan/paypal/return' do
+ require_login
+
+ if params[:token].nil? || params[:PayerID].nil?
+ flash[:error] = 'Unknown error, could not complete the request. Please contact Neocities support.'
+ end
+
+ ppr = PayPal::Recurring.new(paypal_recurring_hash.merge(
+ token: params[:token],
+ payer_id: params[:PayerID]
+ ))
+
+ paypal_response = ppr.request_payment
+ unless paypal_response.approved? && paypal_response.completed?
+ flash[:error] = 'Unknown error, could not complete the request. Please contact Neocities support.'
+ redirect '/plan'
+ end
+
+ ppr = PayPal::Recurring.new(paypal_recurring_authorization_hash.merge(
+ frequency: 1,
+ token: params[:token],
+ period: :monthly,
+ reference: current_site.id.to_s,
+ payer_id: params[:PayerID],
+ start_at: 1.month.from_now,
+ failed: 3,
+ outstanding: :next_billing
+ ))
+
+ paypal_response = ppr.create_recurring_profile
+
+ current_site.paypal_token = params[:token]
+ current_site.paypal_profile_id = paypal_response.profile_id
+ current_site.paypal_active = true
+ current_site.plan_type = 'supporter'
+ current_site.save_changes validate: false
+
+ redirect '/plan/thanks-paypal'
+end
+
get '/plan/thanks-paypal' do
require_login
erb :'plan/thanks-paypal'
end
-get '/plan/alternate/?' do
- erb :'/plan/alternate'
+get '/plan/paypal/cancel' do
+ require_login
+ redirect '/plan' unless parent_site.paypal_active
+ ppr = PayPal::Recurring.new profile_id: parent_site.paypal_profile_id
+ ppr.cancel
+
+ parent_site.plan_type = nil
+ parent_site.paypal_active = false
+ parent_site.paypal_profile_id = nil
+ parent_site.paypal_token = nil
+ parent_site.save_changes validate: false
+ redirect '/plan'
end
diff --git a/app/settings.rb b/app/settings.rb
index fb35e541..f623e6cf 100644
--- a/app/settings.rb
+++ b/app/settings.rb
@@ -66,6 +66,7 @@ post '/settings/:username/profile' do
redirect "/settings/#{@site.username}#profile"
end
+=begin
post '/settings/:username/ssl' do
require_login
require_ownership_for_settings
@@ -167,6 +168,7 @@ post '/settings/:username/ssl' do
flash[:success] = 'Updated SSL key/certificate.'
redirect "/settings/#{@site.username}#custom_domain"
end
+=end
post '/settings/:username/change_name' do
require_login
@@ -179,13 +181,13 @@ post '/settings/:username/change_name' do
redirect "/settings/#{@site.username}#username"
end
- if old_username == params[:name]
+ if old_username.downcase == params[:name].downcase
flash[:error] = 'You already have this name.'
redirect "/settings/#{@site.username}#username"
end
old_host = @site.host
- old_file_paths = @site.file_list.collect {|f| f[:path]}
+ old_site_file_paths = @site.site_files.collect {|site_file| site_file.path}
@site.username = params[:name]
@@ -195,11 +197,11 @@ post '/settings/:username/change_name' do
@site.move_files_from old_username
}
- old_file_paths.each do |file_path|
- @site.purge_cache file_path
+ old_site_file_paths.each do |site_file_path|
+ @site.delete_cache site_file_path
end
- flash[:success] = "Site/user name has been changed. You will need to use this name to login, don't forget it."
+ flash[:success] = "Site/user name has been changed. You will need to use this name to login, don't forget it!"
redirect "/settings/#{@site.username}#username"
else
flash[:error] = @site.errors.first.last.first
@@ -211,6 +213,8 @@ post '/settings/:username/change_nsfw' do
require_login
require_ownership_for_settings
+ redirect "/settings/#{@site.username}" if @site.admin_nsfw == true
+
@site.is_nsfw = params[:is_nsfw]
@site.save_changes validate: false
flash[:success] = @site.is_nsfw ? 'Marked 18+' : 'Unmarked 18+'
@@ -221,10 +225,30 @@ post '/settings/:username/custom_domain' do
require_login
require_ownership_for_settings
+ original_domain = @site.domain
@site.domain = params[:domain]
+ begin
+ Socket.gethostbyname @site.values[:domain]
+ rescue SocketError => e
+ if e.message =~ /name or service not known/i
+ flash[:error] = 'Domain needs to be valid and already registered.'
+ redirect "/settings/#{@site.username}#custom_domain"
+ elsif e.message =~ /No address associated with hostname/i
+ flash[:error] = "The domain isn't setup to use Neocities yet, cannot add. Please make the A and CNAME record changes where you registered your domain."
+ redirect "/settings/#{@site.username}#custom_domain"
+ end
+
+ raise e
+ end
+
if @site.valid?
@site.save_changes
+
+ if @site.domain != original_domain
+ LetsEncryptWorker.perform_async @site.id
+ end
+
flash[:success] = 'The domain has been successfully updated.'
redirect "/settings/#{@site.username}#custom_domain"
else
@@ -236,7 +260,7 @@ end
post '/settings/change_password' do
require_login
- if !Site.valid_login?(parent_site.username, params[:current_password])
+ if !current_site.password_reset_confirmed && !Site.valid_login?(parent_site.username, params[:current_password])
flash[:error] = 'Your provided password does not match the current one.'
redirect "/settings#password"
end
@@ -248,6 +272,9 @@ post '/settings/change_password' do
parent_site.errors.add :password, 'New passwords do not match.'
end
+ parent_site.password_reset_token = nil
+ parent_site.password_reset_confirmed = false
+
if parent_site.errors.empty?
parent_site.save_changes
flash[:success] = 'Successfully changed password.'
@@ -260,10 +287,16 @@ end
post '/settings/change_email' do
require_login
-
+
+ if params[:from_confirm]
+ redirect_url = "/site/#{parent_site.username}/confirm_email"
+ else
+ redirect_url = '/settings#email'
+ end
+
if params[:email] == parent_site.email
flash[:error] = 'You are already using this email address for this account.'
- redirect '/settings#email'
+ redirect redirect_url
end
parent_site.email = params[:email]
@@ -273,12 +306,17 @@ post '/settings/change_email' do
if parent_site.valid?
parent_site.save_changes
send_confirmation_email
- flash[:success] = 'Successfully changed email. We have sent a confirmation email, please use it to confirm your email address.'
- redirect '/settings#email'
+ if !parent_site.supporter?
+ session[:fromsettings] = true
+ redirect "/site/#{parent_site.email}/confirm_email"
+ else
+ flash[:success] = 'Email address changed.'
+ redirect '/settings#email'
+ end
end
flash[:error] = parent_site.errors.first.last.first
- redirect '/settings#email'
+ redirect redirect_url
end
post '/settings/change_email_notification' do
@@ -331,4 +369,30 @@ get '/settings/unsubscribe_email/?' do
@message = 'There was an error unsubscribing your email address. Please contact support.'
end
erb :'settings/account/unsubscribe'
-end
\ No newline at end of file
+end
+
+post '/settings/update_card' do
+ require_login
+
+ customer = Stripe::Customer.retrieve current_site.stripe_customer_id
+
+ old_card_ids = customer.sources.collect {|s| s.id}
+
+ begin
+ customer.sources.create source: params[:stripe_token]
+ rescue Stripe::InvalidRequestError => e
+ if e.message.match /cannot use a.+token more than once/
+ flash[:error] = 'Card is already being used.'
+ redirect '/settings#billing'
+ else
+ raise e
+ end
+ end
+
+ old_card_ids.each do |card_id|
+ customer.sources.retrieve(card_id).delete
+ end
+
+ flash[:success] = 'Card information updated.'
+ redirect '/settings#billing'
+end
diff --git a/app/site.rb b/app/site.rb
index d716ec56..a6d0e397 100644
--- a/app/site.rb
+++ b/app/site.rb
@@ -13,9 +13,9 @@ get '/site/:username/?' do |username|
@title = site.title
- @current_page = params[:current_page]
- @current_page = @current_page.to_i
- @current_page = 1 if @current_page == 0
+ @page = params[:page]
+ @page = @page.to_i
+ @page = 1 if @page == 0
if params[:event_id]
not_found unless params[:event_id].is_integer?
@@ -23,10 +23,11 @@ get '/site/:username/?' do |username|
not_found if event.nil?
events_dataset = Event.where(id: params[:event_id]).paginate(1, 1)
else
- events_dataset = site.latest_events(@current_page, 10)
+ events_dataset = site.latest_events(@page, 10)
end
@page_count = events_dataset.page_count || 1
+ @pagination_dataset = events_dataset
@latest_events = events_dataset.all
erb :'site', locals: {site: site, is_current_site: site == current_site}
@@ -161,7 +162,6 @@ post '/site/create_directory' do
require_login
path = "#{params[:dir] || ''}/#{params[:name]}"
-
result = current_site.create_directory path
if result != true
@@ -172,8 +172,23 @@ post '/site/create_directory' do
end
get '/site/:username/confirm_email/:token' do
+ if current_site && current_site.email_confirmed
+ return erb(:'site_email_confirmed')
+ end
+
site = Site[username: params[:username]]
- if !site.nil? && site.email_confirmation_token == params[:token]
+
+ if site.nil?
+ return erb(:'site_email_not_confirmed')
+ end
+
+ if site.email_confirmed
+ return erb(:'site_email_confirmed')
+ end
+
+ if site.email_confirmation_token == params[:token]
+ site.email_confirmation_token = nil
+ site.email_confirmation_count = 0
site.email_confirmed = true
site.save_changes
@@ -183,6 +198,47 @@ get '/site/:username/confirm_email/:token' do
end
end
+get '/site/:username/confirm_email' do
+ require_login
+ @fromsettings = session[:fromsettings]
+ redirect '/' if current_site.username != params[:username] || !current_site.parent? || current_site.email_confirmed
+ erb :'site/confirm_email'
+end
+
+post '/site/:username/confirm_email' do
+ require_login
+
+ redirect '/' if current_site.username != params[:username] || !current_site.parent? || current_site.email_confirmed
+
+ # Update email, resend token
+ if params[:email]
+ send_confirmation_email @site
+ end
+
+ if params[:token].blank?
+ flash[:error] = 'You must enter a valid token.'
+ redirect "/site/#{current_site.username}/confirm_email"
+ end
+
+ if current_site.email_confirmation_token == params[:token]
+ current_site.email_confirmation_token = nil
+ current_site.email_confirmation_count = 0
+ current_site.email_confirmed = true
+ current_site.save_changes
+
+ if session[:fromsettings]
+ session[:fromsettings] = nil
+ flash[:success] = 'Email address changed.'
+ redirect '/settings#email'
+ end
+
+ redirect '/tutorial'
+ else
+ flash[:error] = 'You must enter a valid token.'
+ redirect "/site/#{current_site.username}/confirm_email"
+ end
+end
+
post '/site/:username/report' do |username|
site = Site[username: username]
diff --git a/app/site_files.rb b/app/site_files.rb
index aa7107dc..cf06c470 100644
--- a/app/site_files.rb
+++ b/app/site_files.rb
@@ -134,10 +134,11 @@ end
post '/site_files/delete' do
require_login
- current_site.delete_file params[:filename]
- flash[:success] = "Deleted #{params[:filename]}."
+ path = HTMLEntities.new.decode params[:filename]
+ current_site.delete_file path
+ flash[:success] = "Deleted #{params[:filename]}. Please note it can take up to 30 minutes for deleted files to stop being viewable on your site."
- dirname = Pathname(params[:filename]).dirname
+ dirname = Pathname(path).dirname
dir_query = dirname.nil? || dirname.to_s == '.' ? '' : "?dir=#{Rack::Utils.escape dirname}"
redirect "/dashboard#{dir_query}"
@@ -151,15 +152,18 @@ get '/site_files/:username.zip' do |username|
send_file zipfile_path
end
-get '/site_files/download/:filename' do |filename|
+get %r{\/site_files\/download\/(.+)} do
require_login
- content_type 'application/octet-stream'
+ not_found if params[:captures].nil? || params[:captures].length != 1
+ filename = params[:captures].first
attachment filename
- current_site.get_file filename
+ send_file current_site.current_files_path(filename)
end
get %r{\/site_files\/text_editor\/(.+)} do
require_login
+ dont_browser_cache
+
@filename = params[:captures].first
extname = File.extname @filename
@ace_mode = case extname
@@ -171,15 +175,20 @@ get %r{\/site_files\/text_editor\/(.+)} do
nil
end
- begin
- @file_data = current_site.get_file @filename
- rescue Errno::ENOENT
- flash[:error] = 'We could not find the requested file.'
- redirect '/dashboard'
- rescue Errno::EISDIR
+ file_path = current_site.current_files_path @filename
+
+ if File.directory? file_path
flash[:error] = 'Cannot edit a directory.'
redirect '/dashboard'
end
+
+ if !File.exist?(file_path)
+ flash[:error] = 'We could not find the requested file.'
+ redirect '/dashboard'
+ end
+
+ @title = "Editing #{@filename}"
+
erb :'site_files/text_editor'
end
@@ -207,6 +216,10 @@ get '/site_files/allowed_types' do
erb :'site_files/allowed_types'
end
+get '/site_files/hotlinking' do
+ erb :'site_files/hotlinking'
+end
+
get '/site_files/mount_info' do
erb :'site_files/mount_info'
end
diff --git a/app/stats.rb b/app/stats.rb
deleted file mode 100644
index 02101bd9..00000000
--- a/app/stats.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-get '/stats/?' do
- # expires 14400, :public, :must_revalidate if self.class.production? # 4 hours
-
- @stats = {
- total_hosted_site_hits: DB['SELECT SUM(hits) FROM sites'].first[:sum],
- total_hosted_site_views: DB['SELECT SUM(views) FROM sites'].first[:sum],
- total_sites: Site.count,
- total_unbanned_sites: Site.where(is_banned: false).count,
- total_banned_sites: Site.where(is_banned: true).count,
- total_nsfw_sites: Site.where(is_nsfw: true).count,
- total_unbanned_nsfw_sites: Site.where(is_banned: false, is_nsfw: true).count,
- total_banned_nsfw_sites: Site.where(is_banned: true, is_nsfw: true).count
- }
-
- # Start with the date of the first created site
-
- start = Site.select(:created_at).
- exclude(created_at: nil).
- order(:created_at).
- first[:created_at].to_date
-
- runner = start
-
- monthly_stats = []
-
- now = Date.today
-
- while Date.new(runner.year, runner.month, 1) <= Date.new(now.year, now.month, 1)
- monthly_stats.push(
- date: runner,
- sites_created: Site.where(created_at: runner..runner.next_month).count,
- total_from_start: Site.where(created_at: start..runner.next_month).count,
- supporters: Site.where(created_at: start..runner.next_month).exclude(stripe_customer_id: nil).count,
- )
-
- runner = runner.next_month
- end
-
- @stats[:monthly_stats] = monthly_stats
-
- if $stripe_cache && Time.now < $stripe_cache[:time] + 14400
- customers = $stripe_cache[:customers]
- else
- customers = Stripe::Customer.all limit: 100000
- $stripe_cache = {
- customers: customers,
- time: Time.now
- }
- end
-
- @stats[:monthly_revenue] = 0.0
-
- subscriptions = []
- @stats[:cancelled_subscriptions] = 0
-
- customers.each do |customer|
- sub = {created_at: Time.at(customer.created)}
-
- if customer[:subscriptions][:data].empty?
- @stats[:cancelled_subscriptions] += 1
- next
- end
-
- next if customer[:subscriptions][:data].first[:plan][:amount] == 0
-
- sub[:status] = 'active'
- plan = customer[:subscriptions][:data].first[:plan]
-
- sub[:amount_without_fees] = (plan[:amount] / 100.0).round(2)
- sub[:percentage_fee] = (sub[:amount_without_fees]/(100/2.9)).ceil_to(2)
- sub[:fixed_fee] = 0.30
- sub[:amount] = sub[:amount_without_fees] - sub[:percentage_fee] - sub[:fixed_fee]
-
- if(plan[:interval] == 'year')
- sub[:amount] = (sub[:amount] / 12).round(2)
- end
-
- @stats[:monthly_revenue] += sub[:amount]
-
- subscriptions.push sub
- end
-
- @stats[:subscriptions] = subscriptions
-
- # Hotwired for now
- @stats[:expenses] = 300.0 #/mo
- @stats[:percent_until_profit] = (
- (@stats[:monthly_revenue].to_f / @stats[:expenses]) * 100
- )
-
- @stats[:poverty_threshold] = 11_945
- @stats[:poverty_threshold_percent] = (@stats[:monthly_revenue].to_f / ((@stats[:poverty_threshold]/12) + @stats[:expenses])) * 100
-
- # http://en.wikipedia.org/wiki/Poverty_threshold
-
- @stats[:average_developer_salary] = 93_280.00 # google "average developer salary"
- @stats[:percent_until_developer_salary] = (@stats[:monthly_revenue].to_f / ((@stats[:average_developer_salary]/12) + @stats[:expenses])) * 100
-
- erb :'stats'
-end
diff --git a/app/surf.rb b/app/surf.rb
index 95d819db..a96b8995 100644
--- a/app/surf.rb
+++ b/app/surf.rb
@@ -1,8 +1,8 @@
get '/surf/?' do
- @current_page = params[:current_page].to_i || 1
+ @page = params[:page].to_i || 1
params.delete 'tag' if params[:tag].nil? || params[:tag].strip.empty?
site_dataset = browse_sites_dataset
- site_dataset = site_dataset.paginate @current_page, 1
+ site_dataset = site_dataset.paginate @page, 1
@page_count = site_dataset.page_count || 1
@site = site_dataset.first
redirect "/browse?#{Rack::Utils.build_query params}" if @site.nil?
diff --git a/app/tags.rb b/app/tags.rb
index a0431762..fc806f14 100644
--- a/app/tags.rb
+++ b/app/tags.rb
@@ -16,7 +16,10 @@ post '/tags/remove' do
if params[:tags].is_a?(Array)
DB.transaction {
- params[:tags].each {|tag| current_site.remove_tag Tag[name: tag]}
+ params[:tags].each do |tag|
+ tag_to_remove = current_site.tags.select {|t| t.name == tag}.first
+ current_site.remove_tag(tag_to_remove) if tag_to_remove
+ end
}
end
diff --git a/app/tutorial.rb b/app/tutorial.rb
new file mode 100644
index 00000000..d3a217fa
--- /dev/null
+++ b/app/tutorial.rb
@@ -0,0 +1,41 @@
+def default_tutorial_html
+ <<-EOT.strip
+
+
+
+
+
+ Hello World!
+
+
+
+
+EOT
+end
+
+get '/tutorials' do
+ erb :'tutorials'
+end
+
+get '/tutorial/?' do
+ require_login
+ erb :'tutorial/index'
+end
+
+get '/tutorial/:section/?' do
+ require_login
+ redirect "/tutorial/#{params[:section]}/1"
+end
+
+get '/tutorial/:section/:page/?' do
+ require_login
+ @page = params[:page]
+ not_found if @page.to_i == 0
+ not_found unless %w{html css js}.include?(params[:section])
+
+ @section = params[:section]
+
+ @title = "#{params[:section].upcase} Tutorial - #{@page}/10"
+
+ erb "tutorial/layout".to_sym
+end
diff --git a/app/stripe_webhook.rb b/app/webhooks.rb
similarity index 86%
rename from app/stripe_webhook.rb
rename to app/webhooks.rb
index eefcac1b..695be1c7 100644
--- a/app/stripe_webhook.rb
+++ b/app/webhooks.rb
@@ -1,24 +1,16 @@
-def stripe_get_site_from_event(event)
- customer_id = event['data']['object']['customer']
- customer = Stripe::Customer.retrieve customer_id
+post '/webhooks/paypal' do
+ EmailWorker.perform_async({
+ from: 'web@neocities.org',
+ to: 'errors@neocities.org',
+ subject: "[Neocities Paypal Webhook] Received a Webhook from Paypal",
+ body: params.inspect,
+ no_footer: true
+ })
- # Some old accounts only have a username for the desc
- desc_split = customer.description.split(' - ')
-
- if desc_split.length == 1
- site_where = {username: desc_split.first}
- end
-
- if desc_split.last.to_i == 0
- site_where = {username: desc_split.first}
- else
- site_where = {id: desc_split.last}
- end
-
- Site.where(site_where).first
+ 'ok'
end
-post '/stripe_webhook' do
+post '/webhooks/stripe' do
event = JSON.parse request.body.read
if event['type'] == 'customer.created'
username = event['data']['object']['description'].split(' - ').first
@@ -60,3 +52,23 @@ post '/stripe_webhook' do
'ok'
end
+
+def stripe_get_site_from_event(event)
+ customer_id = event['data']['object']['customer']
+ customer = Stripe::Customer.retrieve customer_id
+
+ # Some old accounts only have a username for the desc
+ desc_split = customer.description.split(' - ')
+
+ if desc_split.length == 1
+ site_where = {username: desc_split.first}
+ end
+
+ if desc_split.last.to_i == 0
+ site_where = {username: desc_split.first}
+ else
+ site_where = {id: desc_split.last}
+ end
+
+ Site.where(site_where).first
+end
diff --git a/app_helpers.rb b/app_helpers.rb
index 7c9e5e5b..f564e730 100644
--- a/app_helpers.rb
+++ b/app_helpers.rb
@@ -1,13 +1,3 @@
-def kickstarter_days_remaining
- ending = Time.parse('Sat, Jul 25 2015 3:05 PM PDT')
- today = Time.now
-
- remaining = ending - today
- return 0 if remaining < 0
-
- ((ending - today) / 86400).to_i
-end
-
def dashboard_if_signed_in
redirect '/dashboard' if signed_in?
end
@@ -82,12 +72,19 @@ def encoding_fix(file)
end
def send_confirmation_email(site=current_site)
+ if site.email_confirmation_count > Site::MAXIMUM_EMAIL_CONFIRMATIONS
+ flash[:error] = 'You sent too many email confirmation requests, cannot continue.'
+ redirect request.referrer
+ end
+
+ DB['UPDATE sites set email_confirmation_count=email_confirmation_count+1 WHERE id=?', site.id].first
+
EmailWorker.perform_async({
from: 'web@neocities.org',
reply_to: 'contact@neocities.org',
to: site.email,
subject: "[Neocities] Confirm your email address",
- body: Tilt.new('./views/templates/email_confirm.erb', pretty: true).render(self, site: site)
+ body: Tilt.new('./views/templates/email/confirm.erb', pretty: true).render(self, site: site)
})
end
@@ -115,6 +112,20 @@ def plan_pricing_button(plan_type)
button_title = parent_site.plan_type == 'free' ? 'Upgrade' : 'Change'
end
+ if button_title == 'Change' && parent_site && parent_site.paypal_active
+ return %{Change}
+ end
+
%{#{button_title}}
end
end
+
+def dont_browser_cache
+ @dont_browser_cache = true
+end
+
+def email_not_validated?
+ return false if current_site && current_site.created_at < Site::EMAIL_VALIDATION_CUTOFF_DATE
+
+ current_site && current_site.parent? && !current_site.is_education && !current_site.email_confirmed && !current_site.supporter?
+end
diff --git a/code-of-conduct.txt b/code-of-conduct.txt
new file mode 100644
index 00000000..034f7194
--- /dev/null
+++ b/code-of-conduct.txt
@@ -0,0 +1,9 @@
+These guidelines apply to Neocities contributor communications, including
+messages in code repositories, collaboration tools, outreach channels, etc.
+
+* Language and actions must be free of personal attacks, harassment,
+ discrimination, and NSFW content.
+
+Instances of unacceptable behavior can be reported by contacting the project
+team at abuse@neocities.org. All complaints will be investigated and reporter
+confidentiality will be maintained.
diff --git a/config.ru b/config.ru
index f664a028..16e3721c 100644
--- a/config.ru
+++ b/config.ru
@@ -1,11 +1,6 @@
require 'rubygems'
require './app.rb'
require 'sidekiq/web'
-require 'unicorn/preread_input'
-
-if defined?(Unicorn)
- use Unicorn::PrereadInput
-end
map('/') do
use(Rack::Cache,
@@ -35,13 +30,18 @@ map '/webdav' do
end
if Site.valid_file_type?(filename: path, tempfile: tmpfile)
- site.store_file path, tmpfile
+ site.store_files [{filename: path, tempfile: tmpfile}]
return [201, {}, ['']]
else
return [415, {}, ['']]
end
end
+ if env['REQUEST_METHOD'] == 'MKCOL'
+ site.create_directory env['PATH_INFO']
+ return [201, {}, ['']]
+ end
+
if env['REQUEST_METHOD'] == 'MOVE'
tmpfile = Tempfile.new 'moved_file'
tmpfile.close
@@ -51,7 +51,7 @@ map '/webdav' do
FileUtils.cp site.files_path(env['PATH_INFO']), tmpfile.path
DB.transaction do
- site.store_file destination, tmpfile
+ site.store_files [{filename: destination, tempfile: tmpfile}]
site.delete_file env['PATH_INFO']
end
diff --git a/config.yml.template b/config.yml.template
index 2cd830cc..0238d383 100644
--- a/config.yml.template
+++ b/config.yml.template
@@ -12,6 +12,11 @@ development:
proxy_pass: 'somethinglongandrandom'
email_unsubscribe_token: 'somethingrandom'
logs_path: /path/to/nginx/logs
+ paypal_api_username: derp
+ paypal_api_password: ing
+ paypal_api_signature: tonz
+ letsencrypt_key: ./tests/files/letsencrypt.key
+ letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
test:
database: 'postgres://neocities@localhost/neocities_test'
database_pool: 1
@@ -25,3 +30,8 @@ test:
ip_hash_salt: "400$8$1$fc21863da5d531c1"
proxy_pass: 'somethinglongandrandom'
email_unsubscribe_token: 'somethingrandom'
+ paypal_api_username: derp
+ paypal_api_password: ing
+ paypal_api_signature: tonz
+ letsencrypt_key: ./tests/files/letsencrypt.key
+ letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
\ No newline at end of file
diff --git a/config.yml.travis b/config.yml.travis
index 876179cf..1f832c7f 100644
--- a/config.yml.travis
+++ b/config.yml.travis
@@ -7,4 +7,9 @@ phantomjs_url:
- http://localhost:8910
ip_hash_salt: "400$8$1$fc21863da5d531c1"
email_unsubscribe_token: "somethingrandomderrrrp"
+paypal_api_username: derp
+paypal_api_password: ing
+paypal_api_signature: tonz
logs_path: "/tmp/neocitiestestlogs"
+letsencrypt_key: ./tests/files/letsencrypt.key
+letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
\ No newline at end of file
diff --git a/environment.rb b/environment.rb
index 6d9911a9..387685a6 100644
--- a/environment.rb
+++ b/environment.rb
@@ -11,6 +11,8 @@ require 'logger'
Bundler.require
Bundler.require :development if ENV['RACK_ENV'] == 'development'
+require 'tilt/erubis'
+
Dir['./ext/**/*.rb'].each {|f| require f}
# :nocov:
@@ -31,6 +33,8 @@ raise 'hash_ip_salt is required' unless $config['ip_hash_salt']
DB = Sequel.connect $config['database'], sslmode: 'disable', max_connections: $config['database_pool']
DB.extension :pagination
+require 'will_paginate/sequel'
+
# :nocov:
=begin
if defined?(Pry)
@@ -58,6 +62,14 @@ Sidekiq.configure_client do |config|
config.redis = sidekiq_redis_config
end
+if ENV['RACK_ENV'] == 'test'
+ $redis = MockRedis.new
+else
+ $redis = Redis.new
+end
+
+$redis_cache = Redis::Namespace.new :cache, redis: $redis
+
# :nocov:
if ENV['RACK_ENV'] == 'development'
# Run async jobs immediately in development.
@@ -122,6 +134,13 @@ if ENV['RACK_ENV'] != 'development'
Sass::Plugin.options[:full_exception] = false
end
+PayPal::Recurring.configure do |config|
+ config.sandbox = false
+ config.username = $config['paypal_api_username']
+ config.password = $config['paypal_api_password']
+ config.signature = $config['paypal_api_signature']
+end
+
require 'csv'
$country_codes = {}
@@ -129,3 +148,7 @@ $country_codes = {}
CSV.foreach("./files/country_codes.csv") do |row|
$country_codes[row.last] = row.first
end
+
+gandi_opts = {}
+gandi_opts[:env] = :test unless ENV['RACK_ENV'] == 'production'
+$gandi = Gandi::Session.new $config['gandi_api_key'], gandi_opts
diff --git a/ext/NilClass.rb b/ext/NilClass.rb
index 4560b84b..a225ef88 100644
--- a/ext/NilClass.rb
+++ b/ext/NilClass.rb
@@ -2,4 +2,8 @@ class NilClass
def empty?
true
end
+
+ def blank?
+ true
+ end
end
diff --git a/ext/base58.rb b/ext/base58.rb
new file mode 100644
index 00000000..048f7cb8
--- /dev/null
+++ b/ext/base58.rb
@@ -0,0 +1,41 @@
+module Base58
+ class << self
+ def int_to_base58(int_val, leading_zero_bytes=0)
+ alpha = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+ base58_val, base = '', alpha.size
+ while int_val > 0
+ int_val, remainder = int_val.divmod(base)
+ base58_val = alpha[remainder] + base58_val
+ end
+ base58_val
+ end
+
+ def base58_to_int(base58_val)
+ alpha = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+ int_val, base = 0, alpha.size
+ base58_val.reverse.each_char.with_index do |char,index|
+ raise ArgumentError, 'Value not a valid Base58 String.' unless char_index = alpha.index(char)
+ int_val += char_index*(base**index)
+ end
+ int_val
+ end
+
+ def base58_to_bytestring(base58_val)
+ [Base58.decode_base58(base58_val)].pack('H*')
+ end
+
+ def encode_base58(hex)
+ leading_zero_bytes = (hex.match(/^([0]+)/) ? $1 : '').size / 2
+ ("1"*leading_zero_bytes) + int_to_base58( hex.to_i(16) )
+ end
+
+ def decode_base58(base58_val)
+ s = base58_to_int(base58_val).to_s(16); s = (s.bytesize.odd? ? '0'+s : s)
+ s = '' if s == '00'
+ leading_zero_bytes = (base58_val.match(/^([1]+)/) ? $1 : '').size
+ s = ("00"*leading_zero_bytes) + s if leading_zero_bytes > 0
+ s
+ end
+ alias_method :base58_to_hex, :decode_base58
+ end
+end
diff --git a/ext/numeric.rb b/ext/numeric.rb
index 3f385fa2..084e5c29 100644
--- a/ext/numeric.rb
+++ b/ext/numeric.rb
@@ -31,7 +31,7 @@ class Numeric
end
def to_comma_separated
- self.to_s.chars.to_a.reverse.each_slice(3).map(&:join).join(",").reverse
+ self.to_i.to_s.chars.to_a.reverse.each_slice(3).map(&:join).join(",").reverse
end
def format_large_number
diff --git a/ext/string.rb b/ext/string.rb
index d4954f6f..911a3682 100644
--- a/ext/string.rb
+++ b/ext/string.rb
@@ -15,4 +15,9 @@ class String
def is_integer?
true if Integer(self) rescue false
end
+
+ def blank?
+ return true if self == ''
+ false
+ end
end
diff --git a/files/phantomjs_screenshot.js b/files/phantomjs_screenshot.js
new file mode 100644
index 00000000..0ac8e24b
--- /dev/null
+++ b/files/phantomjs_screenshot.js
@@ -0,0 +1,56 @@
+var page = require('webpage').create()
+var system = require('system')
+
+var maxTimeout = 15000
+
+if (system.args.length === 1) {
+ console.log('required args: ');
+ phantom.exit(1)
+}
+
+var address = system.args[1]
+var outputPath = system.args[2]
+
+page.viewportSize = { width: 1280, height: 960 }
+page.clipRect = { top: 0, left: 0, width: 1280, height: 960}
+
+/*
+In development, not working yet.
+
+page.settings.scriptTimeout = 1000
+
+page.onLongRunningScript = function() {
+ page.stopJavaScript()
+ phantom.exit(4)
+}
+*/
+
+var t = Date.now()
+
+console.log('Loading ' + address)
+
+setTimeout(function() {
+ console.log('timeout')
+ phantom.exit(62)
+}, maxTimeout)
+
+page.settings.resourceTimeout = maxTimeout
+
+page.onResourceTimeout = function(e) {
+ console.log(e.errorCode)
+ console.log(e.errorString)
+ console.log(e.url)
+ phantom.exit(3)
+}
+
+page.open(address, function(status) {
+ if(status !== 'success') {
+ console.log('failed')
+ phantom.exit(2)
+ }
+
+ page.render(outputPath)
+ console.log('Loading time ' + (Date.now() - t) + ' msec');
+ phantom.exit()
+})
+
diff --git a/migrations/070_add_admin_nsfw.rb b/migrations/070_add_admin_nsfw.rb
new file mode 100644
index 00000000..e03921d0
--- /dev/null
+++ b/migrations/070_add_admin_nsfw.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :admin_nsfw, :boolean
+ }
+
+ down {
+ DB.drop_column :sites, :admin_nsfw
+ }
+end
diff --git a/migrations/071_banned_referrers.rb b/migrations/071_banned_referrers.rb
new file mode 100644
index 00000000..c23d728a
--- /dev/null
+++ b/migrations/071_banned_referrers.rb
@@ -0,0 +1,12 @@
+Sequel.migration do
+ up {
+ DB.create_table! :banned_referrers do
+ primary_key :id
+ String :name
+ end
+ }
+
+ down {
+ DB.drop_table :banned_referrers
+ }
+end
diff --git a/migrations/072_banned_commenting.rb b/migrations/072_banned_commenting.rb
new file mode 100644
index 00000000..88176fd2
--- /dev/null
+++ b/migrations/072_banned_commenting.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :commenting_banned, :boolean, default: false
+ }
+
+ down {
+ DB.drop_column :sites, :commenting_banned
+ }
+end
diff --git a/migrations/073_add_follow_uniqueness.rb b/migrations/073_add_follow_uniqueness.rb
new file mode 100644
index 00000000..4e80ff28
--- /dev/null
+++ b/migrations/073_add_follow_uniqueness.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB['alter table follows add constraint one_follow_per_site unique (site_id, actioning_site_id)'].first
+ }
+
+ down {
+ DB['alter table follows drop constraint one_follow_per_site'].first
+ }
+end
diff --git a/migrations/074_add_custom_max_space.rb b/migrations/074_add_custom_max_space.rb
new file mode 100644
index 00000000..1bc09b18
--- /dev/null
+++ b/migrations/074_add_custom_max_space.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :custom_max_space, :bigint, default: 0
+ }
+
+ down {
+ DB.drop_column :sites, :custom_max_space
+ }
+end
diff --git a/migrations/075_special_sauce.rb b/migrations/075_special_sauce.rb
new file mode 100644
index 00000000..394f2ded
--- /dev/null
+++ b/migrations/075_special_sauce.rb
@@ -0,0 +1,11 @@
+# IT'S MADE OUT OF FUCKING PEOPLE
+
+Sequel.migration do
+ up {
+ DB.add_column :sites, :score, :integer
+ }
+
+ down {
+ DB.drop_column :sites, :score
+ }
+end
diff --git a/migrations/076_decimal_sauce.rb b/migrations/076_decimal_sauce.rb
new file mode 100644
index 00000000..dcef090c
--- /dev/null
+++ b/migrations/076_decimal_sauce.rb
@@ -0,0 +1,13 @@
+# IT'S MADE OUT OF FUCKING DECIMAL PEOPLE
+
+Sequel.migration do
+ up {
+ DB.drop_column :sites, :score
+ DB.add_column :sites, :score, :decimal, default: 0
+ }
+
+ down {
+ DB.drop_column :sites, :score
+ DB.add_column :sites, :score, :integer
+ }
+end
diff --git a/migrations/077_decimal_sauce_index.rb b/migrations/077_decimal_sauce_index.rb
new file mode 100644
index 00000000..fa242b45
--- /dev/null
+++ b/migrations/077_decimal_sauce_index.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_index :sites, :score
+ }
+
+ down {
+ DB.drop_index :sites, :score
+ }
+end
diff --git a/migrations/078_total_stat.rb b/migrations/078_total_stat.rb
new file mode 100644
index 00000000..6698e2a9
--- /dev/null
+++ b/migrations/078_total_stat.rb
@@ -0,0 +1,17 @@
+Sequel.migration do
+ up {
+ DB.create_table! :daily_site_stats do
+ primary_key :id
+ Date :created_at, index: true
+ Integer :hits, default: 0
+ Integer :views, default: 0
+ Integer :comments, default: 0
+ Integer :follows, default: 0
+ Integer :site_updates, default: 0
+ end
+ }
+
+ down {
+ DB.drop_table :daily_site_stats
+ }
+end
diff --git a/migrations/079_add_total_stat_bandwidth.rb b/migrations/079_add_total_stat_bandwidth.rb
new file mode 100644
index 00000000..a3d30feb
--- /dev/null
+++ b/migrations/079_add_total_stat_bandwidth.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :daily_site_stats, :bandwidth, :integer
+ }
+
+ down {
+ DB.drop_column :daily_site_stats, :bandwidth
+ }
+end
diff --git a/migrations/080_fix_total_stat_bandwidth.rb b/migrations/080_fix_total_stat_bandwidth.rb
new file mode 100644
index 00000000..3c87e95a
--- /dev/null
+++ b/migrations/080_fix_total_stat_bandwidth.rb
@@ -0,0 +1,11 @@
+Sequel.migration do
+ up {
+ DB.drop_column :daily_site_stats, :bandwidth
+ DB.add_column :daily_site_stats, :bandwidth, :integer, default: 0
+ }
+
+ down {
+ DB.drop_column :daily_site_stats, :bandwidth
+ DB.add_column :daily_site_stats, :bandwidth, :integer
+ }
+end
diff --git a/migrations/081_stats_bigint.rb b/migrations/081_stats_bigint.rb
new file mode 100644
index 00000000..f8f0081b
--- /dev/null
+++ b/migrations/081_stats_bigint.rb
@@ -0,0 +1,15 @@
+Sequel.migration do
+ up {
+ alter_table(:stats) do
+ set_column_type :hits, Bignum
+ set_column_type :views, Bignum
+ end
+ }
+
+ down {
+ alter_table(:stats) do
+ set_column_type :hits, Integer
+ set_column_type :views, Integer
+ end
+ }
+end
diff --git a/migrations/082_daily_stats_bigint.rb b/migrations/082_daily_stats_bigint.rb
new file mode 100644
index 00000000..5cefebed
--- /dev/null
+++ b/migrations/082_daily_stats_bigint.rb
@@ -0,0 +1,19 @@
+Sequel.migration do
+ up {
+ alter_table(:daily_site_stats) do
+ set_column_type :hits, Bignum
+ set_column_type :views, Bignum
+ set_column_type :bandwidth, Bignum
+ set_column_type :site_updates, Bignum
+ end
+ }
+
+ down {
+ alter_table(:daily_site_stats) do
+ set_column_type :hits, Integer
+ set_column_type :views, Integer
+ set_column_type :bandwidth, Integer
+ set_column_type :site_updates, Integer
+ end
+ }
+end
diff --git a/migrations/083_add_classifiers.rb b/migrations/083_add_classifiers.rb
new file mode 100644
index 00000000..9292780b
--- /dev/null
+++ b/migrations/083_add_classifiers.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :site_files, :classifier, :text, default: nil, index: true
+ }
+
+ down {
+ DB.drop_column :site_files, :classifier
+ }
+end
diff --git a/migrations/084_dashboard_accessed.rb b/migrations/084_dashboard_accessed.rb
new file mode 100644
index 00000000..0b34fd39
--- /dev/null
+++ b/migrations/084_dashboard_accessed.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :dashboard_accessed, :boolean, default: false
+ }
+
+ down {
+ DB.drop_column :sites, :dashboard_accessed
+ }
+end
diff --git a/migrations/085_gandi_contact_handle.rb b/migrations/085_gandi_contact_handle.rb
new file mode 100644
index 00000000..a1f1a615
--- /dev/null
+++ b/migrations/085_gandi_contact_handle.rb
@@ -0,0 +1,14 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :gandi_handle, :text, index: true
+
+ # This is not as horrible as it looks.
+ # It basically serves as a temp password when account is released from reseller account.
+ DB.add_column :sites, :gandi_password, :text
+ }
+
+ down {
+ DB.drop_column :sites, :gandi_handle
+ DB.drop_column :sites, :gandi_password
+ }
+end
diff --git a/migrations/086_create_domains.rb b/migrations/086_create_domains.rb
new file mode 100644
index 00000000..0ce7c3db
--- /dev/null
+++ b/migrations/086_create_domains.rb
@@ -0,0 +1,23 @@
+Sequel.migration do
+ up {
+ DB.drop_column :sites, :gandi_handle
+ DB.drop_column :sites, :gandi_password
+
+ DB.create_table! :domains do
+ primary_key :id
+ Integer :site_id, index: true
+ String :gandi_handle
+ String :gandi_password
+ String :gandi_domain_id
+ String :name
+ DateTime :created_at
+ DateTime :released_at
+ end
+ }
+
+ down {
+ DB.drop_table :domains
+ DB.add_column :sites, :gandi_handle, :text, index: true
+ DB.add_column :sites, :gandi_password, :text
+ }
+end
diff --git a/migrations/087_password_reset_confirmed.rb b/migrations/087_password_reset_confirmed.rb
new file mode 100644
index 00000000..feb39a6d
--- /dev/null
+++ b/migrations/087_password_reset_confirmed.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :password_reset_confirmed, :boolean, default: false
+ }
+
+ down {
+ DB.drop_column :sites, :password_reset_confirmed
+ }
+end
diff --git a/migrations/088_site_change_files_indexes.rb b/migrations/088_site_change_files_indexes.rb
new file mode 100644
index 00000000..375246c1
--- /dev/null
+++ b/migrations/088_site_change_files_indexes.rb
@@ -0,0 +1,11 @@
+Sequel.migration do
+ up {
+ DB.add_index :site_change_files, :site_change_id
+ DB.add_index :site_change_files, :site_id
+ }
+
+ down {
+ DB.drop_index :site_change_files, :site_change_id
+ DB.drop_index :site_change_files, :site_id
+ }
+end
diff --git a/migrations/089_maximum_email_confirmations.rb b/migrations/089_maximum_email_confirmations.rb
new file mode 100644
index 00000000..58527a91
--- /dev/null
+++ b/migrations/089_maximum_email_confirmations.rb
@@ -0,0 +1,10 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :email_confirmation_count, :integer, default: 0
+
+ }
+
+ down {
+ DB.drop_column :sites, :email_confirmation_count
+ }
+end
diff --git a/migrations/090_add_cert_updated_at.rb b/migrations/090_add_cert_updated_at.rb
new file mode 100644
index 00000000..746d9fed
--- /dev/null
+++ b/migrations/090_add_cert_updated_at.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :cert_updated_at, Time
+ }
+
+ down {
+ DB.drop_column :sites, :cert_updated_at
+ }
+end
diff --git a/migrations/091_site_banned_at.rb b/migrations/091_site_banned_at.rb
new file mode 100644
index 00000000..313280df
--- /dev/null
+++ b/migrations/091_site_banned_at.rb
@@ -0,0 +1,9 @@
+Sequel.migration do
+ up {
+ DB.add_column :sites, :banned_at, Time
+ }
+
+ down {
+ DB.drop_column :sites, :banned_at
+ }
+end
diff --git a/models/archive.rb b/models/archive.rb
index 53731056..7a434120 100644
--- a/models/archive.rb
+++ b/models/archive.rb
@@ -1,9 +1,47 @@
+require 'base32'
+
class Archive < Sequel::Model
many_to_one :site
set_primary_key [:site_id, :ipfs_hash]
unrestrict_primary_key
+ MAXIMUM_ARCHIVES_PER_SITE = 100
+ ARCHIVE_WAIT_TIME = 1.hour
+
+ def before_destroy
+ unpin
+ super
+ end
+
+ def self.base58_to_hshca(base58)
+ Base32.encode(Base58.base58_to_bytestring(base58)).gsub('=', '').downcase
+ end
+
+ def hshca_hash
+ self.class.base58_to_hshca ipfs_hash
+ end
+
+ def unpin
+ # Not ideal. An SoA version is in progress.
+ if ENV['RACK_ENV'] == 'production' && $config['ipfs_ssh_host'] && $config['ipfs_ssh_user']
+ rbox = Rye::Box.new $config['ipfs_ssh_host'], :user => $config['ipfs_ssh_user']
+ rbox.disable_safe_mode
+ begin
+ response = rbox.execute "ipfs pin rm #{ipfs_hash}"
+ output_array = response
+ rescue => e
+ return true if e.message =~ /indirect pins cannot be removed directly/
+ ensure
+ rbox.disconnect
+ end
+ else
+ line = Cocaine::CommandLine.new('ipfs', 'pin rm :ipfs_hash')
+ binding.pry
+ response = line.run ipfs_hash: ipfs_hash
+ output_array = response.to_s.split("\n")
+ end
+ end
def url
- "https://#{ipfs_hash}.ipfs.neocities.org"
+ "http://#{hshca_hash}.ipfs.neocitiesops.net"
end
end
diff --git a/models/daily_site_stat.rb b/models/daily_site_stat.rb
new file mode 100644
index 00000000..cdcc3622
--- /dev/null
+++ b/models/daily_site_stat.rb
@@ -0,0 +1,2 @@
+class DailySiteStat < Sequel::Model
+end
diff --git a/models/event.rb b/models/event.rb
index a711f17b..5f454b22 100644
--- a/models/event.rb
+++ b/models/event.rb
@@ -8,7 +8,7 @@ class Event < Sequel::Model
many_to_one :site_change
many_to_one :profile_comment
one_to_many :likes
- one_to_many :comments
+ one_to_many :comments, order: :created_at
many_to_one :site
many_to_one :actioning_site, key: :actioning_site_id, class: :Site
diff --git a/models/site.rb b/models/site.rb
index a9e5aeb4..1632c7fc 100644
--- a/models/site.rb
+++ b/models/site.rb
@@ -30,14 +30,15 @@ class Site < Sequel::Model
application/xml
audio/midi
text/cache-manifest
+ application/rss+xml
}
VALID_EXTENSIONS = %w{
- html htm txt text css js jpg jpeg png gif svg md markdown eot ttf woff woff2 json geojson csv tsv mf ico pdf asc key pgp xml mid midi manifest otf webapp
+ html htm txt text css js jpg jpeg png gif svg md markdown eot ttf woff woff2 json geojson csv tsv mf ico pdf asc key pgp xml mid midi manifest otf webapp less sass rss kml dae obj mtl
}
VALID_EDITABLE_EXTENSIONS = %w{
- html htm txt js css md manifest
+ html htm txt js css md manifest less
}
MINIMUM_PASSWORD_LENGTH = 5
@@ -74,6 +75,8 @@ class Site < Sequel::Model
/PHP\.Hide/
]
+ EMPTY_FILE_HASH = Digest::SHA1.hexdigest ''
+
PHISHING_FORM_REGEX = /www.formbuddy.com\/cgi-bin\/form.pl/i
SPAM_MATCH_REGEX = ENV['RACK_ENV'] == 'test' ? /pillz/ : /#{$config['spam_smart_filter'].join('|')}/i
EMAIL_SANITY_REGEX = /.+@.+\..+/i
@@ -85,7 +88,7 @@ class Site < Sequel::Model
SUGGESTIONS_LIMIT = 30
SUGGESTIONS_VIEWS_MIN = 500
- CHILD_SITES_MAX = 100
+ CHILD_SITES_MAX = 30
IP_CREATE_LIMIT = 1000
TOTAL_IP_CREATE_LIMIT = 10000
@@ -103,7 +106,7 @@ class Site < Sequel::Model
custom_ssl_certificates: true,
no_file_restrictions: true,
custom_domains: true,
- maximum_site_files: 25000
+ maximum_site_files: 50000
}
PLAN_FEATURES[:free] = PLAN_FEATURES[:supporter].merge(
@@ -115,9 +118,12 @@ class Site < Sequel::Model
custom_ssl_certificates: false,
no_file_restrictions: false,
custom_domains: false,
- maximum_site_files: 1000
+ maximum_site_files: 2000
)
+ EMAIL_VALIDATION_CUTOFF_DATE = Time.parse('May 16, 2016')
+ DISPOSABLE_EMAIL_BLACKLIST_PATH = File.join(DIR_ROOT, 'files', 'disposable_email_blacklist.conf')
+
def self.newsletter_sites
Site.select(:email).
exclude(email: 'nil').exclude(is_banned: true).
@@ -157,6 +163,8 @@ class Site < Sequel::Model
EMAIL_BLAST_MAXIMUM_PER_DAY = 1000
end
+ MAXIMUM_EMAIL_CONFIRMATIONS = 20
+
many_to_many :tags
one_to_many :profile_comments
@@ -319,7 +327,7 @@ class Site < Sequel::Model
end
def is_following?(site)
- followings_dataset.select(:id).filter(site_id: site.id).first ? true : false
+ followings_dataset.select(:follows__id).filter(site_id: site.id).first ? true : false
end
def toggle_follow(site)
@@ -409,16 +417,16 @@ class Site < Sequel::Model
FileUtils.cp template_file_path('style.css'), tmpfile.path
files << {filename: 'style.css', tempfile: tmpfile}
- tmpfile = Tempfile.new 'cat.png'
+ tmpfile = Tempfile.new 'neocities.png'
tmpfile.close
- FileUtils.cp template_file_path('cat.png'), tmpfile.path
- files << {filename: 'cat.png', tempfile: tmpfile}
+ FileUtils.cp template_file_path('neocities.png'), tmpfile.path
+ files << {filename: 'neocities.png', tempfile: tmpfile}
store_files files, new_install: true
end
def get_file(path)
- File.read files_path(path)
+ File.read current_files_path(path)
end
def before_destroy
@@ -447,7 +455,7 @@ class Site < Sequel::Model
DB.transaction {
self.is_banned = true
- self.updated_at = Time.now
+ self.banned_at = Time.now
save(validate: false)
if !Dir.exist? BANNED_SITES_ROOT
@@ -457,8 +465,8 @@ class Site < Sequel::Model
FileUtils.mv files_path, File.join(BANNED_SITES_ROOT, username)
}
- file_list.each do |path|
- purge_cache path
+ site_files.each do |site_file|
+ delete_cache site_file.path
end
end
@@ -468,6 +476,16 @@ class Site < Sequel::Model
}
end
+ # Who this site follows
+ def followings_dataset
+ super.select_all(:follows).inner_join(:sites, :id=>:site_id).exclude(:sites__is_deleted => true).exclude(:sites__is_banned => true)
+ end
+
+ # Who this site is following
+ def follows_dataset
+ super.select_all(:follows).inner_join(:sites, :id=>:actioning_site_id).exclude(:sites__is_deleted => true).exclude(:sites__is_banned => true)
+ end
+
=begin
def follows_dataset
super.where(Sequel.~(site_id: blocking_site_ids))
@@ -486,6 +504,7 @@ class Site < Sequel::Model
=end
def commenting_allowed?
+ return false if owner.commenting_banned == true
return true if owner.commenting_allowed
if owner.supporter?
@@ -522,6 +541,21 @@ class Site < Sequel::Model
!username.empty? && username.match(/^[a-zA-Z0-9_\-]+$/i)
end
+ def self.disposable_email?(email)
+ return false unless File.exist?(DISPOSABLE_EMAIL_BLACKLIST_PATH)
+ return false if email.blank?
+
+ email.strip!
+
+ disposable_email_domains = File.readlines DISPOSABLE_EMAIL_BLACKLIST_PATH
+
+ disposable_email_domains.each do |disposable_email_domain|
+ return true if email.match disposable_email_domain.strip
+ end
+
+ false
+ end
+
def okay_to_upload?(uploaded_file)
return true if [:supporter].include?(plan_type.to_sym)
return false if self.class.possible_phishing?(uploaded_file)
@@ -580,16 +614,41 @@ class Site < Sequel::Model
# We gotta flush the dirname too if it's an index file.
if relative_path != '' && relative_path.match(/\/$|index\.html?$/i)
PurgeCacheOrderWorker.perform_async username, relative_path
- PurgeCacheOrderWorker.perform_async username, Pathname(relative_path).dirname.to_s
+
+ purge_file_path = Pathname(relative_path).dirname.to_s
+
+ PurgeCacheOrderWorker.perform_async username, '/?surf=1' if purge_file_path == '/'
+ PurgeCacheOrderWorker.perform_async username, purge_file_path
else
PurgeCacheOrderWorker.perform_async username, relative_path
end
end
+ # TODO DRY this up
+
+ def delete_cache(path)
+ relative_path = path.gsub base_files_path, ''
+
+ DeleteCacheOrderWorker.perform_async username, relative_path
+
+ # We gotta flush the dirname too if it's an index file.
+ if relative_path != '' && relative_path.match(/\/$|index\.html?$/i)
+ purge_file_path = Pathname(relative_path).dirname.to_s
+
+ DeleteCacheOrderWorker.perform_async username, '/?surf=1' if purge_file_path == '/'
+ DeleteCacheOrderWorker.perform_async username, purge_file_path
+ end
+ end
+
Rye::Cmd.add_command :ipfs, nil, 'add', :r
def add_to_ipfs
# Not ideal. An SoA version is in progress.
+
+ if archives_dataset.count > Archive::MAXIMUM_ARCHIVES_PER_SITE
+ archives_dataset.order(:updated_at).first.destroy
+ end
+
if $config['ipfs_ssh_host'] && $config['ipfs_ssh_user']
rbox = Rye::Box.new $config['ipfs_ssh_host'], :user => $config['ipfs_ssh_user']
begin
@@ -607,6 +666,12 @@ class Site < Sequel::Model
output_array.last.split(' ')[1]
end
+ def purge_old_archives
+ archives_dataset.order(:updated_at).offset(Archive::MAXIMUM_ARCHIVES_PER_SITE).all.each do |archive|
+ archive.destroy
+ end
+ end
+
def archive!
#if ENV["RACK_ENV"] == 'test'
# ipfs_hash = "QmcKi2ae3uGb1kBg1yBpsuwoVqfmcByNdMiZ2pukxyLWD8"
@@ -638,6 +703,32 @@ class Site < Sequel::Model
return 'Directory (or file) already exists.'
end
+ path_dirs = path.to_s.split('/').select {|p| ![nil, '.', ''].include?(p) }
+
+ path_site_file = ''
+
+ until path_dirs.empty?
+ if path_site_file == ''
+ path_site_file += path_dirs.shift
+ else
+ path_site_file += '/' + path_dirs.shift
+ end
+
+ raise ArgumentError, 'directory name cannot be empty' if path_site_file == ''
+
+ site_file = SiteFile.where(site_id: self.id, path: path_site_file).first
+
+ if site_file.nil?
+ SiteFile.create(
+ site_id: self.id,
+ path: path_site_file,
+ is_directory: true,
+ created_at: Time.now,
+ updated_at: Time.now
+ )
+ end
+ end
+
FileUtils.mkdir_p relative_path
true
end
@@ -650,10 +741,12 @@ class Site < Sequel::Model
Zip::Archive.open(tmpfile.path, Zip::CREATE) do |ar|
ar.add_dir(zip_name)
+ end
- Dir.glob("#{base_files_path}/**/*").each do |path|
- relative_path = path.gsub(base_files_path+'/', '')
+ Dir.glob("#{base_files_path}/**/*").each do |path|
+ relative_path = path.gsub(base_files_path+'/', '')
+ Zip::Archive.open(tmpfile.path, Zip::CREATE) do |ar|
if File.directory?(path)
ar.add_dir(zip_name+'/'+relative_path)
else
@@ -741,6 +834,14 @@ class Site < Sequel::Model
# super
# end
+ def domain=(domain)
+ super SimpleIDN.to_ascii(domain)
+ end
+
+ def domain
+ SimpleIDN.to_unicode values[:domain]
+ end
+
def validate
super
@@ -764,6 +865,14 @@ class Site < Sequel::Model
errors.add :email, 'An email address is required.'
end
+ if parent? && values[:email] =~ /@neocities.org/
+ errors.add :email, 'Cannot use this email address.'
+ end
+
+ if parent? && new? && self.class.disposable_email?(values[:email])
+ errors.add :email, 'Cannot use a disposable email address.'
+ end
+
# Check for existing email if new or changing email.
if new? || @original_email
email_check = self.class.select(:id).filter(email: values[:email])
@@ -793,15 +902,10 @@ class Site < Sequel::Model
end
if !values[:domain].nil? && !values[:domain].empty?
-
if values[:domain] =~ /neocities\.org/ || values[:domain] =~ /neocitiesops\.net/
errors.add :domain, "Domain is already being used.. by Neocities."
end
- if !(values[:domain] =~ /^[a-zA-Z0-9.-]+\.[a-zA-Z0-9]+$/i) || values[:domain].length > 90
- errors.add :domain, "Domain provided is not valid. Must take the form of domain.com"
- end
-
site = Site[domain: values[:domain]]
if !site.nil? && site.id != self.id
errors.add :domain, "Domain provided is already being used by another site, please choose another."
@@ -871,6 +975,12 @@ class Site < Sequel::Model
File.join TEMPLATE_ROOT, name
end
+ def current_base_files_path(name=username)
+ raise 'username missing' if name.nil? || name.empty?
+ return File.join BANNED_SITES_ROOT, name if is_banned
+ base_files_path name
+ end
+
def base_files_path(name=username)
raise 'username missing' if name.nil? || name.empty?
File.join SITE_FILES_ROOT, name
@@ -881,7 +991,7 @@ class Site < Sequel::Model
path ||= ''
clean = []
- parts = path.split '/'
+ parts = path.to_s.split '/'
parts.each do |part|
next if part.empty? || part == '.'
@@ -891,6 +1001,10 @@ class Site < Sequel::Model
clean.join '/'
end
+ def current_files_path(path='')
+ File.join current_base_files_path, scrubbed_path(path)
+ end
+
def files_path(path='')
File.join base_files_path, scrubbed_path(path)
end
@@ -929,8 +1043,16 @@ class Site < Sequel::Model
end
def actual_space_used
- space = Dir.glob(File.join(files_path, '*')).collect {|p| File.size(p)}.inject {|sum,x| sum += x}
- space.nil? ? 0 : space
+ space = 0
+
+ files = Dir.glob File.join(files_path, '**', '*')
+
+ files.each do |file|
+ next if File.directory? file
+ space += File.size file
+ end
+
+ space
end
def total_space_used
@@ -945,14 +1067,18 @@ class Site < Sequel::Model
end
def maximum_space
- PLAN_FEATURES[(parent? ? self : parent).plan_type.to_sym][:space]
+ plan_space = PLAN_FEATURES[(parent? ? self : parent).plan_type.to_sym][:space]
+
+ return custom_max_space if custom_max_space > plan_space
+
+ plan_space
end
def space_percentage_used
((total_space_used.to_f / maximum_space) * 100).round(1)
end
- # Note: Change Stat#prune! if you change this business logic.
+ # Note: Change Stat#prune! and the nginx map compiler if you change this business logic.
def supporter?
owner.plan_type != 'free'
end
@@ -965,6 +1091,10 @@ class Site < Sequel::Model
PLAN_FEATURES[plan_type.to_sym][:name]
end
+ def stripe_paying_supporter?
+ stripe_customer_id && !plan_ended && values[:plan_type].match(/free|special/).nil?
+ end
+
def unconverted_legacy_supporter?
stripe_customer_id && !plan_ended && values[:plan_type].nil? && stripe_subscription_id.nil?
end
@@ -974,8 +1104,9 @@ class Site < Sequel::Model
!values[:plan_type].match(/plan_/).nil?
end
- # Note: Change Stat#prune! if you change this business logic.
+ # Note: Change Stat#prune! and the nginx map compiler if you change this business logic.
def plan_type
+ return 'supporter' if owner.values[:paypal_active] == true
return 'free' if owner.values[:plan_type].nil?
return 'supporter' if owner.values[:plan_type].match /^plan_/
return 'supporter' if owner.values[:plan_type] == 'special'
@@ -1037,13 +1168,70 @@ class Site < Sequel::Model
end
end
+ def self.compute_scores
+ select(:id, :username, :created_at, :updated_at, :views, :featured_at, :changed_count, :api_calls).exclude(is_banned: true).exclude(is_crashing: true).exclude(is_nsfw: true).exclude(updated_at: nil).where(site_changed: true).all.each do |s|
+ s.score = s.compute_score
+ s.save_changes validate: false
+ end
+ end
+
+ SCORE_GRAVITY = 1.8
+
+ def compute_score
+ points = 0
+ points += follows_dataset.count * 30
+ points += profile_comments_dataset.count * 1
+ points += views / 1000
+ points += 20 if !featured_at.nil?
+
+ # penalties
+ points = 0 if changed_count < 2
+ points = 0 if api_calls && api_calls > 1000
+
+ (points / ((Time.now - updated_at) / 7.days)**SCORE_GRAVITY).round(4)
+ end
+
+=begin
+ def compute_score
+ score = 0
+ score += (Time.now - created_at) / 1.day
+ score -= ((Time.now - updated_at) / 1.day) * 2
+ score += 500 if (updated_at > 1.week.ago)
+ score -= 1000 if
+ follow_count = follows_dataset.count
+ score -= 1000 if follow_count == 0
+ score += follow_count * 100
+ score += profile_comments_dataset.count * 5
+ score += profile_commentings_dataset.count
+ score.to_i
+ end
+=end
+
+ def self.browse_dataset
+ dataset.where is_deleted: false, is_banned: false, is_crashing: false, site_changed: true
+ end
+
def suggestions(limit=SUGGESTIONS_LIMIT, offset=0)
suggestions_dataset = Site.exclude(id: id).exclude(is_banned: true).exclude(is_nsfw: true).order(:views.desc, :updated_at.desc)
suggestions = suggestions_dataset.where(tags: tags).limit(limit, offset).all
return suggestions if suggestions.length == limit
- suggestions += suggestions_dataset.where("views >= #{SUGGESTIONS_VIEWS_MIN}").limit(limit-suggestions.length).order(Sequel.lit('RANDOM()')).all
+ # Old.
+ #suggestions += suggestions_dataset.where("views >= #{SUGGESTIONS_VIEWS_MIN}").limit(limit-suggestions.length).order(Sequel.lit('RANDOM()')).all
+
+ # New:
+
+ site_dataset = self.class.browse_dataset.association_left_join :follows
+ site_dataset.select_all! :sites
+ site_dataset.select_append! Sequel.lit("count(follows.site_id) AS follow_count")
+ site_dataset.group! :sites__id
+ site_dataset.order! :follow_count.desc, :updated_at.desc
+ site_dataset.where! "views >= #{SUGGESTIONS_VIEWS_MIN}"
+ site_dataset.limit! limit-suggestions.length
+ #site_dataset.order! Sequel.lit('RANDOM()')
+
+ suggestions += site_dataset.all
end
def screenshot_path(path, resolution)
@@ -1099,11 +1287,53 @@ class Site < Sequel::Model
end
end
+ def empty_index?
+ !site_files_dataset.where(path: /^\/?index.html$/).where(sha1_hash: EMPTY_FILE_HASH).first.nil?
+ end
+
+ def classify(path)
+ return nil unless classification_allowed? path
+ #$classifier.classify process_for_classification(path)
+ end
+
+ def classification_scores(path)
+ return nil unless classification_allowed? path
+ #$classifier.classification_scores process_for_classification(path)
+ end
+
+ def train(path, category='ham')
+ return nil unless classification_allowed? path
+ # $trainer.train(category, process_for_classification(path))
+ site_file = site_files_dataset.where(path: path).first
+ site_file.classifier = category
+ site_file.save_changes validate: false
+ end
+
+ def untrain(path, category='ham')
+ return nil unless classification_allowed? path
+ # $trainer.untrain(category, process_for_classification(path))
+ site_file = site_files_dataset.where(path: path).first
+ site_file.classifier = category
+ site_file.save_changes validate: false
+ end
+
+ def classification_allowed?(path)
+ site_file = site_files_dataset.where(path: path).first
+ return false if site_file.is_directory
+ return false if site_file.size > SiteFile::CLASSIFIER_LIMIT
+ return false if !path.match(/\.html$/)
+ true
+ end
+
+ def process_for_classification(path)
+ sanitized = Sanitize.fragment get_file(path)
+ sanitized.gsub(/(http|https):\/\//, '').gsub(/[^\w\s]/, '').downcase.split.uniq.select{|v| v.length < SiteFile::CLASSIFIER_WORD_LIMIT}.join(' ')
+ end
+
# array of hashes: filename, tempfile, opts.
def store_files(files, opts={})
results = []
new_size = 0
- html_uploaded = false
if too_many_files?(files.length)
results << false
@@ -1111,35 +1341,53 @@ class Site < Sequel::Model
end
files.each do |file|
- html_uploaded = true if file[:filename].match HTML_REGEX
-
existing_size = 0
+
site_file = site_files_dataset.where(path: scrubbed_path(file[:filename])).first
+
if site_file
existing_size = site_file.size
end
res = store_file(file[:filename], file[:tempfile], file[:opts] || opts)
+
if res == true
new_size -= existing_size
new_size += file[:tempfile].size
end
+
results << res
end
- if results.include? true && opts[:new_install] != true
- time = Time.now
- sql = DB["update sites set site_changed=?, site_updated_at=?, updated_at=?, changed_count=changed_count+1, space_used=space_used#{new_size < 0 ? new_size.to_s : '+'+new_size.to_s} where id=?",
- true,
- time,
- time,
- self.id
- ]
- sql.first
+ if results.include? true
+
+ DB["update sites set space_used=space_used#{new_size < 0 ? new_size.to_s : '+'+new_size.to_s} where id=?", self.id].first
+
+ if opts[:new_install] != true
+ if files.select {|f| f[:filename] =~ /^\/?index.html$/}.length > 0 || site_changed == true
+ index_changed = true
+ else
+ index_changed = false
+ end
+
+ index_changed = false if empty_index?
+
+ time = Time.now
+
+ sql = DB["update sites set site_changed=?, site_updated_at=?, updated_at=?, changed_count=changed_count+1 where id=?",
+ index_changed,
+ time,
+ time,
+ self.id
+ ]
+ sql.first
+
+ ArchiveWorker.perform_in Archive::ARCHIVE_WAIT_TIME, self.id
+ end
+
reload
#SiteChange.record self, relative_path unless opts[:new_install]
- ArchiveWorker.perform_async self.id
end
results
@@ -1147,36 +1395,9 @@ class Site < Sequel::Model
def delete_file(path)
return false if files_path(path) == files_path
- begin
- FileUtils.rm files_path(path)
- rescue Errno::EISDIR
- site_files.each do |site_file|
- if site_file.path.match /^#{path}\//
- site_file.destroy
- end
- end
- FileUtils.remove_dir files_path(path), true
- rescue Errno::ENOENT
- end
-
- purge_cache path
-
- ext = File.extname(path).gsub(/^./, '')
-
- screenshots_delete(path) if ext.match HTML_REGEX
- thumbnails_delete(path) if ext.match IMAGE_REGEX
-
- path = path[1..path.length] if path[0] == '/'
-
- DB.transaction do
- site_file = site_files_dataset.where(path: path).first
- if site_file
- DB['update sites set space_used=space_used-? where id=?', site_file.size, self.id].first
- site_file.delete
- end
- SiteChangeFile.filter(site_id: self.id, filename: path).delete
- end
-
+ path = scrubbed_path path
+ site_file = site_files_dataset.where(path: path).first
+ site_file.destroy if site_file
true
end
@@ -1195,6 +1416,15 @@ class Site < Sequel::Model
return false
end
+ if pathname.extname.match HTML_REGEX
+ # SPAM and phishing checking code goes here
+ end
+
+ relative_path_dir = Pathname(relative_path).dirname
+ create_directory relative_path_dir unless relative_path_dir == '.'
+
+ uploaded_size = uploaded.size
+
if relative_path == 'index.html'
begin
new_title = Nokogiri::HTML(File.read(uploaded.path)).css('title').first.text
@@ -1207,18 +1437,6 @@ class Site < Sequel::Model
end
end
- if pathname.extname.match HTML_REGEX
- # SPAM and phishing checking code goes here
- end
-
- dirname = pathname.dirname.to_s
-
- if !File.exists? dirname
- FileUtils.mkdir_p dirname
- end
-
- uploaded_size = uploaded.size
-
FileUtils.cp uploaded.path, path
File.chmod 0640, path
@@ -1236,11 +1454,11 @@ class Site < Sequel::Model
purge_cache path
if pathname.extname.match HTML_REGEX
- ScreenshotWorker.perform_async values[:username], relative_path
+ ScreenshotWorker.perform_in 1.minute, values[:username], relative_path
elsif pathname.extname.match IMAGE_REGEX
ThumbnailWorker.perform_async values[:username], relative_path
end
+
true
end
-
end
diff --git a/models/site_file.rb b/models/site_file.rb
index fa5db8d6..7da4be4a 100644
--- a/models/site_file.rb
+++ b/models/site_file.rb
@@ -1,5 +1,52 @@
+require 'sanitize'
+
class SiteFile < Sequel::Model
+ CLASSIFIER_LIMIT = 1_000_000.freeze
+ CLASSIFIER_WORD_LIMIT = 25.freeze
unrestrict_primary_key
plugin :update_primary_key
many_to_one :site
+
+ def before_destroy
+ if is_directory
+ site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: true).all.each do |site_file|
+ begin
+ site_file.destroy
+ rescue Sequel::NoExistingObject
+ end
+ end
+
+ site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: false).all.each do |site_file|
+ site_file.destroy
+ end
+
+ begin
+ FileUtils.remove_dir site.files_path(path)
+ rescue Errno::ENOENT
+ end
+
+ else
+
+ begin
+ FileUtils.rm site.files_path(path)
+ rescue Errno::ENOENT
+ end
+
+ ext = File.extname(path).gsub(/^./, '')
+ site.screenshots_delete(path) if ext.match Site::HTML_REGEX
+ site.thumbnails_delete(path) if ext.match Site::IMAGE_REGEX
+ end
+
+ super
+ end
+
+ def after_destroy
+ super
+ unless is_directory
+ DB['update sites set space_used=space_used-? where id=?', size, site_id].first
+ end
+
+ site.delete_cache site.files_path(path)
+ SiteChangeFile.filter(site_id: site_id, filename: path).delete
+ end
end
diff --git a/models/stat.rb b/models/stat.rb
index 687997ee..fe2ea15c 100644
--- a/models/stat.rb
+++ b/models/stat.rb
@@ -15,8 +15,11 @@ class Stat < Sequel::Model
end
def parse_logfiles(path)
+ total_site_stats = {}
+
Dir["#{path}/*.log"].each do |log_path|
site_logs = {}
+
logfile = File.open log_path, 'r'
while hit = logfile.gets
@@ -26,9 +29,13 @@ class Stat < Sequel::Model
time, username, size, path, ip, referrer = hit_array
+ log_time = Time.parse time
+
next if !referrer.nil? && referrer.match(/bot/i)
- site_logs[username] = {
+ site_logs[log_time] = {} unless site_logs[log_time]
+
+ site_logs[log_time][username] = {
hits: 0,
views: 0,
bandwidth: 0,
@@ -36,78 +43,111 @@ class Stat < Sequel::Model
ips: [],
referrers: {},
paths: {}
- } unless site_logs[username]
+ } unless site_logs[log_time][username]
- site_logs[username][:hits] += 1
- site_logs[username][:bandwidth] += size.to_i
+ total_site_stats[log_time] = {
+ hits: 0,
+ views: 0,
+ bandwidth: 0
+ } unless total_site_stats[log_time]
- unless site_logs[username][:view_ips].include?(ip)
- site_logs[username][:views] += 1
- site_logs[username][:view_ips] << ip
+ site_logs[log_time][username][:hits] += 1
+ site_logs[log_time][username][:bandwidth] += size.to_i
+
+ total_site_stats[log_time][:hits] += 1
+ total_site_stats[log_time][:bandwidth] += size.to_i
+
+ unless site_logs[log_time][username][:view_ips].include?(ip)
+ site_logs[log_time][username][:views] += 1
+
+ total_site_stats[log_time][:views] += 1
+
+ site_logs[log_time][username][:view_ips] << ip
if referrer != '-' && !referrer.nil?
- site_logs[username][:referrers][referrer] ||= 0
- site_logs[username][:referrers][referrer] += 1
+ site_logs[log_time][username][:referrers][referrer] ||= 0
+ site_logs[log_time][username][:referrers][referrer] += 1
end
end
- site_logs[username][:paths][path] ||= 0
- site_logs[username][:paths][path] += 1
+ site_logs[log_time][username][:paths][path] ||= 0
+ site_logs[log_time][username][:paths][path] += 1
end
logfile.close
- current_time = Time.now.utc
- current_day_string = current_time.to_date.to_s
-
- Site.select(:id, :username).where(username: site_logs.keys).all.each do |site|
- site_logs[site.username][:id] = site.id
- end
-
DB.transaction do
- site_logs.each do |username, site_log|
- DB['update sites set hits=hits+?, views=views+? where username=?',
- site_log[:hits],
- site_log[:views],
- username
+ site_logs.each do |log_time, usernames|
+ Site.select(:id, :username).where(username: usernames.keys).all.each do |site|
+ site_logs[log_time][site.username][:id] = site.id
+ end
+
+ usernames.each do |username, site_log|
+ DB['update sites set hits=hits+?, views=views+? where username=?',
+ site_log[:hits],
+ site_log[:views],
+ username
].first
- opts = {site_id: site_log[:id], created_at: current_day_string}
+ opts = {site_id: site_log[:id], created_at: log_time.to_date.to_s}
- stat = Stat.select(:id).where(opts).first
- DB[:stats].lock('EXCLUSIVE') { stat = Stat.create opts } if stat.nil?
+ stat = nil
- DB[
- 'update stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where id=?',
- site_log[:hits],
- site_log[:views],
- site_log[:bandwidth],
- stat.id
- ].first
+ DB[:stats].lock('EXCLUSIVE') {
+ stat = Stat.select(:id).where(opts).first
+ stat = Stat.create opts if stat.nil?
+ }
+
+ DB[
+ 'update stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where id=?',
+ site_log[:hits],
+ site_log[:views],
+ site_log[:bandwidth],
+ stat.id
+ ].first
=begin
- site_log[:referrers].each do |referrer, views|
- stat_referrer = StatReferrer.create_or_get site_log[:id], referrer
- DB['update stat_referrers set views=views+? where site_id=?', views, site_log[:id]].first
- end
+ site_log[:referrers].each do |referrer, views|
+ stat_referrer = StatReferrer.create_or_get site_log[:id], referrer
+ DB['update stat_referrers set views=views+? where site_id=?', views, site_log[:id]].first
+ end
- site_log[:view_ips].each do |ip|
- site_location = StatLocation.create_or_get site_log[:id], ip
- next if site_location.nil?
- DB['update stat_locations set views=views+1 where id=?', site_location.id].first
- end
+ site_log[:view_ips].each do |ip|
+ site_location = StatLocation.create_or_get site_log[:id], ip
+ next if site_location.nil?
+ DB['update stat_locations set views=views+1 where id=?', site_location.id].first
+ end
- site_log[:paths].each do |path, views|
- site_path = StatPath.create_or_get site_log[:id], path
- next if site_path.nil?
- DB['update stat_paths set views=views+? where id=?', views, site_path.id].first
- end
+ site_log[:paths].each do |path, views|
+ site_path = StatPath.create_or_get site_log[:id], path
+ next if site_path.nil?
+ DB['update stat_paths set views=views+? where id=?', views, site_path.id].first
+ end
=end
+ end
end
end
FileUtils.rm log_path
end
+
+ total_site_stats.each do |time, stats|
+ opts = {created_at: time.to_date.to_s}
+
+ DB[:stats].lock('EXCLUSIVE') {
+ stat = DailySiteStat.select(:id).where(opts).first
+ stat = DailySiteStat.create opts if stat.nil?
+ }
+
+ DB[
+ 'update daily_site_stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where created_at=?',
+ stats[:hits],
+ stats[:views],
+ stats[:bandwidth],
+ time.to_date
+ ].first
+ end
+
end
end
end
diff --git a/models/tag.rb b/models/tag.rb
index 2ec4cce4..e1844e28 100644
--- a/models/tag.rb
+++ b/models/tag.rb
@@ -23,6 +23,14 @@ class Tag < Sequel::Model
end
def self.popular_names(limit=10)
- DB["select tags.name,count(*) as c from sites_tags inner join tags on tags.id=sites_tags.tag_id where tags.name != '' and tags.is_nsfw='f' group by tags.name having count(*) > 1 order by c desc LIMIT ?", limit].all
+ cache = $redis_cache['tag_popular_names']
+ if cache.nil?
+ res = DB["select tags.name,count(*) as c from sites_tags inner join tags on tags.id=sites_tags.tag_id where tags.name != '' and tags.is_nsfw='f' group by tags.name having count(*) > 1 order by c desc LIMIT ?", limit].all
+ $redis_cache.set :tag_popular_names, res.to_msgpack
+ $redis_cache.expire :tag_popular_names, 86400 # 24 hours
+ else
+ res = MessagePack.unpack cache, symbolize_keys: true
+ end
+ res
end
end
diff --git a/public/cat.png b/public/cat.png
new file mode 100644
index 00000000..7d8eeadb
Binary files /dev/null and b/public/cat.png differ
diff --git a/public/css/highlight/styles/sunburst.css b/public/css/highlight/styles/sunburst.css
new file mode 100644
index 00000000..f56dd5e9
--- /dev/null
+++ b/public/css/highlight/styles/sunburst.css
@@ -0,0 +1,102 @@
+/*
+
+Sunburst-like style (c) Vasily Polovnyov
+
+*/
+
+.hljs {
+ display: block;
+ overflow-x: auto;
+ padding: 0.5em;
+ background: #000;
+ color: #f8f8f8;
+}
+
+.hljs-comment,
+.hljs-quote {
+ color: #aeaeae;
+ font-style: italic;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-type {
+ color: #e28964;
+}
+
+.hljs-string {
+ color: #65b042;
+}
+
+.hljs-subst {
+ color: #daefa3;
+}
+
+.hljs-regexp,
+.hljs-link {
+ color: #e9c062;
+}
+
+.hljs-title,
+.hljs-section,
+.hljs-tag,
+.hljs-name {
+ color: #89bdff;
+}
+
+.hljs-class .hljs-title,
+.hljs-doctag {
+ text-decoration: underline;
+}
+
+.hljs-symbol,
+.hljs-bullet,
+.hljs-number {
+ color: #3387cc;
+}
+
+.hljs-params,
+.hljs-variable,
+.hljs-template-variable {
+ color: #3e87e3;
+}
+
+.hljs-attribute {
+ color: #cda869;
+}
+
+.hljs-meta {
+ color: #8996a8;
+}
+
+.hljs-formula {
+ background-color: #0e2231;
+ color: #f8f8f8;
+ font-style: italic;
+}
+
+.hljs-addition {
+ background-color: #253b22;
+ color: #f8f8f8;
+}
+
+.hljs-deletion {
+ background-color: #420e09;
+ color: #f8f8f8;
+}
+
+.hljs-selector-class {
+ color: #9b703f;
+}
+
+.hljs-selector-id {
+ color: #8b98ab;
+}
+
+.hljs-emphasis {
+ font-style: italic;
+}
+
+.hljs-strong {
+ font-weight: bold;
+}
diff --git a/public/css/highlight/styles/tomorrow-night.css b/public/css/highlight/styles/tomorrow-night.css
new file mode 100644
index 00000000..ddd270a4
--- /dev/null
+++ b/public/css/highlight/styles/tomorrow-night.css
@@ -0,0 +1,75 @@
+/* Tomorrow Night Theme */
+/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
+/* Original theme - https://github.com/chriskempson/tomorrow-theme */
+/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
+
+/* Tomorrow Comment */
+.hljs-comment,
+.hljs-quote {
+ color: #969896;
+}
+
+/* Tomorrow Red */
+.hljs-variable,
+.hljs-template-variable,
+.hljs-tag,
+.hljs-name,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-regexp,
+.hljs-deletion {
+ color: #cc6666;
+}
+
+/* Tomorrow Orange */
+.hljs-number,
+.hljs-built_in,
+.hljs-builtin-name,
+.hljs-literal,
+.hljs-type,
+.hljs-params,
+.hljs-meta,
+.hljs-link {
+ color: #de935f;
+}
+
+/* Tomorrow Yellow */
+.hljs-attribute {
+ color: #f0c674;
+}
+
+/* Tomorrow Green */
+.hljs-string,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-addition {
+ color: #b5bd68;
+}
+
+/* Tomorrow Blue */
+.hljs-title,
+.hljs-section {
+ color: #81a2be;
+}
+
+/* Tomorrow Purple */
+.hljs-keyword,
+.hljs-selector-tag {
+ color: #b294bb;
+}
+
+.hljs {
+ display: block;
+ overflow-x: auto;
+ background: #1d1f21;
+ color: #c5c8c6;
+ padding: 0.5em;
+}
+
+.hljs-emphasis {
+ font-style: italic;
+}
+
+.hljs-strong {
+ font-weight: bold;
+}
diff --git a/public/fonts/dlfonts.sh b/public/fonts/dlfonts.sh
old mode 100755
new mode 100644
diff --git a/public/fonts/ocra-webfont.eot b/public/fonts/ocra-webfont.eot
old mode 100755
new mode 100644
diff --git a/public/fonts/ocra-webfont.svg b/public/fonts/ocra-webfont.svg
old mode 100755
new mode 100644
diff --git a/public/fonts/ocra-webfont.ttf b/public/fonts/ocra-webfont.ttf
old mode 100755
new mode 100644
diff --git a/public/fonts/ocra-webfont.woff b/public/fonts/ocra-webfont.woff
old mode 100755
new mode 100644
diff --git a/public/img/discourse-logo.png b/public/img/discourse-logo.png
new file mode 100644
index 00000000..a0e952f1
Binary files /dev/null and b/public/img/discourse-logo.png differ
diff --git a/public/img/front-browse-screenshot.jpg b/public/img/front-browse-screenshot.jpg
new file mode 100644
index 00000000..5df8119c
Binary files /dev/null and b/public/img/front-browse-screenshot.jpg differ
diff --git a/public/img/front-follow-screenshot.jpg b/public/img/front-follow-screenshot.jpg
new file mode 100644
index 00000000..231dd63f
Binary files /dev/null and b/public/img/front-follow-screenshot.jpg differ
diff --git a/public/img/kickstarterendpenelope.png b/public/img/kickstarterendpenelope.png
new file mode 100644
index 00000000..c2f61c64
Binary files /dev/null and b/public/img/kickstarterendpenelope.png differ
diff --git a/public/img/loading.gif b/public/img/loading.gif
new file mode 100644
index 00000000..45e3f167
Binary files /dev/null and b/public/img/loading.gif differ
diff --git a/public/img/tutorial/ch1pg1.png b/public/img/tutorial/ch1pg1.png
new file mode 100644
index 00000000..67be68c0
Binary files /dev/null and b/public/img/tutorial/ch1pg1.png differ
diff --git a/public/img/tutorial/ch1pg10.png b/public/img/tutorial/ch1pg10.png
new file mode 100644
index 00000000..e8375d6b
Binary files /dev/null and b/public/img/tutorial/ch1pg10.png differ
diff --git a/public/img/tutorial/ch1pg1_2.png b/public/img/tutorial/ch1pg1_2.png
new file mode 100644
index 00000000..efb82492
Binary files /dev/null and b/public/img/tutorial/ch1pg1_2.png differ
diff --git a/public/img/tutorial/ch1pg2.png b/public/img/tutorial/ch1pg2.png
new file mode 100644
index 00000000..d316d906
Binary files /dev/null and b/public/img/tutorial/ch1pg2.png differ
diff --git a/public/img/tutorial/ch1pg2_2.png b/public/img/tutorial/ch1pg2_2.png
new file mode 100644
index 00000000..85323fbd
Binary files /dev/null and b/public/img/tutorial/ch1pg2_2.png differ
diff --git a/public/img/tutorial/ch1pg3.png b/public/img/tutorial/ch1pg3.png
new file mode 100644
index 00000000..b3106058
Binary files /dev/null and b/public/img/tutorial/ch1pg3.png differ
diff --git a/public/img/tutorial/ch1pg4.png b/public/img/tutorial/ch1pg4.png
new file mode 100644
index 00000000..1f3fcfa7
Binary files /dev/null and b/public/img/tutorial/ch1pg4.png differ
diff --git a/public/img/tutorial/ch1pg5.png b/public/img/tutorial/ch1pg5.png
new file mode 100644
index 00000000..5ed85918
Binary files /dev/null and b/public/img/tutorial/ch1pg5.png differ
diff --git a/public/img/tutorial/ch1pg6.png b/public/img/tutorial/ch1pg6.png
new file mode 100644
index 00000000..71ef3165
Binary files /dev/null and b/public/img/tutorial/ch1pg6.png differ
diff --git a/public/img/tutorial/ch1pg7.png b/public/img/tutorial/ch1pg7.png
new file mode 100644
index 00000000..7229ab89
Binary files /dev/null and b/public/img/tutorial/ch1pg7.png differ
diff --git a/public/img/tutorial/ch1pg8.png b/public/img/tutorial/ch1pg8.png
new file mode 100644
index 00000000..f45bb1a1
Binary files /dev/null and b/public/img/tutorial/ch1pg8.png differ
diff --git a/public/img/tutorial/ch1pg9.png b/public/img/tutorial/ch1pg9.png
new file mode 100644
index 00000000..83e53f08
Binary files /dev/null and b/public/img/tutorial/ch1pg9.png differ
diff --git a/public/img/usedcarad.jpg b/public/img/usedcarad.jpg
new file mode 100644
index 00000000..d3b00e24
Binary files /dev/null and b/public/img/usedcarad.jpg differ
diff --git a/public/img/welcomingcat.png b/public/img/welcomingcat.png
new file mode 100644
index 00000000..7fce69e4
Binary files /dev/null and b/public/img/welcomingcat.png differ
diff --git a/public/js/highlight.pack.js b/public/js/highlight.pack.js
new file mode 100644
index 00000000..c99313da
--- /dev/null
+++ b/public/js/highlight.pack.js
@@ -0,0 +1,2 @@
+/*! highlight.js v9.3.0 | BSD3 License | git.io/hljslicense */
+!function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0==t.index}function a(e){return/^(no-?highlight|plain|text)$/i.test(e)}function i(e){var n,t,r,i=e.className+" ";if(i+=e.parentNode?e.parentNode.className:"",t=/\blang(?:uage)?-([\w-]+)\b/i.exec(i))return w(t[1])?t[1]:"no-highlight";for(i=i.split(/\s+/),n=0,r=i.length;r>n;n++)if(w(i[n])||a(i[n]))return i[n]}function o(e,n){var t,r={};for(t in e)r[t]=e[t];if(n)for(t in n)r[t]=n[t];return r}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3==i.nodeType?a+=i.nodeValue.length:1==i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!=r[0].offset?e[0].offset"}function u(e){f+=""+t(e)+">"}function c(e){("start"==e.event?o:u)(e.node)}for(var s=0,f="",l=[];e.length||r.length;){var g=i();if(f+=n(a.substr(s,g[0].offset-s)),s=g[0].offset,g==e){l.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g==e&&g.length&&g[0].offset==s);l.reverse().forEach(o)}else"start"==g[0].event?l.push(g[0].node):l.pop(),c(g.splice(0,1)[0])}return f+n(a.substr(s))}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var u={},c=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");u[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?c("keyword",a.k):Object.keys(a.k).forEach(function(e){c(e,a.k[e])}),a.k=u}a.lR=t(a.l||/\w+/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var s=[];a.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(o(e,n))}):s.push("self"==e?a:e)}),a.c=s,a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var f=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=f.length?t(f.join("|"),!0):{exec:function(){return null}}}}r(e)}function f(e,t,a,i){function o(e,n){for(var t=0;t";return i+=e+'">',i+n+o}function h(){if(!k.k)return n(M);var e="",t=0;k.lR.lastIndex=0;for(var r=k.lR.exec(M);r;){e+=n(M.substr(t,r.index-t));var a=g(k,r);a?(B+=a[1],e+=p(a[0],n(r[0]))):e+=n(r[0]),t=k.lR.lastIndex,r=k.lR.exec(M)}return e+n(M.substr(t))}function d(){var e="string"==typeof k.sL;if(e&&!R[k.sL])return n(M);var t=e?f(k.sL,M,!0,y[k.sL]):l(M,k.sL.length?k.sL:void 0);return k.r>0&&(B+=t.r),e&&(y[k.sL]=t.top),p(t.language,t.value,!1,!0)}function b(){L+=void 0!==k.sL?d():h(),M=""}function v(e,n){L+=e.cN?p(e.cN,"",!0):"",k=Object.create(e,{parent:{value:k}})}function m(e,n){if(M+=e,void 0===n)return b(),0;var t=o(n,k);if(t)return t.skip?M+=n:(t.eB&&(M+=n),b(),t.rB||t.eB||(M=n)),v(t,n),t.rB?0:n.length;var r=u(k,n);if(r){var a=k;a.skip?M+=n:(a.rE||a.eE||(M+=n),b(),a.eE&&(M=n));do k.cN&&(L+=""),k.skip||(B+=k.r),k=k.parent;while(k!=r.parent);return r.starts&&v(r.starts,""),a.rE?0:n.length}if(c(n,k))throw new Error('Illegal lexeme "'+n+'" for mode "'+(k.cN||"")+'"');return M+=n,n.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var x,k=i||N,y={},L="";for(x=k;x!=N;x=x.parent)x.cN&&(L=p(x.cN,"",!0)+L);var M="",B=0;try{for(var C,j,I=0;;){if(k.t.lastIndex=I,C=k.t.exec(t),!C)break;j=m(t.substr(I,C.index-I),C[0]),I=C.index+j}for(m(t.substr(I)),x=k;x.parent;x=x.parent)x.cN&&(L+="");return{r:B,value:L,language:e,top:k}}catch(O){if(-1!=O.message.indexOf("Illegal"))return{r:0,value:n(t)};throw O}}function l(e,t){t=t||E.languages||Object.keys(R);var r={r:0,value:n(e)},a=r;return t.filter(w).forEach(function(n){var t=f(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}),a.language&&(r.second_best=a),r}function g(e){return E.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,n){return n.replace(/\t/g,E.tabReplace)})),E.useBR&&(e=e.replace(/\n/g,"
")),e}function p(e,n,t){var r=n?x[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function h(e){var n=i(e);if(!a(n)){var t;E.useBR?(t=document.createElementNS("http://www.w3.org/1999/xhtml","div"),t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/
/g,"\n")):t=e;var r=t.textContent,o=n?f(n,r,!0):l(r),s=u(t);if(s.length){var h=document.createElementNS("http://www.w3.org/1999/xhtml","div");h.innerHTML=o.value,o.value=c(s,u(h),r)}o.value=g(o.value),e.innerHTML=o.value,e.className=p(e.className,n,o.language),e.result={language:o.language,re:o.r},o.second_best&&(e.second_best={language:o.second_best.language,re:o.second_best.r})}}function d(e){E=o(E,e)}function b(){if(!b.called){b.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,h)}}function v(){addEventListener("DOMContentLoaded",b,!1),addEventListener("load",b,!1)}function m(n,t){var r=R[n]=t(e);r.aliases&&r.aliases.forEach(function(e){x[e]=n})}function N(){return Object.keys(R)}function w(e){return e=(e||"").toLowerCase(),R[e]||R[x[e]]}var E={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},R={},x={};return e.highlight=f,e.highlightAuto=l,e.fixMarkup=g,e.highlightBlock=h,e.configure=d,e.initHighlighting=b,e.initHighlightingOnLoad=v,e.registerLanguage=m,e.listLanguages=N,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("xml",function(s){var e="[A-Za-z0-9\\._:-]+",t={eW:!0,i:/,r:0,c:[{cN:"attr",b:e,r:0},{b:/=\s*/,r:0,c:[{cN:"string",endsParent:!0,v:[{b:/"/,e:/"/},{b:/'/,e:/'/},{b:/[^\s"'=<>`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},s.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{b:/<\?(php)?/,e:/\?>/,sL:"php",c:[{b:"/\\*",e:"\\*/",skip:!0}]},{cN:"tag",b:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},{cN:"meta",v:[{b:/<\?xml/,e:/\?>/,r:10},{b:/<\?\w+/,e:/\?>/}]},{cN:"tag",b:"?",e:"/?>",c:[{cN:"name",b:/[^\/><\s]+/,r:0},t]}]}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",t={b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{eW:!0,eE:!0,c:[{b:/[\w-]+\(/,rB:!0,c:[{cN:"built_in",b:/[\w-]+/},{b:/\(/,e:/\)/,c:[e.ASM,e.QSM]}]},e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"number",b:"#[0-9A-Fa-f]+"},{cN:"meta",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"selector-id",b:/#[A-Za-z0-9_-]+/},{cN:"selector-class",b:/\.[A-Za-z0-9_-]+/},{cN:"selector-attr",b:/\[/,e:/\]/,i:"$"},{cN:"selector-pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{b:"@",e:"[{;]",i:/:/,c:[{cN:"keyword",b:/\w+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[e.ASM,e.QSM,e.CSSNM]}]},{cN:"selector-tag",b:c,r:0},{b:"{",e:"}",i:/\S/,c:[e.CBCM,t]}]}});hljs.registerLanguage("ruby",function(e){var r="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",b={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},c={cN:"doctag",b:"@[A-Za-z]+"},a={b:"#<",e:">"},s=[e.C("#","$",{c:[c]}),e.C("^\\=begin","^\\=end",{c:[c],r:10}),e.C("^__END__","\\n$")],n={cN:"subst",b:"#\\{",e:"}",k:b},t={cN:"string",c:[e.BE,n],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},i={cN:"params",b:"\\(",e:"\\)",endsParent:!0,k:b},d=[t,a,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{b:"<\\s*",c:[{b:"("+e.IR+"::)?"+e.IR}]}].concat(s)},{cN:"function",bK:"def",e:"$|;",c:[e.inherit(e.TM,{b:r}),i].concat(s)},{b:e.IR+"::"},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":(?!\\s)",c:[t,{b:r}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{cN:"params",b:/\|/,e:/\|/,k:b},{b:"("+e.RSR+")\\s*",c:[a,{cN:"regexp",c:[e.BE,n],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}].concat(s),r:0}].concat(s);n.c=d,i.c=d;var l="[>?]>",o="[\\w#]+\\(\\w+\\):\\d+:\\d+>",u="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",w=[{b:/^\s*=>/,starts:{e:"$",c:d}},{cN:"meta",b:"^("+l+"|"+o+"|"+u+")",starts:{e:"$",c:d}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:b,i:/\/\*/,c:s.concat(w).concat(d)}});hljs.registerLanguage("diff",function(e){return{aliases:["patch"],c:[{cN:"meta",r:10,v:[{b:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{b:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{b:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{cN:"comment",v:[{b:/Index: /,e:/$/},{b:/=====/,e:/=====$/},{b:/^\-\-\-/,e:/$/},{b:/^\*{3} /,e:/$/},{b:/^\+\+\+/,e:/$/},{b:/\*{5}/,e:/\*{5}$/}]},{cN:"addition",b:"^\\+",e:"$"},{cN:"deletion",b:"^\\-",e:"$"},{cN:"addition",b:"^\\!",e:"$"}]}});hljs.registerLanguage("http",function(e){var t="HTTP/[0-9\\.]+";return{aliases:["https"],i:"\\S",c:[{b:"^"+t,e:"$",c:[{cN:"number",b:"\\b\\d{3}\\b"}]},{b:"^[A-Z]+ (.*?) "+t+"$",rB:!0,e:"$",c:[{cN:"string",b:" ",e:" ",eB:!0,eE:!0},{b:t},{cN:"keyword",b:"[A-Z]+"}]},{cN:"attribute",b:"^\\w",e:": ",eE:!0,i:"\\n|\\s|=",starts:{e:"$",r:0}},{b:"\\n\\n",starts:{sL:[],eW:!0}}]}});hljs.registerLanguage("markdown",function(e){return{aliases:["md","mkdown","mkd"],c:[{cN:"section",v:[{b:"^#{1,6}",e:"$"},{b:"^.+?\\n[=-]{2,}$"}]},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",v:[{b:"\\*.+?\\*"},{b:"_.+?_",r:0}]},{cN:"quote",b:"^>\\s+",e:"$"},{cN:"code",v:[{b:"^```w*s*$",e:"^```s*$"},{b:"`.+?`"},{b:"^( {4}| )",e:"$",r:0}]},{b:"^[-\\*]{3,}",e:"$"},{b:"\\[.+?\\][\\(\\[].*?[\\)\\]]",rB:!0,c:[{cN:"string",b:"\\[",e:"\\]",eB:!0,rE:!0,r:0},{cN:"link",b:"\\]\\(",e:"\\)",eB:!0,eE:!0},{cN:"symbol",b:"\\]\\[",e:"\\]",eB:!0,eE:!0}],r:10},{b:"^\\[.+\\]:",rB:!0,c:[{cN:"symbol",b:"\\[",e:"\\]:",eB:!0,eE:!0,starts:{cN:"link",e:"$"}}]}]}});hljs.registerLanguage("python",function(e){var r={cN:"meta",b:/^(>>>|\.\.\.) /},b={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[r],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[r],r:10},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},e.ASM,e.QSM]},a={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},l={cN:"params",b:/\(/,e:/\)/,c:["self",r,a,b]};return{aliases:["py","gyp"],k:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},i:/(<\/|->|\?)/,c:[r,a,b,e.HCM,{v:[{cN:"function",bK:"def",r:10},{cN:"class",bK:"class"}],e:/:/,i:/[${=;\n,]/,c:[e.UTM,l,{b:/->/,eW:!0,k:"None"}]},{cN:"meta",b:/^[\t ]*@/,e:/$/},{b:/\b(print|exec)\(/}]}});hljs.registerLanguage("php",function(e){var c={b:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},a={cN:"meta",b:/<\?(php)?|\?>/},i={cN:"string",c:[e.BE,a],v:[{b:'b"',e:'"'},{b:"b'",e:"'"},e.inherit(e.ASM,{i:null}),e.inherit(e.QSM,{i:null})]},t={v:[e.BNM,e.CNM]};return{aliases:["php3","php4","php5","php6"],cI:!0,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",c:[e.HCM,e.C("//","$",{c:[a]}),e.C("/\\*","\\*/",{c:[{cN:"doctag",b:"@[A-Za-z]+"}]}),e.C("__halt_compiler.+?;",!1,{eW:!0,k:"__halt_compiler",l:e.UIR}),{cN:"string",b:/<<<['"]?\w+['"]?$/,e:/^\w+;?$/,c:[e.BE,{cN:"subst",v:[{b:/\$\w+/},{b:/\{\$/,e:/\}/}]}]},a,c,{b:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{cN:"function",bK:"function",e:/[;{]/,eE:!0,i:"\\$|\\[|%",c:[e.UTM,{cN:"params",b:"\\(",e:"\\)",c:["self",c,e.CBCM,i,t]}]},{cN:"class",bK:"class interface",e:"{",eE:!0,i:/[:\(\$"]/,c:[{bK:"extends implements"},e.UTM]},{bK:"namespace",e:";",i:/[\.']/,c:[e.UTM]},{bK:"use",e:";",c:[e.UTM]},{b:"=>"},i,t]}});hljs.registerLanguage("json",function(e){var i={literal:"true false null"},n=[e.QSM,e.CNM],r={e:",",eW:!0,eE:!0,c:n,k:i},t={b:"{",e:"}",c:[{cN:"attr",b:/"/,e:/"/,c:[e.BE],i:"\\n"},e.inherit(r,{b:/:/})],i:"\\S"},c={b:"\\[",e:"\\]",c:[e.inherit(r)],i:"\\S"};return n.splice(n.length,0,t,c),{c:n,k:i,i:"\\S"}});hljs.registerLanguage("javascript",function(e){return{aliases:["js","jsx"],k:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,{cN:"string",b:"`",e:"`",c:[e.BE,{cN:"subst",b:"\\$\\{",e:"\\}"}]},e.CLCM,e.CBCM,{cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{b:/,e:/(\/\w+|\w+\/)>/,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:["self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:[e.CLCM,e.CBCM]}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}});hljs.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)}/}]},s={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},a={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/-?[a-z\.]+/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"meta",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,s,a,t]}});
\ No newline at end of file
diff --git a/public/js/keyboard.js b/public/js/keyboard.js
deleted file mode 100644
index 5a518d7d..00000000
--- a/public/js/keyboard.js
+++ /dev/null
@@ -1,961 +0,0 @@
-/**
- * Title: KeyboardJS
- * Version: v0.4.1
- * Description: KeyboardJS is a flexible and easy to use keyboard binding
- * library.
- * Author: Robert Hurst.
- *
- * Copyright 2011, Robert William Hurst
- * Licenced under the BSD License.
- * See https://raw.github.com/RobertWHurst/KeyboardJS/master/license.txt
- */
-(function(context, factory) {
-
- //INDEXOF POLLYFILL
- [].indexOf||(Array.prototype.indexOf=function(a,b,c){for(c=this.length,b=(c+~~b)%c;b"]],
- ['shift + /', ["questionmark", "?"]]
- ]
- };
- //a-z and A-Z
- for (aI = 65; aI <= 90; aI += 1) {
- usLocale.map[aI] = String.fromCharCode(aI + 32);
- usLocale.macros.push(['shift + ' + String.fromCharCode(aI + 32) + ', capslock + ' + String.fromCharCode(aI + 32), [String.fromCharCode(aI)]]);
- }
- registerLocale('us', usLocale);
- getSetLocale('us');
-
-
- //////////
- // INIT //
- //////////
-
- //enable the library
- enable();
-
-
- /////////
- // API //
- /////////
-
- //assemble the library and return it
- KeyboardJS.enable = enable;
- KeyboardJS.disable = disable;
- KeyboardJS.activeKeys = getActiveKeys;
- KeyboardJS.releaseKey = removeActiveKey;
- KeyboardJS.pressKey = addActiveKey;
- KeyboardJS.on = createBinding;
- KeyboardJS.clear = removeBindingByKeyCombo;
- KeyboardJS.clear.key = removeBindingByKeyName;
- KeyboardJS.locale = getSetLocale;
- KeyboardJS.locale.register = registerLocale;
- KeyboardJS.macro = createMacro;
- KeyboardJS.macro.remove = removeMacro;
- KeyboardJS.key = {};
- KeyboardJS.key.name = getKeyName;
- KeyboardJS.key.code = getKeyCode;
- KeyboardJS.combo = {};
- KeyboardJS.combo.active = isSatisfiedCombo;
- KeyboardJS.combo.parse = parseKeyCombo;
- KeyboardJS.combo.stringify = stringifyKeyCombo;
- return KeyboardJS;
-
-
- //////////////////////
- // INSTANCE METHODS //
- //////////////////////
-
- /**
- * Enables KeyboardJS
- */
- function enable() {
- if(targetWindow.addEventListener) {
- targetWindow.document.addEventListener('keydown', keydown, false);
- targetWindow.document.addEventListener('keyup', keyup, false);
- targetWindow.addEventListener('blur', reset, false);
- targetWindow.addEventListener('webkitfullscreenchange', reset, false);
- targetWindow.addEventListener('mozfullscreenchange', reset, false);
- } else if(targetWindow.attachEvent) {
- targetWindow.document.attachEvent('onkeydown', keydown);
- targetWindow.document.attachEvent('onkeyup', keyup);
- targetWindow.attachEvent('onblur', reset);
- }
- }
-
- /**
- * Exits all active bindings and disables KeyboardJS
- */
- function disable() {
- reset();
- if(targetWindow.removeEventListener) {
- targetWindow.document.removeEventListener('keydown', keydown, false);
- targetWindow.document.removeEventListener('keyup', keyup, false);
- targetWindow.removeEventListener('blur', reset, false);
- targetWindow.removeEventListener('webkitfullscreenchange', reset, false);
- targetWindow.removeEventListener('mozfullscreenchange', reset, false);
- } else if(targetWindow.detachEvent) {
- targetWindow.document.detachEvent('onkeydown', keydown);
- targetWindow.document.detachEvent('onkeyup', keyup);
- targetWindow.detachEvent('onblur', reset);
- }
- }
-
-
- ////////////////////
- // EVENT HANDLERS //
- ////////////////////
-
- /**
- * Exits all active bindings. Optionally passes an event to all binding
- * handlers.
- * @param {KeyboardEvent} event [Optional]
- */
- function reset(event) {
- activeKeys = [];
- pruneMacros();
- pruneBindings(event);
- }
-
- /**
- * Key down event handler.
- * @param {KeyboardEvent} event
- */
- function keydown(event) {
- var keyNames, keyName, kI;
- keyNames = getKeyName(event.keyCode);
- if(keyNames.length < 1) { return; }
- event.isRepeat = false;
- for(kI = 0; kI < keyNames.length; kI += 1) {
- keyName = keyNames[kI];
- if (getActiveKeys().indexOf(keyName) != -1)
- event.isRepeat = true;
- addActiveKey(keyName);
- }
- executeMacros();
- executeBindings(event);
- }
-
- /**
- * Key up event handler.
- * @param {KeyboardEvent} event
- */
- function keyup(event) {
- var keyNames, kI;
- keyNames = getKeyName(event.keyCode);
- if(keyNames.length < 1) { return; }
- for(kI = 0; kI < keyNames.length; kI += 1) {
- removeActiveKey(keyNames[kI]);
- }
- pruneMacros();
- pruneBindings(event);
- }
-
- /**
- * Accepts a key code and returns the key names defined by the current
- * locale.
- * @param {Number} keyCode
- * @return {Array} keyNames An array of key names defined for the key
- * code as defined by the current locale.
- */
- function getKeyName(keyCode) {
- return map[keyCode] || [];
- }
-
- /**
- * Accepts a key name and returns the key code defined by the current
- * locale.
- * @param {Number} keyName
- * @return {Number|false}
- */
- function getKeyCode(keyName) {
- var keyCode;
- for(keyCode in map) {
- if(!map.hasOwnProperty(keyCode)) { continue; }
- if(map[keyCode].indexOf(keyName) > -1) { return keyCode; }
- }
- return false;
- }
-
-
- ////////////
- // MACROS //
- ////////////
-
- /**
- * Accepts a key combo and an array of key names to inject once the key
- * combo is satisfied.
- * @param {String} combo
- * @param {Array} injectedKeys
- */
- function createMacro(combo, injectedKeys) {
- if(typeof combo !== 'string' && (typeof combo !== 'object' || typeof combo.push !== 'function')) {
- throw new Error("Cannot create macro. The combo must be a string or array.");
- }
- if(typeof injectedKeys !== 'object' || typeof injectedKeys.push !== 'function') {
- throw new Error("Cannot create macro. The injectedKeys must be an array.");
- }
- macros.push([combo, injectedKeys]);
- }
-
- /**
- * Accepts a key combo and clears any and all macros bound to that key
- * combo.
- * @param {String} combo
- */
- function removeMacro(combo) {
- var macro;
- if(typeof combo !== 'string' && (typeof combo !== 'object' || typeof combo.push !== 'function')) { throw new Error("Cannot remove macro. The combo must be a string or array."); }
- for(mI = 0; mI < macros.length; mI += 1) {
- macro = macros[mI];
- if(compareCombos(combo, macro[0])) {
- removeActiveKey(macro[1]);
- macros.splice(mI, 1);
- break;
- }
- }
- }
-
- /**
- * Executes macros against the active keys. Each macro's key combo is
- * checked and if found to be satisfied, the macro's key names are injected
- * into active keys.
- */
- function executeMacros() {
- var mI, combo, kI;
- for(mI = 0; mI < macros.length; mI += 1) {
- combo = parseKeyCombo(macros[mI][0]);
- if(activeMacros.indexOf(macros[mI]) === -1 && isSatisfiedCombo(combo)) {
- activeMacros.push(macros[mI]);
- for(kI = 0; kI < macros[mI][1].length; kI += 1) {
- addActiveKey(macros[mI][1][kI]);
- }
- }
- }
- }
-
- /**
- * Prunes active macros. Checks each active macro's key combo and if found
- * to no longer to be satisfied, each of the macro's key names are removed
- * from active keys.
- */
- function pruneMacros() {
- var mI, combo, kI;
- for(mI = 0; mI < activeMacros.length; mI += 1) {
- combo = parseKeyCombo(activeMacros[mI][0]);
- if(isSatisfiedCombo(combo) === false) {
- for(kI = 0; kI < activeMacros[mI][1].length; kI += 1) {
- removeActiveKey(activeMacros[mI][1][kI]);
- }
- activeMacros.splice(mI, 1);
- mI -= 1;
- }
- }
- }
-
-
- //////////////
- // BINDINGS //
- //////////////
-
- /**
- * Creates a binding object, and, if provided, binds a key down hander and
- * a key up handler. Returns a binding object that emits keyup and
- * keydown events.
- * @param {String} keyCombo
- * @param {Function} keyDownCallback [Optional]
- * @param {Function} keyUpCallback [Optional]
- * @return {Object} binding
- */
- function createBinding(keyCombo, keyDownCallback, keyUpCallback) {
- var api = {}, binding, subBindings = [], bindingApi = {}, kI,
- subCombo;
-
- //break the combo down into a combo array
- if(typeof keyCombo === 'string') {
- keyCombo = parseKeyCombo(keyCombo);
- }
-
- //bind each sub combo contained within the combo string
- for(kI = 0; kI < keyCombo.length; kI += 1) {
- binding = {};
-
- //stringify the combo again
- subCombo = stringifyKeyCombo([keyCombo[kI]]);
-
- //validate the sub combo
- if(typeof subCombo !== 'string') { throw new Error('Failed to bind key combo. The key combo must be string.'); }
-
- //create the binding
- binding.keyCombo = subCombo;
- binding.keyDownCallback = [];
- binding.keyUpCallback = [];
-
- //inject the key down and key up callbacks if given
- if(keyDownCallback) { binding.keyDownCallback.push(keyDownCallback); }
- if(keyUpCallback) { binding.keyUpCallback.push(keyUpCallback); }
-
- //stash the new binding
- bindings.push(binding);
- subBindings.push(binding);
- }
-
- //build the binding api
- api.clear = clear;
- api.on = on;
- return api;
-
- /**
- * Clears the binding
- */
- function clear() {
- var bI;
- for(bI = 0; bI < subBindings.length; bI += 1) {
- bindings.splice(bindings.indexOf(subBindings[bI]), 1);
- }
- }
-
- /**
- * Accepts an event name. and any number of callbacks. When the event is
- * emitted, all callbacks are executed. Available events are key up and
- * key down.
- * @param {String} eventName
- * @return {Object} subBinding
- */
- function on(eventName ) {
- var api = {}, callbacks, cI, bI;
-
- //validate event name
- if(typeof eventName !== 'string') { throw new Error('Cannot bind callback. The event name must be a string.'); }
- if(eventName !== 'keyup' && eventName !== 'keydown') { throw new Error('Cannot bind callback. The event name must be a "keyup" or "keydown".'); }
-
- //gather the callbacks
- callbacks = Array.prototype.slice.apply(arguments, [1]);
-
- //stash each the new binding
- for(cI = 0; cI < callbacks.length; cI += 1) {
- if(typeof callbacks[cI] === 'function') {
- if(eventName === 'keyup') {
- for(bI = 0; bI < subBindings.length; bI += 1) {
- subBindings[bI].keyUpCallback.push(callbacks[cI]);
- }
- } else if(eventName === 'keydown') {
- for(bI = 0; bI < subBindings.length; bI += 1) {
- subBindings[bI].keyDownCallback.push(callbacks[cI]);
- }
- }
- }
- }
-
- //construct and return the sub binding api
- api.clear = clear;
- return api;
-
- /**
- * Clears the binding
- */
- function clear() {
- var cI, bI;
- for(cI = 0; cI < callbacks.length; cI += 1) {
- if(typeof callbacks[cI] === 'function') {
- if(eventName === 'keyup') {
- for(bI = 0; bI < subBindings.length; bI += 1) {
- subBindings[bI].keyUpCallback.splice(subBindings[bI].keyUpCallback.indexOf(callbacks[cI]), 1);
- }
- } else {
- for(bI = 0; bI < subBindings.length; bI += 1) {
- subBindings[bI].keyDownCallback.splice(subBindings[bI].keyDownCallback.indexOf(callbacks[cI]), 1);
- }
- }
- }
- }
- }
- }
- }
-
- /**
- * Clears all binding attached to a given key combo. Key name order does not
- * matter as long as the key combos equate.
- * @param {String} keyCombo
- */
- function removeBindingByKeyCombo(keyCombo) {
- var bI, binding, keyName;
- for(bI = 0; bI < bindings.length; bI += 1) {
- binding = bindings[bI];
- if(compareCombos(keyCombo, binding.keyCombo)) {
- bindings.splice(bI, 1); bI -= 1;
- }
- }
- }
-
- /**
- * Clears all binding attached to key combos containing a given key name.
- * @param {String} keyName
- */
- function removeBindingByKeyName(keyName) {
- var bI, kI, binding;
- if(keyName) {
- for(bI = 0; bI < bindings.length; bI += 1) {
- binding = bindings[bI];
- for(kI = 0; kI < binding.keyCombo.length; kI += 1) {
- if(binding.keyCombo[kI].indexOf(keyName) > -1) {
- bindings.splice(bI, 1); bI -= 1;
- break;
- }
- }
- }
- } else {
- bindings = [];
- }
- }
-
- /**
- * Executes bindings that are active. Only allows the keys to be used once
- * as to prevent binding overlap.
- * @param {KeyboardEvent} event The keyboard event.
- */
- function executeBindings(event) {
- var bI, sBI, binding, bindingKeys, remainingKeys, cI, killEventBubble, kI, bindingKeysSatisfied,
- index, sortedBindings = [], bindingWeight;
-
- remainingKeys = [].concat(activeKeys);
- for(bI = 0; bI < bindings.length; bI += 1) {
- bindingWeight = extractComboKeys(bindings[bI].keyCombo).length;
- if(!sortedBindings[bindingWeight]) { sortedBindings[bindingWeight] = []; }
- sortedBindings[bindingWeight].push(bindings[bI]);
- }
- for(sBI = sortedBindings.length - 1; sBI >= 0; sBI -= 1) {
- if(!sortedBindings[sBI]) { continue; }
- for(bI = 0; bI < sortedBindings[sBI].length; bI += 1) {
- binding = sortedBindings[sBI][bI];
- bindingKeys = extractComboKeys(binding.keyCombo);
- bindingKeysSatisfied = true;
- for(kI = 0; kI < bindingKeys.length; kI += 1) {
- if(remainingKeys.indexOf(bindingKeys[kI]) === -1) {
- bindingKeysSatisfied = false;
- break;
- }
- }
- if(bindingKeysSatisfied && isSatisfiedCombo(binding.keyCombo)) {
- activeBindings.push(binding);
- for(kI = 0; kI < bindingKeys.length; kI += 1) {
- index = remainingKeys.indexOf(bindingKeys[kI]);
- if(index > -1) {
- remainingKeys.splice(index, 1);
- kI -= 1;
- }
- }
- for(cI = 0; cI < binding.keyDownCallback.length; cI += 1) {
- if (binding.keyDownCallback[cI](event, getActiveKeys(), binding.keyCombo) === false) {
- killEventBubble = true;
- }
- }
- if(killEventBubble === true) {
- event.preventDefault();
- event.stopPropagation();
- }
- }
- }
- }
- }
-
- /**
- * Removes bindings that are no longer satisfied by the active keys. Also
- * fires the key up callbacks.
- * @param {KeyboardEvent} event
- */
- function pruneBindings(event) {
- var bI, cI, binding, killEventBubble;
- for(bI = 0; bI < activeBindings.length; bI += 1) {
- binding = activeBindings[bI];
- if(isSatisfiedCombo(binding.keyCombo) === false) {
- for(cI = 0; cI < binding.keyUpCallback.length; cI += 1) {
- if (binding.keyUpCallback[cI](event, getActiveKeys(), binding.keyCombo) === false) {
- killEventBubble = true;
- }
- }
- if(killEventBubble === true) {
- event.preventDefault();
- event.stopPropagation();
- }
- activeBindings.splice(bI, 1);
- bI -= 1;
- }
- }
- }
-
-
- ///////////////////
- // COMBO STRINGS //
- ///////////////////
-
- /**
- * Compares two key combos returning true when they are functionally
- * equivalent.
- * @param {String} keyComboArrayA keyCombo A key combo string or array.
- * @param {String} keyComboArrayB keyCombo A key combo string or array.
- * @return {Boolean}
- */
- function compareCombos(keyComboArrayA, keyComboArrayB) {
- var cI, sI, kI;
- keyComboArrayA = parseKeyCombo(keyComboArrayA);
- keyComboArrayB = parseKeyCombo(keyComboArrayB);
- if(keyComboArrayA.length !== keyComboArrayB.length) { return false; }
- for(cI = 0; cI < keyComboArrayA.length; cI += 1) {
- if(keyComboArrayA[cI].length !== keyComboArrayB[cI].length) { return false; }
- for(sI = 0; sI < keyComboArrayA[cI].length; sI += 1) {
- if(keyComboArrayA[cI][sI].length !== keyComboArrayB[cI][sI].length) { return false; }
- for(kI = 0; kI < keyComboArrayA[cI][sI].length; kI += 1) {
- if(keyComboArrayB[cI][sI].indexOf(keyComboArrayA[cI][sI][kI]) === -1) { return false; }
- }
- }
- }
- return true;
- }
-
- /**
- * Checks to see if a key combo string or key array is satisfied by the
- * currently active keys. It does not take into account spent keys.
- * @param {String} keyCombo A key combo string or array.
- * @return {Boolean}
- */
- function isSatisfiedCombo(keyCombo) {
- var cI, sI, stage, kI, stageOffset = 0, index, comboMatches;
- keyCombo = parseKeyCombo(keyCombo);
- for(cI = 0; cI < keyCombo.length; cI += 1) {
- comboMatches = true;
- stageOffset = 0;
- for(sI = 0; sI < keyCombo[cI].length; sI += 1) {
- stage = [].concat(keyCombo[cI][sI]);
- for(kI = stageOffset; kI < activeKeys.length; kI += 1) {
- index = stage.indexOf(activeKeys[kI]);
- if(index > -1) {
- stage.splice(index, 1);
- stageOffset = kI;
- }
- }
- if(stage.length !== 0) { comboMatches = false; break; }
- }
- if(comboMatches) { return true; }
- }
- return false;
- }
-
- /**
- * Accepts a key combo array or string and returns a flat array containing all keys referenced by
- * the key combo.
- * @param {String} keyCombo A key combo string or array.
- * @return {Array}
- */
- function extractComboKeys(keyCombo) {
- var cI, sI, kI, keys = [];
- keyCombo = parseKeyCombo(keyCombo);
- for(cI = 0; cI < keyCombo.length; cI += 1) {
- for(sI = 0; sI < keyCombo[cI].length; sI += 1) {
- keys = keys.concat(keyCombo[cI][sI]);
- }
- }
- return keys;
- }
-
- /**
- * Parses a key combo string into a 3 dimensional array.
- * - Level 1 - sub combos.
- * - Level 2 - combo stages. A stage is a set of key name pairs that must
- * be satisfied in the order they are defined.
- * - Level 3 - each key name to the stage.
- * @param {String|Array} keyCombo A key combo string.
- * @return {Array}
- */
- function parseKeyCombo(keyCombo) {
- var s = keyCombo, i = 0, op = 0, ws = false, nc = false, combos = [], combo = [], stage = [], key = '';
-
- if(typeof keyCombo === 'object' && typeof keyCombo.push === 'function') { return keyCombo; }
- if(typeof keyCombo !== 'string') { throw new Error('Cannot parse "keyCombo" because its type is "' + (typeof keyCombo) + '". It must be a "string".'); }
-
- //remove leading whitespace
- while(s.charAt(i) === ' ') { i += 1; }
- while(true) {
- if(s.charAt(i) === ' ') {
- //white space & next combo op
- while(s.charAt(i) === ' ') { i += 1; }
- ws = true;
- } else if(s.charAt(i) === ',') {
- if(op || nc) { throw new Error('Failed to parse key combo. Unexpected , at character index ' + i + '.'); }
- nc = true;
- i += 1;
- } else if(s.charAt(i) === '+') {
- //next key
- if(key.length) { stage.push(key); key = ''; }
- if(op || nc) { throw new Error('Failed to parse key combo. Unexpected + at character index ' + i + '.'); }
- op = true;
- i += 1;
- } else if(s.charAt(i) === '>') {
- //next stage op
- if(key.length) { stage.push(key); key = ''; }
- if(stage.length) { combo.push(stage); stage = []; }
- if(op || nc) { throw new Error('Failed to parse key combo. Unexpected > at character index ' + i + '.'); }
- op = true;
- i += 1;
- } else if(i < s.length - 1 && s.charAt(i) === '!' && (s.charAt(i + 1) === '>' || s.charAt(i + 1) === ',' || s.charAt(i + 1) === '+')) {
- key += s.charAt(i + 1);
- op = false;
- ws = false;
- nc = false;
- i += 2;
- } else if(i < s.length && s.charAt(i) !== '+' && s.charAt(i) !== '>' && s.charAt(i) !== ',' && s.charAt(i) !== ' ') {
- //end combo
- if(op === false && ws === true || nc === true) {
- if(key.length) { stage.push(key); key = ''; }
- if(stage.length) { combo.push(stage); stage = []; }
- if(combo.length) { combos.push(combo); combo = []; }
- }
- op = false;
- ws = false;
- nc = false;
- //key
- while(i < s.length && s.charAt(i) !== '+' && s.charAt(i) !== '>' && s.charAt(i) !== ',' && s.charAt(i) !== ' ') {
- key += s.charAt(i);
- i += 1;
- }
- } else {
- //unknown char
- i += 1;
- continue;
- }
- //end of combos string
- if(i >= s.length) {
- if(key.length) { stage.push(key); key = ''; }
- if(stage.length) { combo.push(stage); stage = []; }
- if(combo.length) { combos.push(combo); combo = []; }
- break;
- }
- }
- return combos;
- }
-
- /**
- * Stringifys a key combo.
- * @param {Array|String} keyComboArray A key combo array. If a key
- * combo string is given it will be returned.
- * @return {String}
- */
- function stringifyKeyCombo(keyComboArray) {
- var cI, ccI, output = [];
- if(typeof keyComboArray === 'string') { return keyComboArray; }
- if(typeof keyComboArray !== 'object' || typeof keyComboArray.push !== 'function') { throw new Error('Cannot stringify key combo.'); }
- for(cI = 0; cI < keyComboArray.length; cI += 1) {
- output[cI] = [];
- for(ccI = 0; ccI < keyComboArray[cI].length; ccI += 1) {
- output[cI][ccI] = keyComboArray[cI][ccI].join(' + ');
- }
- output[cI] = output[cI].join(' > ');
- }
- return output.join(' ');
- }
-
-
- /////////////////
- // ACTIVE KEYS //
- /////////////////
-
- /**
- * Returns the a copy of the active keys array.
- * @return {Array}
- */
- function getActiveKeys() {
- return [].concat(activeKeys);
- }
-
- /**
- * Adds a key to the active keys array, but only if it has not already been
- * added.
- * @param {String} keyName The key name string.
- */
- function addActiveKey(keyName) {
- if(keyName.match(/\s/)) { throw new Error('Cannot add key name ' + keyName + ' to active keys because it contains whitespace.'); }
- if(activeKeys.indexOf(keyName) > -1) { return; }
- activeKeys.push(keyName);
- }
-
- /**
- * Removes a key from the active keys array.
- * @param {String} keyNames The key name string.
- */
- function removeActiveKey(keyName) {
- var keyCode = getKeyCode(keyName);
- if(keyCode === '91' || keyCode === '92') { activeKeys = []; } //remove all key on release of super.
- else { activeKeys.splice(activeKeys.indexOf(keyName), 1); }
- }
-
-
- /////////////
- // LOCALES //
- /////////////
-
- /**
- * Registers a new locale. This is useful if you would like to add support for a new keyboard layout. It could also be useful for
- * alternative key names. For example if you program games you could create a locale for your key mappings. Instead of key 65 mapped
- * to 'a' you could map it to 'jump'.
- * @param {String} localeName The name of the new locale.
- * @param {Object} localeMap The locale map.
- */
- function registerLocale(localeName, localeMap) {
-
- //validate arguments
- if(typeof localeName !== 'string') { throw new Error('Cannot register new locale. The locale name must be a string.'); }
- if(typeof localeMap !== 'object') { throw new Error('Cannot register ' + localeName + ' locale. The locale map must be an object.'); }
- if(typeof localeMap.map !== 'object') { throw new Error('Cannot register ' + localeName + ' locale. The locale map is invalid.'); }
-
- //stash the locale
- if(!localeMap.macros) { localeMap.macros = []; }
- locales[localeName] = localeMap;
- }
-
- /**
- * Swaps the current locale.
- * @param {String} localeName The locale to activate.
- * @return {Object}
- */
- function getSetLocale(localeName) {
-
- //if a new locale is given then set it
- if(localeName) {
- if(typeof localeName !== 'string') { throw new Error('Cannot set locale. The locale name must be a string.'); }
- if(!locales[localeName]) { throw new Error('Cannot set locale to ' + localeName + ' because it does not exist. If you would like to submit a ' + localeName + ' locale map for KeyboardJS please submit it at https://github.com/RobertWHurst/KeyboardJS/issues.'); }
-
- //set the current map and macros
- map = locales[localeName].map;
- macros = locales[localeName].macros;
-
- //set the current locale
- locale = localeName;
- }
-
- //return the current locale
- return locale;
- }
-});
diff --git a/public/js/xregexp-min.js b/public/js/xregexp-min.js
new file mode 100644
index 00000000..ade6f9a3
--- /dev/null
+++ b/public/js/xregexp-min.js
@@ -0,0 +1,2 @@
+//XRegExp 3.0.0 MIT License
+var XRegExp=function(a){"use strict";function u(a,d,e,f,g){var h;if(a[c]={captureNames:d},g)return a;if(a.__proto__)a.__proto__=b.prototype;else for(h in b.prototype)a[h]=b.prototype[h];return a[c].source=e,a[c].flags=f?f.split("").sort().join(""):f,a}function v(a){return e.replace.call(a,/([\s\S])(?=[\s\S]*\1)/g,"")}function w(d,f){if(!b.isRegExp(d))throw new TypeError("Type RegExp expected");var g=d[c]||{},h=y(d),i="",j="",k=null,l=null;return f=f||{},f.removeG&&(j+="g"),f.removeY&&(j+="y"),j&&(h=e.replace.call(h,new RegExp("["+j+"]+","g"),"")),f.addG&&(i+="g"),f.addY&&(i+="y"),i&&(h=v(h+i)),f.isInternalOnly||(g.source!==a&&(k=g.source),null!=g.flags&&(l=i?v(g.flags+i):g.flags)),d=u(new RegExp(d.source,h),z(d)?g.captureNames.slice(0):null,k,l,f.isInternalOnly)}function x(a){return parseInt(a,16)}function y(a){return q?a.flags:e.exec.call(/\/([a-z]*)$/i,RegExp.prototype.toString.call(a))[1]}function z(a){return!(!a[c]||!a[c].captureNames)}function A(a){return parseInt(a,10).toString(16)}function B(a,b){var d,c=a.length;for(d=0;c>d;++d)if(a[d]===b)return d;return-1}function C(a,b){return s.call(a)==="[object "+b+"]"}function D(a,b,c){return e.test.call(c.indexOf("x")>-1?/^(?:\s+|#.*|\(\?#[^)]*\))*(?:[?*+]|{\d+(?:,\d*)?})/:/^(?:\(\?#[^)]*\))*(?:[?*+]|{\d+(?:,\d*)?})/,a.slice(b))}function E(a){for(;a.length<4;)a="0"+a;return a}function F(a,b){var c;if(v(b)!==b)throw new SyntaxError("Invalid duplicate regex flag "+b);for(a=e.replace.call(a,/^\(\?([\w$]+)\)/,function(a,c){if(e.test.call(/[gy]/,c))throw new SyntaxError("Cannot use flag g or y in mode modifier "+a);return b=v(b+c),""}),c=0;c"}else if(c)return"\\"+(+c+i);return a};if(!C(a,"Array")||!a.length)throw new TypeError("Must provide a nonempty array of patterns to merge");for(m=0;m1&&B(f,"")>-1&&(h=w(this,{removeG:!0,isInternalOnly:!0}),e.replace.call(String(b).slice(f.index),h,function(){var c,b=arguments.length;for(c=1;b-2>c;++c)arguments[c]===a&&(f[c]=a)})),this[c]&&this[c].captureNames)for(i=1;if.index&&(this.lastIndex=f.index)}return this.global||(this.lastIndex=d),f},f.test=function(a){return!!f.exec.call(this,a)},f.match=function(a){var c;if(b.isRegExp(a)){if(a.global)return c=e.match.apply(this,arguments),a.lastIndex=0,c}else a=new RegExp(a);return f.exec.call(a,L(this))},f.replace=function(d,f){var h,i,j,g=b.isRegExp(d);return g?(d[c]&&(i=d[c].captureNames),h=d.lastIndex):d+="",j=C(f,"Function")?e.replace.call(String(this),d,function(){var c,b=arguments;if(i)for(b[0]=new String(b[0]),c=0;ce)throw new SyntaxError("Backreference to undefined group "+b);return a[e+1]||""}if("$"===d)return"$";if("&"===d||0===+d)return a[0];if("`"===d)return a[a.length-1].slice(0,a[a.length-2]);if("'"===d)return a[a.length-1].slice(a[a.length-2]+a[0].length);if(d=+d,!isNaN(d)){if(d>a.length-3)throw new SyntaxError("Backreference to undefined group "+b);return a[d]||""}throw new SyntaxError("Invalid token "+b)})}),g&&(d.global?d.lastIndex=0:d.lastIndex=h),j},f.split=function(c,d){if(!b.isRegExp(c))return e.split.apply(this,arguments);var j,f=String(this),g=[],h=c.lastIndex,i=0;return d=(d===a?-1:d)>>>0,b.forEach(f,c,function(a){a.index+a[0].length>i&&(g.push(f.slice(i,a.index)),a.length>1&&a.indexd?g.slice(0,d):g},t=b.addToken,t(/\\([ABCE-RTUVXYZaeg-mopqyz]|c(?![A-Za-z])|u(?![\dA-Fa-f]{4}|{[\dA-Fa-f]+})|x(?![\dA-Fa-f]{2}))/,function(a,b){if("B"===a[1]&&b===j)return a[0];throw new SyntaxError("Invalid escape "+a[0])},{scope:"all",leadChar:"\\"}),t(/\\u{([\dA-Fa-f]+)}/,function(a,b,c){var d=x(a[1]);if(d>1114111)throw new SyntaxError("Invalid Unicode code point "+a[0]);if(65535>=d)return"\\u"+E(A(d));if(o&&c.indexOf("u")>-1)return a[0];throw new SyntaxError("Cannot use Unicode code point above \\u{FFFF} without flag u")},{scope:"all",leadChar:"\\"}),t(/\[(\^?)]/,function(a){return a[1]?"[\\s\\S]":"\\b\\B"},{leadChar:"["}),t(/\(\?#[^)]*\)/,function(a,b,c){return D(a.input,a.index+a[0].length,c)?"":"(?:)"},{leadChar:"("}),t(/\s+|#.*/,function(a,b,c){return D(a.input,a.index+a[0].length,c)?"":"(?:)"},{flag:"x"}),t(/\./,function(){return"[\\s\\S]"},{flag:"s",leadChar:"."}),t(/\\k<([\w$]+)>/,function(a){var b=isNaN(a[1])?B(this.captureNames,a[1])+1:+a[1],c=a.index+a[0].length;if(!b||b>this.captureNames.length)throw new SyntaxError("Backreference to undefined group "+a[0]);return"\\"+b+(c===a.input.length||isNaN(a.input.charAt(c))?"":"(?:)")},{leadChar:"\\"}),t(/\\(\d+)/,function(a,b){if(!(b===j&&/^[1-9]/.test(a[1])&&+a[1]<=this.captureNames.length)&&"0"!==a[1])throw new SyntaxError("Cannot use octal escape or backreference to undefined group "+a[0]);return a[0]},{scope:"all",leadChar:"\\"}),t(/\(\?P?<([\w$]+)>/,function(a){if(!isNaN(a[1]))throw new SyntaxError("Cannot use integer as capture name "+a[0]);if("length"===a[1]||"__proto__"===a[1])throw new SyntaxError("Cannot use reserved word as capture name "+a[0]);if(B(this.captureNames,a[1])>-1)throw new SyntaxError("Cannot use same name for multiple groups "+a[0]);return this.captureNames.push(a[1]),this.hasNamedCapture=!0,"("},{leadChar:"("}),t(/\((?!\?)/,function(a,b,c){return c.indexOf("n")>-1?"(?:":(this.captureNames.push(null),"(")},{optionalFlags:"n",leadChar:"("}),b}();
\ No newline at end of file
diff --git a/puma_config.rb b/puma_config.rb
new file mode 100644
index 00000000..5fb01d68
--- /dev/null
+++ b/puma_config.rb
@@ -0,0 +1,28 @@
+def processor_count
+ case RbConfig::CONFIG['host_os']
+ when /darwin9/
+ `hwprefs cpu_count`.to_i
+ when /darwin/
+ ((`which hwprefs` != '') ? `hwprefs thread_count` : `sysctl -n hw.ncpu`).to_i
+ when /linux/
+ `cat /proc/cpuinfo | grep processor | wc -l`.to_i
+ when /freebsd/
+ `sysctl -n hw.ncpu`.to_i
+ when /mswin|mingw/
+ require 'win32ole'
+ wmi = WIN32OLE.connect("winmgmts://")
+ cpu = wmi.ExecQuery("select NumberOfCores from Win32_Processor") # TODO count hyper-threaded in this
+ cpu.to_enum.first.NumberOfCores
+ end
+end
+
+environment 'production'
+daemonize
+pidfile '/var/run/neocities/neocities.pid'
+stdout_redirect '/var/log/neocities/neocities.log', '/var/log/neocities/neocities-errors.log', true
+quiet
+workers processor_count
+worker_timeout 600
+preload_app!
+on_worker_boot { DB.disconnect }
+bind 'unix:/var/run/neocities/neocities.sock?backlog=2048'
diff --git a/sass/_project-sass/_project-Header.scss b/sass/_project-sass/_project-Header.scss
index 28e64804..46a6f35a 100644
--- a/sass/_project-sass/_project-Header.scss
+++ b/sass/_project-sass/_project-Header.scss
@@ -14,7 +14,7 @@
}
.row.header-Content.content {
- padding-bottom:27px;
+ padding-bottom: 31px;
}
.header-Content.content{
@@ -28,7 +28,7 @@
background:url(/img/neocity.png) 95% bottom no-repeat;
background-size: 734px;
min-height:214px;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
@include vendor(background-size, cover);
min-height:2px;
@@ -64,7 +64,7 @@
color: #B8D375;
font-size: .9em;
margin-bottom: 1.8em;
-
+
a {
color: #B8D375;
border-bottom: 1px solid rgba(184, 211, 117, 0.5);
@@ -90,7 +90,7 @@
@include vendor(transform, scaleX(-1));
width: 100px;
margin-right: 25px;
-
+
&.float-Right {
margin-left: 10px;
}
@@ -107,19 +107,19 @@
margin-bottom:20px;
padding:4px 20px 4px 14px;
}
-
+
li{
padding-left:$spacing*9;
padding-right:$spacing*3;
margin-bottom:20px;
margin-left: 8px;
}
-
+
h3 {
margin-bottom: 0px;
font-size: 1.7em;
}
-
+
p {
color:#B2BCC1;
line-height: 170%;
@@ -139,14 +139,14 @@
.intro-Tools{position:relative}
.intro-Question{
position:relative;
-
+
.intro-Icon{
background-position:0 -40px;
- }
+ }
}
.intro-Social {
position:relative;
-
+
.intro-Icon {
background-position: 0 -80px;
}
@@ -166,7 +166,7 @@
position:absolute;
top: -40px;
margin-bottom: 15px;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
height:auto;
margin:0;
@@ -175,35 +175,35 @@
position:static;
width:100%;
}
-
+
h2{
margin-bottom:0;
text-shadow:0 1px 1px rgba(0,0,0,.5);
font-size: 1.8em;
}
-
+
hr{
border-bottom:1px solid #4a6677;
border-top:1px solid #1d282d;
margin: 4px 0 21px;
}
-
+
fieldset{
padding: 25px 33px;
- background:url(/img/sign-up-bg.png) repeat-x center top;
+ background:url(/img/sign-up-bg.png) repeat-x center top;
border-radius: 4px 4px 0 0;
}
-
+
label{
color:#81b8c6;
}
-
+
label#domain-name {
display: inline;
vertical-align: 8px;
color: #C2CFD4;
}
-
+
.input-Area{
background:#29383f;
border:0 solid black;
@@ -213,7 +213,7 @@
margin-right:$spacing;
padding: 10px 10px 7px 10px;
width:100%;
-
+
// &:focus{color:#eee}
}
.input-Area#create-Input {
@@ -223,7 +223,7 @@
margin-left: 0;
position: static;
}
- .tooltip {
+ .tooltip {
&.left .tooltip-arrow {
border-left-color: #971D31;
}
@@ -249,41 +249,41 @@
.header-Nav{
background:#5e95a1;
border-bottom:1px solid #92B4BD;
-
+
@include vendor(transition, all 0.35s);
-
+
@media (max-device-width:480px), screen and (max-width:800px){
position:fixed;
top:-900px!important;
}
-
+
&.show-Nav{
top:0!important;
padding-bottom: 2px;
}
-
+
a, a:visited{
- color:#fff;
- padding:$spacing*2 $spacing*3;
+ color:#fff;
+ padding:$spacing*2 $spacing*3;
text-decoration:none;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
display:block;
}
-
+
>.fa-heart {
vertical-align: .5em;
margin-left: .3em;
font-size: 9px;
- position: relative;
+ position: relative;
color: rgba(255, 255, 255, 0.4);
display: inline-block;
-
+
>.fa-heart {
display:none;
}
}
-
+
&:hover{
background:#528995; text-decoration:underline;
>.fa-heart {
@@ -300,7 +300,7 @@
}
&.selected, &:active{background:#528995; text-decoration:underline}
}
-
+
a.small-Nav{
background: #65a0ad;
display:none;
@@ -311,28 +311,28 @@
height:36px;
z-index:9999;
padding: 5px 12px 0px 12px;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
display:block;
}
}
-
+
.notification-value {
background: $c-Brand-1;
padding: 2px 5px;
@include border-radius(4px);
}
-
+
.dropdown {
height: 2.6em; //not sure why I need this :/
-
+
a {
width:100%;
}
.info {
float:right;
margin-left: 10px;
-
+
.fa-caret-down {
margin-left:10px;
}
@@ -345,10 +345,10 @@
min-width: 13em;
@include border-radius(0px 0px 6px 6px);
@include box-shadow(0 2px 7px rgba(0,0,0,0.2));
-
+
li {
width:100%;
-
+
a {
float:left;
}
@@ -367,21 +367,21 @@
}
.dropdown-submenu {
float: left;
-
+
&:hover > a, &:focus > a {
background:#3C6670;
}
-
+
.dropdown-menu {
width: 100%;
margin-left: -1px;
margin-top: -1px;
-
+
li {
overflow: hidden;
}
}
-
+
>a:after {
border-left-color: #fff;
}
@@ -390,27 +390,27 @@
.constant-Nav{
float:left;
position:relative;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
float:none;
-
+
li{float:none;}
}
}
.status-Nav{
float:right;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
float:none;
}
-
+
li{
float:left;
@media (max-device-width:480px), screen and (max-width:800px){
float:none
}
- }
+ }
}
// hp CSS
@@ -429,12 +429,12 @@
left:-90px;
position:fixed;
@include vendor(transition, all 0.35s);
-
-
+
+
&.in-View{
left:0!important;
z-index:99;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
left:-90px!important;
}
@@ -456,11 +456,11 @@
@media (max-device-width:480px), screen and (max-width:800px){
margin-left:0;
}
-
+
&.in-View{
margin-left:0;
padding-left:70px;
-
+
@media (max-device-width:480px), screen and (max-width:800px){
padding-left:0
}
@@ -504,7 +504,7 @@
padding-left:74px;
@media (max-device-width:480px), screen and (max-width:800px){
width:100%;
- }
+ }
}
.interior .constant-Nav, .hp.education .constant-Nav{margin:0}
diff --git a/sass/_project-sass/_project-Main.scss b/sass/_project-sass/_project-Main.scss
index c21d8777..609a12d5 100644
--- a/sass/_project-sass/_project-Main.scss
+++ b/sass/_project-sass/_project-Main.scss
@@ -187,7 +187,7 @@
@media (max-device-width:480px), screen and (max-width:800px) {
width: 100%;
- height: 300px;
+ height: 200px;
}
}
.interior .header-Outro .screenshot.dashboard {
@@ -256,16 +256,16 @@
width: 100%;
position: relative;
margin-top: 7px;
-
+
.column, input[type='checkbox'] {
display: none;
}
-
+
.btn-group {
float: left;
margin-right: 15px;
margin-left: -3px;
-
+
>.btn+.btn {
margin-left: 0px;
border-left: 1px solid rgba(0, 0, 0, 0.1);
@@ -276,9 +276,6 @@
background: #77ABB8;
@include box-shadow(0 0 5px rgba(0, 0, 0, 0.2));
- &:hover {
- background: #83B3C0;
- }
&:focus, &.active {
outline: 0;
background: #4F727B;
@@ -323,11 +320,11 @@
}
.files .actions {
float: right;
-
+
@media (max-device-width:480px), screen and (max-width:800px) {
float: left;
margin-top: 7px;
-
+
.fa {
display: none;
}
@@ -525,7 +522,7 @@
}
@mixin dashboard-list-view {
padding: 0;
-
+
.upload-Boundary {
padding: 0;
border: 0;
@@ -535,20 +532,20 @@
padding: 10px 20px;
margin: 0;
width: 100%;
-
+
&:nth-child(even) {
background: #EFE8DC;
}
.title {
margin: 0;
- margin-left: 7px;
+ margin-left: 9px;
margin-top: 2px;
float: left;
font-size: 14px;
text-align: left;
width: 30%;
text-overflow: ellipsis;
-
+
@media (max-device-width:480px), screen and (max-width:800px) {
width: 33%;
}
@@ -562,14 +559,13 @@
}
.html-thumbnail, .misc-icon {
margin: 0;
- margin-left: 4px;
float: left;
width: 23px;
height: 23px;
background-size: 23px;
padding: 0;
font-size: 8px;
-
+
img {
max-width: 23px;
max-height: 23px;
@@ -590,7 +586,7 @@
background-color: transparent;
display: block;
width: 94%;
-
+
a {
color: #e93250;
display: inline;
@@ -599,7 +595,7 @@
.link-overlay {
width: 30%;
}
-
+
@media (max-device-width:480px), screen and (max-width:800px) {
width: 84%;
}
@@ -613,7 +609,7 @@
font-size: 13px;
display: block;
padding-top: 4px;
-
+
@media (max-device-width:480px), screen and (max-width:800px) {
display: none;
}
@@ -756,10 +752,9 @@
}
}
@media (max-device-width:480px), screen and (max-width:800px) {
- width: 10em!important;
- float: right;
- padding: 0 0 18px 0;
- margin-top: -77px;
+ position: absolute;
+ top: 46px;
+ right: -8px;
}
}
.interior .header-Outro.with-columns .col-66 {
@@ -820,6 +815,7 @@
@media (max-device-width:480px), screen and (max-width:800px) {
width: 60%;
+ height: 160px;
}
}
.site-portrait {
@@ -958,7 +954,7 @@ a.tag:hover {
position: relative;
width: 100%;
float: left;
-
+
.text {
float: left;
margin-top: .45em;
@@ -984,17 +980,17 @@ a.tag:hover {
padding: 0;
margin-left: 0;
margin-right: 17px;
-
+
.html-thumbnail {
width: 102px;
}
&:first-child .html-thumbnail.html {
- width: 322px;
- height: 100px;
+ width: 540px;
+ height: 405px;
}
&:first-child .html-thumbnail.html img {
- width: 322px;
- height: 200px;
+ width: 540px;
+ height: 405px;
}
}
.news-item .file .image-container {
@@ -1020,6 +1016,7 @@ a.tag:hover {
.news-item .comments .actions, .news-item .comments p {
margin-left: 47px;
}
+
.news-item .comments p {
margin-bottom: .4em;
margin-top: .15em;
@@ -1867,7 +1864,7 @@ a.tag:hover {
padding-top: 0;
background: #4F7E89;
- padding-bottom: 7em;
+ padding-bottom: 5em;
a {
color: white;
@@ -2137,43 +2134,71 @@ table#latest-visitors {
width: 100%;
}
}
-.intro-List.kickstarter .col {
- padding-top: 1em;
- padding-bottom: .8em;
- margin-left: 0;
-
- &:first-child{
- padding-left: 2px;
+.section.thankyou {
+ text-align: center;
+ color: #4F7E89;
+ padding: 6.5em 8% 7em;
+
+ a {
+ color: #4F7E89;
+ text-decoration: underline;
}
-
- .title {
- margin-top: 1%;
- }
- .title a {
- color: white;
- font-weight: bold;
- text-decoration: none;
+ img {
+ margin-bottom: 1em;
}
p {
- margin-top: 15px;
+ font-size: 1em;
+ margin-bottom: .5em;
+ margin-top: .5em;
}
-}
-.welcome.kickstarter {
- background: #daeea5 url(/img/tutorialthumbnail.png) no-repeat;
- background-position: right center;
- background-size: auto 100%;
- padding: 15px 100px 4px 23px;
- margin-bottom: 13px;
- font-size: 95%;
- @media (max-device-width:480px), screen and (max-width:800px) {
- background-size: 32%;
- background-position: right top;
- }
-
- h4 {
+ p:first-child {
+ font-size: 120%;
margin-bottom: .2em;
- a {
- color: #2c3e50!important;
- }
}
}
+ul.thankyou {
+ list-style: none;
+ margin-top: 1.5em;
+ clear: both;
+
+ li {
+ display: inline-block;
+ width: 32%;
+ }
+}
+
+pre, code {
+ background: #1d1f21;
+ color: #FFFFFF;
+}
+.welcoming-cat {
+ width: 200px;
+ float: right;
+ margin-top: -40px;
+}
+.section.tutorial-welcome {
+ padding: 80px 18%;
+
+ .option {
+ margin: 3.5em 0;
+ }
+ h3 {
+ margin-top: 0em;
+ font-size: 1.3em;
+ }
+ p {
+ font-size: 1.1em;
+ }
+}
+
+.tagcloud .tag10 { font-size: 0.6em; font-weight: 90; }
+.tagcloud .tag9 { font-size: 0.7em; font-weight: 100; }
+.tagcloud .tag8 { font-size: 0.8em; font-weight: 200; }
+.tagcloud .tag7 { font-size: 0.9em; font-weight: 300; }
+.tagcloud .tag6 { font-size: 1.0em; font-weight: 400; }
+.tagcloud .tag5 { font-size: 1.2em; font-weight: 500; }
+.tagcloud .tag4 { font-size: 1.4em; font-weight: 600; }
+.tagcloud .tag3 { font-size: 1.6em; font-weight: 700; }
+.tagcloud .tag2 { font-size: 1.8em; font-weight: 800; }
+.tagcloud .tag1 { font-size: 2.2em; font-weight: 900; }
+.tagcloud .tag0 { font-size: 2.5em; font-weight: 900; }
diff --git a/sass/_project-sass/_project-Tutorial.scss b/sass/_project-sass/_project-Tutorial.scss
new file mode 100644
index 00000000..6e128d16
--- /dev/null
+++ b/sass/_project-sass/_project-Tutorial.scss
@@ -0,0 +1,91 @@
+// ----------------------------------------------------------------
+// Project Specific: Tutorial
+// ----------------------------------------------------------------
+
+.tutorial {
+ p {
+ font-size: 1em;
+ }
+ .interact {
+ textarea.editor {
+ height: 350px!important;
+ width: 100%;
+ background-color: #1D1F21;
+ color: white;
+ font: 16px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
+ margin-bottom: 0;
+ border: 0;
+ }
+ h3.editor-heading {
+ margin-top: 10px;
+ }
+ .preview {
+ height: 300px;
+ width: 100%;
+ background-color: gray;
+ }
+ .btn {
+ float: right;
+ }
+ .error {
+ background: #93771b;
+ color: #fff;
+ padding: 5px 10px;
+ font-size: 14px;
+ }
+ }
+ .lesson {
+ h1, .subtitle {
+ color: #5e95a1;
+ }
+ .subtitle {
+ font-size: 14px;
+ margin-top: 0;
+ text-align: left;
+ }
+ .comic {
+ position: relative;
+ background-repeat: no-repeat;
+ }
+ .dialogue {
+ width: 180px;
+ position: absolute;
+ text-align: center;
+
+ &:nth-child(1) {
+ left: 30px;
+ top: 30px;
+ }
+ &:nth-child(2) {
+ left: 250px;
+ top: 30px;
+ }
+ &:nth-child(3) {
+ left: 30px;
+ top: 250px;
+ }
+ &:nth-child(4) {
+ left: 250px;
+ top: 250px;
+ }
+ pre, code {
+ padding: 0;
+ margin: 0;
+ background: none;
+ color: #666;
+ border: none;
+ display: block;
+ font-size: 100%;
+ }
+ pre {
+ padding-top: .1em;
+ }
+ }
+ }
+ .welcome {
+ pre, code {
+ background: #16414c;
+ font-size: 100%;
+ }
+ }
+}
\ No newline at end of file
diff --git a/sass/neo.scss b/sass/neo.scss
index fd821dc3..b0ab7f7e 100644
--- a/sass/neo.scss
+++ b/sass/neo.scss
@@ -29,6 +29,7 @@
// Specific Modules
@import '_project-sass/project-Website-Gallery'; // Browse website specific styling
+ @import '_project-sass/project-Tutorial'; // Tutorial specific styling
// FIXITFIXITFIXITFIXIT
// Only import tidyUp if you actually need to make some quick fix hacks that you don't
diff --git a/tests/acceptance/browse_tests.rb b/tests/acceptance/browse_tests.rb
new file mode 100644
index 00000000..af8c390a
--- /dev/null
+++ b/tests/acceptance/browse_tests.rb
@@ -0,0 +1,46 @@
+require_relative './environment.rb'
+
+describe '/browse' do
+ include Capybara::DSL
+
+ describe 'as admin' do
+ before do
+ DB[:sites_tags].delete
+ DB[:sites].delete
+ Capybara.reset_sessions!
+ @admin = Fabricate :site, is_admin: true
+ @site = Fabricate :site, site_changed: true
+ page.set_rack_session id: @admin.id
+ end
+
+ it 'bans from browse for admin' do
+ visit '/browse?sort_by=newest'
+ within(".website-Gallery li#username_#{@site.username}") do
+ click_button 'Ban'
+ end
+
+ @site.reload.is_banned.must_equal true
+ @admin.reload.is_banned.must_equal false
+ end
+
+ it 'bans for spam' do
+ visit '/browse?sort_by=newest'
+ within(".website-Gallery li#username_#{@site.username}") do
+ click_button 'Spam'
+ end
+
+ @site.reload.is_banned.must_equal true
+ @site.site_files_dataset.where(path: 'index.html').first.classifier.must_equal 'spam'
+ end
+
+ it 'bans for phishing' do
+ visit '/browse?sort_by=newest'
+ within(".website-Gallery li#username_#{@site.username}") do
+ click_button 'Phishing'
+ end
+
+ @site.reload.is_banned.must_equal true
+ @site.site_files_dataset.where(path: 'index.html').first.classifier.must_equal 'phishing'
+ end
+ end
+end
diff --git a/tests/acceptance/dashboard_tests.rb b/tests/acceptance/dashboard_tests.rb
index d0f1641d..86dd1c9c 100644
--- a/tests/acceptance/dashboard_tests.rb
+++ b/tests/acceptance/dashboard_tests.rb
@@ -13,6 +13,12 @@ describe 'dashboard' do
page.set_rack_session id: @site.id
end
+ it 'records a dashboard access' do
+ @site.reload.dashboard_accessed.must_equal false
+ visit '/dashboard'
+ @site.reload.dashboard_accessed.must_equal true
+ end
+
it 'creates a base directory' do
visit '/dashboard'
click_link 'New Folder'
diff --git a/tests/acceptance/environment.rb b/tests/acceptance/environment.rb
index 352aeafd..c8154237 100644
--- a/tests/acceptance/environment.rb
+++ b/tests/acceptance/environment.rb
@@ -1,9 +1,12 @@
require_relative '../environment'
+require 'capybara'
+require 'capybara/dsl'
+require 'capybara/poltergeist'
+require 'rack_session_access/capybara'
+
Capybara.app = Sinatra::Application
def teardown
Capybara.reset_sessions!
end
-
-Capybara.default_wait_time = 5
diff --git a/tests/acceptance/index_tests.rb b/tests/acceptance/index_tests.rb
index ff8aee1b..80aa15c2 100644
--- a/tests/acceptance/index_tests.rb
+++ b/tests/acceptance/index_tests.rb
@@ -21,7 +21,7 @@ describe '/' do
@another_site = Fabricate :site
@followed_site.toggle_follow @another_site
visit '/'
- find('.news-item', match: :first).text.must_match /#{@followed_site.username} started following the site of #{@another_site.username}/i
+ find('.news-item', match: :first).text.must_match /#{@followed_site.username} followed #{@another_site.username}/i
end
it 'loads my activities only' do
@@ -37,7 +37,7 @@ describe '/' do
@followed_site = Fabricate :site
@site.toggle_follow @followed_site
visit "/?event_id=#{@followed_site.events.first.id}"
- find('.news-item').text.must_match /you started following the site of #{@followed_site.username}/i
+ find('.news-item').text.must_match /you followed #{@followed_site.username}/i
end
end
@@ -63,4 +63,41 @@ describe '/' do
end
end
end
+
+ describe 'username lookup' do
+ before do
+ @site = Fabricate :site
+ Capybara.reset_sessions!
+ EmailWorker.jobs.clear
+
+ visit '/signin'
+ click_link 'I forgot my username.'
+ end
+
+ it 'works for valid email' do
+ page.current_url.must_match /\/forgot_username$/
+ fill_in :email, with: @site.email
+ click_button 'Find username'
+ URI.parse(page.current_url).path.must_equal '/'
+ page.must_have_content 'If your email was valid, the Neocities Cat will send an e-mail with your username in it'
+ email_args = EmailWorker.jobs.first['args'].first
+ email_args['to'].must_equal @site.email
+ email_args['subject'].must_match /username lookup/i
+ email_args['body'].must_match /your username is #{@site.username}/i
+ end
+
+ it 'fails silently for unknown email' do
+ fill_in :email, with: 'N-O-P-E@example.com'
+ click_button 'Find username'
+ URI.parse(page.current_url).path.must_equal '/'
+ page.must_have_content 'If your email was valid, the Neocities Cat will send an e-mail with your username in it'
+ EmailWorker.jobs.length.must_equal 0
+ end
+
+ it 'fails for no input' do
+ click_button 'Find username'
+ URI.parse(page.current_url).path.must_equal '/forgot_username'
+ page.must_have_content 'Cannot use an empty email address'
+ end
+ end
end
diff --git a/tests/acceptance/password_reset_tests.rb b/tests/acceptance/password_reset_tests.rb
new file mode 100644
index 00000000..81e4d10a
--- /dev/null
+++ b/tests/acceptance/password_reset_tests.rb
@@ -0,0 +1,90 @@
+require_relative './environment.rb'
+
+describe '/password_reset' do
+ include Capybara::DSL
+
+ before do
+ Capybara.reset_sessions!
+ EmailWorker.jobs.clear
+ end
+
+ it 'should load the password reset page' do
+ visit '/password_reset'
+ page.body.must_match /Reset Password/
+ end
+
+ it 'should not load password reset if logged in' do
+ @site = Fabricate :site
+ page.set_rack_session id: @site.id
+
+ visit '/password_reset'
+ URI.parse(page.current_url).path.must_equal '/'
+ end
+
+ it 'errors for missing email' do
+ visit '/password_reset'
+ click_button 'Send Reset Token'
+ URI.parse(page.current_url).path.must_equal '/password_reset'
+ body.must_match /You must enter a valid email address/
+ end
+
+ it 'fails for invalid username or token' do
+ @site = Fabricate :site
+ visit '/password_reset'
+ fill_in 'email', with: @site.email
+ click_button 'Send Reset Token'
+
+ @site.reload
+
+ [
+ {username: 'derp', token: @site.password_reset_token},
+ {username: '', token: @site.password_reset_token},
+ {username: @site.username, token: 'derp'},
+
+ {token: 'derp'},
+
+ ].each do |params|
+ visit "/password_reset_confirm?#{Rack::Utils.build_query params}"
+ page.must_have_content 'Could not find a site with this username and token'
+ @site.reload.password_reset_confirmed.must_equal false
+ end
+
+ [
+ {username: @site.username, token: ''},
+ {username: @site.username},
+ {username: '', token: ''}
+ ].each do |params|
+ visit "/password_reset_confirm?#{Rack::Utils.build_query params}"
+ page.must_have_content 'Token cannot be empty'
+ @site.reload.password_reset_confirmed.must_equal false
+ end
+
+ end
+
+ it 'works for valid username and token' do
+ @site = Fabricate :site
+ visit '/password_reset'
+ fill_in 'email', with: @site.email
+ click_button 'Send Reset Token'
+
+ body.must_match /send an e-mail to your account with password reset instructions/
+ @site.reload.password_reset_token.blank?.must_equal false
+ EmailWorker.jobs.first['args'].first['body'].must_match /#{Rack::Utils.build_query(username: @site.username, token: @site.password_reset_token)}/
+
+ visit "/password_reset_confirm?#{Rack::Utils.build_query username: @site.username, token: @site.reload.password_reset_token}"
+
+ page.current_url.must_match /.+\/settings$/
+
+ fill_in 'new_password', with: 'n3wp4s$'
+ fill_in 'new_password_confirm', with: 'n3wp4s$'
+ click_button 'Change Password'
+
+ page.current_url.must_match /.+\/settings$/
+ page.must_have_content 'Successfully changed password'
+ Site.valid_login?(@site.username, 'n3wp4s$').must_equal true
+ page.get_rack_session['id'].must_equal @site.id
+ @site.reload.password_reset_token.must_equal nil
+ @site.password_reset_confirmed.must_equal false
+ end
+
+end
diff --git a/tests/acceptance/plan_tests.rb b/tests/acceptance/plan_tests.rb
index 650b4731..77a7f70d 100644
--- a/tests/acceptance/plan_tests.rb
+++ b/tests/acceptance/plan_tests.rb
@@ -19,6 +19,10 @@ describe '/plan' do
StripeMock.stop
end
+ it 'should work for paypal' do
+
+ end
+
it 'should work for fresh signup' do
visit '/plan'
fill_in 'Card Number', with: '4242424242424242'
@@ -41,4 +45,4 @@ describe '/plan' do
mail = Mail::TestMailer.deliveries.first
mail.subject.must_match "You've become a supporter"
end
-end
\ No newline at end of file
+end
diff --git a/tests/acceptance/settings/account_tests.rb b/tests/acceptance/settings/account_tests.rb
index 90a05483..0c57be7c 100644
--- a/tests/acceptance/settings/account_tests.rb
+++ b/tests/acceptance/settings/account_tests.rb
@@ -6,17 +6,24 @@ describe 'site/settings' do
before do
EmailWorker.jobs.clear
- @email = "#{SecureRandom.uuid.gsub('-', '')}@example.com"
+ @email = "#{SecureRandom.uuid.gsub('-', '')}@exampleedsdfdsf.com"
@site = Fabricate :site, email: @email
page.set_rack_session id: @site.id
visit '/settings'
end
it 'should change email' do
- @new_email = "#{SecureRandom.uuid.gsub('-', '')}@example.com"
+ @new_email = "#{SecureRandom.uuid.gsub('-', '')}@exampleedsdfdsf.com"
fill_in 'email', with: @new_email
click_button 'Change Email'
- page.must_have_content /successfully changed email/i
+
+ page.must_have_content /enter the confirmation code here/
+
+ fill_in 'token', with: @site.reload.email_confirmation_token
+ click_button 'Confirm Email'
+
+ page.must_have_content /Email address changed/i
+
@site.reload
@site.email.must_equal @new_email
EmailWorker.jobs.length.must_equal 1
@@ -38,7 +45,7 @@ describe 'site/settings' do
end
it 'should fail for existing email' do
- @existing_email = "#{SecureRandom.uuid.gsub('-', '')}@example.com"
+ @existing_email = "#{SecureRandom.uuid.gsub('-', '')}@exampleedsdfdsf.com"
@existing_site = Fabricate :site, email: @existing_email
fill_in 'email', with: @existing_email
@@ -70,7 +77,7 @@ describe 'site/settings' do
include Capybara::DSL
before do
- @email = "#{SecureRandom.uuid.gsub('-', '')}@example.com"
+ @email = "#{SecureRandom.uuid.gsub('-', '')}@exampleedsdfdsf.com"
@site = Fabricate :site, email: @email
EmailWorker.jobs.clear
Mail::TestMailer.deliveries.clear
diff --git a/tests/acceptance/settings/site_tests.rb b/tests/acceptance/settings/site_tests.rb
index 968037ff..ad797303 100644
--- a/tests/acceptance/settings/site_tests.rb
+++ b/tests/acceptance/settings/site_tests.rb
@@ -200,29 +200,44 @@ describe 'site/settings' do
end
=end
- describe 'change username' do
+ describe 'changing username' do
include Capybara::DSL
before do
Capybara.reset_sessions!
@site = Fabricate :site
page.set_rack_session id: @site.id
+ visit "/settings/#{@site[:username]}#username"
end
- it 'does not allow bad usernames' do
- visit "/settings/#{@site[:username]}#username"
+ after do
+ Site[username: @site[:username]].wont_equal nil
+ end
+
+ it 'fails for blank username' do
fill_in 'name', with: ''
click_button 'Change Name'
+ page.must_have_content /cannot be blank/i
+ Site[username: ''].must_equal nil
+ end
+
+ it 'fails for subdir periods' do
fill_in 'name', with: '../hack'
click_button 'Change Name'
- fill_in 'name', with: 'derp../hack'
- click_button 'Change Name'
- ## TODO fix this without screwing up legacy sites
- #fill_in 'name', with: '-'
- #click_button 'Change Name'
page.must_have_content /Usernames can only contain/i
- Site[username: @site[:username]].wont_equal nil
- Site[username: ''].must_equal nil
+ Site[username: '../hack'].must_equal nil
+ end
+
+ it 'fails for same username' do
+ fill_in 'name', with: @site.username
+ click_button 'Change Name'
+ page.must_have_content /You already have this name/
+ end
+
+ it 'fails for same username with DiFfErEnT CaSiNg' do
+ fill_in 'name', with: @site.username.upcase
+ click_button 'Change Name'
+ page.must_have_content /You already have this name/
end
end
end
@@ -320,17 +335,21 @@ describe 'delete' do
someone_elses_site.is_deleted.must_equal false
end
+ it 'should not show NSFW tab for admin NSFW flag' do
+ owned_site = Fabricate :site, parent_site_id: @site.id, admin_nsfw: true
+ visit "/settings/#{owned_site.username}"
+ page.body.wont_match /18\+/
+ end
+
it 'should succeed if you own the site' do
owned_site = Fabricate :site, parent_site_id: @site.id
visit "/settings/#{owned_site.username}#delete"
fill_in 'confirm_username', with: owned_site.username
- fill_in 'deleted_reason', with: 'got bored with it'
click_button 'Delete Site'
@site.reload
owned_site.reload
owned_site.is_deleted.must_equal true
- owned_site.deleted_reason.must_equal 'got bored with it'
@site.is_deleted.must_equal false
page.current_path.must_equal "/settings"
@@ -341,4 +360,4 @@ describe 'delete' do
visit "/settings/#{@site.username}#delete"
page.body.must_match /You cannot delete the parent site without deleting the children sites first/i
end
-end
\ No newline at end of file
+end
diff --git a/tests/acceptance/signup_tests.rb b/tests/acceptance/signup_tests.rb
index 32e1b70d..591f60dc 100644
--- a/tests/acceptance/signup_tests.rb
+++ b/tests/acceptance/signup_tests.rb
@@ -9,9 +9,19 @@ describe 'signup' do
def fill_in_valid
@site = Fabricate.attributes_for(:site)
- fill_in 'username', with: @site[:username]
- fill_in 'password', with: @site[:password]
- fill_in 'email', with: @site[:email]
+
+ time = Time.now
+ begin
+ fill_in 'username', with: @site[:username]
+ fill_in 'password', with: @site[:password]
+ fill_in 'email', with: @site[:email]
+ rescue Capybara::ElementNotFound
+ puts "Waiting on fill_in #{Time.now - time} seconds"
+ raise if Time.now - time > 30
+ visit_signup
+ sleep 0.5
+ retry
+ end
end
def click_signup_button
@@ -44,6 +54,13 @@ describe 'signup' do
click_signup_button
site_created?
+ click_link 'Continue'
+ page.must_have_content /almost ready!/
+ fill_in 'token', with: Site[username: @site[:username]].email_confirmation_token
+ click_button 'Confirm Email'
+ current_path.must_equal '/tutorial'
+ page.must_have_content /Let's Get Started/
+
index_file_path = File.join Site::SITE_FILES_ROOT, @site[:username], 'index.html'
File.exist?(index_file_path).must_equal true
diff --git a/tests/api_tests.rb b/tests/api_tests.rb
index 668589f8..4a26dde8 100644
--- a/tests/api_tests.rb
+++ b/tests/api_tests.rb
@@ -14,6 +14,46 @@ def create_site(opts={})
@pass = site_attr[:password]
end
+describe 'api list' do
+ it 'returns all files without path' do
+ create_site
+ basic_authorize @user, @pass
+ get '/api/list'
+
+ res[:result].must_equal 'success'
+ res[:files].length.must_equal @site.site_files.length
+
+ res[:files].each do |file|
+ site_file = @site.site_files.select {|s| s[:path] == file[:path]}.first
+ site_file[:is_directory].must_equal file[:is_directory]
+ site_file[:size].must_equal file[:size]
+ site_file[:updated_at].rfc2822.must_equal file[:updated_at]
+ end
+ end
+
+ it 'shows empty array for missing path' do
+ create_site
+ basic_authorize @user, @pass
+ get '/api/list', path: '/fail'
+ res[:result].must_equal 'success'
+ res[:files].must_equal []
+ end
+
+ it 'shows files in path' do
+ create_site
+ tempfile = Tempfile.new
+ tempfile.write('meep html')
+ @site.store_files [{filename: '/derp/test.html', tempfile: tempfile}]
+ basic_authorize @user, @pass
+ get '/api/list', path: '/derp'
+ res[:result].must_equal 'success'
+ res[:files].length.must_equal 1
+ file = res[:files].first
+ file[:path].must_equal 'derp/test.html'
+ file[:updated_at].must_equal @site.site_files.select {|s| s.path == 'derp/test.html'}.first.updated_at.rfc2822
+ end
+end
+
describe 'api info' do
it 'fails for no input' do
get '/api/info'
@@ -36,6 +76,7 @@ describe 'api info' do
it 'succeeds for valid sitename' do
create_site
@site.update hits: 31337, domain: 'derp.com', new_tags_string: 'derpie, man'
+ @site.add_archive ipfs_hash: 'QmXGTaGWTT1uUtfSb2sBAvArMEVLK4rQEcQg5bv7wwdzwU'
get '/api/info', sitename: @user
res[:result].must_equal 'success'
res[:info][:sitename].must_equal @site.username
@@ -44,9 +85,16 @@ describe 'api info' do
res[:info][:last_updated].must_equal nil
res[:info][:domain].must_equal 'derp.com'
res[:info][:tags].must_equal ['derpie', 'man']
+ res[:info][:latest_ipfs_hash].must_equal 'QmXGTaGWTT1uUtfSb2sBAvArMEVLK4rQEcQg5bv7wwdzwU'
@site.reload.api_calls.must_equal 0
end
+ it 'shows latest ipfs hash as nil when not present' do
+ create_site
+ get '/api/info', sitename: @user
+ res[:info][:latest_ipfs_hash].must_equal nil
+ end
+
it 'fails for bad auth' do
basic_authorize 'derp', 'fake'
get '/api/info'
diff --git a/tests/environment.rb b/tests/environment.rb
index 3247bc9f..9b6a6d4f 100644
--- a/tests/environment.rb
+++ b/tests/environment.rb
@@ -1,10 +1,18 @@
ENV['RACK_ENV'] = 'test'
raise 'Forget it.' if ENV['RACK_ENV'] == 'production'
+require 'coveralls'
require 'simplecov'
+require 'mock_redis'
+
+SimpleCov.formatters = [
+ SimpleCov::Formatter::HTMLFormatter,
+ Coveralls::SimpleCov::Formatter
+]
SimpleCov.coverage_dir File.join('tests', 'coverage')
SimpleCov.start do
add_filter "/migrations/"
+ add_filter "/tests/"
end
SimpleCov.command_name 'minitest'
@@ -28,9 +36,6 @@ Sinatra::Application.configure do |app|
app.use RackSessionAccess::Middleware
end
-require 'capybara/poltergeist'
-require 'rack_session_access/capybara'
-
Site.bcrypt_cost = BCrypt::Engine::MIN_COST
MiniTest::Reporters.use! MiniTest::Reporters::SpecReporter.new
diff --git a/tests/fabricators/site_fabricator.rb b/tests/fabricators/site_fabricator.rb
index 9a16c9a9..45e613c9 100644
--- a/tests/fabricators/site_fabricator.rb
+++ b/tests/fabricators/site_fabricator.rb
@@ -1,5 +1,6 @@
Fabricator(:site) do
- username { SecureRandom.hex }
- password { 'abcde' }
- email { SecureRandom.uuid.gsub('-', '')+'@example.com' }
-end
\ No newline at end of file
+ username { SecureRandom.hex }
+ password { 'abcde' }
+ email { SecureRandom.uuid.gsub('-', '')+'@examplesdlfjdslfj.com' }
+ email_confirmed { true }
+end
diff --git a/public/banned_sites/.gitignore b/tests/files/blankindex/index.html
similarity index 100%
rename from public/banned_sites/.gitignore
rename to tests/files/blankindex/index.html
diff --git a/tests/files/classifier/ham.html b/tests/files/classifier/ham.html
new file mode 100644
index 00000000..fb2e3f75
--- /dev/null
+++ b/tests/files/classifier/ham.html
@@ -0,0 +1 @@
+I am a piece of ham.
diff --git a/tests/files/classifier/phishing.html b/tests/files/classifier/phishing.html
new file mode 100644
index 00000000..b7a57acd
--- /dev/null
+++ b/tests/files/classifier/phishing.html
@@ -0,0 +1 @@
+Facebook login enter your password derrp
diff --git a/tests/files/classifier/spam.html b/tests/files/classifier/spam.html
new file mode 100644
index 00000000..bea343a8
--- /dev/null
+++ b/tests/files/classifier/spam.html
@@ -0,0 +1 @@
+Ham sucks. How would you like to buy some spam?
diff --git a/tests/files/letsencrypt.key b/tests/files/letsencrypt.key
new file mode 100644
index 00000000..fc2ef03b
--- /dev/null
+++ b/tests/files/letsencrypt.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEogIBAAKCAQEAwsY6rlj09XsfnxbzatQKEqjN9Kqynjp+o7jaHBaWMjRSOSqC
+xj0ThnxghxsXnUZEP+Wc/e38JQ15Ob8LRe5AnigP65ArvC3Fk9p19uV9JkX+gBtr
+LSzQqSD0As+4SQc8iE7zoAH8Oz2t1WXGOJnhw/D3DZBoEis4TBXctc9FMYYWtCSH
+2wfqUMTVUru6vOG9dv1w/7lQr4+YIHJGGai9p+XEqLGmkH/JkHwQ11XJZcBjKQtQ
+1jTfryzMtCGtdRBvF9Au2JCWz3zrVdh4Pz6pj8NTQ87izf97u3RvmnnZIT9RSPWF
+LVfkFSLNExguMqreWi8NzI79rfP08bMAhIcEFQIDAQABAoIBAEABJAdm9Bg2/Y0p
+3ZOdWjIHhqZ/8XJNhpAGjqXsRi75QovGjHLYXzIybQX4wSnNjV/x9ET9j34dj1iK
+jwnL4TAXuiOOnNCZufN8NoeLojSRi7BNUkrmDyuEyKqj18sntZyVVTmfbWOKQTV+
+3ER9TNM2u/iZG5nc2gMmrACSvAopktYvBYRPHAmCR8ZLkE6ZjPeOKcJbr6F5D9Ie
+X5+QKsoG0by7Gu8eHkOh0WtufeOM38OfSStDpPW5eu5JOFrtgEQPolaMi0Mw2h4D
+aaNLUQbmk1I9JMS17yVJFnTrrKTSeHFiC16rwKWJvG0S4DUfsyMW9u0oog4sYrAT
+5j0+oAECgYEA8ZD2QRbuZD52F6SPB6tV0LokUijKLLzUW1+d8R54sW7ATurCSspU
+J9CRSmyi9cfFn9ReP776dRBgtfYmf6IV5Ju6fH86+B2+WI/2YLqbMkpcAILnRF80
+IlfJK4mDy0ljxvubUNaEUcZj/bpINx5PwddU7Xl6POWuYxXzBn4TZNUCgYEAzmmH
+t+Qv9l+LxxaNDIdZYtWhkYUWGzupiLKWHjrnprqmFLODDCbfA95Y1jxK3tBxZ3Yp
+TRD9XJ/0+PrLK992OUDvlPxX4XSMZPMtsqLSAwlnAvUzzZQimhyhC8rx2eogbDSx
+Wf0pKqMRVJdk6zFEMKNx+Lcgvpv5rCWoIJ7twkECgYAJdFf/AivAZqVulXU3oqAQ
+NEjZolkPWTCihuKCnmOw5hnGvO9vx+11RXd6Rzg1kGUOtVwe+JWK4WI3nPOyySA8
+O1AYMU6YiWl6w9+rt4H9fOWO65Crn2JF+dOYzaAH485w6kYQ6uRw4ufk9VaAOcJ7
+XrcnODrtiTvDCwfg+CxAJQKBgFHlCNXrESR9ECYzSk8YPFy8SdhEp1qytzbnNCxW
+TqaWE2LPPkVJ/t24ECMf1MzGgtf7x7Mt9HgVdsp6JrYHeQ6KNwQzgmKPLUy4Nv9T
+HmPaDSbdRmpgRcJDbZoSMRa2j5qe5WbAzN5/yFZ5oq6140ow7v0xGyrFE7A7WJNo
+uwiBAoGAMyptWRsi0vFMXy/0b26whVj2LGFLRDS03bT3XhyQmCnp+f+03GHbuggM
+/IAf3RMj8oub1RrPoFqzd78n0CnZLwk+A6w8EMkKDFCY1sLESy7dVtPdS91deIxk
+H07fMxqzLYQO6XplcmqsD06PuTLmWYDjA9e8hSSMQo1n+rSw4ko=
+-----END RSA PRIVATE KEY-----
diff --git a/tests/site_file_tests.rb b/tests/site_file_tests.rb
index f2aee5ee..3c8d808b 100644
--- a/tests/site_file_tests.rb
+++ b/tests/site_file_tests.rb
@@ -24,16 +24,59 @@ describe 'site_files' do
end
describe 'delete' do
+ before do
+ DeleteCacheWorker.jobs.clear
+ DeleteCacheOrderWorker.jobs.clear
+ end
+
it 'works' do
+ initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
- @site.reload.space_used.must_equal uploaded_file.size
+
+ PurgeCacheOrderWorker.jobs.clear
+
+ @site.reload.space_used.must_equal initial_space_used + uploaded_file.size
+ @site.actual_space_used.must_equal @site.space_used
file_path = @site.files_path 'test.jpg'
File.exists?(file_path).must_equal true
delete_file filename: 'test.jpg'
+
File.exists?(file_path).must_equal false
SiteFile[site_id: @site.id, path: 'test.jpg'].must_be_nil
- @site.reload.space_used.must_equal 0
+ @site.reload.space_used.must_equal initial_space_used
+ @site.actual_space_used.must_equal @site.space_used
+
+ PurgeCacheOrderWorker.jobs.length.must_equal 0
+ DeleteCacheOrderWorker.jobs.length.must_equal 1
+ args = DeleteCacheOrderWorker.jobs.first['args']
+ args[0].must_equal @site.username
+ args[1].must_equal '/test.jpg'
+ end
+
+ it 'flushes surf for index.html' do
+ uploaded_file = Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
+ upload 'files[]' => uploaded_file
+ delete_file filename: '/index.html'
+
+ DeleteCacheOrderWorker.jobs.length.must_equal 3
+ DeleteCacheOrderWorker.jobs.collect {|j| j['args'].last}.must_equal ['/index.html', '/?surf=1', '/']
+ end
+
+ it 'property deletes directories with regexp special chars in them' do
+ upload 'dir' => '8)', 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
+ delete_file filename: '8)'
+ @site.reload.site_files.select {|f| f.path =~ /#{Regexp.quote '8)'}/}.length.must_equal 0
+ end
+
+ it 'deletes with escaped apostrophe' do
+ upload(
+ 'dir' => "test'ing",
+ 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
+ )
+ @site.reload.site_files.select {|s| s.path == "test'ing"}.length.must_equal 1
+ delete_file filename: "test'ing"
+ @site.reload.site_files.select {|s| s.path == "test'ing"}.length.must_equal 0
end
it 'deletes a directory and all files in it' do
@@ -45,9 +88,36 @@ describe 'site_files' do
'dir' => '',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
+
+ space_used = @site.reload.space_used
delete_file filename: 'test'
+
+ @site.reload.space_used.must_equal(space_used - File.size('./tests/files/test.jpg'))
+
+ @site.site_files.select {|f| f.path == 'test'}.length.must_equal 0
@site.site_files.select {|f| f.path =~ /^test\//}.length.must_equal 0
- @site.site_files.select {|f| f.path =~ /^test/}.length.must_equal 1
+ @site.site_files.select {|f| f.path =~ /^test.jpg/}.length.must_equal 1
+ end
+
+ it 'deletes records for nested directories' do
+ upload(
+ 'dir' => 'derp/ing/tons',
+ 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
+ )
+
+ expected_site_file_paths = ['derp', 'derp/ing', 'derp/ing/tons', 'derp/ing/tons/test.jpg']
+
+ expected_site_file_paths.each do |path|
+ @site.site_files.select {|f| f.path == path}.length.must_equal 1
+ end
+
+ delete_file filename: 'derp'
+
+ @site.reload
+
+ expected_site_file_paths.each do |path|
+ @site.site_files.select {|f| f.path == path}.length.must_equal 0
+ end
end
it 'goes back to deleting directory' do
@@ -107,25 +177,35 @@ describe 'site_files' do
@site.title.must_equal 'Hello?'
# Purge cache needs to flush / and index.html for either scenario.
- PurgeCacheOrderWorker.jobs.length.must_equal 2
+ PurgeCacheOrderWorker.jobs.length.must_equal 3
first_purge = PurgeCacheOrderWorker.jobs.first
+ surf_purge = PurgeCacheOrderWorker.jobs[1]
dirname_purge = PurgeCacheOrderWorker.jobs.last
username, pathname = first_purge['args']
username.must_equal @site.username
pathname.must_equal '/index.html'
+
+ surf_purge['args'].last.must_equal '/?surf=1'
+
username, pathame = nil
username, pathname = dirname_purge['args']
username.must_equal @site.username
pathname.must_equal '/'
+
+ @site.space_used.must_equal @site.actual_space_used
+
+ (@site.space_used > 0).must_equal true
end
it 'provides the correct space used after overwriting an existing file' do
+ initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
second_uploaded_file = Rack::Test::UploadedFile.new('./tests/files/img/test.jpg', 'image/jpeg')
upload 'files[]' => second_uploaded_file
- @site.reload.space_used.must_equal second_uploaded_file.size
+ @site.reload.space_used.must_equal initial_space_used + second_uploaded_file.size
+ @site.space_used.must_equal @site.actual_space_used
end
it 'does not change title for subdir index.html' do
@@ -138,6 +218,7 @@ describe 'site_files' do
end
it 'succeeds with valid file' do
+ initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
last_response.body.must_match /successfully uploaded/i
@@ -149,7 +230,8 @@ describe 'site_files' do
@site.reload
@site.space_used.wont_equal 0
- @site.space_used.must_equal uploaded_file.size
+ @site.space_used.must_equal initial_space_used + uploaded_file.size
+ @site.space_used.must_equal @site.actual_space_used
ThumbnailWorker.jobs.length.must_equal 1
ThumbnailWorker.drain
@@ -158,7 +240,15 @@ describe 'site_files' do
File.exists?(@site.thumbnail_path('test.jpg', resolution)).must_equal true
end
- @site.site_changed.must_equal true
+ @site.site_changed.must_equal false
+ end
+
+ it 'sets site changed to false if index is empty' do
+ uploaded_file = Rack::Test::UploadedFile.new('./tests/files/blankindex/index.html', 'text/html')
+ upload 'files[]' => uploaded_file
+ last_response.body.must_match /successfully uploaded/i
+ @site.empty_index?.must_equal true
+ @site.site_changed.must_equal false
end
it 'fails with unsupported file' do
@@ -213,6 +303,10 @@ describe 'site_files' do
ThumbnailWorker.jobs.length.must_equal 1
ThumbnailWorker.drain
+ @site.site_files_dataset.where(path: 'derpie').count.must_equal 1
+ @site.site_files_dataset.where(path: 'derpie/derptest').count.must_equal 1
+ @site.site_files_dataset.where(path: 'derpie/derptest/test.jpg').count.must_equal 1
+
Site::THUMBNAIL_RESOLUTIONS.each do |resolution|
File.exists?(@site.thumbnail_path('derpie/derptest/test.jpg', resolution)).must_equal true
@site.thumbnail_url('derpie/derptest/test.jpg', resolution).must_equal(
@@ -221,6 +315,20 @@ describe 'site_files' do
end
end
+ it 'does not register site changing until root index.html is changed' do
+ upload(
+ 'dir' => 'derpie/derptest',
+ 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
+ )
+ @site.reload.site_changed.must_equal false
+
+ upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
+ @site.reload.site_changed.must_equal true
+
+ upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/chunkfive.otf', 'application/vnd.ms-opentype')
+ @site.reload.site_changed.must_equal true
+ end
+
it 'does not store new file if hash matches' do
upload(
'dir' => 'derpie/derptest',
@@ -237,5 +345,27 @@ describe 'site_files' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
@site.reload.changed_count.must_equal 2
end
+
+ describe 'classification' do
+ before do
+ puts "TODO FINISH CLASSIFIER"
+ #$trainer.instance_variable_get('@db').redis.flushall
+ end
+=begin
+ it 'trains files' do
+ upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/ham.html', 'text/html')
+ upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/spam.html', 'text/html')
+ upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/phishing.html', 'text/html')
+
+ @site.train 'ham.html'
+ @site.train 'spam.html', 'spam'
+ @site.train 'phishing.html', 'phishing'
+
+ @site.classify('ham.html').must_equal 'ham'
+ @site.classify('spam.html').must_equal 'spam'
+ @site.classify('phishing.html').must_equal 'phishing'
+ end
+=end
+ end
end
end
diff --git a/tests/site_tests.rb b/tests/site_tests.rb
index e72a685e..5cf387cb 100644
--- a/tests/site_tests.rb
+++ b/tests/site_tests.rb
@@ -5,6 +5,38 @@ def app
end
describe Site do
+ describe 'banning' do
+ it 'still makes files available' do
+ site = Fabricate :site
+ site.ban!
+ File.exist?(site.current_files_path('index.html')).must_equal true
+ site.current_files_path('index.html').must_equal File.join(Site::BANNED_SITES_ROOT, site.username, 'index.html')
+ end
+ end
+
+ describe 'directory create' do
+ it 'handles wacky pathnames' do
+ ['/derp', '/derp/'].each do |path|
+ site = Fabricate :site
+ site_file_count = site.site_files_dataset.count
+ site.create_directory path
+ site.site_files.select {|s| s.path == '' || s.path == '.'}.length.must_equal 0
+ site.site_files.select {|s| s.path == path.gsub('/', '')}.first.wont_be_nil
+ site.site_files_dataset.count.must_equal site_file_count+1
+ end
+ end
+ end
+
+ describe 'custom_max_space' do
+ it 'should use the custom max space if it is more' do
+ site = Fabricate :site
+ site.maximum_space.must_equal Site::PLAN_FEATURES[:free][:space]
+ site.custom_max_space = 10**9
+ site.save_changes
+ site.maximum_space.must_equal 10**9
+ end
+ end
+
describe 'can_email' do
it 'should fail if send_emails is false' do
site = Fabricate :site
@@ -76,7 +108,7 @@ describe Site do
Fabricate :site, new_tags_string: 'gardening', views: Site::SUGGESTIONS_VIEWS_MIN
}
- site.suggestions.length.must_equal Site::SUGGESTIONS_LIMIT
+ site.suggestions.length.must_equal(Site::SUGGESTIONS_LIMIT - 5)
end
end
end
diff --git a/tests/stat_logs/.gitignore b/tests/stat_logs/.gitignore
new file mode 100644
index 00000000..72e8ffc0
--- /dev/null
+++ b/tests/stat_logs/.gitignore
@@ -0,0 +1 @@
+*
diff --git a/tests/stat_tests.rb b/tests/stat_tests.rb
index 4216d39e..3c22fdeb 100644
--- a/tests/stat_tests.rb
+++ b/tests/stat_tests.rb
@@ -35,11 +35,34 @@ describe 'stats' do
end
Stat.parse_logfiles STAT_LOGS_PATH
+
@site.stats.first.bandwidth.must_equal 612917*2
#@site.stat_referrers.first.url.must_equal 'http://derp.com'
#@site.stat_locations.first.city_name.must_equal 'Menlo Park'
end
+ it 'takes accout for log hit time' do
+ @site = Fabricate :site
+ File.open("tests/stat_logs/#{SecureRandom.uuid}.log", 'w') do |file|
+ file.write "2015-05-01T21:16:35+00:00\t#{@site.username}\t612917\t/images/derpie space.png\t67.180.75.140\thttp://derp.com\n"
+ file.write "2015-05-02T21:16:35+00:00\t#{@site.username}\t612917\t/images/derpie space.png\t67.180.75.140\thttp://derp.com\n"
+ end
+
+ Stat.parse_logfiles STAT_LOGS_PATH
+
+ @site.stats.length.must_equal 2
+
+ [Date.new(2015, 5, 2), Date.new(2015, 5, 1)].each do |date|
+ stats = @site.stats.select {|stat| stat.created_at == date}
+ stats.length.must_equal 1
+ stat = stats.first
+ stat.hits.must_equal 1
+ stat.views.must_equal 1
+ stat.bandwidth.must_equal 612917
+ end
+
+ end
+
it 'deals with spaces in referrer' do
@site = Fabricate :site
File.open("tests/stat_logs/#{SecureRandom.uuid}.log", 'w') do |file|
@@ -103,6 +126,7 @@ describe 'stats' do
end
it 'parses logfile' do
+ DB[:daily_site_stats].delete
Stat.parse_logfiles STAT_LOGS_PATH
@site_one.reload
@@ -149,5 +173,14 @@ describe 'stats' do
#stat_paths.last.name.must_equal '/derp.html'
# [geoip.city('67.180.75.140'), geoip.city('172.56.16.152')]
+
+ # Saves to daily_site_stats
+
+ DailySiteStat.count.must_equal 1
+ d = DailySiteStat.first
+ d.created_at.must_equal Date.new(@time.year, @time.month, @time.day)
+ d.hits.must_equal 7
+ d.views.must_equal 5
+ d.bandwidth.must_equal 35000
end
end
diff --git a/tests/workers/delete_cache_order_worker_tests.rb b/tests/workers/delete_cache_order_worker_tests.rb
new file mode 100644
index 00000000..9810fcd8
--- /dev/null
+++ b/tests/workers/delete_cache_order_worker_tests.rb
@@ -0,0 +1,21 @@
+require_relative '../environment.rb'
+
+describe DeleteCacheWorker do
+ before do
+ PurgeCacheOrderWorker.jobs.clear
+ PurgeCacheWorker.jobs.clear
+ end
+
+ it 'queues up purges' do
+ DeleteCacheOrderWorker.new.perform('kyledrake', '/test.jpg')
+
+ job_one_args = DeleteCacheWorker.jobs.first['args']
+ job_two_args = DeleteCacheWorker.jobs.last['args']
+ job_one_args[0].must_equal '10.0.0.1'
+ job_one_args[1].must_equal 'kyledrake'
+ job_one_args[2].must_equal '/test.jpg'
+ job_two_args[0].must_equal '10.0.0.2'
+ job_two_args[1].must_equal 'kyledrake'
+ job_two_args[2].must_equal '/test.jpg'
+ end
+end
diff --git a/tests/workers/delete_cache_worker_tests.rb b/tests/workers/delete_cache_worker_tests.rb
new file mode 100644
index 00000000..70f6f690
--- /dev/null
+++ b/tests/workers/delete_cache_worker_tests.rb
@@ -0,0 +1,64 @@
+require_relative '../environment.rb'
+
+describe DeleteCacheWorker do
+ before do
+ @test_ip = '10.0.0.1'
+ end
+
+ it 'throws exception without 200 or 404 http status' do
+ stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org'})
+ .to_return(status: 503)
+
+ worker = DeleteCacheWorker.new
+
+ proc {
+ worker.perform @test_ip, 'kyledrake', '/test.jpg'
+ }.must_raise RestClient::ServiceUnavailable
+ end
+
+ it 'handles 404 without exception' do
+ stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org'})
+ .to_return(status: 404)
+
+ worker = DeleteCacheWorker.new
+ worker.perform @test_ip, 'kyledrake', '/test.jpg'
+ end
+
+ it 'sends a purge request' do
+ stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org'})
+ .to_return(status: 200)
+
+ worker = DeleteCacheWorker.new
+ worker.perform @test_ip, 'kyledrake', '/test.jpg'
+
+ assert_requested :get, "http://#{@test_ip}/:cache/purge/test.jpg"
+ end
+
+ it 'handles spaces correctly' do
+ stub_request(:get, "http://#{@test_ip}/:cache/purge/te st.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org'})
+ .to_return(status: 200)
+
+ url = Addressable::URI.encode_component(
+ "http://#{@test_ip}/:cache/purge/te st.jpg",
+ Addressable::URI::CharacterClasses::QUERY
+ )
+
+ worker = DeleteCacheWorker.new
+ worker.perform @test_ip, 'kyledrake', '/te st.jpg'
+
+ assert_requested :get, url
+ end
+
+ it 'works without forward slash' do
+ stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org'})
+ .to_return(status: 200)
+
+ worker = DeleteCacheWorker.new
+ worker.perform @test_ip, 'kyledrake', 'test.jpg'
+ end
+end
diff --git a/tests/workers/purge_cache_worker_tests.rb b/tests/workers/purge_cache_worker_tests.rb
index 90be0da8..b07bc3c7 100644
--- a/tests/workers/purge_cache_worker_tests.rb
+++ b/tests/workers/purge_cache_worker_tests.rb
@@ -6,8 +6,8 @@ describe PurgeCacheWorker do
end
it 'throws exception without 200 or 404 http status' do
- stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
- with(headers: {'Host' => 'kyledrake.neocities.org'})
+ stub_request(:head, "http://#{@test_ip}/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org', 'Cache-Purge' => '1'})
.to_return(status: 503)
worker = PurgeCacheWorker.new
@@ -18,8 +18,8 @@ describe PurgeCacheWorker do
end
it 'handles 404 without exception' do
- stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
- with(headers: {'Host' => 'kyledrake.neocities.org'})
+ stub_request(:head, "http://#{@test_ip}/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org', 'Cache-Purge' => '1'})
.to_return(status: 404)
worker = PurgeCacheWorker.new
@@ -27,35 +27,35 @@ describe PurgeCacheWorker do
end
it 'sends a purge request' do
- stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
- with(headers: {'Host' => 'kyledrake.neocities.org'})
+ stub_request(:head, "http://#{@test_ip}/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org', 'Cache-Purge' => '1'})
.to_return(status: 200)
worker = PurgeCacheWorker.new
worker.perform @test_ip, 'kyledrake', '/test.jpg'
- assert_requested :get, "http://#{@test_ip}/:cache/purge/test.jpg"
+ assert_requested :head, "http://#{@test_ip}/test.jpg"
end
it 'handles spaces correctly' do
- stub_request(:get, "http://#{@test_ip}/:cache/purge/te st.jpg").
- with(headers: {'Host' => 'kyledrake.neocities.org'})
+ stub_request(:head, "http://#{@test_ip}/te st.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org', 'Cache-Purge' => '1'})
.to_return(status: 200)
url = Addressable::URI.encode_component(
- "http://#{@test_ip}/:cache/purge/te st.jpg",
+ "http://#{@test_ip}/te st.jpg",
Addressable::URI::CharacterClasses::QUERY
)
worker = PurgeCacheWorker.new
worker.perform @test_ip, 'kyledrake', '/te st.jpg'
- assert_requested :get, url
+ assert_requested :head, url
end
it 'works without forward slash' do
- stub_request(:get, "http://#{@test_ip}/:cache/purge/test.jpg").
- with(headers: {'Host' => 'kyledrake.neocities.org'})
+ stub_request(:head, "http://#{@test_ip}/test.jpg").
+ with(headers: {'Host' => 'kyledrake.neocities.org', 'Cache-Purge' => '1'})
.to_return(status: 200)
worker = PurgeCacheWorker.new
diff --git a/views/_footer.erb b/views/_footer.erb
index cd67fe04..180638f5 100644
--- a/views/_footer.erb
+++ b/views/_footer.erb
@@ -3,7 +3,7 @@
diff --git a/views/_header_links.erb b/views/_header_links.erb
index f74e35c5..7a1ce4a3 100644
--- a/views/_header_links.erb
+++ b/views/_header_links.erb
@@ -14,8 +14,14 @@
Learn
- <% unless is_education? %>
- Support Our Kickstarter
+
+ <% if signed_in? %>
+ <% unless current_site.supporter? %>
+ Upgrade to Supporter
+ <% end %>
+ <% else %>
+ Support Us
+ <% end %>
+
- <% end %>
diff --git a/views/_index_signup_script.erb b/views/_index_signup_script.erb
index 47a4983d..2073df31 100644
--- a/views/_index_signup_script.erb
+++ b/views/_index_signup_script.erb
@@ -28,7 +28,7 @@
})
})
- $('input[type=text],input[type=password]').on('change focusout', function(obj) {
+ $('input[type=text],input[type=password],input[type=email]').on('change focusout', function(obj) {
$.post('/create_validate', {field: obj.target.name, value: obj.target.value, is_education: $('input[name=is_education]')[0].value, csrf_token: '<%= csrf_token %>'}, function(res) {
if(res.result == 'ok') {
return $(obj.target).tooltip('hide')
diff --git a/views/_news.erb b/views/_news.erb
index 06057f9b..856bf8d9 100644
--- a/views/_news.erb
+++ b/views/_news.erb
@@ -12,7 +12,7 @@
<% events.each do |event| %>
<% if event.profile_comment_id %>
-
@@ -96,7 +96,7 @@
Upgrade for $<%= Site::PLAN_FEATURES[:supporter][:price] %>/mo
- or pay with PayPal or Bitcoin
+ or pay with PayPal or Bitcoin
diff --git a/workers/archive_worker.rb b/workers/archive_worker.rb
index 51067e14..e69e6cca 100644
--- a/workers/archive_worker.rb
+++ b/workers/archive_worker.rb
@@ -1,4 +1,5 @@
require 'sidekiq/api'
+require 'redis-namespace'
class ArchiveWorker
include Sidekiq::Worker
@@ -17,11 +18,21 @@ class ArchiveWorker
logger.info "JOB ID: #{jid} #{site_id.inspect}"
queue.each do |job|
if job.args == [site_id] && job.jid != jid
- logger.info "DELETING #{job.jid} #{job.args.inspect}"
+ logger.info "DELETING #{job.jid} for site_id #{site_id}"
job.delete
end
end
+ scheduled_jobs = Sidekiq::ScheduledSet.new.select do |scheduled_job|
+ scheduled_job.klass == 'ArchiveWorker' &&
+ scheduled_job.args[0] == site_id
+ end
+
+ scheduled_jobs.each do |scheduled_job|
+ logger.info "DELETING scheduled job #{scheduled_job.jid} for site_id #{site_id}"
+ scheduled_job.delete
+ end
+
site.archive!
end
end
diff --git a/workers/delete_cache_order_worker.rb b/workers/delete_cache_order_worker.rb
new file mode 100644
index 00000000..cc9bacf5
--- /dev/null
+++ b/workers/delete_cache_order_worker.rb
@@ -0,0 +1,22 @@
+class DeleteCacheOrderWorker
+ include Sidekiq::Worker
+ sidekiq_options queue: :deletecacheorder, retry: 1000, backtrace: true, average_scheduled_poll_interval: 1
+
+ sidekiq_retry_in do |count|
+ return 10 if count < 10
+ 180
+ end
+
+ def perform(username, path)
+ if ENV['RACK_ENV'] == 'test'
+ proxy_ips = ['10.0.0.1', '10.0.0.2']
+ else
+ #proxy_ips = Resolv.getaddresses($config['cache_purge_ips_uri'])
+ proxy_ips = Resolv.getaddresses($config['cache_purge_ips_uri']).keep_if {|r| !r.match(/:/)}
+ end
+
+ proxy_ips.each do |proxy_ip|
+ DeleteCacheWorker.perform_async proxy_ip, username, path
+ end
+ end
+end
diff --git a/workers/delete_cache_worker.rb b/workers/delete_cache_worker.rb
new file mode 100644
index 00000000..3b0dc35e
--- /dev/null
+++ b/workers/delete_cache_worker.rb
@@ -0,0 +1,34 @@
+require 'open-uri'
+
+# PurgeCacheWorker refreshes the cache, this actually deletes it.
+# This is because when the file is 404ing the PurgeCacheWorker
+# will just sit on the stale cache, even though it's not supposed to.
+# It's some nginx bug. I'm not going to deal with it.
+
+class DeleteCacheWorker
+ HTTP_TIMEOUT = 5
+ include Sidekiq::Worker
+ sidekiq_options queue: :deletecache, retry: 1000, backtrace: false, average_scheduled_poll_interval: 1
+
+ sidekiq_retry_in do |count|
+ return 10 if count < 10
+ 180
+ end
+
+ def perform(proxy_ip, username, path)
+ # Must always have a forward slash
+ path = '/' + path if path[0] != '/'
+
+ url = Addressable::URI.encode_component(
+ "http://#{proxy_ip}/:cache/purge#{path}",
+ Addressable::URI::CharacterClasses::QUERY
+ )
+ begin
+ RestClient::Request.execute method: :get, url: url, timeout: HTTP_TIMEOUT, headers: {
+ host: URI::encode("#{username}.neocities.org")
+ }
+ rescue RestClient::ResourceNotFound
+ rescue RestClient::Forbidden
+ end
+ end
+end
diff --git a/workers/lets_encrypt_worker.rb b/workers/lets_encrypt_worker.rb
new file mode 100644
index 00000000..b16fef5a
--- /dev/null
+++ b/workers/lets_encrypt_worker.rb
@@ -0,0 +1,162 @@
+class LetsEncryptWorker
+ class NotAuthorizedYetError < StandardError; end
+ class VerificationTimeoutError < StandardError; end
+ class InvalidAuthError < StandardError; end
+ class VerifyNotFoundWithDomain < StandardError; end
+ include Sidekiq::Worker
+ sidekiq_options queue: :lets_encrypt_worker, retry: 5, backtrace: true
+
+ sidekiq_retry_in do |count|
+ 1.hour.to_i
+ end
+
+ # If you need to clear scheduled jobs:
+ # Sidekiq::ScheduledSet.new.select {|s| JSON.parse(s.value)['class'] == 'LetsEncryptWorker'}.each {|j| j.delete}
+
+ def letsencrypt
+ Acme::Client.new(
+ private_key: OpenSSL::PKey::RSA.new(File.read($config['letsencrypt_key'])),
+ endpoint: $config['letsencrypt_endpoint']
+ )
+ end
+
+ def perform(site_id)
+ # Dispose of dupes
+ queue = Sidekiq::Queue.new self.class.sidekiq_options_hash['queue']
+ queue.each do |job|
+ if job.args == [site_id] && job.jid != jid
+ job.delete
+ end
+ end
+
+ site = Site[site_id]
+ return if site.domain.blank? || site.is_deleted || site.is_banned
+
+ return if site.values[:domain].match /\.neocities\.org$/i
+
+ domain_raw = site.values[:domain].gsub(/^www\./, '')
+
+ domains = [domain_raw, "www.#{domain_raw}"]
+
+ verified_domains = []
+
+ domains.each_with_index do |domain, index|
+ puts "verifying accessability of test file on #{domain}"
+ challenge_base_path = File.join '.well-known', 'acme-challenge'
+ testfile_name, testfile_key = "test#{UUIDTools::UUID.random_create}", SecureRandom.hex
+ testfile_fs_path = File.join site.base_files_path, challenge_base_path
+
+ begin
+ FileUtils.mkdir_p File.join(site.base_files_path, challenge_base_path)
+ File.write File.join(testfile_fs_path, testfile_name), testfile_key
+ rescue => e
+ puts "ERROR WRITING TO WELLKNOWN FILE, SKIPPING #{domain}: #{e.inspect}"
+ next
+ end
+
+ # Ensure that both domains work before sending request. Let's Encrypt has a low
+ # pending request limit, and it takes one week (!) to flush out.
+
+ challenge_url = "http://#{domain}/#{challenge_base_path}/#{testfile_name}"
+
+ puts "testing #{challenge_url}"
+
+ begin
+ res = HTTP.timeout(:global, write: 5, connect: 10, read: 10).follow.get(challenge_url)
+ rescue
+ puts "error with #{challenge_url}"
+ next
+ end
+
+ if res.status != 200 && res.body != testfile_key
+ puts "CONTENT DOWNLOADED DID NOT MATCH #{challenge_url}"
+ next
+ end
+
+ puts "test succeeded, sending challenge request verification"
+
+ begin
+ auth = letsencrypt.authorize domain: domain
+ challenge = auth.http01
+ rescue Acme::Client::Error::Malformed
+ puts "international domains not supported yet, quitting"
+ return
+ end
+
+ begin
+ FileUtils.mkdir_p File.join(site.base_files_path, File.dirname(challenge.filename))
+ File.write File.join(site.base_files_path, challenge.filename), challenge.file_content
+ rescue => e
+ put "FAILED TO WRITE CHALLENGE: #{site.domain} #{challenge.filename}"
+ # A verification needs to be made anyways, otherwise 300 of them will jam up the system for a week
+ end
+
+ challenge.request_verification
+
+ sleep 1
+ attempts = 0
+
+ while true
+ result = challenge.verify_status
+ puts "#{domain} : #{result}"
+
+ if result == 'valid'
+ puts "VALIDATED: #{domain}"
+ clean_wellknown_turds site
+ verified_domains.push domain
+ break
+ end
+
+ raise VerificationTimeoutError if attempts == 60
+
+ if result == 'invalid'
+ puts "returned invalid, walking away"
+ clean_wellknown_turds site
+ break
+ end
+
+ attempts += 1
+ sleep 2
+ end
+ end
+
+ if verified_domains.empty?
+ puts "no verified domains, skipping"
+ return
+ end
+
+ puts "verified domains: #{verified_domains.inspect}"
+
+ clean_wellknown_turds site
+
+ retries = 0
+ begin
+ csr = Acme::Client::CertificateRequest.new names: verified_domains
+ certificate = letsencrypt.new_certificate csr
+ rescue Acme::Client::Error => e
+ if retries == 2
+ puts "Failed to create cert, returning: #{e.message}"
+ return
+ end
+ retries += 1
+ retry
+ end
+
+ site.ssl_key = certificate.request.private_key.to_pem
+ site.ssl_cert = certificate.fullchain_to_pem
+ site.cert_updated_at = Time.now
+ site.save_changes validate: false
+
+ # Refresh the cert periodically, current expire time is 90 days
+ # We're going for a cron task for this now, so this is commented out.
+ #LetsEncryptWorker.perform_in 60.days, site.id
+ end
+
+ def clean_wellknown_turds(site)
+ wellknown_path = File.join(site.base_files_path, '.well-known')
+
+ if File.exist?(wellknown_path)
+ FileUtils.rm_rf wellknown_path
+ end
+ end
+end
diff --git a/workers/purge_cache_order_worker.rb b/workers/purge_cache_order_worker.rb
index 0c079eb4..2f78e516 100644
--- a/workers/purge_cache_order_worker.rb
+++ b/workers/purge_cache_order_worker.rb
@@ -7,13 +7,12 @@ class PurgeCacheOrderWorker
180
end
- RESOLVER = Dnsruby::Resolver.new
-
def perform(username, path)
if ENV['RACK_ENV'] == 'test'
proxy_ips = ['10.0.0.1', '10.0.0.2']
else
- proxy_ips = RESOLVER.query($config['cache_purge_ips_uri']).answer.collect {|a| a.address.to_s}
+ #proxy_ips = Resolv.getaddresses($config['cache_purge_ips_uri'])
+ proxy_ips = Resolv.getaddresses($config['cache_purge_ips_uri']).keep_if {|r| !r.match(/:/)}
end
proxy_ips.each do |proxy_ip|
diff --git a/workers/purge_cache_worker.rb b/workers/purge_cache_worker.rb
index eaf123c9..1ce5a260 100644
--- a/workers/purge_cache_worker.rb
+++ b/workers/purge_cache_worker.rb
@@ -15,14 +15,24 @@ class PurgeCacheWorker
path = '/' + path if path[0] != '/'
url = Addressable::URI.encode_component(
- "http://#{proxy_ip}/:cache/purge#{path}",
+ "http://#{proxy_ip}#{path}",
Addressable::URI::CharacterClasses::QUERY
)
+
+ retry_encoded = false
+
begin
- RestClient::Request.execute method: :get, url: url, timeout: HTTP_TIMEOUT, headers: {
- host: URI::encode("#{username}.neocities.org")
+ RestClient::Request.execute method: :head, url: url, timeout: HTTP_TIMEOUT, headers: {
+ host: URI::encode("#{username}.neocities.org"),
+ cache_purge: '1'
}
+ rescue URI::InvalidURIError
+ raise if retry_encoded == true
+ url = URI.encode url
+ retry_encoded = true
+ retry
rescue RestClient::ResourceNotFound
+ rescue RestClient::Forbidden
end
end
end
diff --git a/workers/screenshot_worker.rb b/workers/screenshot_worker.rb
index 8831a1d4..53e84c10 100644
--- a/workers/screenshot_worker.rb
+++ b/workers/screenshot_worker.rb
@@ -4,32 +4,9 @@ require 'securerandom'
require 'thread'
require 'open3'
-# Don't judge - Ruby handling of timeouts is a joke..
-module Phantomjs
- def self.run(*args, &block)
- pid = nil
- stdin, stdout, stderr, wait_thr = nil
- begin
- Timeout::timeout(50) do
- stdin, stdout, stderr, wait_thr = Open3.popen3(path, *args)
- pid = wait_thr.pid
- wait_thr.join
- return stdout.read
- end
- # :nocov:
- rescue Timeout::Error
- stdin.close
- stdout.close
- stderr.close
- Process.kill 'QUIT', pid
- raise Timeout::Error
- end
- # :nocov:
- end
-end
-
class ScreenshotWorker
SCREENSHOTS_PATH = Site::SCREENSHOTS_ROOT
+ HARD_TIMEOUT = 30.freeze
include Sidekiq::Worker
sidekiq_options queue: :screenshots, retry: 3, backtrace: true
@@ -39,40 +16,31 @@ class ScreenshotWorker
screenshot.close
screenshot_output_path = screenshot.path+'.png'
- begin
- f = Screencap::Fetcher.new("http://#{username}.neocities.org#{path}")
- f.fetch(
- output: screenshot_output_path,
- width: 1280,
- height: 960
- )
- rescue Timeout::Error
- # :nocov:
- puts "#{username}/#{path} is timing out, discontinuing"
- site = Site[username: username]
- site.is_crashing = true
- site.save_changes validate: false
+ line = Cocaine::CommandLine.new(
+ "timeout #{HARD_TIMEOUT} phantomjs #{File.join DIR_ROOT, 'files', 'phantomjs_screenshot.js'}", ":url :output",
+ expected_outcodes: [0]
+ )
- # Don't enable until we know it works well.
-=begin
- if site.email
- EmailWorker.perform_async({
- from: 'web@neocities.org',
- to: site.email,
- subject: "[NeoCities] The web page \"#{path}\" on your site (#{username}.neocities.org) is slow",
- body: "Hi there! This is an automated email to inform you that we're having issues loading your site to take a "+
- "screenshot. It is possible that this is an error specific to our screenshot program, but it is much more "+
- "likely that your site is too slow to be used with browsers. We don't want Neocities sites crashing browsers, "+
- "so we're taking steps to inform you and see if you can resolve the issue. "+
- "We may have to de-list your web site from being viewable in our browse page if it is not resolved shortly. "+
- "We will review the site manually before taking this step, so don't worry if your site is fine and we made "+
- "a mistake."+
- "\n\nOur best,\n- Neocities"
- })
- end
-=end
- return
- # :nocov:
+ begin
+ output = line.run(
+ url: "http://#{username}.neocities.org#{path}",
+ output: screenshot_output_path
+ )
+ rescue Cocaine::ExitStatusError => e
+ raise e
+
+ # We set is_crashing after retries now, but use this code to go back to instant:
+
+ #if e.message && e.message.match(/returned 124/)
+ # puts "#{username}/#{path} is timing out, discontinuing"
+ # site = Site[username: username]
+ # site.is_crashing = true
+ # site.save_changes validate: false
+ # return true
+ #
+ #else
+ # raise
+ #end
end
img_list = Magick::ImageList.new
@@ -97,10 +65,34 @@ class ScreenshotWorker
else
new_img = img.scale width, height
end
-
new_img.write(File.join(user_screenshots_path, "#{path}.#{res}.jpg")) {
self.quality = 90
}
end
end
+
+ sidekiq_retries_exhausted do |msg|
+ username, path = msg['args']
+ site = Site[username: username]
+ site.is_crashing = true
+ site.save_changes validate: false
+
+=begin
+ if site.email
+ EmailWorker.perform_async({
+ from: 'web@neocities.org',
+ to: site.email,
+ subject: "[NeoCities] The web page \"#{path}\" on your site (#{username}.neocities.org) is slow",
+ body: "Hi there! This is an automated email to inform you that we're having issues loading your site to take a "+
+ "screenshot. It is possible that this is an error specific to our screenshot program, but it is much more "+
+ "likely that your site is too slow to be used with browsers. We don't want Neocities sites crashing browsers, "+
+ "so we're taking steps to inform you and see if you can resolve the issue. "+
+ "We may have to de-list your web site from being viewable in our browse page if it is not resolved shortly. "+
+ "We will review the site manually before taking this step, so don't worry if your site is fine and we made "+
+ "a mistake."+
+ "\n\nOur best,\n- Neocities"
+ })
+ end
+=end
+ end
end
diff --git a/workers/thumbnail_worker.rb b/workers/thumbnail_worker.rb
index 181b2183..ad6a3e06 100644
--- a/workers/thumbnail_worker.rb
+++ b/workers/thumbnail_worker.rb
@@ -7,7 +7,13 @@ class ThumbnailWorker
def perform(username, path)
img_list = Magick::ImageList.new
- img_list.from_blob File.read(File.join(Site::SITE_FILES_ROOT, username, path))
+
+ begin
+ img_list.from_blob File.read(File.join(Site::SITE_FILES_ROOT, username, path))
+ rescue Errno::ENOENT => e # Not found, skip
+ return
+ end
+
img = img_list.first
user_thumbnails_path = File.join THUMBNAILS_PATH, username
The Neocities Team
Kyle Drake
Victoria Wang
- Scott O'Hara -
Follow us on Twitter or Facebook
-+ Site Bandwidth Usage
Ban Site
Ban by IP
- -Upgrade to Supporter
Feature Site
-- <%= (Site::PLAN_FEATURES[:supporter][:space] / (10**6)).to_comma_separated %> MB storage
- - <%= Site::PLAN_FEATURES[:supporter][:bandwidth].to_bytes_pretty %> bandwidth
+ - <%= (Site::PLAN_FEATURES[:supporter][:space] / (10**6)).to_comma_separated %> MB storage
+ - <%= Site::PLAN_FEATURES[:supporter][:bandwidth].to_bytes_pretty %> bandwidth
-- No File Upload Type Restrictions
+ - No File Upload Type Restrictions
- Unlimited Site Creation
- - Full Domain Support
+ - Custom Domain Support
- Remote Filesystem Support