Merge branch 'master' into patch-2

This commit is contained in:
Kyle Drake 2025-04-22 21:59:57 -05:00 committed by GitHub
commit 739a797a2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1286 changed files with 713001 additions and 5263 deletions

46
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-22.04
services:
postgres:
image: postgres
env:
POSTGRES_DB: ci_test
POSTGRES_PASSWORD: citestpassword
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
redis:
image: redis
# Set health checks to wait until redis has started
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps port 6379 on service container to the host
- 6379:6379
steps:
- uses: actions/checkout@v2
- run: sudo apt-get update && sudo apt-get -y install libimlib2-dev chromium-browser
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install dependencies
run: bundle install
- name: Run tests with Coveralls
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
run: bundle exec rake

1
.gitignore vendored
View file

@ -20,6 +20,7 @@ files/sslsites.zip
.vagrant .vagrant
public/banned_sites public/banned_sites
public/deleted_sites public/deleted_sites
files/disposable_email_whitelist.conf
files/disposable_email_blacklist.conf files/disposable_email_blacklist.conf
files/banned_email_blacklist.conf files/banned_email_blacklist.conf
files/letsencrypt.key files/letsencrypt.key

View file

@ -1,11 +0,0 @@
language: ruby
rvm:
- "2.6.0"
services:
- redis-server
- postgresql
before_script:
- psql -c 'create database travis_ci_test;' -U postgres
sudo: false
bundler_args: --jobs=1
before_install: gem install bundler

59
Gemfile
View file

@ -1,34 +1,31 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gem 'sinatra', '2.0.5' gem 'sinatra'
gem 'redis' gem 'redis'
gem 'redis-namespace'
gem 'sequel' gem 'sequel'
gem 'redis-namespace'
gem 'bcrypt' gem 'bcrypt'
gem 'sinatra-flash', require: 'sinatra/flash' gem 'sinatra-flash', require: 'sinatra/flash'
gem 'sinatra-xsendfile', require: 'sinatra/xsendfile' gem 'sinatra-xsendfile', require: 'sinatra/xsendfile'
gem 'puma', require: nil gem 'puma', '< 7', require: nil
gem 'rmagick', require: nil gem 'sidekiq', '~> 7'
gem 'sidekiq', '~> 4.2.10'
gem 'mail' gem 'mail'
gem 'tilt' gem 'tilt'
gem 'erubis' gem 'erubi'
gem 'stripe', '~> 5.17.0' #, source: 'https://code.stripe.com/' gem 'stripe' #, source: 'https://code.stripe.com/'
gem 'terrapin' gem 'terrapin'
gem 'zipruby'
gem 'sass', require: nil gem 'sass', require: nil
gem 'dav4rack', git: 'https://github.com/neocities/dav4rack.git', ref: '3ecde122a0b8bcc1d85581dc85ef3a7120b6a8f0' gem 'dav4rack', git: 'https://github.com/neocities/dav4rack.git', ref: '1bf1975c613d4f14d00f1e70ce7e0bb9e2e6cd9b'
gem 'filesize' gem 'filesize'
gem 'thread' gem 'thread'
gem 'rack-cache' gem 'rack-cache'
gem 'rest-client', require: 'rest_client' gem 'rest-client', require: 'rest_client'
gem 'addressable', require: 'addressable/uri' gem 'addressable', '>= 2.8.0', require: 'addressable/uri'
gem 'paypal-recurring', require: 'paypal/recurring' gem 'paypal-recurring', require: 'paypal/recurring'
gem 'geoip' gem 'geoip'
gem 'io-extra', require: 'io/extra' gem 'io-extra', require: 'io/extra'
gem 'rye' #gem 'rye'
gem 'base32' gem 'coveralls_reborn', require: false
gem 'coveralls', require: false
gem 'sanitize' gem 'sanitize'
gem 'will_paginate' gem 'will_paginate'
gem 'simpleidn' gem 'simpleidn'
@ -53,10 +50,22 @@ gem 'activesupport'
gem 'facter', require: nil gem 'facter', require: nil
gem 'maxmind-db' gem 'maxmind-db'
gem 'json', '>= 2.3.0' gem 'json', '>= 2.3.0'
gem 'nokogiri'
gem 'webp-ffi'
gem 'rszr'
gem 'zip_tricks'
gem 'adequate_crypto_address'
gem 'twilio-ruby'
gem 'phonelib'
gem 'dnsbl-client'
gem 'minfraud'
gem 'image_optimizer' # apt install optipng jpegoptim pngquant
gem 'rubyzip', require: 'zip'
gem 'airbrake'
gem 'csv'
group :development, :test do group :development, :test do
gem 'pry' gem 'pry'
gem 'pry-byebug'
end end
group :development do group :development do
@ -66,19 +75,19 @@ end
group :test do group :test do
gem 'faker' gem 'faker'
gem 'fabrication', require: 'fabrication' gem 'fabrication', require: 'fabrication'
gem 'minitest' gem 'minitest'
gem 'minitest-reporters', require: 'minitest/reporters' gem 'minitest-reporters', require: 'minitest/reporters'
gem 'rack-test', require: 'rack/test' gem 'rack-test', require: 'rack/test'
gem 'mocha', require: nil gem 'mocha', require: nil
gem 'rake', '>= 12.3.3', require: nil gem 'rake', '>= 12.3.3', require: nil
gem 'capybara', require: nil #, '2.10.1', require: nil gem 'capybara', require: nil #, '2.10.1', require: nil
gem 'rack_session_access', require: nil gem 'selenium-webdriver'
gem 'webmock', '3.5.1', require: nil gem 'rack_session_access', require: nil
gem 'stripe-ruby-mock', '2.5.8', require: 'stripe_mock' gem 'webmock', require: nil
gem 'stripe-ruby-mock', '~> 3.1.0.rc3', require: 'stripe_mock'
gem 'timecop' gem 'timecop'
gem 'mock_redis' gem 'mock_redis'
gem 'simplecov', require: nil gem 'simplecov', require: nil
gem 'm' gem 'm'
gem 'apparition'
end end

View file

@ -1,306 +1,423 @@
GIT GIT
remote: https://github.com/neocities/dav4rack.git remote: https://github.com/neocities/dav4rack.git
revision: 3ecde122a0b8bcc1d85581dc85ef3a7120b6a8f0 revision: 1bf1975c613d4f14d00f1e70ce7e0bb9e2e6cd9b
ref: 3ecde122a0b8bcc1d85581dc85ef3a7120b6a8f0 ref: 1bf1975c613d4f14d00f1e70ce7e0bb9e2e6cd9b
specs: specs:
dav4rack (1.1.0) dav4rack (0.3.0)
addressable (>= 2.5.0) nokogiri (>= 1.4.2)
nokogiri (>= 1.6.0) rack (~> 3.0)
ox (>= 2.1.0)
rack (>= 1.6)
uuidtools (~> 2.1.1) uuidtools (~> 2.1.1)
webrick
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
acme-client (2.0.6) acme-client (2.0.19)
faraday (>= 0.17, < 2.0.0) base64 (~> 0.2.0)
activesupport (6.0.3.1) faraday (>= 1.0, < 3.0.0)
concurrent-ruby (~> 1.0, >= 1.0.2) faraday-retry (>= 1.0, < 3.0.0)
i18n (>= 0.7, < 2) activesupport (8.0.1)
minitest (~> 5.1) base64
tzinfo (~> 1.1) benchmark (>= 0.3)
zeitwerk (~> 2.2, >= 2.2.2) bigdecimal
addressable (2.7.0) concurrent-ruby (~> 1.0, >= 1.3.1)
public_suffix (>= 2.0.2, < 5.0) connection_pool (>= 2.2.5)
annoy (0.5.6) drb
highline (>= 1.5.0) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
adequate_crypto_address (0.1.9)
base58 (~> 0.2)
keccak (~> 1.3)
airbrake (13.0.5)
airbrake-ruby (~> 6.0)
airbrake-ruby (6.2.2)
rbtree3 (~> 0.6)
ansi (1.5.0) ansi (1.5.0)
apparition (0.5.0) base58 (0.2.3)
capybara (~> 3.13, < 4) base64 (0.2.0)
websocket-driver (>= 0.6.5) bcrypt (3.1.20)
base32 (0.3.2) benchmark (0.4.0)
bcrypt (3.1.13) bigdecimal (3.1.9)
builder (3.2.3) builder (3.3.0)
byebug (11.0.1) capybara (3.40.0)
capybara (3.32.2)
addressable addressable
matrix
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.11)
rack (>= 1.6.0) rack (>= 1.6.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (~> 1.5) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
certified (1.0.0) certified (1.0.0)
climate_control (0.2.0) climate_control (1.2.0)
coderay (1.1.2) coderay (1.1.3)
concurrent-ruby (1.1.6) concurrent-ruby (1.3.5)
connection_pool (2.2.2) connection_pool (2.5.0)
coveralls (0.8.23) coveralls_reborn (0.28.0)
json (>= 1.8, < 3) simplecov (~> 0.22.0)
simplecov (~> 0.16.1) term-ansicolor (~> 1.7)
term-ansicolor (~> 1.3) thor (~> 1.2)
thor (>= 0.19.4, < 2.0) tins (~> 1.32)
tins (~> 1.6) crack (1.0.0)
crack (0.4.3) bigdecimal
safe_yaml (~> 1.0.0) rexml
crass (1.0.6) crass (1.0.6)
csv (3.3.2)
dante (0.2.0) dante (0.2.0)
docile (1.3.2) date (3.4.1)
domain_name (0.5.20190701) dnsbl-client (1.1.1)
unf (>= 0.0.5, < 1.0.0) docile (1.4.1)
drydock (0.6.9) domain_name (0.6.20240107)
erubis (2.7.0) drb (2.2.1)
exifr (1.3.6) erubi (1.13.1)
fabrication (2.20.2) exifr (1.4.1)
facter (2.5.6) fabrication (2.31.0)
faker (2.4.0) facter (4.10.0)
i18n (~> 1.6.0) hocon (~> 1.3)
faraday (0.17.3) thor (>= 1.0.1, < 1.3)
multipart-post (>= 1.2, < 3) faker (3.5.1)
faraday_middleware (0.14.0) i18n (>= 1.8.11, < 2)
faraday (>= 0.7.4, < 1.0) faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
feedjira (2.1.4) feedjira (2.1.4)
faraday (>= 0.9) faraday (>= 0.9)
faraday_middleware (>= 0.9) faraday_middleware (>= 0.9)
loofah (>= 2.0) loofah (>= 2.0)
sax-machine (>= 1.0) sax-machine (>= 1.0)
ffi (1.11.1) ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
ffi (1.17.1-arm-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
filesize (0.2.0) filesize (0.2.0)
fspath (3.1.2) fspath (3.1.2)
gandi (3.3.28) gandi (3.3.28)
hashie hashie
xmlrpc xmlrpc
geoip (1.6.4) geoip (1.6.4)
hashdiff (1.0.0) hashdiff (1.1.2)
hashie (3.6.0) hashie (5.0.0)
highline (2.0.2)
hiredis (0.6.3) hiredis (0.6.3)
hoe (3.22.1) hocon (1.4.0)
hoe (4.2.2)
rake (>= 0.8, < 15.0) rake (>= 0.8, < 15.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (4.1.1) http (5.2.0)
addressable (~> 2.3) addressable (~> 2.8)
base64 (~> 0.1)
http-cookie (~> 1.0) http-cookie (~> 1.0)
http-form_data (~> 2.0) http-form_data (~> 2.2)
http_parser.rb (~> 0.6.0) llhttp-ffi (~> 0.5.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.3) http-cookie (1.0.8)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.1.1) http-form_data (2.3.0)
http_parser.rb (0.6.0) i18n (1.14.7)
i18n (1.6.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_optim (0.26.5) image_optim (0.31.4)
exifr (~> 1.2, >= 1.2.2) exifr (~> 1.2, >= 1.2.2)
fspath (~> 3.0) fspath (~> 3.0)
image_size (>= 1.5, < 3) image_size (>= 1.5, < 4)
in_threads (~> 1.3) in_threads (~> 1.3)
progress (~> 3.0, >= 3.0.1) progress (~> 3.0, >= 3.0.1)
image_optim_pack (0.6.0) image_optim_pack (0.11.2)
fspath (>= 2.1, < 4) fspath (>= 2.1, < 4)
image_optim (~> 0.19) image_optim (~> 0.19)
image_optim_pack (0.6.0-x86_64-linux) image_optim_pack (0.11.2-x86_64-darwin)
fspath (>= 2.1, < 4) fspath (>= 2.1, < 4)
image_optim (~> 0.19) image_optim (~> 0.19)
image_size (2.0.2) image_optim_pack (0.11.2-x86_64-linux)
in_threads (1.5.3) fspath (>= 2.1, < 4)
io-extra (1.3.0) image_optim (~> 0.19)
image_optimizer (1.9.0)
image_size (3.4.0)
in_threads (1.6.0)
io-extra (1.4.0)
ipaddress (0.8.3) ipaddress (0.8.3)
json (2.3.1) json (2.9.1)
loofah (2.5.0) jwt (2.10.1)
base64
keccak (1.3.2)
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.5)
loofah (2.24.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.12.0)
m (1.5.1) m (1.6.2)
method_source (>= 0.6.7) method_source (>= 0.6.7)
rake (>= 0.9.2.2) rake (>= 0.9.2.2)
magic (0.2.9) magic (0.2.9)
ffi (>= 0.6.3) ffi (>= 0.6.3)
mail (2.7.1) mail (2.8.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
maxmind-db (1.0.0) net-imap
metaclass (0.0.4) net-pop
method_source (0.9.2) net-smtp
mime-types (3.3) matrix (0.4.2)
maxmind-db (1.2.0)
maxmind-geoip2 (1.2.0)
connection_pool (~> 2.2)
http (>= 4.3, < 6.0)
maxmind-db (~> 1.2)
method_source (1.1.0)
mime-types (3.6.0)
logger
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2019.0904) mime-types-data (3.2025.0107)
mini_mime (1.0.2) minfraud (2.6.0)
mini_portile2 (2.4.0) connection_pool (~> 2.2)
minitest (5.11.3) http (>= 4.3, < 6.0)
minitest-reporters (1.3.8) maxmind-geoip2 (~> 1.2)
simpleidn (~> 0.1, >= 0.1.1)
mini_mime (1.1.5)
minitest (5.25.4)
minitest-reporters (1.7.1)
ansi ansi
builder builder
minitest (>= 5.0) minitest (>= 5.0)
ruby-progressbar ruby-progressbar
mocha (1.9.0) mocha (2.7.1)
metaclass (~> 0.0.1) ruby2_keywords (>= 0.0.5)
mock_redis (0.21.0) mock_redis (0.49.0)
monetize (1.9.2) redis (~> 5)
monetize (1.13.0)
money (~> 6.12) money (~> 6.12)
money (6.13.4) money (6.19.0)
i18n (>= 0.6.4, <= 2) i18n (>= 0.6.4, <= 2)
msgpack (1.3.1) msgpack (1.7.5)
multi_json (1.13.1) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.4.1)
mustermann (1.0.3) mustermann (3.0.3)
net-scp (2.0.0) ruby2_keywords (~> 0.0.1)
net-ssh (>= 2.6.5, < 6.0.0) net-imap (0.5.6)
net-ssh (5.2.0) date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
netrc (0.11.0) netrc (0.11.0)
nio4r (2.5.2) nio4r (2.7.4)
nokogiri (1.10.9) nokogiri (1.18.8-aarch64-linux-gnu)
mini_portile2 (~> 2.4.0) racc (~> 1.4)
nokogumbo (2.0.2) nokogiri (1.18.8-aarch64-linux-musl)
nokogiri (~> 1.8, >= 1.8.4) racc (~> 1.4)
ox (2.11.0) nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
paypal-recurring (1.1.0) paypal-recurring (1.1.0)
pg (1.1.4) pg (1.5.9)
progress (3.5.2) phonelib (0.10.3)
pry (0.12.2) progress (3.6.0)
coderay (~> 1.1.0) pry (0.15.2)
method_source (~> 0.9.0) coderay (~> 1.1)
pry-byebug (3.7.0) method_source (~> 1.0)
byebug (~> 11.0) public_suffix (6.0.1)
pry (~> 0.10) puma (6.6.0)
public_suffix (4.0.5)
puma (4.3.5)
nio4r (~> 2.0) nio4r (~> 2.0)
rack (2.2.3) racc (1.8.1)
rack-cache (1.9.0) rack (3.1.12)
rack-cache (1.17.0)
rack (>= 0.4) rack (>= 0.4)
rack-protection (2.0.5) rack-protection (4.1.1)
rack base64 (>= 0.1.0)
rack-test (1.1.0) logger (>= 1.6.0)
rack (>= 1.0, < 3) rack (>= 3.0.0, < 4)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rack_session_access (0.2.0) rack_session_access (0.2.0)
builder (>= 2.0.0) builder (>= 2.0.0)
rack (>= 1.0.0) rack (>= 1.0.0)
rake (13.0.1) rake (13.2.1)
rb-fsevent (0.10.3) rb-fsevent (0.11.2)
rb-inotify (0.10.0) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
redis (3.3.5) rbtree3 (0.7.1)
redis-namespace (1.6.0) redis (5.3.0)
redis (>= 3.0.4) redis-client (>= 0.22.0)
regexp_parser (1.7.1) redis-client (0.23.2)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.10.0)
rest-client (2.1.0) rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0) http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0) http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0) mime-types (>= 1.16, < 4.0)
netrc (~> 0.8) netrc (~> 0.8)
rexml (3.4.1)
rinku (2.0.6) rinku (2.0.6)
rmagick (4.1.2) rszr (1.5.0)
ruby-progressbar (1.10.1) ruby-progressbar (1.13.0)
rye (0.9.13) ruby2_keywords (0.0.5)
annoy rubyzip (2.4.1)
docile (>= 1.0.1) sanitize (7.0.0)
highline (>= 1.5.1)
net-scp (>= 1.0.2)
net-ssh (>= 2.0.13)
sysinfo (>= 0.8.1)
safe_yaml (1.0.5)
sanitize (5.2.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.8.0) nokogiri (>= 1.16.8)
nokogumbo (~> 2.0)
sass (3.7.4) sass (3.7.4)
sass-listen (~> 4.0.0) sass-listen (~> 4.0.0)
sass-listen (4.0.0) sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4) rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7) rb-inotify (~> 0.9, >= 0.9.7)
sax-machine (1.3.2) sax-machine (1.3.2)
sequel (5.24.0) securerandom (0.4.1)
sequel_pg (1.12.2) selenium-webdriver (4.28.0)
pg (>= 0.18.0) base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sequel (5.89.0)
bigdecimal
sequel_pg (1.17.1)
pg (>= 0.18.0, != 1.2.0)
sequel (>= 4.38.0) sequel (>= 4.38.0)
shotgun (0.9.2) shotgun (0.9.2)
rack (>= 1.0) rack (>= 1.0)
sidekiq (4.2.10) sidekiq (7.3.8)
concurrent-ruby (~> 1.0) base64
connection_pool (~> 2.2, >= 2.2.0) connection_pool (>= 2.3.0)
rack-protection (>= 1.5.0) logger
redis (~> 3.2, >= 3.2.1) rack (>= 2.2.4)
simplecov (0.16.1) redis-client (>= 0.22.2)
simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
json (>= 1.8, < 3) simplecov-html (~> 0.11)
simplecov-html (~> 0.10.0) simplecov_json_formatter (~> 0.1)
simplecov-html (0.10.2) simplecov-html (0.13.1)
simpleidn (0.1.1) simplecov_json_formatter (0.1.4)
unf (~> 0.1.4) simpleidn (0.2.3)
sinatra (2.0.5) sinatra (4.1.1)
mustermann (~> 1.0) logger (>= 1.6.0)
rack (~> 2.0) mustermann (~> 3.0)
rack-protection (= 2.0.5) rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0) tilt (~> 2.0)
sinatra-flash (0.3.0) sinatra-flash (0.3.0)
sinatra (>= 1.0.0) sinatra (>= 1.0.0)
sinatra-xsendfile (0.4.2) sinatra-xsendfile (0.4.2)
sinatra (>= 0.9.1) sinatra (>= 0.9.1)
storable (0.8.9) stripe (5.55.0)
stripe (5.17.0) stripe-ruby-mock (3.1.0)
stripe-ruby-mock (2.5.8)
dante (>= 0.2.0) dante (>= 0.2.0)
multi_json (~> 1.0) multi_json (~> 1.0)
stripe (>= 2.0.3) stripe (> 5, < 6)
sysinfo (0.8.1) sync (0.5.0)
drydock term-ansicolor (1.11.2)
storable
term-ansicolor (1.7.1)
tins (~> 1.0) tins (~> 1.0)
terrapin (0.6.0) terrapin (1.0.1)
climate_control (>= 0.0.3, < 1.0) climate_control
thor (0.20.3) thor (1.2.2)
thread (0.2.2) thread (0.2.2)
thread_safe (0.3.6) tilt (2.6.0)
tilt (2.0.9) timecop (0.9.10)
timecop (0.9.1) timeout (0.4.3)
tins (1.21.1) tins (1.38.0)
tzinfo (1.2.7) bigdecimal
thread_safe (~> 0.1) sync
unf (0.1.4) twilio-ruby (7.4.3)
unf_ext benchmark
unf_ext (0.0.7.6) faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
ostruct
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.0.3)
uuidtools (2.1.5) uuidtools (2.1.5)
webmock (3.5.1) webmock (3.25.0)
addressable (>= 2.3.6) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.2) webp-ffi (0.4.0)
websocket-extensions (>= 0.1.0) ffi (>= 1.9.0)
websocket-extensions (0.1.5) ffi-compiler (>= 0.1.2)
will_paginate (3.1.8) webrick (1.9.1)
xmlrpc (0.3.0) websocket (1.2.11)
will_paginate (4.0.1)
xmlrpc (0.3.3)
webrick
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.3.0) zip_tricks (5.6.0)
zipruby (0.3.6)
PLATFORMS PLATFORMS
ruby aarch64-linux-gnu
x86_64-linux aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
acme-client (~> 2.0.0) acme-client (~> 2.0.0)
activesupport activesupport
addressable addressable (>= 2.8.0)
apparition adequate_crypto_address
base32 airbrake
bcrypt bcrypt
capybara capybara
certified certified
coveralls coveralls_reborn
csv
dav4rack! dav4rack!
erubis dnsbl-client
erubi
fabrication fabrication
facter facter
faker faker
@ -314,6 +431,7 @@ DEPENDENCIES
http http
image_optim image_optim
image_optim_pack image_optim_pack
image_optimizer
io-extra io-extra
ipaddress ipaddress
json (>= 2.3.0) json (>= 2.3.0)
@ -321,17 +439,19 @@ DEPENDENCIES
magic magic
mail mail
maxmind-db maxmind-db
minfraud
minitest minitest
minitest-reporters minitest-reporters
mocha mocha
mock_redis mock_redis
monetize monetize
msgpack msgpack
nokogiri
paypal-recurring paypal-recurring
pg pg
phonelib
pry pry
pry-byebug puma (< 7)
puma
rack-cache rack-cache
rack-test rack-test
rack_session_access rack_session_access
@ -340,29 +460,32 @@ DEPENDENCIES
redis-namespace redis-namespace
rest-client rest-client
rinku rinku
rmagick rszr
rye rubyzip
sanitize sanitize
sass sass
selenium-webdriver
sequel sequel
sequel_pg sequel_pg
shotgun shotgun
sidekiq (~> 4.2.10) sidekiq (~> 7)
simplecov simplecov
simpleidn simpleidn
sinatra (= 2.0.5) sinatra
sinatra-flash sinatra-flash
sinatra-xsendfile sinatra-xsendfile
stripe (~> 5.17.0) stripe
stripe-ruby-mock (= 2.5.8) stripe-ruby-mock (~> 3.1.0.rc3)
terrapin terrapin
thread thread
tilt tilt
timecop timecop
webmock (= 3.5.1) twilio-ruby
webmock
webp-ffi
will_paginate will_paginate
xmlrpc xmlrpc
zipruby zip_tricks
BUNDLED WITH BUNDLED WITH
2.1.4 2.6.3

View file

@ -2,7 +2,7 @@
# Neocities.org # Neocities.org
[![Build Status](https://travis-ci.org/neocities/neocities.png?branch=master)](https://travis-ci.org/neocities/neocities) [![Build Status](https://github.com/neocities/neocities/actions/workflows/ci.yml/badge.svg)](https://github.com/neocities/neocities/actions?query=workflow%3ACI)
[![Coverage Status](https://coveralls.io/repos/neocities/neocities/badge.svg?branch=master&service=github)](https://coveralls.io/github/neocities/neocities?branch=master) [![Coverage Status](https://coveralls.io/repos/neocities/neocities/badge.svg?branch=master&service=github)](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! The web site for Neocities! It's open source. Want a feature on the site? Send a pull request!
@ -17,6 +17,8 @@ vagrant up --provision
![Vagrant takes a while, make a pizza while waiting](https://i.imgur.com/dKa8LUs.png) ![Vagrant takes a while, make a pizza while waiting](https://i.imgur.com/dKa8LUs.png)
Make a copy of `config.yml.template` in the root directory, and rename it to `config.yml`. Then:
``` ```
vagrant ssh vagrant ssh
bundle exec rackup -o 0.0.0.0 bundle exec rackup -o 0.0.0.0
@ -28,7 +30,7 @@ Now you can access the running site from your browser: http://127.0.0.1:9292
If you'd like to fix a bug, or make an improvement, or add a new feature, it's easy! Just send us a Pull Request. If you'd like to fix a bug, or make an improvement, or add a new feature, it's easy! Just send us a Pull Request.
1. Fork it (<http://github.com/YOURUSERNAME/neocities/fork>) 1. Fork it (https://github.com/neocities/neocities/fork)
2. Create your feature branch (`git checkout -b my-new-feature`) 2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`) 3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`) 4. Push to the branch (`git push origin my-new-feature`)

444
Rakefile
View file

@ -14,226 +14,57 @@ end
task :default => :test task :default => :test
=begin desc "prune logs"
desc "send domain update email" task :prune_logs => [:environment] do
task :send_domain_update_email => [:environment] do
Site.exclude(domain: nil).exclude(domain: '').all.each do |site|
msg = <<-HERE
MESSAGE GOES HERE TEST
HERE
site.send_email(
subject: 'SUBJECT GOES HERE',
body: msg
)
end
end
=end
desc "parse logs"
task :parse_logs => [:environment] do
Stat.prune! Stat.prune!
StatLocation.prune! StatLocation.prune!
StatReferrer.prune! StatReferrer.prune!
StatPath.prune! StatPath.prune!
end
desc "parse logs"
task :parse_logs => [:environment] do
Stat.parse_logfiles $config['logs_path'] Stat.parse_logfiles $config['logs_path']
end end
desc 'Update disposable email blacklist' desc 'Update disposable email blacklist'
task :update_disposable_email_blacklist => [:environment] do task :update_disposable_email_blacklist => [:environment] do
uri = URI.parse('https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf') # Formerly: https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf
uri = URI.parse('https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt')
File.write(Site::DISPOSABLE_EMAIL_BLACKLIST_PATH, Net::HTTP.get(uri)) File.write(Site::DISPOSABLE_EMAIL_BLACKLIST_PATH, HTTP.get(uri))
end end
desc 'Update banned IPs list' desc 'Update banned IPs list'
task :update_blocked_ips => [:environment] do task :update_blocked_ips => [:environment] do
IO.copy_stream( filename = 'listed_ip_365_ipv46'
open('https://www.stopforumspam.com/downloads/listed_ip_90.zip'), zip_path = "/tmp/#{filename}.zip"
'/tmp/listed_ip_90.zip'
)
Zip::Archive.open('/tmp/listed_ip_90.zip') do |ar| File.open(zip_path, 'wb') do |file|
ar.fopen('listed_ip_90.txt') do |f| response = HTTP.get "https://www.stopforumspam.com/downloads/#{filename}.zip"
ips = f.read response.body.each do |chunk|
insert_hashes = [] file.write chunk
ips.each_line {|ip| insert_hashes << {ip: ip.strip, created_at: Time.now}}
ips = nil
DB.transaction do
DB[:blocked_ips].delete
DB[:blocked_ips].multi_insert insert_hashes
end
end
end
end
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
end end
File.open('./files/maps/supporters.txt', 'w') do |file| Zip::File.open(zip_path) do |zip_file|
Site.select(:username, :domain).exclude(plan_type: 'free').exclude(plan_type: nil).all.each do |parent_site| zip_file.each do |entry|
sites = [parent_site] + parent_site.children if entry.name == "#{filename}.txt"
sites.each do |site| ips = entry.get_input_stream.read
file.write "#{site.username}.neocities.org 1;\n" insert_hashes = []
unless site.host.match(/\.neocities\.org$/) ips.each_line { |ip| insert_hashes << { ip: ip.strip, created_at: Time.now } }
file.write ".#{site.values[:domain]} 1;\n" ips = nil
# Database transaction
DB.transaction do
DB[:blocked_ips].delete
DB[:blocked_ips].multi_insert insert_hashes
end end
end end
end end
end end
File.open('./files/maps/subdomain-to-domain.txt', 'w') do |file| FileUtils.rm zip_path
Site.select(:username, :domain).exclude(domain: nil).exclude(domain: '').all.each do |site|
file.write "#{site.username}.neocities.org #{site.values[:domain]};\n"
end
end
File.open('./files/maps/sandboxed.txt', 'w') do |file|
usernames = DB["select username from sites where created_at > ? and parent_site_id is null and (plan_type is null or plan_type='free') and is_banned != 't' and is_deleted != 't'", 2.days.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'
task :buildssl => [:environment] do
sites = Site.select(:id, :username, :domain, :ssl_key, :ssl_cert).
exclude(domain: nil).
exclude(ssl_key: nil).
exclude(ssl_cert: nil).
all
payload = []
begin
FileUtils.rm './files/sslsites.zip'
rescue Errno::ENOENT
end
Zip::Archive.open('./files/sslsites.zip', Zip::CREATE) do |ar|
ar.add_dir 'ssl'
sites.each do |site|
ar.add_buffer "ssl/#{site.username}.key", site.ssl_key
ar.add_buffer "ssl/#{site.username}.crt", site.ssl_cert
payload << {username: site.username, domain: site.domain}
end
ar.add_buffer 'sslsites.json', payload.to_json
end
end
desc 'Set existing stripe customers to internal supporter plan'
task :primenewstriperunonlyonce => [:environment] do
# Site.exclude(stripe_customer_id: nil).all.each do |site|
# site.plan_type = 'supporter'
# site.save_changes validate: false
# end
Site.exclude(stripe_customer_id: nil).where(plan_type: nil).where(plan_ended: false).all.each do |s|
customer = Stripe::Customer.retrieve(s.stripe_customer_id)
subscription = customer.subscriptions.first
next if subscription.nil?
puts "set subscription id to #{subscription.id}"
puts "set plan type to #{subscription.plan.id}"
s.stripe_subscription_id = subscription.id
s.plan_type = subscription.plan.id
s.save_changes(validate: false)
end
end
desc 'dedupe tags'
task :dedupetags => [:environment] do
Tag.all.each do |tag|
begin
tag.reload
rescue Sequel::Error => e
next if e.message =~ /Record not found/
end
matching_tags = Tag.exclude(id: tag.id).where(name: tag.name).all
matching_tags.each do |matching_tag|
DB[:sites_tags].where(tag_id: matching_tag.id).update(tag_id: tag.id)
matching_tag.delete
end
end
end
desc 'Clean tags'
task :cleantags => [:environment] do
Site.select(:id).all.each do |site|
if site.tags.length > 5
site.tags.slice(5, site.tags.length).each {|tag| site.remove_tag tag}
end
end
empty_tag = Tag.where(name: '').first
if empty_tag
DB[:sites_tags].where(tag_id: empty_tag.id).delete
end
Tag.all.each do |tag|
if tag.name.length > Tag::NAME_LENGTH_MAX || tag.name.match(/ /)
DB[:sites_tags].where(tag_id: tag.id).delete
DB[:tags].where(id: tag.id).delete
else
tag.update name: tag.name.downcase.strip
end
end
Tag.where(name: 'porn').first.update is_nsfw: true
end
desc 'update screenshots'
task :update_screenshots => [:environment] do
Site.select(:username).where(site_changed: true, is_banned: false, is_crashing: false).filter(~{updated_at: nil}).order(:updated_at.desc).all.each do |site|
ScreenshotWorker.perform_async site.username, 'index.html'
end
end end
desc 'rebuild_thumbnails' desc 'rebuild_thumbnails'
@ -256,142 +87,11 @@ task :rebuild_thumbnails => [:environment] do
end end
end end
desc 'prime_space_used'
task :prime_space_used => [:environment] do
Site.select(:id,:username,:space_used).all.each do |s|
s.space_used = s.actual_space_used
s.save_changes validate: false
end
end
desc 'prime site_updated_at'
task :prime_site_updated_at => [:environment] do
Site.select(:id,:username,:site_updated_at, :updated_at).all.each do |s|
s.site_updated_at = s.updated_at
s.save_changes validate: false
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' desc 'compute_scores'
task :compute_scores => [:environment] do task :compute_scores => [:environment] do
Site.compute_scores Site.compute_scores
end end
=begin
desc 'Update screenshots'
task :update_screenshots => [:environment] do
Site.select(:username).filter(is_banned: false).filter(~{updated_at: nil}).order(:updated_at.desc).all.collect {|s|
ScreenshotWorker.perform_async s.username
}
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' desc 'renew_ssl_certs'
task :renew_ssl_certs => [:environment] do task :renew_ssl_certs => [:environment] do
delay = 0 delay = 0
@ -408,24 +108,6 @@ task :purge_tmp_turds => [:environment] do
end end
end end
desc 'shard_migration'
task :shard_migration => [:environment] do
#Site.exclude(is_deleted: true).exclude(is_banned: true).select(:username).each do |site|
# FileUtils.mkdir_p File.join('public', 'testsites', site.username)
#end
#exit
Dir.chdir('./public/testsites')
Dir.glob('*').each do |dir|
sharding_dir = Site.sharding_dir(dir)
FileUtils.mkdir_p File.join('..', 'newtestsites', sharding_dir)
FileUtils.mv dir, File.join('..', 'newtestsites', sharding_dir)
end
sleep 1
FileUtils.rmdir './public/testsites'
sleep 1
FileUtils.mv './public/newtestsites', './public/testsites'
end
desc 'compute_follow_count_scores' desc 'compute_follow_count_scores'
task :compute_follow_count_scores => [:environment] do task :compute_follow_count_scores => [:environment] do
@ -439,41 +121,10 @@ task :compute_follow_count_scores => [:environment] do
end end
end end
desc 'prime_redis_proxy_ssl'
task :prime_redis_proxy_ssl => [:environment] do
site_ids = DB[%{
select id from sites where domain is not null and ssl_cert is not null and ssl_key is not null
and is_deleted != ? and is_banned != ?
}, true, true].all.collect {|site_id| site_id[:id]}
site_ids.each do |site_id|
Site[site_id].store_ssl_in_redis_proxy
end
end
desc 'dedupe_site_blocks'
task :dedupe_site_blocks => [:environment] do
duped_blocks = []
block_ids = Block.select(:id).all.collect {|b| b.id}
block_ids.each do |block_id|
next unless duped_blocks.select {|db| db.id == block_id}.empty?
block = Block[block_id]
if block
blocks = Block.exclude(id: block.id).where(site_id: block.site_id).where(actioning_site_id: block.actioning_site_id).all
duped_blocks << blocks
duped_blocks.flatten!
end
end
duped_blocks.each do |duped_block|
duped_block.destroy
end
end
desc 'ml_screenshots_list_dump' desc 'ml_screenshots_list_dump'
task :ml_screenshots_list_dump => [:environment] do task :ml_screenshots_list_dump => [:environment] do
['phishing', 'spam', 'ham', nil].each do |classifier| ['phishing', 'spam', 'ham', nil].each do |classifier|
File.open("./files/screenshot-urls-#{classifier.to_s}.txt", 'w') do |fp| File.open("./files/screenshot-urls#{classifier.nil? ? '' : '-'+classifier.to_s}.txt", 'w') do |fp|
SiteFile.where(classifier: classifier).where(path: 'index.html').each do |site_file| SiteFile.where(classifier: classifier).where(path: 'index.html').each do |site_file|
begin begin
fp.write "#{site_file.site.screenshot_url('index.html', Site::SCREENSHOT_RESOLUTIONS.first)}\n" fp.write "#{site_file.site.screenshot_url('index.html', Site::SCREENSHOT_RESOLUTIONS.first)}\n"
@ -488,11 +139,13 @@ desc 'generate_sitemap'
task :generate_sitemap => [:environment] do task :generate_sitemap => [:environment] do
sorted_sites = {} sorted_sites = {}
# We pop off array, so highest scores go last.
sites = Site. sites = Site.
select(:id, :username, :updated_at, :profile_enabled). select(:id, :username, :updated_at, :profile_enabled).
where(site_changed: true). where(site_changed: true).
exclude(updated_at: nil). exclude(updated_at: nil).
order(:follow_count, :updated_at). exclude(is_deleted: true).
order(:score).
all all
site_files = [] site_files = []
@ -500,13 +153,13 @@ task :generate_sitemap => [:environment] do
sites.each do |site| sites.each do |site|
site.site_files_dataset.exclude(path: 'not_found.html').where(path: /\.html?$/).all.each do |site_file| site.site_files_dataset.exclude(path: 'not_found.html').where(path: /\.html?$/).all.each do |site_file|
if site.file_uri(site_file.path) == site.uri+'/' if site.uri(site_file.path) == site.uri
priority = 0.5 priority = 0.5
else else
priority = 0.4 priority = 0.4
end end
site_files << [site.file_uri(site_file.path), site_file.updated_at.utc.iso8601, priority] site_files << [site.uri(site_file.path), site_file.updated_at.utc.iso8601, priority]
end end
end end
@ -585,19 +238,22 @@ task :generate_sitemap => [:environment] do
end end
gz.write %{</sitemapindex>} gz.write %{</sitemapindex>}
end end
end
desc 'ml_screenshots_list_dump' desc 'dedupe tags'
task :ml_screenshots_list_dump => [:environment] do task :dedupetags => [:environment] do
['phishing', 'spam', 'ham', nil].each do |classifier| Tag.all.each do |tag|
File.open("./files/screenshot-urls-#{classifier.to_s}.txt", 'w') do |fp| begin
SiteFile.where(classifier: classifier).where(path: 'index.html').each do |site_file| tag.reload
begin rescue Sequel::Error => e
fp.write "#{site_file.site.screenshot_url('index.html', Site::SCREENSHOT_RESOLUTIONS.first)}\n" next if e.message =~ /Record not found/
rescue NoMethodError end
end
end matching_tags = Tag.exclude(id: tag.id).where(name: tag.name).all
end
matching_tags.each do |matching_tag|
DB[:sites_tags].where(tag_id: matching_tag.id).update(tag_id: tag.id)
matching_tag.delete
end end
end end
end
end

4
Vagrantfile vendored
View file

@ -1,12 +1,12 @@
VAGRANTFILE_API_VERSION = '2' VAGRANTFILE_API_VERSION = '2'
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = 'ubuntu/trusty64' config.vm.box = 'ubuntu/jammy64'
config.vm.provision :shell, path: './vagrant/development.sh' config.vm.provision :shell, path: './vagrant/development.sh'
config.vm.network :forwarded_port, guest: 9292, host: 9292 config.vm.network :forwarded_port, guest: 9292, host: 9292
config.vm.provider :virtualbox do |vb| config.vm.provider :virtualbox do |vb|
vb.customize ['modifyvm', :id, '--memory', '1536'] vb.customize ['modifyvm', :id, '--memory', '8192']
vb.name = 'neocities' vb.name = 'neocities'
end end
end end

48
app.rb
View file

@ -4,7 +4,7 @@ require './app_helpers.rb'
use Rack::Session::Cookie, key: 'neocities', use Rack::Session::Cookie, key: 'neocities',
path: '/', path: '/',
expire_after: 31556926, # one year in seconds expire_after: 31556926, # one year in seconds
secret: $config['session_secret'], secret: Base64.strict_decode64($config['session_secret']),
httponly: true, httponly: true,
same_site: :lax, same_site: :lax,
secure: ENV['RACK_ENV'] == 'production' secure: ENV['RACK_ENV'] == 'production'
@ -21,6 +21,13 @@ helpers do
def csrf_token_input_html def csrf_token_input_html
%{<input name="csrf_token" type="hidden" value="#{csrf_token}">} %{<input name="csrf_token" type="hidden" value="#{csrf_token}">}
end end
def hcaptcha_input
%{
<script src="https://hcaptcha.com/1/api.js" async defer></script>
<div id="captcha_input" class="h-captcha" data-sitekey="#{$config['hcaptcha_site_key']}"></div>
}
end
end end
set :protection, :frame_options => "DENY" set :protection, :frame_options => "DENY"
@ -51,6 +58,7 @@ GEOCITIES_NEIGHBORHOODS = %w{
televisioncity televisioncity
tokyo tokyo
vienna vienna
westhollywood
yosemite yosemite
}.freeze }.freeze
@ -61,30 +69,54 @@ def redirect_to_internet_archive_for_geocities_sites
end end
end end
WHITELISTED_POST_PATHS = ['/create_validate_all', '/create_validate', '/create'].freeze
before do before do
if request.path.match /^\/api\//i if request.path.match /^\/api\//i
@api = true @api = true
content_type :json content_type :json
elsif request.path.match /^\/webhooks\// elsif request.path.match /^\/webhooks\//
# Skips the CSRF/validation check for stripe web hooks # Skips the CSRF/validation check for stripe web hooks
elsif email_not_validated? && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/signout|^\/welcome|^\/supporter/) elsif current_site && current_site.email_not_validated? && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/welcome|^\/supporter|^\/signout/)
redirect "/site/#{current_site.username}/confirm_email" redirect "/site/#{current_site.username}/confirm_email"
elsif current_site && current_site.phone_verification_needed? && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/site\/.+\/confirm_phone|^\/welcome|^\/supporter|^\/signout/)
redirect "/site/#{current_site.username}/confirm_phone"
elsif current_site && current_site.tutorial_required && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/site\/.+\/confirm_phone|^\/welcome|^\/supporter|^\/tutorial\/.+/)
redirect '/tutorial/html/1'
else else
content_type :html, 'charset' => 'utf-8' content_type :html, 'charset' => 'utf-8'
redirect '/' if request.post? && !csrf_safe? redirect '/' if request.post? && !WHITELISTED_POST_PATHS.include?(request.path_info) && !csrf_safe?
end
if params[:page]
params[:page] = params[:page].to_s
unless params[:page] =~ /^\d+$/ && params[:page].to_i > 0
params[:page] = '1'
end
end
if params[:tag]
begin
params.delete 'tag' if params[:tag].nil? || !params[:tag].is_a?(String) || params[:tag].strip.empty? || params[:tag].match?(Tag::INVALID_TAG_REGEX)
rescue Encoding::CompatibilityError
params.delete 'tag'
end
end end
end end
after do after do
if @api if @api
request.session_options[:skip] = true request.session_options[:skip] = true
else
# Set issue timestamp on session cookie if it doesn't exist yet
session['i'] = Time.now.to_i if session && !session['i'] && session['id']
end
unless self.class.development?
response.headers['Content-Security-Policy'] = %{default-src 'self' data: blob: 'unsafe-inline'; script-src 'self' blob: 'unsafe-inline' 'unsafe-eval' https://hcaptcha.com https://*.hcaptcha.com https://js.stripe.com; style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.stripe.com; frame-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://js.stripe.com}
end end
end end
#after do
#response.headers['Content-Security-Policy'] = %{block-all-mixed-content; default-src 'self'; connect-src 'self' https://api.stripe.com; frame-src https://www.google.com/recaptcha/ https://js.stripe.com; script-src 'self' 'unsafe-inline' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: }
#end
not_found do not_found do
api_not_found if @api api_not_found if @api
redirect_to_internet_archive_for_geocities_sites redirect_to_internet_archive_for_geocities_sites
@ -93,6 +125,7 @@ not_found do
end end
error do error do
=begin
EmailWorker.perform_async({ EmailWorker.perform_async({
from: 'web@neocities.org', from: 'web@neocities.org',
to: 'errors@neocities.org', to: 'errors@neocities.org',
@ -100,6 +133,7 @@ error do
body: erb(:'templates/email/error', layout: false), body: erb(:'templates/email/error', layout: false),
no_footer: true no_footer: true
}) })
=end
if @api if @api
api_error 500, 'server_error', 'there has been an unknown server error, please try again later' api_error 500, 'server_error', 'there has been an unknown server error, please try again later'

View file

@ -1,73 +1,54 @@
get '/activity' do get '/activity' do
#expires 7200, :public, :must_revalidate if self.class.production? # 2 hours #expires 7200, :public, :must_revalidate if self.class.production? # 2 hours
params[:activity] = 'global' # FIXME this is a bad hack
global_dataset = Event.global_dataset @page = params[:page] || 1
if params[:event_id] if params[:tag]
global_dataset = global_dataset.where Sequel.qualify(:events, :id) => params[:event_id] query1 = Event
end .join(:sites, id: :site_id)
.join(:sites_tags, site_id: :id)
.join(:tags, id: :tag_id)
.where(tags__name: params[:tag])
.where(events__is_deleted: false, sites__is_deleted: false)
.where{sites__score > Event::ACTIVITY_TAG_SCORE_LIMIT}
.where(sites__is_nsfw: false)
.where(follow_id: nil)
.select_all(:events)
=begin query2 = Event
.join(:sites, id: :actioning_site_id)
.join(:sites_tags, site_id: :id)
.join(:tags, id: :tag_id)
.where(tags__name: params[:tag])
.where(events__is_deleted: false, sites__is_deleted: false)
.where{sites__score > Event::ACTIVITY_TAG_SCORE_LIMIT}
.where(sites__is_nsfw: false)
.where(follow_id: nil)
.select_all(:events)
initial_events = global_dataset.all if current_site
events = [] blocking_site_ids = current_site.blocking_site_ids
query1 = query1.where(Sequel.|({events__site_id: nil}, ~{events__site_id: blocking_site_ids})).where(Sequel.|({events__actioning_site_id: nil}, ~{events__actioning_site_id: blocking_site_ids}))
initial_events.each do |event| query2 = query2.where(Sequel.|({events__site_id: nil}, ~{events__site_id: blocking_site_ids})).where(Sequel.|({events__actioning_site_id: nil}, ~{events__actioning_site_id: blocking_site_ids}))
site = Site.select(:id).where(id: event.site_id).first
actioning_site = Site.select(:id).where(id: event.actioning_site_id).first
disclude_event = false
disclude_event = true if site.is_a_jerk?
if event.tip_id
disclude_event = true if actioning_site && actioning_site.is_a_jerk?
else
disclude_event = true if actioning_site && (actioning_site.is_a_jerk? || actioning_site.follows_dataset.count < 2)
end end
events.push(event) unless disclude_event ds = query1.union(query2, all: false).order(Sequel.desc(:created_at))
end
initial_site_change_events = Event.global_site_changes_dataset.limit(100).all
site_change_events = []
initial_site_change_events.each do |event|
site = Site.select(:id).where(id: event.site_id).first
site_change_events.push(event) if !site.is_a_jerk? && site.follows_dataset.count > 1
end
@events = []
events.each do |event|
unless site_change_events.empty?
until site_change_events.first.created_at < event.created_at
@events << site_change_events.shift
break if site_change_events.empty?
end
end
@events << event
end
=end
if SimpleCache.expired?(:activity_event_ids)
initial_events = Event.global_site_changes_dataset.limit(500).all
@events = []
initial_events.each do |event|
event_site = event.site
next if @events.select {|e| e.site_id == event.site_id}.count >= 1
next if event_site.is_a_jerk?
next unless event_site.follows_dataset.count > 1
@events.push event
end
SimpleCache.store :activity_event_ids, @events.collect {|e| e.id}, 60.minutes
else else
@events = Event.where(id: SimpleCache.get(:activity_event_ids)).order(:created_at.desc).all ds = Event.news_feed_default_dataset.exclude(sites__is_nsfw: true)
if current_site
blocking_site_ids = current_site.blocking_site_ids
ds = ds.where(Sequel.|({events__site_id: nil}, ~{events__site_id: blocking_site_ids})).where(Sequel.|({events__actioning_site_id: nil}, ~{events__actioning_site_id: blocking_site_ids}))
end
ds = ds.where(
Sequel.expr(Sequel[:sites][:score] > Event::GLOBAL_SCORE_LIMIT) |
Sequel.expr(Sequel[:actioning_sites][:score] > Event::GLOBAL_SCORE_LIMIT)
)
end end
@pagination_dataset = ds.paginate @page.to_i, Event::GLOBAL_PAGINATION_LENGTH
@events = @pagination_dataset.all
erb :'activity' erb :'activity'
end end

View file

@ -250,7 +250,8 @@ post '/admin/banhammer' do
StopForumSpamWorker.perform_async( StopForumSpamWorker.perform_async(
username: site.username, username: site.username,
email: site.email, email: site.email,
ip: site.ip ip: site.ip,
classifier: params[:classifier]
) )
end end
end end
@ -300,5 +301,9 @@ get '/admin/masquerade/:username' do
end end
def require_admin def require_admin
redirect '/' unless signed_in? && current_site.is_admin redirect '/' unless is_admin?
end end
def is_admin?
signed_in? && current_site.is_admin
end

View file

@ -31,6 +31,7 @@ get '/api/list' do
new_file[:path] = file[:path] new_file[:path] = file[:path]
new_file[:is_directory] = file[:is_directory] new_file[:is_directory] = file[:is_directory]
new_file[:size] = file[:size] unless file[:is_directory] new_file[:size] = file[:size] unless file[:is_directory]
new_file[:created_at] = file[:created_at].rfc2822
new_file[:updated_at] = file[:updated_at].rfc2822 new_file[:updated_at] = file[:updated_at].rfc2822
new_file[:sha1_hash] = file[:sha1_hash] unless file[:is_directory] new_file[:sha1_hash] = file[:sha1_hash] unless file[:is_directory]
files << new_file files << new_file
@ -41,14 +42,55 @@ get '/api/list' do
api_success files: files api_success files: files
end end
def extract_files(params, files = [])
# Check if the entire input is directly an array of files
if params.is_a?(Array)
params.each do |item|
# Call extract_files on each item if it's an Array or Hash to handle nested structures
if item.is_a?(Array) || item.is_a?(Hash)
extract_files(item, files)
end
end
elsif params.is_a?(Hash)
params.each do |key, value|
# If the value is a Hash and contains a :tempfile key, it's considered an uploaded file.
if value.is_a?(Hash) && value.has_key?(:tempfile) && !value[:tempfile].nil?
files << {filename: value[:name], tempfile: value[:tempfile]}
elsif value.is_a?(Array)
value.each do |val|
if val.is_a?(Hash) && val.has_key?(:tempfile) && !val[:tempfile].nil?
# Directly add the file info if it's an uploaded file within an array
files << {filename: val[:name], tempfile: val[:tempfile]}
elsif val.is_a?(Hash) || val.is_a?(Array)
# Recursively search for more files if the element is a Hash or Array
extract_files(val, files)
end
end
elsif value.is_a?(Hash)
# Recursively search for more files if the value is a Hash
extract_files(value, files)
end
end
end
files
end
post '/api/upload' do post '/api/upload' do
require_api_credentials require_api_credentials
files = extract_files params
files = [] if !params[:username].blank?
params.each do |k,v| site = Site[username: params[:username]]
next unless v.is_a?(Hash) && v[:tempfile]
path = k.to_s if site.nil? || site.is_deleted
files << {filename: k || v[:filename], tempfile: v[:tempfile]} api_error 400, 'site_not_found', "could not find site"
end
if site.owned_by?(current_site)
@_site = site
else
api_error 400, 'site_not_allowed', "not allowed to change this site with your current logged in site"
end
end end
api_error 400, 'missing_files', 'you must provide files to upload' if files.empty? api_error 400, 'missing_files', 'you must provide files to upload' if files.empty?
@ -60,16 +102,29 @@ post '/api/upload' do
end end
if current_site.too_many_files?(files.length) if current_site.too_many_files?(files.length)
api_error 400, 'too_many_files', "cannot exceed the maximum site files limit (#{current_site.plan_feature(:maximum_site_files)}), #{current_site.supporter? ? 'please contact support' : 'please upgrade to a supporter account'}" api_error 400, 'too_many_files', "cannot exceed the maximum site files limit (#{current_site.plan_feature(:maximum_site_files)})"
end end
files.each do |file| files.each do |file|
file[:filename] = Rack::Utils.unescape file[:filename]
if !current_site.okay_to_upload?(file) if !current_site.okay_to_upload?(file)
api_error 400, 'invalid_file_type', "#{file[:filename]} is not a valid file type (or contains not allowed content) for this site, files have not been uploaded" api_error 400, 'invalid_file_type', "#{file[:filename]} is not an allowed file type for free sites, supporter required"
end end
if File.directory? file[:filename] if File.directory? file[:filename]
api_error 400, 'directory_exists', 'this name is being used by a directory, cannot continue' api_error 400, 'directory_exists', "#{file[:filename]} being used by a directory"
end
if current_site.file_size_too_large? file[:tempfile].size
api_error 400, 'file_too_large' "#{file[:filename]} is too large"
end
if SiteFile.path_too_long? file[:filename]
api_error 400, 'file_path_too_long', "#{file[:filename]} path is too long"
end
if SiteFile.name_too_long? file[:filename]
api_error 400, 'file_name_too_long', "#{file[:filename]} filename is too long (exceeds #{SiteFile::FILE_NAME_CHARACTER_LIMIT} characters)"
end end
end end
@ -167,8 +222,7 @@ def api_info_for(site)
created_at: site.created_at.rfc2822, created_at: site.created_at.rfc2822,
last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil, last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil,
domain: site.domain, 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 end
@ -184,9 +238,10 @@ post '/api/:name' do
end end
def require_api_credentials def require_api_credentials
return true if current_site && csrf_safe?
if !request.env['HTTP_AUTHORIZATION'].nil? if !request.env['HTTP_AUTHORIZATION'].nil?
init_api_credentials 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 else
api_error_invalid_auth api_error_invalid_auth
end end
@ -214,7 +269,7 @@ def init_api_credentials
api_error_invalid_auth api_error_invalid_auth
end end
if site.nil? || site.is_banned || site.is_deleted if site.nil? || site.is_banned || site.is_deleted || !(site.required_validations_met?)
api_error_invalid_auth api_error_invalid_auth
end end

View file

@ -1,9 +1,11 @@
get '/browse/?' do get '/browse/?' do
@surfmode = false @page = params[:page]
@page = params[:page].to_i @page = 1 if @page.not_an_integer?
@page = 1 if @page == 0
params.delete 'tag' if params[:tag].nil? || params[:tag].strip.empty? if params[:tag]
params[:tag] = params[:tag].gsub(Tag::INVALID_TAG_REGEX, '').gsub(/\s+/, '').slice(0, Tag::NAME_LENGTH_MAX)
@title = "Sites tagged #{params[:tag]}"
end
if is_education? if is_education?
ds = education_sites_dataset ds = education_sites_dataset
@ -11,7 +13,7 @@ get '/browse/?' do
ds = browse_sites_dataset ds = browse_sites_dataset
end end
ds = ds.paginate @page, Site::BROWSE_PAGINATION_LENGTH ds = ds.paginate @page.to_i, Site::BROWSE_PAGINATION_LENGTH
@pagination_dataset = ds @pagination_dataset = ds
@sites = ds.all @sites = ds.all
@ -23,10 +25,6 @@ get '/browse/?' do
@site_tags[site_id] = tags.select {|t| t[:site_id] == site_id}.collect {|t| t[:name]} @site_tags[site_id] = tags.select {|t| t[:site_id] == site_id}.collect {|t| t[:name]}
end end
if params[:tag]
@title = "Sites tagged #{params[:tag]}"
end
erb :browse erb :browse
end end
@ -55,41 +53,50 @@ def browse_sites_dataset
end end
end end
if current_site && current_site.is_admin && params[:sites]
ds = ds.where sites__username: params[:sites].split(',')
return ds
end
params[:sort_by] ||= 'special_sauce'
case params[:sort_by] case params[:sort_by]
when 'special_sauce' when 'special_sauce'
ds = ds.exclude score: nil ds = ds.where{score > 1} unless params[:tag]
ds = ds.order :score.desc ds = ds.order :score.desc, :follow_count.desc, :views.desc, :site_updated_at.desc
when 'followers' when 'random'
ds = ds.order :follow_count.desc, :updated_at.desc ds = ds.where{score > 3} unless params[:tag]
when 'supporters' ds = ds.order(Sequel.lit('RANDOM()'))
ds = ds.where id: Site.supporter_ids when 'most_followed'
ds = ds.order :follow_count.desc, :views.desc, :site_updated_at.desc ds = ds.where{views > Site::BROWSE_MINIMUM_FOLLOWER_VIEWS}
ds = ds.where{follow_count > Site::BROWSE_FOLLOWER_MINIMUM_FOLLOWS}
ds = ds.where{updated_at > Site::BROWSE_FOLLOWER_UPDATED_AT_CUTOFF.ago} unless params[:tag]
ds = ds.order :follow_count.desc, :score.desc, :updated_at.desc
when 'last_updated'
ds = ds.where{score > 3} unless params[:tag]
ds = ds.exclude site_updated_at: nil
ds = ds.order :site_updated_at.desc
when 'newest'
ds = ds.where{views > Site::BROWSE_MINIMUM_VIEWS} unless is_admin?
ds = ds.exclude site_updated_at: nil
ds = ds.order :created_at.desc, :views.desc
when 'oldest'
ds = ds.where{score > 0.4} unless params[:tag]
ds = ds.exclude site_updated_at: nil
ds = ds.order(:created_at, :views.desc)
when 'hits'
ds = ds.where{score > 1}
ds = ds.order(:hits.desc, :site_updated_at.desc)
when 'views'
ds = ds.where{score > 3}
ds = ds.order(:views.desc, :site_updated_at.desc)
when 'featured' when 'featured'
ds = ds.exclude featured_at: nil ds = ds.exclude featured_at: nil
ds = ds.order :featured_at.desc ds = ds.order :featured_at.desc
when 'hits'
ds = ds.where{views > 100}
ds = ds.order(:hits.desc, :site_updated_at.desc)
when 'views'
ds = ds.where{views > 100}
ds = ds.order(:views.desc, :site_updated_at.desc)
when 'newest'
ds = ds.order(:created_at.desc, :views.desc)
when 'oldest'
ds = ds.where{views > 100}
ds = ds.order(:created_at, :views.desc)
when 'random'
ds = ds.where{views > 100}
ds = ds.where 'random() < 0.01'
when 'last_updated'
ds = ds.where{views > 100}
params[:sort_by] = 'last_updated'
ds = ds.exclude(site_updated_at: nil)
ds = ds.order(:site_updated_at.desc, :views.desc)
when 'tipping_enabled' when 'tipping_enabled'
ds = ds.where tipping_enabled: true ds = ds.where tipping_enabled: true
ds = ds.where("(tipping_paypal is not null and tipping_paypal != '') or (tipping_bitcoin is not null and tipping_bitcoin != '')") ds = ds.where("(tipping_paypal is not null and tipping_paypal != '') or (tipping_bitcoin is not null and tipping_bitcoin != '')")
ds = ds.where{views > 10_000} ds = ds.where{score > 1} unless params[:tag]
ds = ds.group :sites__id ds = ds.group :sites__id
ds = ds.order :follow_count.desc, :views.desc, :updated_at.desc ds = ds.order :follow_count.desc, :views.desc, :updated_at.desc
when 'blocks' when 'blocks'
@ -98,10 +105,6 @@ def browse_sites_dataset
ds = ds.inner_join :blocks, :site_id => :id ds = ds.inner_join :blocks, :site_id => :id
ds = ds.group :sites__id ds = ds.group :sites__id
ds = ds.order :total.desc ds = ds.order :total.desc
else
params[:sort_by] = 'followers'
ds = ds.where{views > 10_000}
ds = ds.order :follow_count.desc, :views.desc, :updated_at.desc
end end
ds = ds.where ['sites.is_nsfw = ?', (params[:is_nsfw] == 'true' ? true : false)] ds = ds.where ['sites.is_nsfw = ?', (params[:is_nsfw] == 'true' ? true : false)]
@ -116,3 +119,71 @@ def browse_sites_dataset
ds ds
end end
def daily_search_max?
query_count = $redis_cache.get('search_query_count').to_i
query_count >= $config['google_custom_search_query_limit']
end
get '/browse/search' do
@title = 'Site Search'
@daily_search_max_reached = daily_search_max?
if @daily_search_max_reached
params[:q] = nil
end
if !params[:q].blank?
created = $redis_cache.set('search_query_count', 1, nx: true, ex: 86400)
$redis_cache.incr('search_query_count') unless created
@start = params[:start].to_i
@start = 0 if @start < 0
@resp = JSON.parse HTTP.get('https://www.googleapis.com/customsearch/v1', params: {
key: $config['google_custom_search_key'],
cx: $config['google_custom_search_cx'],
safe: 'active',
start: @start,
q: Rack::Utils.escape(params[:q]) + ' -filetype:pdf -filetype:txt site:*.neocities.org'
})
@items = []
if @total_results != 0 && @resp['error'].nil? && @resp['searchInformation']['totalResults'] != "0"
@total_results = @resp['searchInformation']['totalResults'].to_i
@resp['items'].each do |item|
link = Addressable::URI.parse(item['link'])
unencoded_path = Rack::Utils.unescape(Rack::Utils.unescape(link.path)) # Yes, it needs to be decoded twice
item['unencoded_link'] = unencoded_path == '/' ? link.host : link.host+unencoded_path
item['link'] = link
next if link.host == 'neocities.org'
username = link.host.split('.').first
site = Site[username: username]
next if site.nil? || site.is_deleted || site.is_nsfw
screenshot_path = unencoded_path
screenshot_path << 'index' if screenshot_path[-1] == '/'
['.html', '.htm'].each do |ext|
if site.screenshot_exists?(screenshot_path + ext, '540x405')
screenshot_path += ext
break
end
end
item['screenshot_url'] = site.screenshot_url(screenshot_path, '540x405')
@items << item
end
end
else
@items = nil
@total_results = 0
end
erb :'search'
end

View file

@ -2,6 +2,7 @@ post '/comment/:comment_id/toggle_like' do |comment_id|
require_login require_login
content_type :json content_type :json
comment = Comment[id: comment_id] comment = Comment[id: comment_id]
return 403 if comment.event.site.is_blocking?(current_site) || current_site.is_blocking?(comment.event.site)
liked_response = comment.toggle_site_like(current_site) ? 'liked' : 'unliked' liked_response = comment.toggle_site_like(current_site) ? 'liked' : 'unliked'
{result: liked_response, comment_like_count: comment.comment_likes_dataset.count, liking_site_names: comment.liking_site_usernames}.to_json {result: liked_response, comment_like_count: comment.comment_likes_dataset.count, liking_site_names: comment.liking_site_usernames}.to_json
end end

View file

@ -9,7 +9,11 @@ post '/contact' do
@errors << 'Please fill out all fields' @errors << 'Please fill out all fields'
end end
if !recaptcha_valid? if params[:faq_check] == 'no'
@errors << 'Please check Frequently Asked Questions before sending a contact message'
end
unless hcaptcha_valid?
@errors << 'Captcha was not filled out (or was filled out incorrectly)' @errors << 'Captcha was not filled out (or was filled out incorrectly)'
end end

View file

@ -1,7 +1,12 @@
CREATE_MATCH_REGEX = /^username$|^password$|^email$|^new_tags_string$|^is_education$/ CREATE_MATCH_REGEX = /^username$|^password$|^email$|^new_tags_string$|^is_education$/
def education_whitelist_required?
return true if params[:is_education] == 'true' && $config['education_tag_whitelist']
false
end
def education_whitelisted? def education_whitelisted?
return true if params[:is_education] == 'true' && $config['education_tag_whitelist'] && !$config['education_tag_whitelist'].select {|t| params[:new_tags_string].match(t)}.empty? return true if education_whitelist_required? && !$config['education_tag_whitelist'].select {|t| params[:new_tags_string].match(t)}.empty?
false false
end end
@ -9,14 +14,23 @@ post '/create_validate_all' do
content_type :json content_type :json
fields = params.select {|p| p.match CREATE_MATCH_REGEX} fields = params.select {|p| p.match CREATE_MATCH_REGEX}
site = Site.new fields begin
site = Site.new fields
if site.valid? rescue ArgumentError => e
return [].to_json if education_whitelisted? || params[:'g-recaptcha-response'] || self.class.test? if e.message == 'input string invalid'
return [['captcha', 'Please complete the captcha.']].to_json return {error: 'invalid input'}.to_json
else
raise e
end
end end
site.errors.collect {|e| [e.first, e.last.first]}.to_json if site.valid?
return [].to_json if education_whitelisted?
end
resp = site.errors.collect {|e| [e.first, e.last.first]}
resp << ['captcha', 'Please complete the captcha.'] if params[:'h-captcha-response'].empty? && !self.class.test?
resp.to_json
end end
post '/create_validate' do post '/create_validate' do
@ -26,7 +40,16 @@ post '/create_validate' do
return {error: 'not a valid field'}.to_json return {error: 'not a valid field'}.to_json
end end
site = Site.new(params[:field] => params[:value]) begin
site = Site.new(params[:field] => params[:value])
rescue ArgumentError => e
if e.message == 'input string invalid'
return {error: 'invalid input'}.to_json
else
raise e
end
end
site.is_education = params[:is_education] site.is_education = params[:is_education]
site.valid? site.valid?
@ -41,15 +64,6 @@ end
post '/create' do post '/create' do
content_type :json content_type :json
if banned?(true)
signout
session[:banned] = true if !session[:banned]
flash[:error] = 'There was an error, please <a href="/contact">contact support</a> to log in.'
redirect '/'
end
dashboard_if_signed_in dashboard_if_signed_in
@site = Site.new( @site = Site.new(
@ -62,10 +76,15 @@ post '/create' do
ga_adgroupid: session[:ga_adgroupid] ga_adgroupid: session[:ga_adgroupid]
) )
if education_whitelisted? if education_whitelist_required?
@site.email_confirmed = true if education_whitelisted?
@site.email_confirmed = true
else
flash[:error] = 'The class tag is invalid.'
return {result: 'error'}.to_json
end
else else
if !recaptcha_valid? if !hcaptcha_valid?
flash[:error] = 'The captcha was not valid, please try again.' flash[:error] = 'The captcha was not valid, please try again.'
return {result: 'error'}.to_json return {result: 'error'}.to_json
end end
@ -80,6 +99,15 @@ post '/create' do
return {result: 'error'}.to_json return {result: 'error'}.to_json
end end
if defined?(BlackBox.create_disabled?) && BlackBox.create_disabled?(@site, request)
flash[:error] = 'Site creation is not currently available from your location, please try again later.'
return {result: 'error'}.to_json
end
if defined?(BlackBox.tutorial_required?) && BlackBox.tutorial_required?(@site, request)
@site.tutorial_required = true
end
if !@site.valid? if !@site.valid?
flash[:error] = @site.errors.first.last.first flash[:error] = @site.errors.first.last.first
return {result: 'error'}.to_json return {result: 'error'}.to_json
@ -87,15 +115,38 @@ post '/create' do
end end
@site.email_confirmed = true if self.class.development? @site.email_confirmed = true if self.class.development?
@site.save @site.phone_verified = true if self.class.development?
begin
@site.phone_verification_required = true if self.class.production? && BlackBox.phone_verification_required?(@site)
rescue => e
EmailWorker.perform_async({
from: 'web@neocities.org',
to: 'errors@neocities.org',
subject: "[Neocities Error] Phone verification exception",
body: "#{e.inspect}\n#{e.backtrace}",
no_footer: true
})
end
begin
@site.save
rescue Sequel::UniqueConstraintViolation => e
if e.message =~ /username.+already exists/
flash[:error] = 'Username already exists.'
return {result: 'error'}.to_json
end
raise e
end
unless education_whitelisted? unless education_whitelisted?
send_confirmation_email @site
@site.send_email( @site.send_email(
subject: "[Neocities] Welcome to Neocities!", subject: "[Neocities] Welcome to Neocities!",
body: Tilt.new('./views/templates/email_welcome.erb', pretty: true).render(self) body: Tilt.new('./views/templates/email/welcome.erb', pretty: true).render(self)
) )
send_confirmation_email @site
end end
session[:id] = @site.id session[:id] = @site.id

View file

@ -8,7 +8,7 @@ get '/dashboard' do
current_site.save_changes validate: false current_site.save_changes validate: false
end end
erb :'dashboard' erb :'dashboard/index'
end end
def dashboard_init def dashboard_init
@ -30,3 +30,11 @@ def dashboard_init
@dir = params[:dir] @dir = params[:dir]
@file_list = current_site.file_list @dir @file_list = current_site.file_list @dir
end end
get '/dashboard/files' do
require_login
dashboard_init
dont_browser_cache
erb :'dashboard/files', layout: false
end

View file

@ -16,7 +16,7 @@ post '/dmca/contact' do
@errors << 'Please fill out all fields' @errors << 'Please fill out all fields'
end end
if !recaptcha_valid? if !hcaptcha_valid?
@errors << 'Captcha was not filled out (or was filled out incorrectly)' @errors << 'Captcha was not filled out (or was filled out incorrectly)'
end end

View file

@ -2,6 +2,8 @@ post '/event/:event_id/toggle_like' do |event_id|
require_login require_login
content_type :json content_type :json
event = Event[id: event_id] event = Event[id: event_id]
return 403 if event.site && event.site.is_blocking?(current_site)
return 403 if event.actioning_site && event.actioning_site.is_blocking?(current_site)
liked_response = event.toggle_site_like(current_site) ? 'liked' : 'unliked' liked_response = event.toggle_site_like(current_site) ? 'liked' : 'unliked'
{result: liked_response, event_like_count: event.likes_dataset.count, liking_site_names: event.liking_site_usernames}.to_json {result: liked_response, event_like_count: event.likes_dataset.count, liking_site_names: event.liking_site_usernames}.to_json
end end
@ -11,6 +13,9 @@ post '/event/:event_id/comment' do |event_id|
content_type :json content_type :json
event = Event[id: event_id] event = Event[id: event_id]
return 403 if event.site && event.site.is_blocking?(current_site)
return 403 if event.actioning_site && event.actioning_site.is_blocking?(current_site)
site = event.site site = event.site
if(site.is_blocking?(current_site) || if(site.is_blocking?(current_site) ||

View file

@ -1,27 +1,21 @@
get '/?' do get '/?' do
if params[:_ga_adgroupid]
session[:ga_adgroupid] = params[:_ga_adgroupid]
end
if current_site if current_site
require_login require_login
redirect '/dashboard' if current_site.is_education redirect '/dashboard' if current_site.is_education
@page = params[:page].to_i @page = params[:page]
@page = 1 if @page == 0 @page = 1 if @page.not_an_integer?
if params[:activity] == 'mine' if params[:activity] == 'mine'
events_dataset = current_site.latest_events(@page, 10) events_dataset = current_site.latest_events(@page)
elsif params[:event_id] elsif params[:event_id]
event = Event.select(:id).where(id: params[:event_id]).first event = Event.select(:id).where(id: params[:event_id]).first
not_found if event.nil? not_found if event.nil?
not_found if event.is_deleted not_found if event.is_deleted
events_dataset = Event.where(id: params[:event_id]).paginate(1, 1) events_dataset = Event.where(id: params[:event_id]).paginate(1, 1)
elsif params[:activity] == 'global'
events_dataset = Event.global_dataset @page
else else
events_dataset = current_site.news_feed(@page, 10) events_dataset = current_site.news_feed(@page)
end end
@pagination_dataset = events_dataset @pagination_dataset = events_dataset
@ -62,6 +56,7 @@ get '/?' do
@changed_count ||= 0 @changed_count ||= 0
=begin
if SimpleCache.expired?(:blog_feed_html) if SimpleCache.expired?(:blog_feed_html)
@blog_feed_html = '' @blog_feed_html = ''
@ -79,6 +74,18 @@ get '/?' do
else else
@blog_feed_html = SimpleCache.get :blog_feed_html @blog_feed_html = SimpleCache.get :blog_feed_html
end end
=end
@blog_feed_html = 'The latest news on Neocities can be found on our blog.'
if SimpleCache.expired?(:featured_sites)
@featured_sites = Site.order(:score.desc).exclude(is_nsfw: true).exclude(is_deleted: true).limit(1000).all.sample(12).collect {|s| {screenshot_url: s.screenshot_url('index.html', '540x405'), uri: s.uri, title: s.title}}
SimpleCache.store :featured_sites, @featured_sites, 1.hour
else
@featured_sites = SimpleCache.get :featured_sites
end
@create_disabled = false
erb :index, layout: :index_layout erb :index, layout: :index_layout
end end
@ -120,15 +127,6 @@ get '/legal/?' do
erb :'legal' erb :'legal'
end end
get '/permanent-web' do
redirect '/distributed-web'
end
get '/distributed-web' do
@title = 'The Distributed Web'
erb :'distributed_web'
end
get '/thankyou' do get '/thankyou' do
@title = 'Thank you!' @title = 'Thank you!'
erb :'thankyou' erb :'thankyou'

View file

@ -13,21 +13,23 @@ post '/send_password_reset' do
sites = Site.get_recovery_sites_with_email params[:email] sites = Site.get_recovery_sites_with_email params[:email]
if sites.length > 0 if sites.length > 0
token = SecureRandom.uuid.gsub('-', '') token = SecureRandom.uuid.gsub('-', '')+'-'+Time.now.to_i.to_s
sites.each do |site| sites.each do |site|
next unless site.parent? next unless site.parent?
site.password_reset_token = token site.password_reset_token = token
site.save_changes validate: false site.save_changes validate: false
body = <<-EOT body = <<-EOT
Hello! This is the Neocities cat, and I have received a password reset request for your e-mail address. Hello! This is the Penelope the Neocities cat, and I have received a password reset request for your e-mail address.
Go to this URL to reset your password: https://neocities.org/password_reset_confirm?username=#{Rack::Utils.escape(site.username)}&token=#{token} Go to this URL to reset your password: https://neocities.org/password_reset_confirm?username=#{Rack::Utils.escape(site.username)}&token=#{Rack::Utils.escape(token)}
This link will expire in 24 hours.
If you didn't request this password 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, Meow,
the Neocities Cat Penelope
EOT EOT
body.strip! body.strip!
@ -42,7 +44,7 @@ the Neocities Cat
end end
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.' flash[:success] = "We sent an e-mail with password reset instructions. Check your spam folder if you don't see it in your inbox."
redirect '/' redirect '/'
end end
@ -61,7 +63,20 @@ get '/password_reset_confirm' do
redirect '/' redirect '/'
end end
reset_site.password_reset_token = nil timestamp = Time.at(reset_site.password_reset_token.split('-').last.to_i)
if Time.now.to_i - timestamp.to_i > Site::PASSWORD_RESET_EXPIRATION_TIME
flash[:error] = 'Token has expired.'
redirect '/'
end
if reset_site.is_deleted
unless reset_site.undelete!
flash[:error] = "Sorry, we cannot restore this account."
redirect '/'
end
end
reset_site.password_reset_confirmed = true reset_site.password_reset_confirmed = true
reset_site.save_changes reset_site.save_changes

View file

@ -1,3 +1,6 @@
require 'socket'
require 'ipaddr'
get '/settings/?' do get '/settings/?' do
require_login require_login
@site = parent_site @site = parent_site
@ -15,11 +18,19 @@ def require_ownership_for_settings
end end
end end
get '/settings/invoices/?' do
require_login
@title = 'Invoices'
@invoices = parent_site.stripe_customer_id ? Stripe::Invoice.list(customer: parent_site.stripe_customer_id) : []
erb :'settings/invoices'
end
get '/settings/:username/?' do |username| get '/settings/:username/?' do |username|
# This is for the email_unsubscribe below # This is for the email_unsubscribe below
pass if Site.select(:id).where(username: username).first.nil? pass if Site.select(:id).where(username: username).first.nil?
require_login require_login
require_ownership_for_settings require_ownership_for_settings
@title = "Site settings for #{username}" @title = "Site settings for #{username}"
erb :'settings/site' erb :'settings/site'
end end
@ -53,8 +64,7 @@ post '/settings/:username/profile' do
@site.update( @site.update(
profile_comments_enabled: params[:site][:profile_comments_enabled], profile_comments_enabled: params[:site][:profile_comments_enabled],
profile_enabled: params[:site][:profile_enabled], profile_enabled: params[:site][:profile_enabled]
ipfs_archiving_enabled: params[:site][:ipfs_archiving_enabled]
) )
flash[:success] = 'Profile settings changed.' flash[:success] = 'Profile settings changed.'
redirect "/settings/#{@site.username}#profile" redirect "/settings/#{@site.username}#profile"
@ -89,8 +99,8 @@ post '/settings/:username/change_name' do
} }
old_site.delete_all_thumbnails_and_screenshots old_site.delete_all_thumbnails_and_screenshots
old_site.delete_all_cache old_site.purge_all_cache
@site.delete_all_cache @site.purge_all_cache
@site.regenerate_thumbnails_and_screenshots @site.regenerate_thumbnails_and_screenshots
flash[:success] = "Site/user name has been changed. You will need to use this name to login, <b>don't forget it!</b>" flash[:success] = "Site/user name has been changed. You will need to use this name to login, <b>don't forget it!</b>"
@ -144,17 +154,19 @@ post '/settings/:username/custom_domain' do
end end
begin begin
Socket.gethostbyname @site.values[:domain] addr = IPAddr.new @site.values[:domain]
rescue SocketError => e if addr.ipv4? || addr.ipv6?
if e.message =~ /name or service not known/i flash[:error] = 'IP addresses are not allowed. Please enter a valid domain name.'
flash[:error] = 'Domain needs to be valid and already registered.'
redirect "/settings/#{@site.username}#custom_domain" 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"
else
raise e
end end
rescue IPAddr::InvalidAddressError
end
begin
Socket.gethostbyname @site.values[:domain]
rescue SocketError, ResolutionError => e
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 end
if @site.valid? if @site.valid?
@ -174,15 +186,37 @@ post '/settings/:username/custom_domain' do
end end
end end
post '/settings/:username/bluesky_set_did' do
require_login
require_ownership_for_settings
# todo standards based validation
if params[:did].length > 50
flash[:error] = 'DID provided was too long'
elsif !params[:did].match(/^did:plc:([a-z|0-9)]+)$/)
flash[:error] = 'DID was invalid'
else
tmpfile = Tempfile.new 'atproto-did'
tmpfile.write params[:did]
tmpfile.close
@site.store_files [{filename: '.well-known/atproto-did', tempfile: tmpfile}]
$redis_proxy.hdel "dns-_atproto.#{@site.username}.neocities.org", 'TXT'
flash[:success] = 'DID set! You can now verify the handle on the Bluesky app.'
end
redirect "/settings/#{@site.username}#bluesky"
end
post '/settings/:username/generate_api_key' do post '/settings/:username/generate_api_key' do
require_login require_login
require_ownership_for_settings require_ownership_for_settings
is_new = current_site.api_key.nil? is_new = @site.api_key.nil?
current_site.generate_api_key! @site.generate_api_key!
msg = is_new ? "New API key has been generated." : "API key has been regenerated." msg = is_new ? "New API key has been generated." : "API key has been regenerated."
flash[:success] = msg flash[:success] = msg
redirect "/settings/#{current_site.username}#api_key" redirect "/settings/#{@site.username}#api_key"
end end
post '/settings/change_password' do post '/settings/change_password' do
@ -275,6 +309,22 @@ post '/settings/change_email_notification' do
redirect '/settings#email' redirect '/settings#email'
end end
post '/settings/change_editor_settings' do
require_login
owner = current_site.owner
owner.editor_autocomplete_enabled = params[:editor_autocomplete_enabled]
owner.editor_font_size = params[:editor_font_size]
owner.editor_keyboard_mode = params[:editor_keyboard_mode]
owner.editor_tab_width = params[:editor_tab_width]
owner.editor_help_tooltips = params[:editor_help_tooltips]
owner.save_changes validate: false
@filename = params[:path]
redirect '/site_files/text_editor?filename=' + Rack::Utils.escape(@filename)
end
post '/settings/create_child' do post '/settings/create_child' do
require_login require_login
@ -323,10 +373,12 @@ post '/settings/update_card' do
begin begin
customer.sources.create source: params[:stripe_token] customer.sources.create source: params[:stripe_token]
rescue Stripe::InvalidRequestError => e rescue Stripe::InvalidRequestError, Stripe::CardError => e
if e.message.match /cannot use a.+token more than once/ if e.message.match /cannot use a.+token more than once/
flash[:error] = 'Card is already being used.' flash[:error] = 'Card is already being used.'
redirect '/settings#billing' redirect '/settings#billing'
elsif e.message.match /Your card was declined/
flash[:error] = 'The card was declined. Please contact your bank.'
else else
raise e raise e
end end

View file

@ -77,8 +77,4 @@ post '/signout' do
require_login require_login
signout signout
redirect '/' redirect '/'
end end
def signout
session[:id] = nil
end

View file

@ -1,13 +1,14 @@
get '/site/:username.rss' do |username| get '/site/:username.rss' do |username|
site = Site[username: username] site = Site[username: username]
halt 404 if site.nil? || (current_site && site.is_blocking?(current_site))
content_type :xml content_type :xml
site.to_rss.to_xml site.to_rss
end end
get '/site/:username/?' do |username| get '/site/:username/?' do |username|
site = Site[username: username] site = Site[username: username]
# TODO: There should probably be a "this site was deleted" page. # TODO: There should probably be a "this site was deleted" page.
not_found if site.nil? || site.is_banned || site.is_deleted not_found if site.nil? || site.is_banned || site.is_deleted || (current_site && site.is_blocking?(current_site))
redirect '/' if site.is_education redirect '/' if site.is_education
@ -16,16 +17,19 @@ get '/site/:username/?' do |username|
@title = site.title @title = site.title
@page = params[:page] @page = params[:page]
@page = @page.to_i @page = 1 if @page.not_an_integer?
@page = 1 if @page == 0
if params[:event_id] if params[:event_id]
not_found unless params[:event_id].is_integer? not_found if params[:event_id].not_an_integer?
event = Event.select(:id).where(id: params[:event_id]).first event = Event.where(id: params[:event_id]).exclude(is_deleted: true).first
not_found if event.nil? not_found if event.nil?
event_site = event.site
event_actioning_site = event.actioning_site
not_found if current_site && event_site && event_site.is_blocking?(current_site)
not_found if current_site && event_actioning_site && event_actioning_site.is_blocking?(current_site)
events_dataset = Event.where(id: params[:event_id]).paginate(1, 1) events_dataset = Event.where(id: params[:event_id]).paginate(1, 1)
else else
events_dataset = site.latest_events(@page, 10) events_dataset = site.latest_events(@page, current_site)
end end
@page_count = events_dataset.page_count || 1 @page_count = events_dataset.page_count || 1
@ -37,19 +41,11 @@ get '/site/:username/?' do |username|
erb :'site', locals: {site: site, is_current_site: site == current_site} erb :'site', locals: {site: site, is_current_site: site == current_site}
end end
get '/site/:username/archives' do
@site = Site[username: params[:username]]
not_found if @site.nil? || @site.is_banned || @site.is_deleted || !@site.ipfs_archiving_enabled
@title = "Site archives for #{@site.title}"
@archives = @site.archives_dataset.limit(300).order(:updated_at.desc).all
erb :'site/archives'
end
MAX_STAT_POINTS = 30 MAX_STAT_POINTS = 30
get '/site/:username/stats' do get '/site/:username/stats' do
@default_stat_points = 7 @default_stat_points = 7
@site = Site[username: params[:username]] @site = Site[username: params[:username]]
not_found if @site.nil? || @site.is_banned || @site.is_deleted not_found if @site.nil? || @site.is_banned || @site.is_deleted || (current_site && @site.is_blocking?(current_site))
@title = "Site stats for #{@site.host}" @title = "Site stats for #{@site.host}"
@ -90,7 +86,7 @@ get '/site/:username/stats' do
if @site.supporter? if @site.supporter?
unless params[:days].to_s == 'sincethebigbang' unless params[:days].to_s == 'sincethebigbang'
if params[:days] && params[:days].to_i != 0 unless params[:days].not_an_integer?
stats_dataset = stats_dataset.limit params[:days] stats_dataset = stats_dataset.limit params[:days]
else else
params[:days] = @default_stat_points params[:days] = @default_stat_points
@ -116,9 +112,7 @@ get '/site/:username/stats' do
end end
if stats.length > MAX_STAT_POINTS if stats.length > MAX_STAT_POINTS
puts stats.length
stats = stats.select.with_index {|a, i| (i % (stats.length / MAX_STAT_POINTS.to_f).round) == 0} stats = stats.select.with_index {|a, i| (i % (stats.length / MAX_STAT_POINTS.to_f).round) == 0}
puts stats.length
end end
@stats[:stat_days] = stats @stats[:stat_days] = stats
@ -137,16 +131,22 @@ end
get '/site/:username/follows' do |username| get '/site/:username/follows' do |username|
@title = "Sites #{username} follows" @title = "Sites #{username} follows"
@site = Site[username: username] @site = Site[username: username]
not_found if @site.nil? || @site.is_banned || @site.is_deleted not_found if @site.nil? || @site.is_deleted || (current_site && (@site.is_blocking?(current_site) || current_site.is_blocking?(@site)))
@sites = @site.followings.collect {|f| f.site}
params[:page] ||= "1"
@pagination_dataset = @site.followings_dataset.paginate(params[:page].to_i, Site::FOLLOW_PAGINATION_LIMIT)
erb :'site/follows' erb :'site/follows'
end end
get '/site/:username/followers' do |username| get '/site/:username/followers' do |username|
@title = "Sites that follow #{username}" @title = "Sites that follow #{username}"
@site = Site[username: username] @site = Site[username: username]
not_found if @site.nil? || @site.is_banned || @site.is_deleted not_found if @site.nil? || @site.is_deleted || (current_site && (@site.is_blocking?(current_site) || current_site.is_blocking?(@site)))
@sites = @site.follows.collect {|f| f.actioning_site}
params[:page] ||= "1"
@pagination_dataset = @site.follows_dataset.paginate(params[:page].to_i, Site::FOLLOW_PAGINATION_LIMIT)
erb :'site/followers' erb :'site/followers'
end end
@ -155,6 +155,8 @@ post '/site/:username/comment' do |username|
site = Site[username: username] site = Site[username: username]
redirect request.referer if current_site && (site.is_blocking?(current_site) || current_site.is_blocking?(site))
last_comment = site.profile_comments_dataset.order(:created_at.desc).first last_comment = site.profile_comments_dataset.order(:created_at.desc).first
if last_comment && last_comment.message == params[:message] && last_comment.created_at > 2.hours.ago if last_comment && last_comment.message == params[:message] && last_comment.created_at > 2.hours.ago
@ -183,6 +185,7 @@ post '/site/:site_id/toggle_follow' do |site_id|
require_login require_login
content_type :json content_type :json
site = Site[id: site_id] site = Site[id: site_id]
return 403 if site.is_blocking?(current_site)
{result: (current_site.toggle_follow(site) ? 'followed' : 'unfollowed')}.to_json {result: (current_site.toggle_follow(site) ? 'followed' : 'unfollowed')}.to_json
end end
@ -283,3 +286,102 @@ post '/site/:username/block' do |username|
redirect request.referer redirect request.referer
end end
end end
get '/site/:username/unblock' do |username|
require_login
site = Site[username: username]
if site.nil? || current_site.id == site.id
redirect request.referer
end
current_site.unblock! site
redirect request.referer
end
get '/site/:username/confirm_phone' do
require_login
redirect '/' unless current_site.phone_verification_needed?
@title = 'Verify your Phone Number'
erb :'site/confirm_phone'
end
def restart_phone_verification
current_site.phone_verification_sent_at = nil
current_site.phone_verification_sid = nil
current_site.save_changes validate: false
redirect "/site/#{current_site.username}/confirm_phone"
end
post '/site/:username/confirm_phone' do
require_login
redirect '/' unless current_site.phone_verification_needed?
if params[:phone_intl]
phone = Phonelib.parse params[:phone_intl]
if !phone.valid?
flash[:error] = "Invalid phone number, please try again."
redirect "/site/#{current_site.username}/confirm_phone"
end
if phone.types.include?(:premium_rate) || phone.types.include?(:shared_cost)
flash[:error] = 'Neocities does not support this type of number, please use another number.'
redirect "/site/#{current_site.username}/confirm_phone"
end
current_site.phone_verification_sent_at = Time.now
current_site.phone_verification_attempts += 1
if current_site.phone_verification_attempts > Site::PHONE_VERIFICATION_LOCKOUT_ATTEMPTS
flash[:error] = 'You have exceeded the number of phone verification attempts allowed.'
redirect "/site/#{current_site.username}/confirm_phone"
end
current_site.save_changes validate: false
verification = $twilio.verify
.v2
.services($config['twilio_service_sid'])
.verifications
.create(to: phone.e164, channel: 'sms')
current_site.phone_verification_sid = verification.sid
current_site.save_changes validate: false
flash[:success] = 'Validation message sent! Check your phone and enter the code below.'
else
restart_phone_verification if current_site.phone_verification_sent_at < Time.now - Site::PHONE_VERIFICATION_EXPIRATION_TIME
minutes_remaining = ((current_site.phone_verification_sent_at - (Time.now - Site::PHONE_VERIFICATION_EXPIRATION_TIME))/60).round
begin
# Check code
vc = $twilio.verify
.v2
.services($config['twilio_service_sid'])
.verification_checks
.create(verification_sid: current_site.phone_verification_sid, code: params[:code])
# puts vc.status (pending if failed, approved if it passed)
if vc.status == 'approved'
current_site.phone_verified = true
current_site.save_changes validate: false
else
flash[:error] = "Code was not correct, please try again. If the phone number you entered was incorrect, you can re-enter the number after #{minutes_remaining} more minutes have passed."
end
rescue Twilio::REST::RestError => e
if e.message =~ /60202/
flash[:error] = "You have exhausted your check attempts. Please try again in #{minutes_remaining} minutes."
elsif e.message =~ /20404/ # Unable to create record
restart_phone_verification
else
raise e
end
end
end
# Will redirect to / automagically if phone was verified
redirect "/site/#{current_site.username}/confirm_phone"
end

View file

@ -14,7 +14,7 @@ post '/site_files/create' do
require_login require_login
@errors = [] @errors = []
filename = params[:pagefilename] || params[:filename] filename = params[:filename]
filename.gsub!(/[^a-zA-Z0-9_\-.]/, '') filename.gsub!(/[^a-zA-Z0-9_\-.]/, '')
@ -39,7 +39,7 @@ post '/site_files/create' do
extname = File.extname name extname = File.extname name
unless extname.match /^\.#{Site::EDITABLE_FILE_EXT}/i unless extname.empty? || extname.match(/^\.#{Site::EDITABLE_FILE_EXT}/i)
flash[:error] = "Must be an editable text file type (#{Site::VALID_EDITABLE_EXTENSIONS.join(', ')})." flash[:error] = "Must be an editable text file type (#{Site::VALID_EDITABLE_EXTENSIONS.join(', ')})."
redirect redirect_uri redirect redirect_uri
end end
@ -52,7 +52,10 @@ post '/site_files/create' do
end end
if extname.match(/^\.html|^\.htm/i) if extname.match(/^\.html|^\.htm/i)
current_site.install_new_html_file name begin
current_site.install_new_html_file name
rescue Sequel::UniqueConstraintViolation
end
else else
file_path = current_site.files_path(name) file_path = current_site.files_path(name)
FileUtils.touch file_path FileUtils.touch file_path
@ -75,7 +78,9 @@ post '/site_files/create' do
end end
def file_upload_response(error=nil) def file_upload_response(error=nil)
flash[:error] = error if error if error
flash[:error] = error
end
if params[:from_button] if params[:from_button]
query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : '' query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : ''
@ -88,75 +93,16 @@ end
def require_login_file_upload_ajax def require_login_file_upload_ajax
file_upload_response 'You are not signed in!' unless signed_in? file_upload_response 'You are not signed in!' unless signed_in?
file_upload_response 'Please contact support.' if banned?
end
post '/site_files/upload' do
if params[:filename]
require_login_file_upload_ajax
tempfile = Tempfile.new 'neocities_saving_file'
input = request.body.read
tempfile.set_encoding input.encoding
tempfile.write input
tempfile.close
params[:files] = [{filename: params[:filename], tempfile: tempfile}]
else
require_login
end
@errors = []
if params[:files].nil?
file_upload_response "Uploaded files were not seen by the server, cancelled. We don't know what's causing this yet. Please contact us so we can help fix it. Thanks!"
end
# For migration from original design.. some pages out there won't have the site_id param yet for a while.
site = params[:site_id].nil? ? current_site : Site[params[:site_id]]
unless site.owned_by?(current_site)
file_upload_response 'You do not have permission to save this file. Did you sign in as a different user?'
end
params[:files].each_with_index do |file,i|
dir_name = ''
dir_name = params[:dir] if params[:dir]
unless params[:file_paths].nil? || params[:file_paths].empty? || params[:file_paths].length == 0
file_path = params[:file_paths][i]
unless file_path.nil?
dir_name += '/' + Pathname(file_path).dirname.to_s
end
end
file[:filename] = "#{dir_name}/#{site.scrubbed_path file[:filename]}"
if current_site.file_size_too_large? file[:tempfile].size
file_upload_response "#{Rack::Utils.escape_html file[:filename]} is too large, upload cancelled."
end
if !site.okay_to_upload? file
file_upload_response %{#{Rack::Utils.escape_html file[:filename]}: file type (or content in file) is only supported by <a href="/supporter">supporter accounts</a>. <a href="/site_files/allowed_types">Why We Do This</a>}
end
end
uploaded_size = params[:files].collect {|f| f[:tempfile].size}.inject{|sum,x| sum + x }
if site.file_size_too_large? uploaded_size
file_upload_response "File(s) do not fit in your available free space, upload cancelled."
end
if site.too_many_files? params[:files].length
file_upload_response "Your site has exceeded the maximum number of files, please delete some files first."
end
results = site.store_files params[:files]
file_upload_response
end end
post '/site_files/delete' do post '/site_files/delete' do
require_login require_login
path = HTMLEntities.new.decode params[:filename] path = HTMLEntities.new.decode params[:filename]
current_site.delete_file path begin
current_site.delete_file path
rescue Sequel::NoExistingObject
# the deed was presumably already done
end
flash[:success] = "Deleted #{Rack::Utils.escape_html params[:filename]}." flash[:success] = "Deleted #{Rack::Utils.escape_html params[:filename]}."
dirname = Pathname(path).dirname dirname = Pathname(path).dirname
@ -171,12 +117,19 @@ post '/site_files/rename' do
new_path = HTMLEntities.new.decode params[:new_path] new_path = HTMLEntities.new.decode params[:new_path]
site_file = current_site.site_files.select {|s| s.path == path}.first site_file = current_site.site_files.select {|s| s.path == path}.first
res = site_file.rename new_path escaped_path = Rack::Utils.escape_html path
escaped_new_path = Rack::Utils.escape_html new_path
if res.first == true if site_file.nil?
flash[:success] = "Renamed #{Rack::Utils.escape_html path} to #{Rack::Utils.escape_html new_path}" flash[:error] = "File #{escaped_path} does not exist."
else else
flash[:error] = "Failed to rename #{Rack::Utils.escape_html path} to #{Rack::Utils.escape_html new_path}: #{Rack::Utils.escape_html res.last}" res = site_file.rename new_path
if res.first == true
flash[:success] = "Renamed #{escaped_path} to #{escaped_new_path}"
else
flash[:error] = "Failed to rename #{escaped_path} to #{escaped_new_path}: #{Rack::Utils.escape_html res.last}"
end
end end
dirname = Pathname(path).dirname dirname = Pathname(path).dirname
@ -185,18 +138,36 @@ post '/site_files/rename' do
redirect "/dashboard#{dir_query}" redirect "/dashboard#{dir_query}"
end end
get '/site_files/:username.zip' do |username| get '/site_files/download' do
require_login require_login
if current_site.too_big_to_download? if !current_site.dl_queued_at.nil? && current_site.dl_queued_at > 1.hour.ago
flash[:error] = 'Cannot download site as zip as it is too large (or contains too many files)' flash[:error] = 'Site downloads are currently limited to once per hour, please try again later.'
redirect '/dashboard' redirect request.referer
end end
zipfile_path = current_site.files_zip content_type 'application/zip'
content_type 'application/octet-stream'
attachment "neocities-#{current_site.username}.zip" attachment "neocities-#{current_site.username}.zip"
send_file zipfile_path
current_site.dl_queued_at = Time.now
current_site.save_changes validate: false
directory_path = current_site.files_path
stream do |out|
ZipTricks::Streamer.open(out) do |zip|
Dir["#{directory_path}/**/*"].each do |file|
next if File.directory?(file)
zip_path = file.sub("#{directory_path}/", '')
zip.write_stored_file(zip_path) do |file_writer|
File.open(file, 'rb') do |file|
IO.copy_stream(file, file_writer)
end
end
end
end
end
end end
get %r{\/site_files\/download\/(.+)} do get %r{\/site_files\/download\/(.+)} do
@ -213,7 +184,16 @@ get %r{\/site_files\/text_editor\/(.+)} do
dont_browser_cache dont_browser_cache
@filename = params[:captures].first @filename = params[:captures].first
redirect '/site_files/text_editor?filename=' + Rack::Utils.escape(@filename)
end
get '/site_files/text_editor' do
require_login
dont_browser_cache
@filename = params[:filename]
extname = File.extname @filename extname = File.extname @filename
@ace_mode = case extname @ace_mode = case extname
when /htm|html/ then 'html' when /htm|html/ then 'html'
when /js/ then 'javascript' when /js/ then 'javascript'
@ -254,3 +234,37 @@ get '/site_files/mount_info' do
@title = 'Site Mount Information' @title = 'Site Mount Information'
erb :'site_files/mount_info' erb :'site_files/mount_info'
end end
post '/site_files/chat' do
require_login
dont_browser_cache
headers 'X-Accel-Buffering' => 'no'
halt(403) unless parent_site.supporter?
# Ensure the request is treated as a stream
stream do |out|
url = 'https://api.anthropic.com/v1/messages'
headers = {
"anthropic-version" => "2023-06-01",
"anthropic-beta" => "messages-2023-12-15",
"content-type" => "application/json",
"x-api-key" => $config['anthropic_api_key']
}
body = {
model: "claude-3-haiku-20240307",
system: params[:system],
messages: JSON.parse(params[:messages]),
max_tokens: 4096,
temperature: 0.5,
stream: true
}.to_json
res = HTTP.headers(headers).post(url, body: body)
while(buffer = res.body.readpartial)
out << buffer
end
end
end

View file

@ -14,7 +14,6 @@ end
post '/supporter/update' do post '/supporter/update' do
require_login require_login
plan_type = 'supporter' plan_type = 'supporter'
if is_special_upgrade if is_special_upgrade
@ -40,19 +39,29 @@ post '/supporter/update' do
customer.sources.create source: params[:stripe_token] customer.sources.create source: params[:stripe_token]
end end
subscription = customer.subscriptions.create plan: plan_type begin
subscription = customer.subscriptions.create plan: plan_type
rescue Stripe::CardError => e
flash[:error] = "Error: #{Rack::Utils.escape_html e.message}"
redirect '/supporter'
end
site.plan_ended = false site.plan_ended = false
site.plan_type = plan_type site.plan_type = plan_type
site.stripe_subscription_id = subscription.id site.stripe_subscription_id = subscription.id
site.save_changes validate: false site.save_changes validate: false
else else
customer = Stripe::Customer.create( begin
source: params[:stripe_token], customer = Stripe::Customer.create(
description: "#{site.username} - #{site.id}", source: params[:stripe_token],
email: site.email, description: "#{site.username} - #{site.id}",
plan: plan_type email: site.email,
) plan: plan_type
)
rescue Stripe::CardError => e
flash[:error] = "Error: #{Rack::Utils.escape_html e.message} This is likely caused by incorrect information, or an issue with your credit card. Please try again, or contact your bank."
redirect '/supporter'
end
site.stripe_customer_id = customer.id site.stripe_customer_id = customer.id
site.stripe_subscription_id = customer.subscriptions.first.id site.stripe_subscription_id = customer.subscriptions.first.id
@ -74,7 +83,7 @@ post '/supporter/update' do
site.send_email( site.send_email(
subject: "[Neocities] You've become a supporter!", subject: "[Neocities] You've become a supporter!",
body: Tilt.new('./views/templates/email_subscription.erb', pretty: true).render( body: Tilt.new('./views/templates/email/subscription.erb', pretty: true).render(
self, { self, {
username: site.username, username: site.username,
plan_name: Site::PLAN_FEATURES[params[:plan_type].to_sym][:name], plan_name: Site::PLAN_FEATURES[params[:plan_type].to_sym][:name],
@ -93,6 +102,7 @@ post '/supporter/update' do
end end
get '/supporter/thanks' do get '/supporter/thanks' do
@title = 'Supporter Confirmation'
require_login require_login
erb :'supporter/thanks' erb :'supporter/thanks'
end end

View file

@ -1,21 +0,0 @@
get '/surf/?' do
not_found # 404 for now
@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 @page, 1
@page_count = site_dataset.page_count || 1
@site = site_dataset.first
redirect "/browse?#{Rack::Utils.build_query params}" if @site.nil?
@title = "Surf Mode - #{@site.title}"
erb :'surf', layout: false
end
get '/surf/:username' do |username|
not_found # 404 for now
@site = Site.select(:id, :username, :title, :domain, :views, :stripe_customer_id).where(username: username).first
not_found if @site.nil?
@title = @site.title
not_found if @site.nil?
erb :'surf', layout: false
end

View file

@ -24,18 +24,29 @@ end
get '/tutorial/:section/?' do get '/tutorial/:section/?' do
require_login require_login
not_found unless %w{html}.include?(params[:section])
redirect "/tutorial/#{params[:section]}/1" redirect "/tutorial/#{params[:section]}/1"
end end
get '/tutorial/:section/:page/?' do get '/tutorial/:section/:page/?' do
require_login require_login
@page = params[:page] @page = params[:page]
not_found if @page.to_i == 0 not_found unless @page.match?(/\A[1-9]\z|\A10\z/)
not_found unless %w{html css js}.include?(params[:section]) not_found unless %w{html}.include?(params[:section])
@section = params[:section] @section = params[:section]
@title = "#{params[:section].upcase} Tutorial - #{@page}/10" @title = "#{params[:section].upcase} Tutorial - #{@page}/10"
if @page == '9'
unless csrf_safe?
signout
redirect '/'
end
current_site.tutorial_required = false
current_site.save_changes validate: false
end
erb "tutorial/layout".to_sym erb "tutorial/layout".to_sym
end end

View file

@ -143,7 +143,7 @@ def stripe_get_site_from_event(event)
site_where = {username: desc_split.first} site_where = {username: desc_split.first}
end end
if desc_split.last.to_i == 0 if desc_split.last.not_an_integer?
site_where = {username: desc_split.first} site_where = {username: desc_split.first}
else else
site_where = {id: desc_split.last} site_where = {id: desc_split.last}

View file

@ -16,41 +16,33 @@ end
def require_login def require_login
redirect '/' unless signed_in? && current_site redirect '/' unless signed_in? && current_site
enforce_ban if banned?
signout if deleted?
end end
def signed_in? def signed_in?
!session[:id].nil? return false if current_site.nil?
true
end
def signout
@_site = nil
@_parent_site = nil
session[:id] = nil
end end
def current_site def current_site
return nil if session[:id].nil? return nil if session[:id].nil?
@_site ||= Site[id: session[:id]] @_site ||= Site[id: session[:id]]
@_parent_site ||= @_site.parent
if @_site.is_banned || @_site.is_deleted || (@_parent_site && (@_parent_site.is_banned || @_parent_site.is_deleted))
signout
end
@_site
end end
def parent_site def parent_site
return nil if current_site.nil? @_parent_site || current_site
current_site.parent? ? current_site : current_site.parent
end
def deleted?
return true if current_site && current_site.is_deleted
false
end
def banned?(ip_check=false)
return true if session[:banned]
return true if current_site && (current_site.is_banned || parent_site.is_banned)
return true if ip_check && Site.banned_ip?(request.ip)
false
end
def enforce_ban
signout
session[:banned] = true
redirect '/'
end end
def meta_robots(newtag=nil) def meta_robots(newtag=nil)
@ -104,12 +96,6 @@ def dont_browser_cache
@dont_browser_cache = true @dont_browser_cache = true
end 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
def sanitize_comment(text) def sanitize_comment(text)
Rinku.auto_link Sanitize.fragment(text), :all, 'target="_blank" rel="nofollow"' Rinku.auto_link Sanitize.fragment(text), :all, 'target="_blank" rel="nofollow"'
end end
@ -118,20 +104,32 @@ def flash_display(opts={})
erb :'_flash', layout: false, locals: {opts: opts} erb :'_flash', layout: false, locals: {opts: opts}
end end
def recaptcha_valid? def hcaptcha_valid?
return true if ENV['RACK_ENV'] == 'test' || ENV['TRAVIS'] return true if ENV['RACK_ENV'] == 'test' || ENV['CI']
return false unless params[:'g-recaptcha-response'] return false unless params[:'h-captcha-response']
resp = Net::HTTP.get URI(
'https://www.google.com/recaptcha/api/siteverify?'+
Rack::Utils.build_query(
secret: $config['recaptcha_private_key'],
response: params[:'g-recaptcha-response']
)
)
if JSON.parse(resp)['success'] == true resp = HTTP.get('https://hcaptcha.com/siteverify', params: {
secret: $config['hcaptcha_secret_key'],
response: params[:'h-captcha-response']
})
resp = JSON.parse resp
if resp['success'] == true
true true
else else
false false
end end
end end
JS_ESCAPE_MAP = {"\\" => "\\\\", "</" => '<\/', "\r\n" => '\n', "\n" => '\n', "\r" => '\n', '"' => '\\"', "'" => "\\'", "`" => "\\`", "$" => "\\$"}
def escape_javascript(javascript)
javascript = javascript.to_s
if javascript.empty?
result = ""
else
result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"']|[`]|[$])/u, JS_ESCAPE_MAP)
end
result
end

119
config.ru
View file

@ -1,6 +1,9 @@
require 'rubygems' require 'rubygems'
require './app.rb' require './app.rb'
require 'sidekiq/web' require 'sidekiq/web'
require 'airbrake/sidekiq'
use Airbrake::Rack::Middleware
map('/') do map('/') do
use(Rack::Cache, use(Rack::Cache,
@ -13,72 +16,92 @@ end
map '/webdav' do map '/webdav' do
use Rack::Auth::Basic do |username, password| use Rack::Auth::Basic do |username, password|
@site = Site.get_site_from_login username, password @site = Site.get_site_from_login(username, password)
@site ? true : false @site ? true : false
end end
run lambda {|env| run lambda { |env|
if env['REQUEST_METHOD'] == 'PUT' request_method = env['REQUEST_METHOD']
path = env['PATH_INFO'] path = env['PATH_INFO']
tmpfile = Tempfile.new 'davfile', encoding: 'binary'
tmpfile.write env['rack.input'].read unless @site.owner.supporter?
return [
402,
{
'Content-Type' => 'application/xml',
'X-Upgrade-Required' => 'https://neocities.org/supporter'
},
[
<<~XML
<?xml version="1.0" encoding="utf-8"?>
<error xmlns="DAV:">
<message>WebDAV access requires a supporter account.</message>
</error>
XML
]
]
end
case request_method
when 'OPTIONS'
return [200, {'Allow' => 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE', 'DAV' => '1,2'}, ['']]
when 'PUT'
tmpfile = Tempfile.new('davfile', encoding: 'binary')
tmpfile.write(env['rack.input'].read)
tmpfile.close tmpfile.close
if @site.file_size_too_large? tmpfile.size return [507, {}, ['']] if @site.file_size_too_large?(tmpfile.size)
return [507, {}, ['']]
end
# if Site.valid_file_type?(filename: path, tempfile: tmpfile) if @site.okay_to_upload?(filename: path, tempfile: tmpfile)
if @site.okay_to_upload? filename: path, tempfile: tmpfile @site.store_files([{ filename: path, tempfile: tmpfile }])
@site.store_files [{filename: path, tempfile: tmpfile}]
return [201, {}, ['']] return [201, {}, ['']]
else else
return [415, {}, ['']] return [415, {}, ['']]
end end
end
if env['REQUEST_METHOD'] == 'MKCOL' when 'MKCOL'
@site.create_directory env['PATH_INFO'] @site.create_directory(path)
return [201, {}, ['']] return [201, {}, ['']]
end
if env['REQUEST_METHOD'] == 'MOVE' when 'MOVE'
destination = env['HTTP_DESTINATION'][/\/webdav(.+)$/i, 1]
return [400, {}, ['Bad Request']] unless destination
destination = env['HTTP_DESTINATION'].match(/^.+\/webdav(.+)$/i).captures.first path.sub!(/^\//, '') # Remove leading slash if present
site_file = @site.site_files.find { |s| s.path == path }
env['PATH_INFO'] = env['PATH_INFO'][1..env['PATH_INFO'].length] if env['PATH_INFO'][0] == '/' return [404, {}, ['']] unless site_file
site_file = @site.site_files.select {|s| s.path == env['PATH_INFO']}.first
res = site_file.rename destination
site_file.rename(destination)
return [201, {}, ['']] return [201, {}, ['']]
end
if env['REQUEST_METHOD'] == 'COPY' when 'DELETE'
return [501, {}, ['']] @site.delete_file(path)
end
if env['REQUEST_METHOD'] == 'LOCK'
return [501, {}, ['']]
end
if env['REQUEST_METHOD'] == 'UNLOCK'
return [501, {}, ['']]
end
if env['REQUEST_METHOD'] == 'PROPPATCH'
return [501, {}, ['']]
end
if env['REQUEST_METHOD'] == 'DELETE'
@site.delete_file env['PATH_INFO']
return [201, {}, ['']] return [201, {}, ['']]
end
res = DAV4Rack::Handler.new( else
root: @site.files_path, unless ['PROPFIND', 'GET', 'HEAD'].include? request_method
root_uri_path: '/webdav' return [501, {}, ['Not Implemented']]
).call(env) end
env['PATH_INFO'] = "/#{@site.scrubbed_path(path)}" unless path.empty?
# Terrible hack to fix WebDAV for the VSC plugin
if env['CONTENT_LENGTH'] == "0"
env['rack.input'] = StringIO.new('<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop>
<getcontentlength xmlns="DAV:"/>
<getlastmodified xmlns="DAV:"/>
<resourcetype xmlns="DAV:"/>
</prop></propfind>')
env['CONTENT_LENGTH'] = env['rack.input'].length.to_s
end
DAV4Rack::Handler.new(
root: @site.files_path,
root_uri_path: '/webdav'
).call(env)
end
} }
end end
@ -88,7 +111,7 @@ map '/sidekiq' do
username == $config['sidekiq_user'] && password == $config['sidekiq_pass'] username == $config['sidekiq_user'] && password == $config['sidekiq_pass']
end end
use Rack::Session::Cookie, key: 'sidekiq.session', secret: $config['session_secret'] use Rack::Session::Cookie, key: 'sidekiq.session', secret: Base64.strict_decode64($config['session_secret'])
use Rack::Protection::AuthenticityToken use Rack::Protection::AuthenticityToken
run Sidekiq::Web run Sidekiq::Web
end end

31
config.yml.ci Normal file
View file

@ -0,0 +1,31 @@
database: 'postgres://postgres:citestpassword@localhost/ci_test'
database_pool: 1
session_secret: 'SSBqdXN0IHdhbnRlZCB0byBzZWUgd2hhdCB5b3UgbG9va2VkIGxpa2UgaW4gYSBkcmVzcywgRGFkZSBNdXJwaHk='
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/
proxy_ips:
- 10.0.0.1
- 10.0.0.2
education_tag_whitelist:
- mrteacher
stop_forum_spam_api_key: testkey
screenshot_urls:
- http://screenshots:derp@screenshotssite.com
cache_control_ips:
- 1.2.3.4
- 4.5.6.7
hcaptcha_site_key: "10000000-ffff-ffff-ffff-000000000001"
hcaptcha_secret_key: "0x0000000000000000000000000000000000000000"
twilio_account_sid: ACEDERPDERP
twilio_auth_token: derpderpderp
twilio_service_sid: VADERPDERPDERP
minfraud_account_id: 696969420
minfraud_license_key: DERPDERPDERP
google_custom_search_key: herpderp
google_custom_search_cx: herpderp
google_custom_search_query_limit: 69

View file

@ -2,9 +2,9 @@ development:
database: 'postgres://localhost/neocities' database: 'postgres://localhost/neocities'
database_pool: 1 database_pool: 1
redis_url: "redis://localhost" redis_url: "redis://localhost"
session_secret: "SECRET GOES HERE" session_secret: "SSBqdXN0IHdhbnRlZCB0byBzZWUgd2hhdCB5b3UgbG9va2VkIGxpa2UgaW4gYSBkcmVzcywgRGFkZSBNdXJwaHk="
recaptcha_public_key: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" hcaptcha_site_key: "10000000-ffff-ffff-ffff-000000000001"
recaptcha_private_key: "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" hcaptcha_secret_key: "0x0000000000000000000000000000000000000000"
sidekiq_user: "ENTER USER HERE" sidekiq_user: "ENTER USER HERE"
sidekiq_pass: "ENTER PASS HERE" sidekiq_pass: "ENTER PASS HERE"
stripe_publishable_key: "ENTER KEY HERE" stripe_publishable_key: "ENTER KEY HERE"
@ -18,19 +18,25 @@ development:
paypal_api_signature: tonz paypal_api_signature: tonz
letsencrypt_key: ./tests/files/letsencrypt.key letsencrypt_key: ./tests/files/letsencrypt.key
letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/ letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
minfraud_account_id: 696969420
minfraud_license_key: DERPDERPDERP
proxy_ips: proxy_ips:
- 10.0.0.1 - 10.0.0.1
- 10.0.0.2 - 10.0.0.2
education_tag_whitelist: education_tag_whitelist:
- mrteacher - mrteacher
screenshots_url: http://screenshots:derp@127.0.0.1:12345 screenshot_urls:
- http://screenshots:derp@127.0.0.1:12345
stop_forum_spam_api_key: testkey stop_forum_spam_api_key: testkey
google_custom_search_key: herpderp
google_custom_search_cx: herpderp
google_custom_search_query_limit: 69
test: test:
database: 'postgres://localhost/neocities_test' database: 'postgres://localhost/neocities_test'
database_pool: 1 database_pool: 1
session_secret: "SECRET GOES HERE" session_secret: "SSBqdXN0IHdhbnRlZCB0byBzZWUgd2hhdCB5b3UgbG9va2VkIGxpa2UgaW4gYSBkcmVzcywgRGFkZSBNdXJwaHk="
recaptcha_public_key: "ENTER PUBLIC KEY HERE" hcaptcha_site_key: "10000000-ffff-ffff-ffff-000000000001"
recaptcha_private_key: "ENTER PRIVATE KEY HERE" hcaptcha_secret_key: "0x0000000000000000000000000000000000000000"
sidekiq_user: "ENTER USER HERE" sidekiq_user: "ENTER USER HERE"
sidekiq_pass: "ENTER PASS HERE" sidekiq_pass: "ENTER PASS HERE"
stripe_publishable_key: "ENTER KEY HERE" stripe_publishable_key: "ENTER KEY HERE"
@ -49,7 +55,16 @@ test:
education_tag_whitelist: education_tag_whitelist:
- mrteacher - mrteacher
stop_forum_spam_api_key: testkey stop_forum_spam_api_key: testkey
screenshots_url: http://screenshots:derp@screenshotssite.com screenshot_urls:
- http://screenshots:derp@screenshotssite.com
cache_control_ips: cache_control_ips:
- 1.2.3.4 - 1.2.3.4
- 4.5.6.7 - 4.5.6.7
twilio_account_sid: ACEDERPDERP
twilio_auth_token: derpderpderp
twilio_service_sid: VADERPDERPDERP
minfraud_account_id: 696969420
minfraud_license_key: DERPDERPDERP
google_custom_search_key: herpderp
google_custom_search_cx: herpderp
google_custom_search_query_limit: 69

View file

@ -1,23 +0,0 @@
database: 'postgres://postgres@localhost/travis_ci_test'
database_pool: 1
session_secret: 's3cr3t'
recaptcha_public_key: '1234'
recaptcha_private_key: '5678'
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/
proxy_ips:
- 10.0.0.1
- 10.0.0.2
education_tag_whitelist:
- mrteacher
stop_forum_spam_api_key: testkey
screenshot_urls:
- http://screenshots:derp@screenshotssite.com
cache_control_ips:
- 1.2.3.4
- 4.5.6.7

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
RubyVM::YJIT.enable
ENV['RACK_ENV'] ||= 'development' ENV['RACK_ENV'] ||= 'development'
ENV['TZ'] = 'UTC' ENV['TZ'] = 'UTC'
DIR_ROOT = File.expand_path File.dirname(__FILE__) DIR_ROOT = File.expand_path File.dirname(__FILE__)
@ -11,14 +13,21 @@ require 'logger'
Bundler.require Bundler.require
Bundler.require :development if ENV['RACK_ENV'] == 'development' Bundler.require :development if ENV['RACK_ENV'] == 'development'
require 'tilt/erubis' require 'tilt/erubi'
require 'active_support/core_ext/integer/time' require 'active_support'
require 'active_support/time'
class File
def self.exists?(val)
self.exist?(val)
end
end
Dir['./ext/**/*.rb'].each {|f| require f} Dir['./ext/**/*.rb'].each {|f| require f}
# :nocov: # :nocov:
if ENV['TRAVIS'] if ENV['CI']
$config = YAML.load_file File.join(DIR_ROOT, 'config.yml.travis') $config = YAML.load_file File.join(DIR_ROOT, 'config.yml.ci')
else else
begin begin
$config = YAML.load_file(File.join(DIR_ROOT, 'config.yml'))[ENV['RACK_ENV']] $config = YAML.load_file(File.join(DIR_ROOT, 'config.yml'))[ENV['RACK_ENV']]
@ -33,6 +42,7 @@ DB = Sequel.connect $config['database'], sslmode: 'disable', max_connections: $c
DB.extension :pagination DB.extension :pagination
DB.extension :auto_literal_strings DB.extension :auto_literal_strings
Sequel.split_symbols = true Sequel.split_symbols = true
Sidekiq.strict_args!(false)
require 'will_paginate/sequel' require 'will_paginate/sequel'
@ -47,9 +57,13 @@ end
=end =end
# :nocov: # :nocov:
Sidekiq::Logging.logger = nil unless ENV['RACK_ENV'] == 'production' unless ENV['RACK_ENV'] == 'production'
Sidekiq.configure_server do |config|
config.logger = nil
end
end
sidekiq_redis_config = {namespace: 'neocitiesworker'} sidekiq_redis_config = {}
sidekiq_redis_config[:url] = $config['sidekiq_url'] if $config['sidekiq_url'] sidekiq_redis_config[:url] = $config['sidekiq_url'] if $config['sidekiq_url']
# :nocov: # :nocov:
@ -114,14 +128,6 @@ Dir.glob('workers/*.rb').each {|w| require File.join(DIR_ROOT, "/#{w}") }
DB.loggers << Logger.new(STDOUT) if ENV['RACK_ENV'] == 'development' DB.loggers << Logger.new(STDOUT) if ENV['RACK_ENV'] == 'development'
Mail.defaults do Mail.defaults do
#options = { :address => "smtp.gmail.com",
# :port => 587,
# :domain => 'your.host.name',
# :user_name => '<username>',
# :password => '<password>',
# :authentication => 'plain',
# :enable_starttls_auto => true }
options = {} options = {}
delivery_method :sendmail, options delivery_method :sendmail, options
end end
@ -163,3 +169,30 @@ $gandi = Gandi::Session.new $config['gandi_api_key'], gandi_opts
$image_optim = ImageOptim.new pngout: false, svgo: false $image_optim = ImageOptim.new pngout: false, svgo: false
Money.locale_backend = nil Money.locale_backend = nil
Money.default_currency = Money::Currency.new("USD")
Money.rounding_mode = BigDecimal::ROUND_HALF_UP
$twilio = Twilio::REST::Client.new $config['twilio_account_sid'], $config['twilio_auth_token']
Minfraud.configure do |c|
c.account_id = $config['minfraud_account_id']
c.license_key = $config['minfraud_license_key']
c.enable_validation = true
end
Airbrake.configure do |c|
c.project_id = $config['airbrake_project_id']
c.project_key = $config['airbrake_project_key']
end
Airbrake.add_filter do |notice|
if notice[:params][:password]
# Filter out password.
notice[:params][:password] = '[Filtered]'
end
notice.ignore! if notice.stash[:exception].is_a?(Sinatra::NotFound)
end
Airbrake.add_filter Airbrake::Sidekiq::RetryableJobsFilter.new

View file

@ -6,4 +6,8 @@ class NilClass
def blank? def blank?
true true
end end
def not_an_integer?
true
end
end end

View file

@ -1,49 +0,0 @@
class BitcoinValidator
class << self
def address_version
"00"
end
def p2sh_version
"05"
end
def valid_address?(address)
hex = decode_base58(address) rescue nil
return false unless hex && hex.bytesize == 50
return false unless [address_version, p2sh_version].include?(hex[0...2])
base58_checksum?(address)
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
def base58_checksum?(base58)
hex = decode_base58(base58) rescue nil
return false unless hex
checksum( hex[0...42] ) == hex[-8..-1]
end
def checksum(hex)
b = [hex].pack("H*") # unpack hex
Digest::SHA256.hexdigest( Digest::SHA256.digest(b) )[0...8]
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
end
end

View file

@ -76,4 +76,8 @@ class Numeric
def to_space_pretty def to_space_pretty
to_bytes_pretty to_bytes_pretty
end end
def not_an_integer?
!self.integer?
end
end end

View file

@ -0,0 +1,5 @@
class Sinatra::IndifferentHash
def not_an_integer?
true
end
end

View file

@ -12,12 +12,15 @@ class String
gsub /^#{scan(/^\s*/).min_by{|l|l.length}}/, "" gsub /^#{scan(/^\s*/).min_by{|l|l.length}}/, ""
end end
def is_integer?
true if Integer(self) rescue false
end
def blank? def blank?
return true if self == '' return true if self == ''
false false
end end
def not_an_integer?
Integer(self)
false
rescue ArgumentError
true
end
end end

View file

@ -1,10 +1,10 @@
Sequel.migration do Sequel.migration do
change do change do
alter_table(:events) { add_index :created_at } #alter_table(:events) { add_index :created_at }
alter_table(:sites) { add_index :updated_at } alter_table(:sites) { add_index :updated_at }
alter_table(:comment_likes) { add_index :comment_id } alter_table(:comment_likes) { add_index :comment_id }
alter_table(:comment_likes) { add_index :actioning_site_id } alter_table(:comment_likes) { add_index :actioning_site_id }
alter_table(:sites_tags) { add_index :tag_id } alter_table(:sites_tags) { add_index :tag_id }
alter_table(:tags) { add_index :name } alter_table(:tags) { add_index :name }
end end
end end

View file

@ -1,13 +1,13 @@
Sequel.migration do Sequel.migration do
up { up {
DB['create index stat_referrers_hash_multi on stat_referrers (site_id, md5(url))'].first DB['create index stat_referrers_hash_multi on stat_referrers (site_id, md5(url))'].first
DB.add_index :stat_locations, :site_id #DB.add_index :stat_locations, :site_id
DB.add_index :stat_paths, :site_id #DB.add_index :stat_paths, :site_id
} }
down { down {
DB['drop index stat_referrers_hash_multi'].first DB['drop index stat_referrers_hash_multi'].first
DB.drop_index :stat_locations, :site_id #DB.drop_index :stat_locations, :site_id
DB.drop_index :stat_paths, :site_id #DB.drop_index :stat_paths, :site_id
} }
end end

View file

@ -1,9 +1,9 @@
Sequel.migration do Sequel.migration do
up { up {
DB.add_index :stat_referrers, :site_id #DB.add_index :stat_referrers, :site_id
} }
down { down {
DB.drop_index :stat_referrers, :site_id #DB.drop_index :stat_referrers, :site_id
} }
end end

View file

@ -1,13 +1,13 @@
Sequel.migration do Sequel.migration do
up { up {
%i{stat_referrers stat_locations stat_paths}.each do |t| #%i{stat_referrers stat_locations stat_paths}.each do |t|
DB.add_index t, :created_at # DB.add_index t, :created_at
end #end
} }
down { down {
%i{stat_referrers stat_locations stat_paths}.each do |t| #%i{stat_referrers stat_locations stat_paths}.each do |t|
DB.drop_index t, :created_at # DB.drop_index t, :created_at
end #end
} }
end end

View file

@ -1,9 +1,9 @@
Sequel.migration do Sequel.migration do
up { up {
DB.add_index :sites, :parent_site_id #DB.add_index :sites, :parent_site_id
} }
down { down {
DB.drop_index :sites, :parent_site_id #DB.drop_index :sites, :parent_site_id
} }
end end

View file

@ -1,9 +1,9 @@
Sequel.migration do Sequel.migration do
up { up {
DB.add_index :sites, :featured_at #DB.add_index :sites, :featured_at
} }
down { down {
DB.drop_index :sites, :featured_at #DB.drop_index :sites, :featured_at
} }
end end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_index :sites, :api_key
}
down {
DB.drop_index :sites, :api_key
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_index :sites, :domain
}
down {
DB.drop_index :sites, :domain
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_column :sites, :dl_queued_at, Time
}
down {
DB.drop_column :sites, :dl_queued_at
}
end

View file

@ -0,0 +1,15 @@
Sequel.migration do
up {
DB.add_column :sites, :phone_verification_required, :boolean, default: false
DB.add_column :sites, :phone_verified, :boolean, default: false
DB.add_column :sites, :phone_verification_sid, :text
DB.add_column :sites, :phone_verification_sent_at, :time
}
down {
DB.drop_column :sites, :phone_verification_required
DB.drop_column :sites, :phone_verified
DB.drop_column :sites, :phone_verification_sid
DB.drop_column :sites, :phone_verification_sent_at
}
end

View file

@ -0,0 +1,11 @@
Sequel.migration do
up {
DB.drop_column :sites, :phone_verification_sent_at
DB.add_column :sites, :phone_verification_sent_at, Time
}
down {
DB.drop_column :sites, :phone_verification_sent_at
DB.add_column :sites, :phone_verification_sent_at, :time
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_column :sites, :phone_verification_attempts, :integer, default: 0
}
down {
DB.drop_column :sites, :phone_verification_attempts
}
end

View file

@ -0,0 +1,17 @@
Sequel.migration do
up {
DB.drop_table :archives
DB.drop_column :sites, :ipfs_archiving_enabled
}
down {
DB.create_table! :archives do
Integer :site_id, index: true
String :ipfs_hash
DateTime :updated_at, index: true
unique [:site_id, :ipfs_hash]
end
DB.add_column :sites, :ipfs_archiving_enabled, :boolean, default: false
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_index :events, [:created_at, :site_id, :site_change_id, :is_deleted], name: :events_rss_index, order: {created_at: :desc}
}
down {
DB.drop_index :events, :rss
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_column :sites, :tutorial_required, :boolean, default: false
}
down {
DB.drop_column :sites, :tutorial_required
}
end

View file

@ -0,0 +1,15 @@
Sequel.migration do
up {
alter_table(:sites) do
set_column_type :hits, :bigint
set_column_type :views, :bigint
end
}
down {
alter_table(:sites) do
set_column_type :hits, Integer
set_column_type :views, Integer
end
}
end

View file

@ -0,0 +1,13 @@
Sequel.migration do
up {
alter_table(:sites) do
set_column_type :score, :real
end
}
down {
alter_table(:sites) do
set_column_type :score, :decimal
end
}
end

View file

@ -0,0 +1,13 @@
Sequel.migration do
up {
alter_table(:tags) do
add_unique_constraint :name
end
}
down {
alter_table(:tags) do
drop_constraint :tags_name_key
end
}
end

View file

@ -0,0 +1,15 @@
Sequel.migration do
up {
DB.add_column :sites, :autocomplete_enabled, :boolean, default: false
DB.add_column :sites, :editor_font_size, :int, default: 14
DB.add_column :sites, :keyboard_mode, :int, default: 0
DB.add_column :sites, :tab_width, :int, default: 2
}
down {
DB.drop_column :sites, :autocomplete_enabled
DB.drop_column :sites, :editor_font_size
DB.drop_column :sites, :keyboard_mode
DB.drop_column :sites, :tab_width
}
end

View file

@ -0,0 +1,16 @@
Sequel.migration do
up {
DB.rename_column :sites, :autocomplete_enabled, :editor_autocomplete_enabled
DB.rename_column :sites, :keyboard_mode, :editor_keyboard_mode
DB.rename_column :sites, :tab_width, :editor_tab_width
DB.drop_column :sites, :editor_keyboard_mode
DB.add_column :sites, :editor_keyboard_mode, String, size: 10
}
down {
DB.rename_column :sites, :editor_autocomplete_enabled, :autocomplete_enabled
DB.rename_column :sites, :editor_tab_width, :tab_width
DB.drop_column :sites, :editor_keyboard_mode
DB.add_column :sites, :keyboard_mode, :int, default: 0
}
end

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.add_column :sites, :editor_help_tooltips, :boolean, default: false
}
down {
DB.drop_column :sites, :editor_help_tooltips
}
end

View file

@ -1,38 +0,0 @@
require 'base32'
class Archive < Sequel::Model
many_to_one :site
set_primary_key [:site_id, :ipfs_hash]
unrestrict_primary_key
MAXIMUM_ARCHIVES_PER_SITE = 10
ARCHIVE_WAIT_TIME = 1.minute
def before_destroy
unpin
super
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 = Terrapin::CommandLine.new('ipfs', 'pin rm :ipfs_hash')
response = line.run ipfs_hash: ipfs_hash
output_array = response.to_s.split("\n")
end
end
def url
"https://#{ipfs_hash}.ipfs.neocitiesops.net"
end
end

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
class Event < Sequel::Model class Event < Sequel::Model
include Sequel::ParanoidDelete include Sequel::ParanoidDelete
@ -12,55 +13,32 @@ class Event < Sequel::Model
many_to_one :site many_to_one :site
many_to_one :actioning_site, key: :actioning_site_id, class: :Site many_to_one :actioning_site, key: :actioning_site_id, class: :Site
DEFAULT_GLOBAL_LIMIT = 300 PAGINATION_LENGTH = 10
GLOBAL_VIEWS_MINIMUM = 5 GLOBAL_PAGINATION_LENGTH = 20
GLOBAL_VIEWS_SITE_CHANGE_MINIMUM = 3_000 GLOBAL_SCORE_LIMIT = 2
ACTIVITY_TAG_SCORE_LIMIT = 0.2
def undeleted_comments_count def undeleted_comments_count
comments_dataset.exclude(is_deleted: true).count comments_dataset.exclude(is_deleted: true).count
end end
def undeleted_comments def undeleted_comments(exclude_ids=nil)
comments_dataset.exclude(is_deleted: true).order(:created_at).all ds = comments_dataset.exclude(is_deleted: true).order(:created_at)
if exclude_ids
ds = ds.exclude actioning_site_id: exclude_ids
end
ds.all
end end
def self.news_feed_default_dataset def self.news_feed_default_dataset
if SimpleCache.expired?(:excluded_actioning_site_ids) select(:events.*).
res = DB[%{select distinct(actioning_site_id) from events join sites on actioning_site_id=sites.id where sites.is_banned='t' or sites.is_nsfw='t' or sites.is_deleted='t'}].all.collect {|r| r[:actioning_site_id]} left_join(:sites, id: :site_id).
excluded_actioning_site_ids = SimpleCache.store :excluded_actioning_site_ids, res, 10.minutes left_join(Sequel[:sites].as(:actioning_sites), id: :events__actioning_site_id).
else exclude(sites__is_deleted: true).
excluded_actioning_site_ids = SimpleCache.get :excluded_actioning_site_ids exclude(actioning_sites__is_deleted: true).
end exclude(events__is_deleted: true).
where(follow_id: nil).
ds = select_all(:events). order(:events__created_at.desc)
order(:created_at.desc).
join_table(:inner, :sites, id: :site_id).
exclude(Sequel.qualify(:sites, :is_deleted) => true).
exclude(Sequel.qualify(:events, :is_deleted) => true).
exclude(is_banned: true)
unless excluded_actioning_site_ids.empty?
return ds.where("actioning_site_id is null or actioning_site_id not in ?", excluded_actioning_site_ids)
end
ds
end
def self.global_dataset(current_page=1, limit=DEFAULT_GLOBAL_LIMIT)
news_feed_default_dataset.
paginate(current_page, 100).
exclude(is_nsfw: true).
exclude(is_crashing: true).
where{views > GLOBAL_VIEWS_MINIMUM}.
where(site_change_id: nil)
end
def self.global_site_changes_dataset
news_feed_default_dataset.
where{views > GLOBAL_VIEWS_SITE_CHANGE_MINIMUM}.
exclude(is_nsfw: true).
exclude(is_crashing: true).
exclude(site_change_id: nil)
end end
def created_by?(site) def created_by?(site)
@ -105,4 +83,12 @@ class Event < Sequel::Model
true true
end end
end end
def name
return 'follow' if follow_id
return 'tip' if tip_id
return 'tag' if tag_id
return 'site change' if site_change_id
return 'comment' if profile_comment_id
end
end end

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
class SiteChange < Sequel::Model class SiteChange < Sequel::Model
NEW_CHANGE_TIMEOUT = 3600 * 24 # 24 hours NEW_CHANGE_TIMEOUT = 3600 * 24 # 24 hours
many_to_one :site many_to_one :site
@ -5,8 +6,8 @@ class SiteChange < Sequel::Model
one_to_one :site_change one_to_one :site_change
one_to_many :site_change_files one_to_many :site_change_files
def site_change_filenames(limit=4) def site_change_filenames(limit=6)
site_change_files_dataset.select(:filename).limit(limit).all.collect {|f| f.filename}.sort_by {|f| f.match('html') ? 0 : 1} site_change_files_dataset.select(:filename).limit(limit).order(:created_at.desc).all.collect {|f| f.filename}.sort_by {|f| f.match('html') ? 0 : 1}
end end
def self.record(site, filename) def self.record(site, filename)

View file

@ -1,12 +1,26 @@
# frozen_string_literal: true
require 'sanitize' require 'sanitize'
class SiteFile < Sequel::Model class SiteFile < Sequel::Model
CLASSIFIER_LIMIT = 1_000_000.freeze CLASSIFIER_LIMIT = 1_000_000
CLASSIFIER_WORD_LIMIT = 25.freeze CLASSIFIER_WORD_LIMIT = 25
FILE_PATH_CHARACTER_LIMIT = 1200
FILE_NAME_CHARACTER_LIMIT = 200
unrestrict_primary_key unrestrict_primary_key
plugin :update_primary_key plugin :update_primary_key
many_to_one :site many_to_one :site
def self.path_too_long?(filename)
return true if filename.length > FILE_PATH_CHARACTER_LIMIT
false
end
def self.name_too_long?(filename)
return true if filename.length > FILE_NAME_CHARACTER_LIMIT
false
end
def before_destroy def before_destroy
if is_directory if is_directory
site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: true).all.each do |site_file| site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: true).all.each do |site_file|
@ -17,7 +31,10 @@ class SiteFile < Sequel::Model
end end
site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: false).all.each do |site_file| site.site_files_dataset.where(path: /^#{Regexp.quote path}\//, is_directory: false).all.each do |site_file|
site_file.destroy begin
site_file.destroy
rescue Sequel::NoExistingObject
end
end end
begin begin
@ -44,6 +61,18 @@ class SiteFile < Sequel::Model
current_path = self.path current_path = self.path
new_path = site.scrubbed_path new_path new_path = site.scrubbed_path new_path
if new_path.length > FILE_PATH_CHARACTER_LIMIT
return false, 'new path too long'
end
if File.basename(new_path).length > FILE_NAME_CHARACTER_LIMIT
return false, 'new filename too long'
end
if new_path == ''
return false, 'cannot rename to empty path'
end
if current_path == 'index.html' if current_path == 'index.html'
return false, 'cannot rename or move root index.html' return false, 'cannot rename or move root index.html'
end end
@ -52,11 +81,18 @@ class SiteFile < Sequel::Model
return false, "#{is_directory ? 'directory' : 'file'} already exists" return false, "#{is_directory ? 'directory' : 'file'} already exists"
end end
unless is_directory if is_directory
if new_path.match(/\.html?$/)
return false, 'directory name cannot end with .htm or .html'
end
else # a file
mime_type = Magic.guess_file_mime_type site.files_path(self.path) mime_type = Magic.guess_file_mime_type site.files_path(self.path)
extname = File.extname new_path extname = File.extname new_path
return false, 'unsupported file type' unless site.class.valid_file_mime_type_and_ext?(mime_type, extname) unless site.supporter? || site.class.valid_file_mime_type_and_ext?(mime_type, extname)
return false, 'unsupported file type'
end
end end
begin begin
@ -73,8 +109,6 @@ class SiteFile < Sequel::Model
DB.transaction do DB.transaction do
self.path = new_path self.path = new_path
self.save_changes self.save_changes
site.purge_cache current_path
site.purge_cache new_path
if is_directory if is_directory
site_files_in_dir = site.site_files.select {|sf| sf.path =~ /^#{current_path}\//} site_files_in_dir = site.site_files.select {|sf| sf.path =~ /^#{current_path}\//}
@ -87,6 +121,9 @@ class SiteFile < Sequel::Model
site.purge_cache site_file.path site.purge_cache site_file.path
site.purge_cache original_site_file_path site.purge_cache original_site_file_path
end end
else
site.purge_cache new_path
site.purge_cache current_path
end end
end end
@ -99,7 +136,7 @@ class SiteFile < Sequel::Model
DB['update sites set space_used=space_used-? where id=?', size, site_id].first DB['update sites set space_used=space_used-? where id=?', size, site_id].first
end end
site.delete_cache site.files_path(path) site.purge_cache site.files_path(path)
SiteChangeFile.filter(site_id: site_id, filename: path).delete SiteChangeFile.filter(site_id: site_id, filename: path).delete
end end
end end

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
require 'resolv' require 'resolv'
require 'zlib' require 'zlib'
@ -18,14 +19,14 @@ class Stat < Sequel::Model
].first ].first
end end
def parse_logfiles(path) def parse_logfiles(logfiles_path)
total_site_stats = {} total_site_stats = {}
cache_control_ips = $config['cache_control_ips'] cache_control_ips = $config['cache_control_ips']
Dir["#{path}/*.log.gz"].each do |log_path| site_logs = {}
site_logs = {}
Dir["#{logfiles_path}/*.log.gz"].each do |log_path|
gzfile = File.open log_path, 'r' gzfile = File.open log_path, 'r'
logfile = Zlib::GzipReader.new gzfile logfile = Zlib::GzipReader.new gzfile
@ -50,7 +51,6 @@ class Stat < Sequel::Model
views: 0, views: 0,
bandwidth: 0, bandwidth: 0,
view_ips: [], view_ips: [],
ips: [],
referrers: {}, referrers: {},
paths: {} paths: {}
} unless site_logs[log_time][username] } unless site_logs[log_time][username]
@ -83,199 +83,59 @@ class Stat < Sequel::Model
site_logs[log_time][username][:paths][path] ||= 0 site_logs[log_time][username][:paths][path] ||= 0
site_logs[log_time][username][:paths][path] += 1 site_logs[log_time][username][:paths][path] += 1
end end
logfile.close
FileUtils.rm log_path
rescue => e rescue => e
puts "Log parse exception: #{e.inspect}" puts "Log parse exception: #{e.inspect}"
logfile.close logfile.close
FileUtils.mv log_path, log_path.gsub('.log', '.brokenlog') FileUtils.mv log_path, log_path.gsub('.log', '.brokenlog')
next next
end end
#FileUtils.rm log_path
end
logfile.close site_logs.each do |log_time, usernames|
Site.select(:id, :username).where(username: usernames.keys).all.each do |site|
site_logs.each do |log_time, usernames| usernames[site.username][:id] = site.id
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.transaction do
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: log_time.to_date.to_s}
stat = nil
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
end
end
end end
FileUtils.rm log_path usernames.each do |username, site_log|
next unless site_log[:id]
opts = {site_id: site_log[:id], created_at: log_time.to_date.to_s}
stat = Stat.select(:id).where(opts).first
stat = Stat.create opts if stat.nil?
DB['update sites set hits=hits+?, views=views+? where id=?',
site_log[:hits],
site_log[:views],
site_log[:id]
].first
DB[
'update stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where id=?',
site_log[:hits],
site_log[:views],
site_log[:bandwidth],
stat.id
].first
end
end end
total_site_stats.each do |time, stats| total_site_stats.each do |time, stats|
opts = {created_at: time.to_date.to_s} opts = {created_at: time.to_date.to_s}
DB[:stats].lock('EXCLUSIVE') {
stat = DailySiteStat.select(:id).where(opts).first stat = DailySiteStat.select(:id).where(opts).first
stat = DailySiteStat.create opts if stat.nil? stat = DailySiteStat.create opts if stat.nil?
}
DB[ DB[
'update daily_site_stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where created_at=?', 'update daily_site_stats set hits=hits+?, views=views+?, bandwidth=bandwidth+? where created_at=?',
stats[:hits], stats[:hits],
stats[:views], stats[:views],
stats[:bandwidth], stats[:bandwidth],
time.to_date time.to_date
].first ].first
end
end
end
end
=begin
require 'io/extra'
require 'geoip'
# Note: This isn't really a class right now.
module Stat
class << self
def parse_logfiles(path)
Dir["#{path}/*.log"].each do |logfile_path|
parse_logfile logfile_path
FileUtils.rm logfile_path
end
end
def parse_logfile(path)
geoip = GeoIP.new GEOCITY_PATH
logfile = File.open path, 'r'
hits = []
while hit = logfile.gets
time, username, size, path, ip, referrer = hit.split ' '
site = Site.select(:id).where(username: username).first
next unless site
paths_dataset = StatsDB[:paths]
path_record = paths_dataset[name: path]
path_id = path_record ? path_record[:id] : paths_dataset.insert(name: path)
referrers_dataset = StatsDB[:referrers]
referrer_record = referrers_dataset[name: referrer]
referrer_id = referrer_record ? referrer_record[:id] : referrers_dataset.insert(name: referrer)
location_id = nil
if city = geoip.city(ip)
locations_dataset = StatsDB[:locations].select(:id)
location_hash = {country_code2: city.country_code2, region_name: city.region_name, city_name: city.city_name}
location = locations_dataset.where(location_hash).first
location_id = location ? location[:id] : locations_dataset.insert(location_hash)
end
hits << [site.id, referrer_id, path_id, location_id, size, time]
end
StatsDB[:hits].import(
[:site_id, :referrer_id, :path_id, :location_id, :bytes_sent, :logged_at],
hits
)
end
end
end
=begin
def parse_logfile(path)
hits = {}
visits = {}
visit_ips = {}
logfile = File.open path, 'r'
while hit = logfile.gets
time, username, size, path, ip, referrer = hit.split ' '
hits[username] ||= 0
hits[username] += 1
visit_ips[username] = [] if !visit_ips[username]
unless visit_ips[username].include? ip
visits[username] ||= 0
visits[username] += 1
visit_ips[username] << ip
end
end
logfile.close
hits.each do |username,hitcount|
DB['update sites set hits=hits+? where username=?', hitcount, username].first
end
visits.each do |username,visitcount|
DB['update sites set views=views+? where username=?', visitcount, username].first
end end
end end
end end
=end end
=begin
def self.parse(logfile_path)
hits = {}
visits = {}
visit_ips = {}
logfile = File.open logfile_path, 'r'
while hit = logfile.gets
time, username, size, path, ip = hit.split ' '
hits[username] ||= 0
hits[username] += 1
visit_ips[username] = [] if !visit_ips[username]
unless visit_ips[username].include?(ip)
visits[username] ||= 0
visits[username] += 1
visit_ips[username] << ip
end
end
logfile.close
hits.each do |username,hitcount|
DB['update sites set hits=hits+? where username=?', hitcount, username].first
end
visits.each do |username,visitcount|
DB['update sites set views=views+? where username=?', visitcount, username].first
end
end
=end

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
require 'geoip' require 'geoip'
class StatLocation < Sequel::Model class StatLocation < Sequel::Model

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
class StatPath < Sequel::Model class StatPath < Sequel::Model
RETAINMENT_DAYS = 7 RETAINMENT_DAYS = 7

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
class StatReferrer < Sequel::Model class StatReferrer < Sequel::Model
many_to_one :site many_to_one :site
RETAINMENT_DAYS = 7 RETAINMENT_DAYS = 7

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Tag < Sequel::Model class Tag < Sequel::Model
NAME_LENGTH_MAX = 25 NAME_LENGTH_MAX = 25
NAME_WORDS_MAX = 1 NAME_WORDS_MAX = 1
INVALID_TAG_REGEX = /[^a-zA-Z0-9 ]/
many_to_many :sites many_to_many :sites
def before_create def before_create
@ -15,7 +17,11 @@ class Tag < Sequel::Model
def self.create_unless_exists(name) def self.create_unless_exists(name)
name = clean_name name name = clean_name name
return nil if name == '' || name.nil? return nil if name == '' || name.nil?
dataset.filter(name: name).first || create(name: name) begin
dataset.filter(name: name).first || create(name: name)
rescue Sequel::UniqueConstraintViolation
dataset.filter(name: name).first
end
end end
def self.autocomplete(name, limit=3) def self.autocomplete(name, limit=3)
@ -24,7 +30,7 @@ class Tag < Sequel::Model
def self.popular_names(limit=10) def self.popular_names(limit=10)
cache_key = "tag_popular_names_#{limit}".to_sym cache_key = "tag_popular_names_#{limit}".to_sym
cache = $redis_cache[cache_key] cache = $redis_cache.get cache_key
if cache.nil? 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 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 cache_key, res.to_msgpack $redis_cache.set cache_key, res.to_msgpack

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
.skeuocard.js *{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}.skeuocard.js html,.skeuocard.js body,.skeuocard.js div,.skeuocard.js span,.skeuocard.js applet,.skeuocard.js object,.skeuocard.js iframe,.skeuocard.js h1,.skeuocard.js h2,.skeuocard.js h3,.skeuocard.js h4,.skeuocard.js h5,.skeuocard.js h6,.skeuocard.js p,.skeuocard.js blockquote,.skeuocard.js pre,.skeuocard.js a,.skeuocard.js abbr,.skeuocard.js acronym,.skeuocard.js address,.skeuocard.js big,.skeuocard.js cite,.skeuocard.js code,.skeuocard.js del,.skeuocard.js dfn,.skeuocard.js em,.skeuocard.js img,.skeuocard.js ins,.skeuocard.js kbd,.skeuocard.js q,.skeuocard.js s,.skeuocard.js samp,.skeuocard.js small,.skeuocard.js strike,.skeuocard.js strong,.skeuocard.js sub,.skeuocard.js sup,.skeuocard.js tt,.skeuocard.js var,.skeuocard.js b,.skeuocard.js u,.skeuocard.js i,.skeuocard.js center,.skeuocard.js dl,.skeuocard.js dt,.skeuocard.js dd,.skeuocard.js ol,.skeuocard.js ul,.skeuocard.js li,.skeuocard.js fieldset,.skeuocard.js form,.skeuocard.js label,.skeuocard.js legend,.skeuocard.js table,.skeuocard.js caption,.skeuocard.js tbody,.skeuocard.js tfoot,.skeuocard.js thead,.skeuocard.js tr,.skeuocard.js th,.skeuocard.js td,.skeuocard.js article,.skeuocard.js aside,.skeuocard.js canvas,.skeuocard.js details,.skeuocard.js figcaption,.skeuocard.js figure,.skeuocard.js footer,.skeuocard.js header,.skeuocard.js hgroup,.skeuocard.js menu,.skeuocard.js nav,.skeuocard.js section,.skeuocard.js summary,.skeuocard.js time,.skeuocard.js mark,.skeuocard.js audio,.skeuocard.js video{margin:0;padding:0;border:0;outline:0;font-size:100%;font:inherit;vertical-align:baseline}.skeuocard.js article,.skeuocard.js aside,.skeuocard.js details,.skeuocard.js figcaption,.skeuocard.js figure,.skeuocard.js footer,.skeuocard.js header,.skeuocard.js hgroup,.skeuocard.js menu,.skeuocard.js nav,.skeuocard.js section{display:block}.skeuocard.js body{line-height:1}.skeuocard.js ol,.skeuocard.js ul{list-style:none}.skeuocard.js blockquote,.skeuocard.js q{quotes:none}.skeuocard.js blockquote:before,.skeuocard.js blockquote:after,.skeuocard.js q:before,.skeuocard.js q:after{content:'';content:none}.skeuocard.js ins{text-decoration:none}.skeuocard.js del{text-decoration:line-through}.skeuocard.js table{border-collapse:collapse;border-spacing:0}.skeuocard.js input,.skeuocard.js fieldset{line-height:normal;height:auto;padding:0px;margin:0px;display:inline-block;width:auto}
/*# sourceMappingURL=skeuocard.reset.css.map */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -6,7 +6,7 @@
</head> </head>
<body> <body>
<h1>Neocities is temporarily unavailable</h1> <h1>Neocities is temporarily unavailable</h1>
<p>Neocities is currently undergoing maintenance (or is experiencing an outage), we will be back shortly! Check <a href="https://twitter.com/neocities">@neocities</a> for status updates.</p> <p>Neocities is currently undergoing maintenance (or is experiencing an outage), we will be back shortly! Check <a href="https://bsky.app/profile/neocities.org">@neocities.org on Bluesky</a> for status updates.</p>
<p>Our apologies for the inconvenience.</p> <p>Our apologies for the inconvenience.</p>
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/-b1ZuF5yKoQ?rel=0&amp;start=2230" frameborder="0" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/-b1ZuF5yKoQ?rel=0&amp;start=2230" frameborder="0" allowfullscreen></iframe>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Some files were not shown because too many files have changed in this diff Show more