accidentally leaked secrets so had to nuke shit fuck
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
Agnes 2026-08-19 17:29:54 -04:00
commit 596fd85f2f
1685 changed files with 776713 additions and 0 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
DATABASE_CONTAINER=Neocities-Database
DATABASE=neocities
WEB_INTERFACE_CONTAINER=Neocities-Interface
REDIS_CONTAINER=Neocities-Caching
DATABASE_USER=neocities
DATABASE_PASSWORD=changeme

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
*.sh text eol=lf
docker/docker-entrypoint.sh text eol=lf
docker/Dockerfile text eol=lf

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

36
.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
/docker/config.yml.docker
.config
tests/coverage
config.yml
.DS_Store
public/css/neo.css
public/css/neo.css.map
public/site_thumbnails
public/sites
public/site_screenshots
public/site_screenshots_test
public/site_thumbnails_test
*.swp
files/map.txt
files/supporter-map.txt
files/maps
.sass-cache
.sass-cache/*
files/sslsites.zip
.tm_properties
.vagrant
public/banned_sites
public/deleted_sites
files/disposable_email_whitelist.conf
files/disposable_email_blacklist.conf
files/banned_email_blacklist.conf
files/letsencrypt.key
files/tor.txt
.bundle
ext/black_box.rb
files/trumpplan.txt
public/sitemap
.env
docker/config.yml.docker
docker/config.yaml.docker
docker/*

95
Gemfile Normal file
View file

@ -0,0 +1,95 @@
source 'https://rubygems.org'
gem 'connection_pool', '~> 2.4'
gem 'uri'
gem 'logger', '1.6.5' # Fixes a load dep error where 1.7.0 breaks init, probably change in the future
gem 'sinatra'
gem 'redis'
gem 'sequel'
gem 'redis-namespace'
gem 'bcrypt'
gem 'sinatra-flash', require: 'sinatra/flash'
gem 'sinatra-xsendfile', require: 'sinatra/xsendfile'
gem 'puma', require: nil
gem 'sidekiq', '~> 7'
gem 'mail'
gem 'tilt'
gem 'erubi'
gem 'stripe' #, source: 'https://code.stripe.com/'
gem 'terrapin'
gem 'sass', require: nil
gem 'filesize'
gem 'thread'
gem 'rest-client', require: 'rest_client'
gem 'addressable', '>= 2.8.0', require: 'addressable/uri'
gem 'ostruct' # stripe-ruby-mock depends on this default gem, which Ruby 4 no longer loads by default
gem 'geoip'
gem 'io-extra', require: 'io/extra'
#gem 'rye'
gem 'coveralls_reborn', require: false
gem 'sanitize'
gem 'will_paginate'
gem 'simpleidn'
gem 'gandi'
gem 'hoe', require: nil
gem 'msgpack'
gem 'acme-client', '~> 2.0.0'
gem 'http'
gem 'htmlentities'
gem 'rinku'
gem 'image_optim'
gem 'image_optim_pack'
gem 'ipaddress'
gem 'feedjira', '2.1.4'
gem 'monetize'
gem 'xmlrpc'
gem 'magic'
gem 'pg'
gem 'sequel_pg', require: nil
gem 'hiredis'
gem 'activesupport'
gem 'facter', require: nil
gem 'maxmind-db'
gem 'json', '>= 2.3.0'
gem 'nokogiri'
gem 'webp-ffi'
gem 'rszr'
gem 'zip_tricks'
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
gem 'pry'
end
group :development do
gem 'shotgun', require: nil
gem 'certified'
end
group :test do
gem 'faker'
gem 'fabrication', require: 'fabrication'
gem 'minitest'
gem 'minitest-reporters', require: 'minitest/reporters'
gem 'rack-test', require: 'rack/test'
gem 'mocha', require: nil
gem 'rake', '>= 12.3.3', require: nil
gem 'capybara', require: nil #, '2.10.1', require: nil
gem 'selenium-webdriver'
gem 'rack_session_access', require: nil
gem 'webmock', require: nil
gem 'stripe-ruby-mock', '~> 5.0.0', require: 'stripe_mock'
gem 'timecop'
gem 'mock_redis'
gem 'simplecov', require: nil
gem 'm'
end
gem "rackup", "~> 2.3"

722
Gemfile.lock Normal file
View file

@ -0,0 +1,722 @@
GEM
remote: https://rubygems.org/
specs:
acme-client (2.0.34)
base64 (~> 0.2)
faraday (>= 1.0, < 3.0.0)
faraday-retry (>= 1.0, < 3.0.0)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
airbrake (13.0.5)
airbrake-ruby (~> 6.0)
airbrake-ruby (6.2.2)
rbtree3 (~> 0.6)
ansi (1.6.0)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.2)
builder (3.3.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
certified (1.0.0)
climate_control (1.2.0)
coderay (1.1.3)
concurrent-ruby (1.3.8)
connection_pool (2.5.5)
coveralls_reborn (1.0.0)
simplecov (>= 1.0)
term-ansicolor (>= 1.11)
thor (>= 1.5)
tins (>= 1.54)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.7)
csv (3.3.6)
dante (0.2.0)
date (3.5.1)
dnsbl-client (1.1.1)
domain_name (0.6.20240107)
drb (2.2.3)
erb (6.0.7)
erubi (1.13.1)
exifr (1.5.1)
fabrication (3.0.0)
facter (4.6.1)
hocon (~> 1.3)
thor (>= 1.0.1, < 2.0)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.6)
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.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.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.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
feedjira (2.1.4)
faraday (>= 0.9)
faraday_middleware (>= 0.9)
loofah (>= 2.0)
sax-machine (>= 1.0)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
ffi-compiler (1.4.2)
ffi (>= 1.15.5)
rake
filesize (0.2.0)
fspath (3.1.2)
gandi (3.3.28)
hashie
xmlrpc
geoip (1.6.4)
hashdiff (1.2.1)
hashie (5.1.0)
logger
hiredis (0.6.3)
hocon (1.4.0)
hoe (4.7.1)
rake (~> 13.0)
htmlentities (4.4.2)
http (6.0.4)
http-cookie (~> 1.0)
llhttp (~> 0.6.1)
http-accept (1.7.0)
http-cookie (1.1.6)
domain_name (~> 0.5)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_optim (0.32.0)
exifr (~> 1.2, >= 1.2.2)
fspath (~> 3.0)
image_size (>= 1.5, < 4)
in_threads (~> 1.3)
progress (~> 3.0, >= 3.0.1)
image_optim_pack (0.13.1.20260816)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-aarch64-linux)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-aarch64-linux-gnu)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-aarch64-linux-musl)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-arm64-darwin)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-x86_64-darwin)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-x86_64-linux-gnu)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optim_pack (0.13.1.20260816-x86_64-linux-musl)
fspath (>= 2.1, < 4)
image_optim (~> 0.19)
image_optimizer (1.9.0)
image_size (3.6.0)
in_threads (1.6.0)
io-console (0.9.2)
io-extra (1.5.0)
ipaddress (0.8.3)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.21.2)
jwt (3.2.0)
base64
llhttp (0.6.2)
logger (1.6.5)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
m (1.7.0)
rake
magic (0.2.9)
ffi (>= 0.6.3)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
matrix (0.4.3)
maxmind-db (1.4.0)
maxmind-geoip2 (1.6.0)
connection_pool (>= 2.2, < 4.0)
http (>= 4.3, < 7.0)
maxmind-db (~> 1.4)
method_source (1.1.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0701)
minfraud (2.10.0)
connection_pool (>= 2.2, < 4.0)
http (>= 4.3, < 7.0)
maxmind-geoip2 (~> 1.6)
simpleidn (~> 0.1, >= 0.1.1)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
minitest-reporters (1.8.0)
ansi
builder
minitest (>= 5.0, < 7)
ruby-progressbar
mize (0.6.1)
mocha (3.1.0)
ruby2_keywords (>= 0.0.5)
mock_redis (0.55.0)
redis (~> 5)
monetize (2.0.0)
money (~> 7.0)
money (7.1.1)
bigdecimal
i18n (~> 1.9)
msgpack (1.8.4)
multi_json (1.21.1)
multipart-post (2.4.1)
mustermann (3.1.1)
net-imap (0.6.6)
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)
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
phonelib (0.10.24)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
progress (3.6.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rack_session_access (0.2.0)
builder (>= 2.0.0)
rack (>= 1.0.0)
rackup (2.3.1)
rack (>= 3)
rake (13.4.2)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rbtree3 (0.7.1)
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
readline (0.0.4)
reline
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.30.1)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.12.0)
reline (0.7.0)
io-console (~> 0.5)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.4.4)
rinku (2.0.6)
rszr (1.5.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (3.4.1)
sanitize (7.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sax-machine (1.3.2)
securerandom (0.4.1)
selenium-webdriver (4.44.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sequel (5.107.0)
bigdecimal
sequel_pg (1.19.0)
pg (>= 0.18.0, != 1.2.0)
sequel (>= 4.38.0)
shotgun (0.9.2)
rack (>= 1.0)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
simplecov (1.1.1)
simpleidn (0.2.3)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sinatra-flash (0.3.0)
sinatra (>= 1.0.0)
sinatra-xsendfile (0.4.2)
sinatra (>= 0.9.1)
stripe (13.5.1)
stripe-ruby-mock (5.0.0)
dante (>= 0.2.0)
drb (>= 2.0.4, < 3)
multi_json (~> 1.0)
stripe (> 5, < 14)
sync (0.5.0)
term-ansicolor (1.11.3)
tins (~> 1)
terrapin (1.1.1)
climate_control
thor (1.5.0)
thread (0.2.2)
tilt (2.8.0)
timecop (0.9.11)
timeout (0.6.1)
tins (1.56.0)
bigdecimal
irb
mize (~> 0.6)
readline
sync
tsort (0.2.0)
twilio-ruby (7.10.5)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 4.0)
nokogiri (>= 1.6, < 2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webp-ffi (0.4.0)
ffi (>= 1.9.0)
ffi-compiler (>= 0.1.2)
webrick (1.9.2)
websocket (1.2.11)
will_paginate (4.0.1)
xmlrpc (0.3.4)
rexml
webrick
xpath (3.2.0)
nokogiri (~> 1.8)
zip_tricks (5.6.0)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
acme-client (~> 2.0.0)
activesupport
addressable (>= 2.8.0)
airbrake
bcrypt
capybara
certified
connection_pool (~> 2.4)
coveralls_reborn
csv
dnsbl-client
erubi
fabrication
facter
faker
feedjira (= 2.1.4)
filesize
gandi
geoip
hiredis
hoe
htmlentities
http
image_optim
image_optim_pack
image_optimizer
io-extra
ipaddress
json (>= 2.3.0)
logger (= 1.6.5)
m
magic
mail
maxmind-db
minfraud
minitest
minitest-reporters
mocha
mock_redis
monetize
msgpack
nokogiri
ostruct
pg
phonelib
pry
puma
rack-test
rack_session_access
rackup (~> 2.3)
rake (>= 12.3.3)
redis
redis-namespace
rest-client
rinku
rszr
rubyzip
sanitize
sass
selenium-webdriver
sequel
sequel_pg
shotgun
sidekiq (~> 7)
simplecov
simpleidn
sinatra
sinatra-flash
sinatra-xsendfile
stripe
stripe-ruby-mock (~> 5.0.0)
terrapin
thread
tilt
timecop
twilio-ruby
uri
webmock
webp-ffi
will_paginate
xmlrpc
zip_tricks
CHECKSUMS
acme-client (2.0.34) sha256=3204a94addbdf82c06fe7a5d87a43c77ee135f0cbd05cb0ada18fffdf1935395
activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
airbrake (13.0.5) sha256=901f5074c25d5ef77ed87f5bde7a28400a7324f5d7013a8a12d07e0099cc31b6
airbrake-ruby (6.2.2) sha256=293e34fb36e763e1b6d67ab584cce7c5b6fe9eea1a70c26d8c13c0f5d7de2fbc
ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
certified (1.0.0) sha256=aa4cdf0e90e7ee96f6e0ce3daae39eaa8f0486124e0d92daf64d2105aeb9069c
climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
connection_pool (2.5.5) sha256=e54ff92855753df1fd7c59fa04a398833355f27dd14c074f8c83a05f72a716ad
coveralls_reborn (1.0.0) sha256=0c1f548f84fe8b51ca423b3d614866d48b6bf9a78a4c7a0692604755b7bb8684
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
dante (0.2.0) sha256=939776f04b4d253ffbbcf53341631aa2ee6e6cf314dedade2e60ac43b40a6fe6
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
dnsbl-client (1.1.1) sha256=ff94b0500c4f6663481367a7a6654d16f6fa4cb78ff188f7976d9d65577e8d56
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
exifr (1.5.1) sha256=ad87a5dbc92946fbf1d8ccd178d557d95d9168e8b3c5c5f381ea2bffcc312521
fabrication (3.0.0) sha256=a6a0bfad9071348ad3cb1701df788524b888c0cdd044b988893e7509f7463be3
facter (4.6.1) sha256=479971e82de8b084f8dd42ad7552fbefc81846507aa80c67ca0d10c75ca3b75f
faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc
faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
feedjira (2.1.4) sha256=4b3dbc74d618ef6a92c5f711160804e9141446c2ed0f87b0e611824efa649a2f
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
ffi-compiler (1.4.2) sha256=a9d69d5d9ced6f64776ddafa535867ad90e5740ad37bed7afb4de6e450da126e
filesize (0.2.0) sha256=0f5db8629d628b60857dd8e7b8d52f427e6b7b9dc49265bfda71c0d383cbe79e
fspath (3.1.2) sha256=b5ac9bafb97e2c8f8f9e303cd98ebd484be76fe9aa588bc4d01c6d99e78c9d75
gandi (3.3.28) sha256=6ced52ab889c76634a39824d266b586122ceba184ef0e30b05805725efa7ab38
geoip (1.6.4) sha256=4c9a4636e0ff66633271e91ac568bdda95520788e7610618d45350798935c1c5
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870
hiredis (0.6.3) sha256=7f052e320f7d24b5c2a9fdf67c6ff6facdf6e256394a703511bce34ecf445212
hocon (1.4.0) sha256=e71023ed7c56ae780ec34c0ce7789a233bcead08c045d50bc7b3af40f5afcd80
hoe (4.7.1) sha256=10c2edac888a16cffdd7d7d523ac685fa7ecc4afc529acbf20ba9ef5bf47738d
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
http (6.0.4) sha256=3768f902489ea8309424f1881c668af8d3ca73675648a755dfe2cb0030d42d26
http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
image_optim (0.32.0) sha256=457d5ea28ee93244c2aabc7134e06455587fb670f210e88e1be40aea882c2ba3
image_optim_pack (0.13.1.20260816) sha256=5e37513014fb90900b032467f83abc36b5c8b87bd715cf1d6c85e3c8642d7438
image_optim_pack (0.13.1.20260816-aarch64-linux) sha256=fd0525ecf79a7d4db58e328b892c1604796451d8aa273f1a816b8d85c1b15a0f
image_optim_pack (0.13.1.20260816-aarch64-linux-gnu) sha256=a4ad086a83f040dff9bab568d178cce858c49cef7599e2dd0ca98f9488063d9a
image_optim_pack (0.13.1.20260816-aarch64-linux-musl) sha256=70359dd3ecfa6d3870c6b0760adcb0d13ff7479ff8c2394089d23f29add37d4b
image_optim_pack (0.13.1.20260816-arm64-darwin) sha256=14429a57b302e3574290efb10405efe84ba264782755eb73d8781d878ba114e4
image_optim_pack (0.13.1.20260816-x86_64-darwin) sha256=5591c9bac5d0a062a6e2c3964071f853ad1f5ee61aeac3f00bd877159c9b39f6
image_optim_pack (0.13.1.20260816-x86_64-linux-gnu) sha256=ff3f4813f76229f6b7c6641cbefa2d289e61c9227e0277789f80e97f98ce63f0
image_optim_pack (0.13.1.20260816-x86_64-linux-musl) sha256=50aa4d521bb9a6d880d9040c1ef3f19d4ab12dbdbaa1b2ec77d584e37ac5f163
image_optimizer (1.9.0) sha256=2181d3a6b8f36c1e39add1a2b626ecf83d269e766a26d48dca18dc025d5ee531
image_size (3.6.0) sha256=1b9c4196cb658bf503a887920543a3991e927a4b767a5c770fb9665bce0dcb28
in_threads (1.6.0) sha256=91a7e6138d279dc632f59b8a9a409e47148948e297c0f69c92f9a2479a182149
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
io-extra (1.5.0) sha256=c5d9e36a72261a2151bbf18d561f0b91320454290cfad41b3e3d73eabf08d294
ipaddress (0.8.3) sha256=85640c4f9194c26937afc8c78e3074a8e7c97d5d1210358d1440f01034d006f5
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
llhttp (0.6.2) sha256=3c3c59aafb1e1594ab2f45293478f697458f5a91eb0f0db4b27f61a064024982
logger (1.6.5) sha256=c3cfe56d01656490ddd103d38b8993d73d86296adebc5f58cefc9ec03741e56b
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
m (1.7.0) sha256=058f793da8150c51353cc59366ffae7774683e868bede3c78f81d920fb9d633a
magic (0.2.9) sha256=f91a61dfd1f894666347178acd9b803372913a5c6e889427789d2372046e73a3
mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
maxmind-db (1.4.0) sha256=0765c79329847fe92687a7f215a6bae843121a09ac7c8d8d9930856bcb849052
maxmind-geoip2 (1.6.0) sha256=886f726e2419293e3d8dc3395568c790574327da5d8d26bd1d0a01c6b36d2f70
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
mime-types-data (3.2026.0701) sha256=cd8811e1fb89d836499ba0582368a10ee74cef929ba956d1d5ddca045e6a730f
minfraud (2.10.0) sha256=88ffbabc700c77770985f49a85cd21e458ee5272fd9deeedfdccdbed9c14cec6
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
minitest-reporters (1.8.0) sha256=8ce5280fb73ad3178ae525454df169b6f28c1b38b1d088ea91815d3a370ba384
mize (0.6.1) sha256=4031558979ff5426fda24c75a149b4e4c0faf4cacf2fae8938f83866ab94b780
mocha (3.1.0) sha256=75f42d69ebfb1f10b32489dff8f8431d37a418120ecdfc07afe3bc183d4e1d56
mock_redis (0.55.0) sha256=1562199cb94b40775ff73b09f33346074138a6f35e1ac0b614b0404777cd41f0
monetize (2.0.0) sha256=152980cadff9fd11a95817d56e3d7d69b07998c4506f15d971edf05851f82aba
money (7.1.1) sha256=8ed5cd99dfbc45e82688ea711cb59ea3c396c2608cd88876502b164c90269d4e
msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca
nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb
nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551
nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99
pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea
pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c
pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f
pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6
pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d
pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009
phonelib (0.10.24) sha256=5e8a623040fa496346fa71c48b3184c95caeddf58a36c041f03c0d201d4681ae
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
progress (3.6.0) sha256=360ed306dfa43d6174e847d563c70736dca249e2333cfec4b0387306c86cd573
pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rack_session_access (0.2.0) sha256=03eb98f2027429ccbbeb18556006dfb6d928b0557ad3770783b8e2f368198d6b
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
rbtree3 (0.7.1) sha256=ab60ead728a5491b70df4f4065e180b18dbab5319f817ce1dbf5dd906f26d8ba
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
readline (0.0.4) sha256=6138eef17be2b98298b672c3ea63bf9cb5158d401324f26e1e84f235879c1d6a
redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
redis-client (0.30.1) sha256=5151bc5c7bbfe48623732cdae3b900d8a22dc691cc7cdfacfb351ac55116522d
redis-namespace (1.11.0) sha256=e91a1aa2b2d888b6dea1d4ab8d39e1ae6fac3426161feb9d91dd5cca598a2239
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rinku (2.0.6) sha256=8b60670e3143f3db2b37efa262971ce3619ec23092045498ef9f077d82828d7d
rszr (1.5.0) sha256=504e2fa83fedbacdcbd16af86ab645f751df38491b2688d274eb8e1238d18298
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (3.4.1) sha256=0a79e745b5c25872ebd148457df5665da8530ed8626c993b01457128f173ca02
sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984
sass (3.7.4) sha256=808b0d39053aa69068df939e24671fe84fd5a9d3314486e1a1457d0934a4255d
sass-listen (4.0.0) sha256=ae9dcb76dd3e234329e5ba6e213f48e532c5a3e7b0b4d8a87f13aaca0cc18377
sax-machine (1.3.2) sha256=a1112678039eea4c402827ca0d377744e0f882fd6386f29f7b0023d938750d3a
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.44.0) sha256=6f1df072529af369589c46f0e01132952aabb250cfd683c274d74dc1eb5d8477
sequel (5.107.0) sha256=019629313205295b007f6df2c5eab19ddc40710d549fd445ac300f2853a370db
sequel_pg (1.19.0) sha256=cf384d09c6ea2c0ab31d1f95dcf12b0801b34829fe496ac102e43394498a82f3
shotgun (0.9.2) sha256=4ef19e63f98c620272666c98fa63e1e733c880b2ac112ac31578918fa2670411
sidekiq (7.3.9) sha256=1108712e1def89002b28e3545d5ae15d4a57ffd4d2c25d97bb1360988826b5a7
simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78
simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
sinatra-flash (0.3.0) sha256=fef044da4341840dcddc6f1cc9582c2babc6932aefbf1b00cda66f3bf6f717ee
sinatra-xsendfile (0.4.2) sha256=f60849bb60922f27191973b8af61c4ac1908fd5e29707bb4ce68bf6fe9e8f2cb
stripe (13.5.1) sha256=748e22cc6d84c944135298b865763524ff1192cee8bc7fd7baa987889a018eb3
stripe-ruby-mock (5.0.0) sha256=20566a0ad10ff026c7678c1aaf467c6f55d2f97316424846154e189f6c422316
sync (0.5.0) sha256=668356cc07c59ac7ed9ecf34fec3929831f179c07adb1f3e1c3b7a1609a638fd
term-ansicolor (1.11.3) sha256=8c01ec3abd095eb94dca8aed5a127d36e594239eb525eb306259c9e930534603
terrapin (1.1.1) sha256=9cae91045cffe5764b2c02ed10ddd847c580346ddd193870558e99891fd75329
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
thread (0.2.2) sha256=0ef704d20878d4dc9090fd2e8e99282fdc7bb047b2ee53fe15916bdfd97776f5
tilt (2.8.0) sha256=ba472eb2716fe1e04112d6d219a9dae938ec09a6a1e2ad3ecc7922e79bde3721
timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tins (1.56.0) sha256=5ae6cf81cab8fd4b63abd2ade4eaf75543e6973c664276d949ec7027e029b46e
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
twilio-ruby (7.10.5) sha256=af0611751e0880be233aff5c4756c8ea2b6c25f5a62f5ed2eea881c88db6b52a
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
webp-ffi (0.4.0) sha256=438c32c614b68a7249fc2ee2920c217061c05978dde2cfb9ee2d28af695beb0a
webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
will_paginate (4.0.1) sha256=107b226ebe1d393d274575956a7c472e1eefdd97d8828e01b72d425d15a875b9
xmlrpc (0.3.4) sha256=53a2e08dcff82e58f652c88e1d1ad49467ae341b8a2020339c00315112de5442
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
zip_tricks (5.6.0) sha256=ea644925d97bceee6f86ce0e1ec5a3f0cb84eabf5e56f740ff5d4b518a5aea19
BUNDLED WITH
4.0.3

23
LICENSE.txt Normal file
View file

@ -0,0 +1,23 @@
Copyright (c) 2013, Kyle Drake
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# HITHIS IS NEOCITIES FOR FEMSLASH AND YURI
thansk to bald-cap for initial docker containerization :)
<a href="https://femslash.city">Come on down to femslash city we got SIGHTS</a>
### NOTE: THIS IS NOT FOR NEOCITIES SUPPORT! Any issues filed not related to the source code itself will be closed. For support please contact: https://neocities.org/contact
# Neocities.org
[![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)
The web site for Neocities! It's open source. Want a feature on the site? Send a pull request!
## Getting Started
Neocities can be quickly launched in development mode with [Vagrant](https://www.vagrantup.com). Vagrant builds a virtual machine that automatically installs everything you need to run Neocities as a developer. Install Vagrant, then from the command line:
```
vagrant up --provision
```
![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
bundle exec rackup -o 0.0.0.0
```
Now you can access the running site from your browser: http://127.0.0.1:9292
## Want to contribute?
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 (https://github.com/neocities/neocities/fork)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request

263
Rakefile Normal file
View file

@ -0,0 +1,263 @@
require 'rake/testtask'
task :environment do
require './environment.rb'
end
desc "Run all tests"
Rake::TestTask.new do |t|
t.libs << "spec"
t.test_files = FileList['tests/**/*_tests.rb']
t.verbose = false
t.warning = false
end
task :default => :test
desc "prune logs"
task :prune_logs => [:environment] do
Stat.prune!
StatLocation.prune!
StatReferrer.prune!
StatPath.prune!
end
desc "parse logs"
task :parse_logs => [:environment] do
Stat.parse_logfiles $config['logs_path']
end
desc 'Update disposable email blacklist'
task :update_disposable_email_blacklist => [:environment] do
# 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, HTTP.get(uri))
end
desc 'Update banned IPs list'
task :update_blocked_ips => [:environment] do
filename = 'listed_ip_365_ipv46'
zip_path = "/tmp/#{filename}.zip"
File.open(zip_path, 'wb') do |file|
response = HTTP.get "https://www.stopforumspam.com/downloads/#{filename}.zip"
response.body.each do |chunk|
file.write chunk
end
end
Zip::File.open(zip_path) do |zip_file|
zip_file.each do |entry|
if entry.name == "#{filename}.txt"
ips = entry.get_input_stream.read
insert_hashes = []
ips.each_line { |ip| insert_hashes << { ip: ip.strip, created_at: Time.now } }
ips = nil
# Database transaction
DB.transaction do
DB[:blocked_ips].delete
DB[:blocked_ips].multi_insert insert_hashes
end
end
end
end
FileUtils.rm zip_path
end
desc 'rebuild_thumbnails'
task :rebuild_thumbnails => [:environment] do
dirs = Dir[Site::SITE_FILES_ROOT+'/**/*'].collect {|s| s.sub(Site::SITE_FILES_ROOT, '')}.collect {|s| s.sub('/', '')}
dirs.each do |d|
next if File.directory?(d)
full_path = d.split('/')
username = full_path.first
path = '/'+full_path[1..full_path.length].join('/')
if Pathname(path).extname.gsub('.', '').match Site::IMAGE_REGEX
begin
ThumbnailWorker.new.perform username, path
rescue Magick::ImageMagickError
end
end
end
end
desc 'compute_scores'
task :compute_scores => [:environment] do
Site.compute_scores
end
desc 'renew_ssl_certs'
task :renew_ssl_certs => [:environment] do
delay = 0
DB[%{select id from sites where (domain is not null or domain != '') and is_banned != 't' and is_deleted != 't' and (cert_updated_at is null or cert_updated_at < ?)}, 60.days.ago].all.each do |site|
LetsEncryptWorker.perform_in delay.seconds, site[:id]
delay += 10
end
end
desc 'purge_tmp_turds'
task :purge_tmp_turds => [:environment] do
['neocities_screenshot*', 'RackMultipart*', 'neocities_saving_file*', 'newinstall-*', '*.dmp', 'davfile*', 'magick*', '*.scan', '*.jpg'].each do |target|
Dir.glob("/tmp/#{target}").select {|filename| File::Stat.new(filename).ctime < (Time.now - 3600)}.each {|filename| FileUtils.rm(filename)}
end
end
desc 'compute_follow_count_scores'
task :compute_follow_count_scores => [:environment] do
Site.select(:id,:username,:follow_count).all.each do |site|
count = site.scorable_follow_count
if count != 0
puts "#{site.username} #{site.follow_count} => #{count}"
end
DB['update sites set follow_count=? where id=?', count, site.id].first
end
end
desc 'ml_screenshots_list_dump'
task :ml_screenshots_list_dump => [:environment] do
['phishing', 'spam', 'ham', nil].each do |classifier|
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|
begin
fp.write "#{site_file.site.screenshot_url('index.html', Site::SCREENSHOT_RESOLUTIONS.first)}\n"
rescue NoMethodError
end
end
end
end
end
desc 'generate_sitemap'
task :generate_sitemap => [:environment] do
=begin
sorted_sites = {}
# We pop off array, so highest scores go last.
sites = Site.
select(:id, :username, :updated_at, :profile_enabled).
where(site_changed: true).
exclude(updated_at: nil).
exclude(is_deleted: true).
order(:score).
all
site_files = []
sites.each do |site|
site.site_files_dataset.exclude(path: 'not_found.html').where(path: /\.html?$/).all.each do |site_file|
if site.uri(site_file.path) == site.uri
priority = 0.5
else
priority = 0.4
end
site_files << [site.uri(site_file.path), site_file.updated_at.utc.iso8601, priority]
end
end
sites = nil
GC.start
=end
sitemap_root = File.join Site::PUBLIC_ROOT, 'sitemap'
FileUtils.mkdir_p sitemap_root
=begin
index = 0
until site_files.empty?
sfs = site_files.pop 50000
file = File.open File.join(sitemap_root, "sites-#{index}.xml.gz"), 'w'
Zlib::GzipWriter.open File.join(sitemap_root, "sites-#{index}.xml.gz") do |gz|
gz.write %{<?xml version="1.0" encoding="UTF-8"?>\n}
gz.write %{<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n}
sfs.each do |sf|
gz.write %{<url><loc>#{sf[0].encode(xml: :text)}</loc><lastmod>#{sf[1].encode(xml: :text)}</lastmod><priority>#{sf[2].to_s.encode(xml: :text)}</priority></url>\n}
end
gz.write %{</urlset>}
end
index += 1
end
=end
# Set basic neocities.org root paths
builder = Nokogiri::XML::Builder.new { |xml|
xml.urlset(xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9') {
File.read(File.join(DIR_ROOT, 'files', 'root_site_uris.txt')).each_line { |uri|
priority, changefreq, uri = uri.strip.split(',')
xml.url {
xml.loc uri
xml.changefreq changefreq
xml.priority priority
}
}
}
}
Zlib::GzipWriter.open File.join(sitemap_root, 'root.xml.gz') do |gz|
gz.write builder.to_xml(encoding: 'UTF-8')
end
# Tagged sites sitemap
builder = Nokogiri::XML::Builder.new { |xml|
xml.urlset(xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9') {
Tag.popular_names(Site.count).each { |tag|
xml.url {
xml.loc "https://neocities.org/browse?sort_by=views&tag=#{tag[:name]}"
xml.changefreq 'daily'
xml.lastmod Time.now.utc.iso8601
}
}
}
}
Zlib::GzipWriter.open File.join(sitemap_root, 'tags.xml.gz') do |gz|
gz.write builder.to_xml(encoding: 'UTF-8')
end
# Final index.xml.gz entrypoint
Zlib::GzipWriter.open File.join(sitemap_root, 'index.xml.gz') do |gz|
gz.write %{<?xml version="1.0" encoding="UTF-8"?>\n}
gz.write %{<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n}
gz.write %{<sitemap><loc>https://neocities.org/sitemap/root.xml.gz</loc><lastmod>#{Time.now.utc.iso8601}</lastmod></sitemap>\n}
gz.write %{<sitemap><loc>https://neocities.org/sitemap/tags.xml.gz</loc><lastmod>#{Time.now.utc.iso8601}</lastmod></sitemap>\n}
=begin
0.upto(index-1).each do |i|
gz.write %{<sitemap><loc>https://neocities.org/sitemap/sites-#{i}.xml.gz</loc><lastmod>#{Time.now.utc.iso8601}</lastmod></sitemap>\n}
end
=end
gz.write %{</sitemapindex>}
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

68
SETUP.md Normal file
View file

@ -0,0 +1,68 @@
# Neocities Local Development Setup
## On Windows (host machine)
1. Install Ruby
```
winget install RubyInstallerTeam.Ruby.4.0
```
2. Add Ruby to your PATH (run in PowerShell)
```powershell
$rubyPath = "C:\Ruby40-x64\bin"
$oldPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($oldPath -notlike "*$rubyPath*") {
[Environment]::SetEnvironmentVariable("Path", $oldPath + ";$rubyPath", "User")
}
```
Then restart your terminal for the change to take effect.
3. Clone the repository
```
git clone https://github.com/bald-cap/neocities.git
cd neocities
```
3. Fix line endings on Vagrant scripts
```
dos2unix vagrant/common.sh vagrant/database.sh vagrant/development.sh vagrant/fs-primary.sh vagrant/redis.sh vagrant/ruby.sh vagrant/webapp.sh
```
4. Install Vagrant
```
winget install --id Hashicorp.Vagrant
```
5. Copy config file
```
cp config.yml.template config.yml
```
6. Start and provision the VM
```
vagrant up --provision
```
7. SSH into the VM
```
vagrant ssh
```
## Inside the VM
8. Add the rackup gem
```
sudo bundle add rackup
```
9. Install gems
```
sudo bundle install
```
10. Start the server
```
bundle exec rackup -o 0.0.0.0
```
The site will be available at http://127.0.0.1:9292

12
Vagrantfile vendored Normal file
View file

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

151
app.rb Normal file
View file

@ -0,0 +1,151 @@
# frozen_string_literal: true
require './environment.rb'
require './app_helpers.rb'
use Rack::Session::Cookie, key: 'neocities',
path: '/',
expire_after: 31556926, # one year in seconds
secret: Base64.strict_decode64($config['session_secret']),
httponly: true,
same_site: :lax,
secure: ENV['RACK_ENV'] == 'production'
use Rack::TempfileReaper
helpers do
def site_change_file_display_class(filename)
return 'html' if filename.match(Site::HTML_REGEX)
return 'image' if filename.match(Site::IMAGE_REGEX)
'misc'
end
def site_change_file_display_name(filename)
path = filename.to_s.sub(%r{\A/+}, '')
path = path.sub(%r{/index\.html?\z}, '')
path = path.sub(/\.html?\z/, '')
path.empty? ? 'index' : path
end
def csrf_token_input_html
%{<input name="csrf_token" type="hidden" value="#{csrf_token}">}
end
def require_admin
redirect '/' unless signed_in? && current_site.is_admin
end
def hcaptcha_input
return '' if ENV['RACK_ENV'] == 'test'
%{
<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
set :protection, :frame_options => "DENY"
GEOCITIES_NEIGHBORHOODS = %w{
area51
athens
augusta
baja
bourbonstreet
capecanaveral
capitolhill
collegepark
colosseum
enchantedforest
hollywood
motorcity
napavalley
nashville
petsburgh
pipeline
rainforest
researchtriangle
siliconvalley
soho
sunsetstrip
timessquare
televisioncity
tokyo
vienna
westhollywood
yosemite
}.freeze
def redirect_to_internet_archive_for_geocities_sites
match = request.path.match /^\/(\w+)\/.+$/i
if match && GEOCITIES_NEIGHBORHOODS.include?(match.captures.first.downcase)
redirect "https://wayback.archive.org/http://geocities.com/#{request.path}"
end
end
WHITELISTED_POST_PATHS = ['/create_validate_all', '/create_validate', '/create', '/search', '/search/'].freeze
before do
if request.path.match /^\/api\//i
@api = true
content_type :json
elsif request.path.match /^\/webhooks\//
# Skips the CSRF/validation check for stripe web hooks
elsif request.path.match /^\/admin/
require_admin
redirect '/' if request.post? && !WHITELISTED_POST_PATHS.include?(request.path_info) && !csrf_safe?
elsif current_site && current_site.email_not_validated? && !(request.path =~ /^\/site\/.+\/confirm_email|^\/settings\/change_email|^\/welcome|^\/supporter|^\/signout|^\/contact|^\/aup|^\/terms/)
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|^\/aup|^\/terms/)
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\/.+|^\/aup|^\/terms/)
redirect '/tutorial/html/1'
else
content_type :html, 'charset' => 'utf-8'
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, ArgumentError
params.delete 'tag'
end
end
end
after do
if @api || (!signed_in? && request.path == '/')
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
not_found do
api_not_found if @api
redirect_to_internet_archive_for_geocities_sites
@title = 'Not Found'
erb :'not_found'
end
error do
if @api
api_error 500, 'server_error', 'there has been an unknown server error, please try again later'
end
erb :'error'
end
Dir['./app/**/*.rb'].each {|f| require f}

102
app/activity.rb Normal file
View file

@ -0,0 +1,102 @@
get '/activity' do
@page = params[:page].to_i
@page = 1 if @page < 1
@description = 'See recent activity from Neocities websites.'
halt 404 if params[:event_id]
tag = nil
if params[:tag]
begin
tag = Tag.clean_name(params[:tag].to_s)
if tag.empty? || tag.length > Tag::NAME_LENGTH_MAX || tag.match?(Tag::INVALID_TAG_REGEX) || tag.match?(/\s/)
params.delete 'tag'
tag = nil
else
params[:tag] = tag
end
rescue Encoding::CompatibilityError, ArgumentError
params.delete 'tag'
end
end
tag_record = nil
if params[:tag]
tag_record = Tag.select(:id).where(name: params[:tag]).first
@description = "See recent activity from Neocities websites tagged #{tag}."
if tag_record
query1 = Event
.join(:sites, id: :site_id)
.join(:sites_tags, site_id: :id)
.where(sites_tags__tag_id: tag_record.id)
.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)
query2 = Event
.join(:sites, id: :actioning_site_id)
.join(:sites_tags, site_id: :id)
.where(sites_tags__tag_id: tag_record.id)
.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)
if current_site
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}))
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}))
end
ds = query1.union(query2, all: false).order(Sequel.desc(:created_at))
else
ds = Event.where(id: nil).order(Sequel.desc(:created_at))
end
else
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
record_count = nil
if current_site.nil?
if params[:tag] && tag_record.nil?
record_count = 0
else
cache_key = if params[:tag]
['activity_public_tag_record_count', params[:tag]]
else
:activity_public_record_count
end
if SimpleCache.expired?(cache_key)
record_count = SimpleCache.store(cache_key, ds.count, 10.minutes)
else
record_count = SimpleCache.get(cache_key)
end
end
max_record_count = Event::GLOBAL_PAGINATION_LENGTH * 100
record_count = [record_count, max_record_count].min
max_page = (record_count / Event::GLOBAL_PAGINATION_LENGTH.to_f).ceil
max_page = 1 if max_page < 1
halt 404 if @page > max_page
end
@pagination_dataset = ds.paginate(@page, Event::GLOBAL_PAGINATION_LENGTH, record_count)
@events = @pagination_dataset.all
erb :'activity'
end

496
app/admin.rb Normal file
View file

@ -0,0 +1,496 @@
get '/admin' do
require_admin
@banned_sites = Site.select(:username).filter(is_banned: true).order(:username).all
@nsfw_sites = Site.select(:username).filter(is_nsfw: true).order(:username).all
erb :'admin'
end
get '/admin/reports' do
require_admin
@page = params[:page] ? params[:page].to_i : 1
@page = 1 if @page < 1
@per_page = 51
@moderation_blur_categories = $config['moderation_blur_categories']
@reports = Report.join(:sites, id: :site_id).where(sites__is_deleted: false, sites__is_banned: false).order(:reports__created_at.desc).select_all(:reports).paginate(@page, @per_page)
@pagination_dataset = @reports
erb :'admin/reports'
end
post '/admin/reports/:report_id/dismiss' do
require_admin
content_type :json
report = Report[params[:report_id]]
return {success: false, error: 'Report not found'}.to_json if report.nil?
report.destroy
# Dismiss any other reports for the site
DB[:reports].where(site_id: report.site_id).delete
{success: true, message: 'Report dismissed'}.to_json
end
post '/admin/site_files/train' do
require_admin
site = Site[params[:site_id]]
site_file = site.site_files_dataset.where(path: params[:path]).first
not_found if site_file.nil?
site.untrain site_file.path
site.train site_file.path, params[:classifier]
'ok'
end
get '/admin/usage' do
require_admin
today = Date.today
current_month = Date.new today.year, today.month, 1
start_month = Date.new(2016, 2, 1)
# Build date range for batch query
months = []
month = current_month
until month < start_month
months << month
month = month.prev_month
end
# Batch query for all monthly stats at once using proper date comparisons
monthly_stats_query = <<-SQL
SELECT
DATE_TRUNC('month', created_at) as month,
SUM(views) as views,
SUM(hits) as hits,
SUM(bandwidth) as bandwidth
FROM daily_site_stats
WHERE created_at >= ?
AND created_at <= ?
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC
SQL
monthly_data = DB[monthly_stats_query, start_month, current_month.next_month].all
# Convert to hash for easy lookup
stats_by_month = {}
monthly_data.each do |row|
month_key = row[:month].to_date
stats_by_month[month_key] = {
views: row[:views] || 0,
hits: row[:hits] || 0,
bandwidth: row[:bandwidth] || 0
}
end
# Batch query for all popular sites data at once
popular_sites_query = <<-SQL
SELECT
DATE_TRUNC('month', stats.created_at) as month,
sites.username,
SUM(stats.bandwidth) as bandwidth
FROM stats
LEFT JOIN sites ON sites.id = stats.site_id
WHERE stats.created_at >= ?
AND stats.created_at <= ?
AND sites.username IS NOT NULL
GROUP BY DATE_TRUNC('month', stats.created_at), sites.username
SQL
all_popular_sites = DB[popular_sites_query, start_month, current_month.next_month].all
# Group popular sites by month and get top 50 for each
popular_by_month = {}
all_popular_sites.group_by { |row| row[:month].to_date }.each do |month_key, sites|
popular_by_month[month_key] = sites
.sort_by { |s| -(s[:bandwidth] || 0) }
.take(50)
.map { |s| { username: s[:username], bandwidth: s[:bandwidth] } }
end
# Combine the results
@monthly_stats = []
months.each do |month|
stats = stats_by_month[month]
next unless stats && stats[:views] != 0 && stats[:hits] != 0 && stats[:bandwidth] != 0
@monthly_stats.push({
views: stats[:views],
hits: stats[:hits],
bandwidth: stats[:bandwidth],
date: month,
popular_sites: popular_by_month[month] || []
})
end
erb :'admin/usage'
end
get '/admin/email' do
require_admin
erb :'admin/email'
end
get '/admin/stats' do
require_admin
@stats = {
total_hosted_site_hits: DB['SELECT SUM(hits) FROM sites'].first[:sum],
total_hosted_site_views: DB['SELECT SUM(views) FROM sites'].first[:sum],
total_site_changes: DB['select max(changed_count) from sites'].first[:max],
total_sites: Site.count
}
# Start with the date of the first created site
start = Site.select(:created_at).
exclude(created_at: nil).
order(:created_at).
first[:created_at].to_date
runner = start
monthly_stats = []
now = Date.today
while Date.new(runner.year, runner.month, 1) <= Date.new(now.year, now.month, 1)
# Get sites created in this time range
sites_in_range = Site.where(created_at: runner..runner.next_month)
sites_from_start = Site.where(created_at: start..runner.next_month)
supporters_count = Site.where(created_at: start..runner.next_month, parent_site_id: nil)
.where(
Sequel.|(
~{stripe_customer_id: nil} & ~{stripe_subscription_id: nil} & {plan_ended: [false, nil]} & ~{plan_type: ['free', 'special']},
{paypal_active: true}
)
).count
monthly_stats.push(
date: runner,
sites_created: sites_in_range.count,
total_from_start: sites_from_start.count,
supporters: supporters_count,
)
runner = runner.next_month
end
@stats[:monthly_stats] = monthly_stats
@stats[:current_supporters] = Site.where(parent_site_id: nil)
.where(
Sequel.|(
~{stripe_customer_id: nil} & ~{stripe_subscription_id: nil} & {plan_ended: [false, nil]} & ~{plan_type: ['free', 'special']},
{paypal_active: true}
)
).count
@stats[:supporter_percentage] = (@stats[:current_supporters].to_f / @stats[:total_sites] * 100).round(2)
erb :'admin/stats'
end
get '/admin/ban_history' do
require_admin
@title = 'Ban History'
@page = params[:page] ? params[:page].to_i : 1
@page = 1 if @page < 1
@per_page = 51
@sites = Site.where(is_banned: true).order(:banned_at.desc).exclude(banned_at: nil). paginate(@page, @per_page)
@pagination_dataset = @sites
erb :'admin/ban_history'
end
post '/admin/email' do
require_admin
%i{subject body}.each do |k|
if params[k].nil? || params[k].empty?
flash[:error] = "#{k.capitalize} is missing."
redirect '/admin/email'
end
end
sites = Site.newsletter_sites
day = 0
until sites.empty?
seconds = 0.0
queued_sites = []
Site::EMAIL_BLAST_MAXIMUM_PER_DAY.times {
break if sites.empty?
queued_sites << sites.pop
}
queued_sites.each do |site|
EmailWorker.perform_at((day.days.from_now + seconds), {
from: Site::FROM_EMAIL,
to: site.email,
subject: params[:subject],
body: params[:body]
})
seconds += 0.5
end
day += 1
end
flash[:success] = "#{sites.length} emails have been queued, #{Site::EMAIL_BLAST_MAXIMUM_PER_DAY} per day."
redirect '/'
end
post '/admin/ban' do
require_admin
if params[:usernames].empty?
flash[:error] = 'no usernames provided'
redirect request.referrer
end
usernames = params[:usernames].split("\n").collect {|u| u.strip}
deleted_count = 0
ip_deleted_count = 0
usernames.each do |username|
next if username == ''
site = Site[username: username]
next if site.nil? || site.is_banned
if !params[:classifier].empty?
site.untrain 'index.html'
site.train 'index.html', params[:classifier]
end
site.ban!
deleted_count += 1
if !params[:ban_using_ips].empty? && IPAddress.valid?(site.ip)
sites = Site.filter(ip: site.ip, is_banned: false).all
sites.each do |s|
next if usernames.include?(s.username)
s.ban!
end
ip_deleted_count += 1
end
if params[:classifier] == 'spam' || params[:classifier] == 'phishing'
next unless IPAddress.valid?(site.ip)
StopForumSpamWorker.perform_async(
username: site.username,
email: site.email,
ip: site.ip,
classifier: params[:classifier]
)
end
end
flash[:success] = "#{ip_deleted_count + deleted_count} sites have been banned, including #{ip_deleted_count} matching IPs."
redirect request.referrer
end
post '/admin/unban' do
require_admin
site = Site[username: params[:username]]
if site.nil?
flash[:error] = 'User not found'
redirect request.referrer
end
if !site.is_banned
flash[:error] = 'Site is not banned'
redirect request.referrer
end
site.unban!
flash[:success] = "Site #{site.username} was unbanned."
redirect request.referrer
end
post '/admin/mark_nsfw' do
require_admin
site = Site[username: params[:username]]
if site.nil?
flash[:error] = 'User not found'
redirect request.referrer
end
site.is_nsfw = true
site.admin_nsfw = true
site.save_changes validate: false
flash[:success] = 'MISSION ACCOMPLISHED'
redirect request.referrer
end
post '/admin/unmark_nsfw' do
require_admin
site = Site[username: params[:username]]
if site.nil?
flash[:error] = 'User not found'
redirect request.referrer
end
site.is_nsfw = false
site.admin_nsfw = false
site.save_changes validate: false
flash[:success] = 'Site unmarked as NSFW'
redirect request.referrer
end
post '/admin/mark_moderated' do
require_admin
not_found if params[:site_id].blank?
site = Site[params[:site_id]]
not_found if site.nil?
site_ids = Site.moderation_dataset.select(:id).where{created_at <= site.created_at}.all.collect {|s| s.id}
DB[:sites].where(id: site_ids).update(needs_moderation: false)
remaining_ds = Site.moderation_dataset.order(:created_at.desc)
remaining_ds = remaining_ds.paginate(1, Site::BROWSE_PAGINATION_LENGTH)
last_page = remaining_ds.page_count
last_page = 1 if last_page < 1
uri = Addressable::URI.parse request.referer
query = Rack::Utils.parse_query uri.query
query['page'] = last_page.to_s
uri.query = Rack::Utils.build_query(query)
redirect uri.to_s
end
post '/admin/feature' do
require_admin
site = Site[username: params[:username]]
if site.nil?
flash[:error] = 'User not found'
redirect request.referrer
end
site.featured_at = Time.now
site.save_changes(validate: false)
flash[:success] = 'Site has been featured.'
redirect request.referrer
end
post '/admin/verify_email' do
require_admin
site = Site[username: params[:username]]
if site.nil?
flash[:error] = 'User not found'
redirect request.referrer
end
if site.email_confirmed
flash[:error] = 'Email is already confirmed'
redirect request.referrer
end
site.email_confirmed = true
site.save_changes(validate: false)
flash[:success] = "Email for #{site.username} has been manually verified."
redirect request.referrer
end
post '/admin/site/change_password' do
require_admin
site = Site[username: params[:username]]
not_found if site.nil?
unless site.parent?
flash[:error] = 'Password can only be changed on parent sites.'
redirect "/admin/site/#{site.username}"
end
new_password = params[:new_password].to_s
site.password = new_password
site.valid?
if new_password != params[:new_password_confirm].to_s
site.errors.add :password, 'New passwords do not match.'
end
if site.errors.empty?
site.password_reset_token = nil
site.password_reset_confirmed = false
site.save_changes
site.send_email(
subject: '[Neocities] Your password has been changed',
body: Tilt.new('./views/templates/email/password_changed.erb', pretty: true).render(self)
)
flash[:success] = 'Password changed.'
else
flash[:error] = site.errors.first.last.first
end
redirect "/admin/site/#{site.username}"
end
get '/admin/masquerade/:username' do
require_admin
site = Site[username: params[:username]]
not_found if site.nil?
session[:id] = site.id
redirect '/'
end
get %r{/admin/site/(.+)} do |username_or_email_or_domain|
require_admin
ident = request.path_info.sub(%r{\A/admin/site/}, '')
if ident.blank?
flash[:error] = 'username or email or domain required'
redirect '/admin'
end
ident = ident.to_s.strip
ident = ident.sub(%r{\A(https?):/+}, '\1://')
begin
parsed_ident = Addressable::URI.parse(ident)
ident = parsed_ident.host if parsed_ident.host
rescue Addressable::URI::InvalidURIError
end
ident = ident.split('/').first.to_s.downcase
if ident =~ /@/
@site = Site[email: ident]
elsif ident.end_with?('.neocities.org')
@site = Site[username: ident.sub(/\.neocities\.org\z/, '')]
elsif ident =~ /.+\..+$/
@site = Site.where(domain: ident).first
else
@site = Site[username: ident]
end
if @site.nil?
flash[:error] = "site not found"
redirect request.referrer
end
@title = "Site Info - #{@site.username}"
@moderation_blur_categories = Array($config['moderation_blur_categories'])
@moderation_blur_paths = Report
.where(site_id: @site.id, type: @moderation_blur_categories)
.all
.map do |report|
path = report.site_file_path.to_s.sub(%r{\A/+}, '')
path.empty? ? 'index.html' : path
end
.uniq
erb :'admin/site'
end

391
app/api.rb Normal file
View file

@ -0,0 +1,391 @@
require 'base64'
require 'rack/mime'
get '/api' do
@title = 'Developers API'
@description = 'Use the Neocities API to upload files and manage your site programmatically.'
erb :'api'
end
post '/api/upload_hash' do
require_api_credentials
res = {}
files = []
params.each do |path, sha1_hash|
unless sha1_hash.is_a?(String)
api_error 400, 'nested_parameters_not_allowed', 'nested parameters are not allowed; each path must directly map to a SHA-1 hash string'
end
end
params.each do |k,v|
res[k] = current_site.sha1_hash_match? k, v
end
api_success files: res
end
get '/api/list' do
require_api_credentials
files = []
if params[:path].nil? || params[:path].empty? || params[:path] == '/'
file_list = current_site.site_files
else
file_list = current_site.file_list params[:path]
end
file_list.each do |file|
new_file = {}
new_file[:path] = file[:path]
new_file[:is_directory] = file[:is_directory]
new_file[:size] = file[:size] unless file[:is_directory]
new_file[:created_at] = file[:created_at].rfc2822
new_file[:updated_at] = file[:updated_at].rfc2822
new_file[:sha1_hash] = file[:sha1_hash] unless file[:is_directory]
files << new_file
end
files.each {|f| f[:path].sub!(/^\//, '')}
api_success files: files
end
get '/api/download' do
require_api_credentials
api_error 400, 'missing_path', 'you must provide path' if params[:path].blank?
api_error 400, 'bad_path', 'path must be a string' unless params[:path].is_a?(String)
api_error 400, 'bad_path', 'path is not valid' if params[:path].include?('..') || current_site.invalid_path?(params[:path])
begin
path = current_site.scrubbed_path params[:path]
rescue ArgumentError
api_error 400, 'bad_path', 'path is not valid'
end
site_file = current_site.site_files_dataset.where(path: path).first
api_error 400, 'missing_file', "could not find #{path}" if site_file.nil?
api_error 400, 'cannot_download_directory', 'cannot download a directory' if site_file.is_directory
file_path = current_site.files_path path
api_error 400, 'missing_file', "could not find #{path}" unless File.file?(file_path)
last_modified = site_file.updated_at || site_file.created_at || File.mtime(file_path)
headers 'Last-Modified' => last_modified.httpdate,
'ETag' => %("#{site_file.sha1_hash}"),
'Cache-Control' => 'private, no-store'
send_file file_path, type: Rack::Mime.mime_type(File.extname(path), 'application/octet-stream')
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
require_api_credentials
files = extract_files params
if !params[:username].blank?
site = Site[username: params[:username]]
if site.nil? || site.is_deleted
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
results = current_site.store_files files
if results.is_a?(Hash) && results[:error]
api_error 400, results[:error_type], results[:message]
end
if results.is_a?(Array)
success_count = results.count(true)
unchanged_count = results.count(:unchanged)
failure_count = results.count(false)
total = results.length
change_attempts = total - unchanged_count
if failure_count == total
api_error 500, 'upload_failed', 'all files failed to upload'
elsif failure_count > 0
message_parts = []
if success_count > 0
denominator = change_attempts.zero? ? total : change_attempts
message_parts << "#{success_count} of #{denominator} file(s) uploaded"
end
message_parts << "#{unchanged_count} file(s) already up to date" if unchanged_count > 0
message_parts << "#{failure_count} file(s) failed" if failure_count > 0
api_success message_parts.join(', ')
elsif success_count > 0
if unchanged_count > 0
api_success "#{success_count} file(s) uploaded, #{unchanged_count} already up to date"
else
api_success 'your file(s) have been successfully uploaded'
end
else
api_success 'no changes, files already up to date'
end
else
api_success 'your file(s) have been successfully uploaded'
end
end
post '/api/rename' do
require_api_credentials
api_error 400, 'missing_arguments', 'you must provide path and new_path' if params[:path].blank? || params[:new_path].blank?
path = current_site.scrubbed_path params[:path]
new_path = current_site.scrubbed_path params[:new_path]
unless path.is_a?(String)
api_error 400, 'bad_path', "#{path} is not a valid path, cancelled renaming"
end
unless new_path.is_a?(String)
api_error 400, 'bad_new_path', "#{new_path} is not a valid new_path, cancelled renaming"
end
site_file = current_site.site_files.select {|sf| sf.path == path}.first
if site_file.nil?
api_error 400, 'missing_file', "could not find #{path}"
end
res = site_file.rename new_path
if res.first == true
api_success "#{path} has been renamed to #{new_path}"
else
api_error 400, 'rename_error', res.last
end
end
post '/api/delete' do
require_api_credentials
api_error 400, 'missing_filenames', 'you must provide files to delete' if params[:filenames].nil? || params[:filenames].empty?
api_error 400, 'bad_filename', 'filenames must be an array, canceled deleting' unless params[:filenames].is_a?(Array)
paths = []
params[:filenames].each do |path|
unless path.is_a?(String)
api_error 400, 'bad_filename', "#{path} is not a valid filename, canceled deleting"
end
submitted_path = path
begin
path = current_site.scrubbed_path submitted_path
rescue ArgumentError
api_error 400, 'bad_filename', "#{submitted_path} is not a valid filename, canceled deleting"
end
if path.empty?
api_error 400, 'cannot_delete_site_directory', 'cannot delete the root directory of the site'
end
if current_site.invalid_path?(submitted_path) || current_site.invalid_path?(path)
api_error 400, 'bad_filename', "#{submitted_path} is not a valid filename, canceled deleting"
end
if path == 'index.html'
api_error 400, 'cannot_delete_index', 'you cannot delete your index.html file, canceled deleting'
end
if !current_site.file_exists?(path)
api_error 400, 'missing_files', "#{path} was not found on your site, canceled deleting"
end
paths << path
end
paths.each do |path|
current_site.delete_file(path)
end
api_success 'file(s) have been deleted'
end
post '/api/create_directory' do
dashboard_request = params[:from_dashboard] == 'true' && current_site && csrf_safe?
require_api_credentials
if params[:path].blank? && !dashboard_request
api_error 400, 'missing_path', 'you must provide path'
end
path = if dashboard_request
"#{params[:dir] || ''}/#{params[:name]}"
else
params[:path]
end
result = current_site.create_directory path
if dashboard_request
dashboard_dir = current_site.scrubbed_path(params[:dir])
@api = false
flash[:error] = result unless result == true
redirect "/dashboard?dir=#{Rack::Utils.escape dashboard_dir}"
end
if result == true
api_success 'directory has been created'
else
api_error 400, 'create_directory_error', result
end
end
get '/api/info' do
if params[:sitename]
site = Site[username: params[:sitename]]
api_error 400, 'site_not_found', "could not find site #{params[:sitename]}" if site.nil? || site.is_banned
api_success api_info_for(site)
else
init_api_credentials
api_success api_info_for(current_site)
end
end
get '/api/key' do
require_api_credentials
current_site.generate_api_key! if current_site.api_key.blank?
api_success api_key: current_site.api_key
end
def api_info_for(site)
{
info: {
sitename: site.username,
views: site.views,
hits: site.hits,
created_at: site.created_at.rfc2822,
last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil,
domain: site.domain,
supporter: site.supporter?,
tags: site.tags.collect {|t| t.name}
}
}
end
# Catch-all for missing api calls
get '/api/:name' do
api_not_found
end
post '/api/:name' do
api_not_found
end
def require_api_credentials
if current_site && csrf_safe?
api_error_invalid_auth unless current_site.required_validations_met?
return true
end
if !request.env['HTTP_AUTHORIZATION'].nil?
init_api_credentials
else
api_error_invalid_auth
end
end
def init_api_credentials
auth = request.env['HTTP_AUTHORIZATION']
begin
if bearer_match = auth.match(/^Bearer (.+)/)
api_key = bearer_match.captures.first
api_error_invalid_auth if api_key.nil? || api_key.empty?
else
user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':')
end
rescue
api_error_invalid_auth
end
if defined?(api_key) && !api_key.blank?
site = Site[api_key: api_key]
elsif defined?(user) && defined?(pass)
site = Site.get_site_from_login user, pass
else
api_error_invalid_auth
end
if site.nil? || site.is_banned || site.is_deleted || !(site.required_validations_met?)
api_error_invalid_auth
end
DB['update sites set api_calls=api_calls+1 where id=?', site.id].first
session[:id] = site.id
end
def api_success(message_or_obj)
output = {result: 'success'}
if message_or_obj.is_a?(String)
output[:message] = message_or_obj
else
output.merge! message_or_obj
end
api_response(200, output)
end
def api_response(status, output)
halt status, JSON.pretty_generate(output)+"\n"
end
def api_error(status, error_type, message)
api_response(status, result: 'error', error_type: error_type, message: message)
end
def api_error_invalid_auth
api_error 403, 'invalid_auth', 'invalid credentials - please check your username and password (or your api key)'
end
def api_not_found
api_error 404, 'not_found', 'the requested api call does not exist'
end

7
app/blog.rb Normal file
View file

@ -0,0 +1,7 @@
get '/blog/?' do
redirect 'https://blog.neocities.org', 301
end
get '/blog/:article' do |article|
redirect "https://blog.neocities.org/#{article}.html", 301
end

131
app/browse.rb Normal file
View file

@ -0,0 +1,131 @@
get '/browse/?' do
@page = params[:page]
@page = 1 if @page.not_an_integer?
@description = 'Browse Neocities websites by tags, popularity, and recent updates.'
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]}"
@description = "Browse Neocities websites tagged #{params[:tag]}."
end
if is_education?
ds = education_sites_dataset
else
ds = browse_sites_dataset
end
ds = ds.paginate @page.to_i, Site::BROWSE_PAGINATION_LENGTH
@pagination_dataset = ds
@sites = ds.all
site_ids = @sites.collect {|s| s[:id]}
tags = DB['select site_id,name from tags join sites_tags on tags.id=sites_tags.tag_id where site_id IN ?', site_ids].all
@site_tags = {}
site_ids.each do |site_id|
@site_tags[site_id] = tags.select {|t| t[:site_id] == site_id}.collect {|t| t[:name]}
end
erb :browse
end
def education_sites_dataset
ds = Site.filter is_deleted: false
ds = ds.association_join(:tags).select_all(:sites)
params[:tag] = current_site.tags.first.name
ds = ds.where ['tags.name = ?', params[:tag]]
end
def browse_sites_dataset
ds = Site.browse_dataset
ds = ds.where is_education: false
if current_site
ds = ds.or sites__id: current_site.id
if !current_site.blocking_site_ids.empty?
ds = ds.where(Sequel.~(Sequel.qualify(:sites, :id) => current_site.blocking_site_ids))
end
if current_site.blocks_dataset.count
ds = ds.where(
Sequel.~(Sequel.qualify(:sites, :id) => current_site.blocks_dataset.select(:actioning_site_id).all.collect {|s| s.actioning_site_id})
)
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]
when 'special_sauce'
ds = ds.where{score > 1} unless params[:tag]
ds = ds.order :score.desc, :follow_count.desc, :views.desc, :site_updated_at.desc
when 'random'
ds = ds.where{score > 3} unless params[:tag]
ds = ds.order(Sequel.lit('RANDOM()'))
when 'most_followed'
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 current_site && current_site.is_admin
ds = ds.exclude site_updated_at: nil
ds = ds.order :created_at.desc, :views.desc
when 'moderation'
not_found unless current_site && current_site.is_admin
ds = Site.moderation_dataset
ds = ds.order :created_at.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 > 5}
ds = ds.order(:hits.desc, :site_updated_at.desc)
when 'views'
ds = ds.where{score > 5}
ds = ds.order(:views.desc, :site_updated_at.desc)
when 'featured'
ds = ds.exclude featured_at: nil
ds = ds.order :featured_at.desc
when 'tipping_enabled'
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{score > 1} unless params[:tag]
ds = ds.group :sites__id
ds = ds.order :follow_count.desc, :views.desc, :updated_at.desc
when 'blocks'
require_admin
ds = ds.select{[sites.*, Sequel[count(site_id)].as(:total)]}
ds = ds.inner_join :blocks, :site_id => :id
ds = ds.group :sites__id
ds = ds.order :total.desc
else
not_found
end
unless params[:sort_by] == 'moderation'
ds = ds.where ['sites.is_nsfw = ?', (params[:is_nsfw] == 'true' ? true : false)]
end
if params[:tag]
ds = ds.select_all :sites
ds = ds.inner_join :sites_tags, :site_id => :id
ds = ds.inner_join :tags, :id => :sites_tags__tag_id
ds = ds.where ['tags.name = ?', params[:tag]]
ds = ds.where ['tags.is_nsfw = ?', (params[:is_nsfw] == 'true' ? true : false)]
end
ds
end

21
app/comment.rb Normal file
View file

@ -0,0 +1,21 @@
post '/comment/:comment_id/toggle_like' do |comment_id|
require_login
content_type :json
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'
{result: liked_response, comment_like_count: comment.comment_likes_dataset.count, liking_site_names: comment.liking_site_usernames}.to_json
end
post '/comment/:comment_id/delete' do |comment_id|
require_login
content_type :json
comment = Comment[id: comment_id]
if comment.event.site == current_site || comment.actioning_site == current_site
comment.delete
return {result: 'success'}.to_json
end
return {result: 'error'}.to_json
end

57
app/contact.rb Normal file
View file

@ -0,0 +1,57 @@
get '/contact' do
@show_contact_form = params[:show_contact_form] == 'yes'
@description = 'Contact Neocities support and get help with your account or site.'
erb :'contact'
end
post '/contact' do
@errors = []
if params[:email].empty? || params[:subject].empty? || params[:body].empty?
@errors << 'Please fill out all fields'
end
if params[:email] != params[:confirm_email]
@errors << 'Email addresses do not match'
end
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)'
end
if !@errors.empty?
erb :'contact'
else
body = params[:body]
if current_site
body = "current username: #{current_site.username}\n\n" + body
if parent_site != current_site
body = "parent username: #{parent_site.username}\n\n" + body
end
end
if current_site && current_site.supporter?
subject = "#{params[:subject]} [Supporter]"
else
subject = params[:subject]
end
subject += " [#{SecureRandom.hex[0..3]}]"
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
reply_to: params[:email],
to: $config['support_email'],
subject: subject,
body: body,
no_footer: true
})
flash[:success] = 'Your contact message has been sent.'
redirect '/'
end
end

154
app/create.rb Normal file
View file

@ -0,0 +1,154 @@
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?
return true if education_whitelist_required? && !$config['education_tag_whitelist'].select {|t| params[:new_tags_string].match(Regexp.quote(t))}.empty?
false
end
post '/create_validate_all' do
content_type :json
fields = params.select {|p| p.match CREATE_MATCH_REGEX}
begin
site = Site.new fields
rescue ArgumentError => e
if e.message == 'input string invalid'
return {error: 'invalid input'}.to_json
else
raise e
end
end
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
post '/create_validate' do
content_type :json
if !params[:field].match CREATE_MATCH_REGEX
return {error: 'not a valid field'}.to_json
end
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.valid?
field_sym = params[:field].to_sym
if site.errors[field_sym]
return {error: site.errors[field_sym].first}.to_json
end
{result: 'ok'}.to_json
end
post '/create' do
content_type :json
dashboard_if_signed_in
@site = Site.new(
username: params[:username],
password: params[:password],
email: params[:email],
new_tags_string: params[:new_tags_string],
is_education: params[:is_education] == 'true' ? true : false,
ip: request.ip,
ga_adgroupid: session[:ga_adgroupid]
)
if education_whitelist_required?
if education_whitelisted?
@site.email_confirmed = true
else
flash[:error] = 'The class tag is invalid.'
return {result: 'error'}.to_json
end
else
if !hcaptcha_valid?
flash[:error] = 'The captcha was not valid, please try again.'
return {result: 'error'}.to_json
end
if Site.ip_create_limit?(request.ip)
flash[:error] = 'Your IP address has created too many sites, please try again later or contact support.'
return {result: 'error'}.to_json
end
if Site.disposable_mx_record?(@site.email)
flash[:error] = 'Cannot use a disposable email address.'
return {result: 'error'}.to_json
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?
flash[:error] = @site.errors.first.last.first
return {result: 'error'}.to_json
end
end
@site.email_confirmed = true if self.class.development?
@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: Site::FROM_EMAIL,
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?
send_confirmation_email @site
@site.send_email(
subject: "[Neocities] Welcome to Neocities!",
body: Tilt.new('./views/templates/email/welcome.erb', pretty: true).render(self)
)
end
session[:id] = @site.id
{result: 'ok'}.to_json
end

40
app/dashboard.rb Normal file
View file

@ -0,0 +1,40 @@
get '/dashboard' do
require_login
dashboard_init
dont_browser_cache
unless current_site.dashboard_accessed
current_site.dashboard_accessed = true
current_site.save_changes validate: false
end
erb :'dashboard/index'
end
def dashboard_init
if params[:dir] && params[:dir][0] != '/'
params[:dir] = '/'+params[:dir]
end
if !File.directory?(current_site.files_path(params[:dir]))
if !File.directory?(current_site.files_path)
flash[:error] = 'Could not find your web site, please contact support.'
signout
redirect '/'
else
flash[:error] = 'Could not find the requested directory.'
redirect '/dashboard'
end
end
@dir = params[:dir]
@file_list = current_site.file_list @dir
end
get '/dashboard/files' do
require_login
dashboard_init
dont_browser_cache
erb :'dashboard/files', layout: false
end

34
app/dmca.rb Normal file
View file

@ -0,0 +1,34 @@
get '/dmca' do
@title = 'DMCA'
@description = 'Submit a DMCA notice and review copyright policy information for Neocities.'
erb :'dmca'
end
post '/dmca/contact' do
@title = 'DMCA'
@errors = []
if params[:email].empty? || params[:subject].empty? || params[:urls].empty? || params[:body].empty?
@errors << 'Please fill out all fields'
end
if !hcaptcha_valid?
@errors << 'Captcha was not filled out (or was filled out incorrectly)'
end
if !@errors.empty?
erb :'dmca'
else
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
reply_to: params[:email],
to: 'dmca@neocities.org',
subject: "[Neocities DMCA Notice]: #{params[:subject]}",
body: "#{params[:urls].to_s}\n#{params[:body].to_s}",
no_footer: true
})
flash[:success] = 'Your DMCA notification has been sent.'
redirect '/'
end
end

29
app/domain.rb Normal file
View file

@ -0,0 +1,29 @@
get '/domain/new' do
require_login
@title = 'Register a Domain'
erb :'domain/new'
end
post '/domain/check_availability.json' do
require_login
content_type :json
timer = Time.now.to_i
while true
if (Time.now.to_i - timer) > 60
api_error 200, :contact_fail, 'Error contacting domain server, please try again.'
end
begin
res = $gandi.domain.available([params[:domain]])[params[:domain]]
rescue => Gandi::DataError
api_error 200, :invalid_domain, 'Domain name was invalid, please try another.'
end
api_success res unless res == 'pending'
sleep 0.2
end
end

69
app/event.rb Normal file
View file

@ -0,0 +1,69 @@
post '/event/:event_id/toggle_like' do |event_id|
require_login
content_type :json
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'
{result: liked_response, event_like_count: event.likes_dataset.count, liking_site_names: event.liking_site_usernames}.to_json
end
post '/event/:event_id/comment' do |event_id|
require_login
content_type :json
event = Event[id: event_id]
if event.site && event.site.is_blocking?(current_site)
flash[:error] = comment_unavailable_message(event.site)
return {result: 'error', message: flash[:error]}.to_json
end
if event.actioning_site && event.actioning_site.is_blocking?(current_site)
flash[:error] = comment_unavailable_message(event.actioning_site)
return {result: 'error', message: flash[:error]}.to_json
end
site = event.site
message = normalize_comment_message(params[:message])
message_error = comment_message_error(message)
unavailable_message = comment_unavailable_message(site)
if(site.is_blocking?(current_site) ||
site.profile_comments_enabled == false ||
current_site.commenting_allowed? == false ||
(current_site.is_a_jerk? && event.site_id != current_site.id && !site.is_following?(current_site)) ||
message_error)
flash[:error] = message_error || unavailable_message
return {result: 'error', message: flash[:error]}.to_json
end
event.add_site_comment current_site, message
{result: 'success'}.to_json
end
post '/event/:event_id/update_profile_comment' do |event_id|
require_login
content_type :json
event = Event[id: event_id]
message = normalize_comment_message(params[:message])
message_error = comment_message_error(message)
return {result: 'error'}.to_json unless (current_site.id == event.profile_comment.actioning_site_id &&
message_error.nil?)
event.profile_comment.update message: message
return {result: 'success'}.to_json
end
post '/event/:event_id/delete' do |event_id|
require_login
content_type :json
event = Event[id: event_id]
if event.site_id == current_site.id || event.actioning_site_id == current_site.id
event.delete
return {result: 'success'}.to_json
end
return {result: 'error'}.to_json
end

240
app/index.rb Normal file
View file

@ -0,0 +1,240 @@
get '/?' do
hosted_site = Site.find_by_host(request.host)
if hosted_site
path = request.path_info.sub(%r{\A/}, '')
path = 'index.html' if path.empty?
path = hosted_site.scrubbed_path(path)
unless hosted_site.file_exists?(path)
not_found
end
file_path = hosted_site.files_path(path)
send_file file_path,
type: Rack::Mime.mime_type(
File.extname(path),
'application/octet-stream'
)
end
if current_site
require_login
redirect '/dashboard' if current_site.is_education
@page = params[:page]
@page = 1 if @page.not_an_integer?
if params[:activity] == 'mine'
events_dataset = current_site.latest_events(@page)
elsif params[:event_id]
event = Event.select(:id).where(id: params[:event_id]).first
not_found if event.nil?
not_found if event.is_deleted
events_dataset = Event.where(id: params[:event_id]).paginate(1, 1)
else
events_dataset = current_site.news_feed(@page)
end
@pagination_dataset = events_dataset
@events = events_dataset.all
current_site.events_dataset.update notification_seen: true
halt erb :'home', locals: {site: current_site}
end
com_preview = (params[:index_com] == '1' || params[:index_com] == 'true') &&
ENV['RACK_ENV'] != 'production'
if request.host == 'neocities.com' || request.host == 'www.neocities.com' || com_preview
@canonical_host = 'https://neocities.com'
@canonical_path = request.path_info
if SimpleCache.expired?(:index_com_html)
index_com_html = SimpleCache.store :index_com_html, erb(:index_com, layout: :index_layout), 1.hour
else
index_com_html = SimpleCache.get :index_com_html
end
halt index_com_html
end
if SimpleCache.expired?(:sites_count)
@sites_count = SimpleCache.store :sites_count, Site.count.roundup(100), 4.hours
else
@sites_count = SimpleCache.get :sites_count
end
if SimpleCache.expired?(:total_hits_count)
@total_hits_count = SimpleCache.store :total_hits_count, DB['SELECT SUM(hits) AS hits FROM SITES'].first[:hits], 4.hours
else
@total_hits_count = SimpleCache.get :total_hits_count
end
@total_hits_count ||= 0
if SimpleCache.expired?(:total_views_count)
@total_views_count = SimpleCache.store :total_views_count, DB['SELECT SUM(views) AS views FROM SITES'].first[:views], 4.hours
else
@total_views_count = SimpleCache.get :total_views_count
end
@total_views_count ||= 0
if SimpleCache.expired?(:changed_count)
@changed_count = SimpleCache.store :changed_count, DB['SELECT SUM(changed_count) AS changed_count FROM SITES'].first[:changed_count], 4.hours
else
@changed_count = SimpleCache.get :changed_count
end
@changed_count ||= 0
=begin
if SimpleCache.expired?(:blog_feed_html)
@blog_feed_html = ''
begin
xml = HTTP.timeout(global: 2).get('https://blog.neocities.org/feed.xml').to_s
feed = Feedjira::Feed.parse xml
feed.entries[0..2].each do |entry|
@blog_feed_html += %{<a href="#{entry.url}">#{entry.title.split('.').first}</a> <span style="float: right">#{entry.published.strftime('%b %-d, %Y')}</span><br>}
end
rescue
@blog_feed_html = 'The latest news on Neocities can be found on our blog.'
end
@blog_feed_html = SimpleCache.store :blog_feed_html, @blog_feed_html, 8.hours
else
@blog_feed_html = SimpleCache.get :blog_feed_html
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
@description = 'Create and surf awesome websites for free.'
erb :index, layout: :index_layout
end
get '/sitemap.xml' do
content_type 'application/xml', charset: 'UTF-8'
if request.host == 'neocities.com' || request.host == 'www.neocities.com'
erb :sitemap_com, layout: false
else
erb :sitemap_org, layout: false
end
end
get '/welcome' do
require_login
redirect '/' if current_site.supporter?
@title = 'Welcome!'
erb :'welcome', locals: {site: current_site}
end
get '/education' do
redirect '/' if signed_in?
@description = 'Learn how educators and students use Neocities to build websites in class.'
erb :education, layout: :index_layout
end
get '/donate' do
@description = 'Support Neocities and help keep independent web publishing open to everyone.'
erb :'donate'
end
get '/about' do
@description = 'Read about the Neocities mission and the team building a better web.'
erb :'about'
end
get '/terms' do
@description = 'Review the Neocities Terms of Service.'
erb :'terms'
end
get '/privacy' do
@description = 'Review the Neocities Privacy Policy.'
erb :'privacy'
end
get '/press' do
@description = 'Press resources, facts, and media information about Neocities.'
erb :'press'
end
get '/legal/?' do
@title = 'Legal Guide to Neocities'
@description = 'Browse legal information and policy guidance for Neocities.'
erb :'legal'
end
get '/thankyou' do
@title = 'Thank you!'
@description = 'Thank you for supporting Neocities.'
erb :'thankyou'
end
get '/cli' do
@title = 'Command Line Interface'
@description = 'Use the Neocities command line tool to deploy and manage your site from terminal.'
erb :'cli'
end
get '/forgot_username' do
@title = 'Forgot Username'
@description = 'Recover the username for your Neocities account.'
erb :'forgot_username'
end
post '/forgot_username' do
if params[:email].blank?
flash[:error] = 'Cannot use an empty email address!'
redirect '/forgot_username'
end
begin
sites = Site.get_recovery_sites_with_email params[:email]
rescue ArgumentError
redirect '/forgot_username'
end
sites.each do |site|
body = <<-EOT
Hello! This is the Neocities cat, and I have received a username lookup request using this email address.
Your username is #{site.username}
If you didn't request this, you can ignore it. Or hide under a bed. Or take a nap. Your call.
Meow,
the Neocities Cat
EOT
body.strip!
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: params[:email],
subject: '[Neocities] Username lookup',
body: body
})
end
flash[:success] = 'If your email was valid, the Neocities Cat will send an e-mail with your username in it.'
redirect '/'
end

37
app/mockup.rb Normal file
View file

@ -0,0 +1,37 @@
=begin
get '/mockup/home' do
erb :'mockup/home'
end
get '/mockup/edit' do
erb :'mockup/edit'
end
get '/mockup/profile' do
require_login
erb :'mockup/profile', locals: {site: current_site}
end
get '/mockup/browse' do
erb :'mockup/browse'
end
get '/mockup/tips' do
erb :'mockup/tips'
end
get '/mockup/welcome' do
require_login
erb :'mockup/welcome', locals: {site: current_site}
end
get '/mockup/stats' do
require_login
erb :'mockup/stats', locals: {site: current_site}
end
get '/mockup/tutorial-c1p2' do
require_login
erb :'mockup/tutorial-c1p2', locals: {site: current_site}
end
=end

99
app/password_reset.rb Normal file
View file

@ -0,0 +1,99 @@
get '/password_reset' do
@title = 'Password Reset'
@description = 'Reset your Neocities account password.'
redirect '/' if signed_in?
erb :'password_reset'
end
post '/send_password_reset' do
if params[:email].blank?
flash[:error] = 'You must enter a valid email address.'
redirect '/password_reset'
end
sites = Site.get_recovery_sites_with_email params[:email]
sites.each do |site|
if site.is_banned
flash[:error] = 'Sorry, we cannot restore this account.'
redirect '/'
end
end
if sites.length > 0
token = SecureRandom.uuid.gsub('-', '')+'-'+Time.now.to_i.to_s
sites.each do |site|
next unless site.parent?
site.password_reset_token = token
site.save_changes validate: false
body = <<-EOT
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=#{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.
Meow,
Penelope
EOT
body.strip!
EmailWorker.perform_async({
from: 'web@neocities.org',
to: params[:email],
subject: '[Neocities] Password Reset',
body: body
})
end
end
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 '/'
end
get '/password_reset_confirm' do
@title = 'Password Reset Confirm'
if params[:token].nil? || params[:token].strip.empty?
flash[:error] = 'Token cannot be empty.'
redirect '/'
end
reset_site = Site.where(username: params[:username], password_reset_token: params[:token]).first
if reset_site.nil?
flash[:error] = 'Could not find a site with this username and token.'
redirect '/'
end
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_banned
flash[:error] = 'Sorry, we cannot restore this account.'
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.save_changes
session[:id] = reset_site.id
redirect '/settings#password'
end

3
app/plan.rb Normal file
View file

@ -0,0 +1,3 @@
get '/plan/?' do
redirect '/supporter'
end

82
app/search.rb Normal file
View file

@ -0,0 +1,82 @@
# frozen_string_literal: true
SEARCH_URL = $config['search_url']
SEARCH_CONNECT_TIMEOUT = $config['search_connect_timeout']
SEARCH_WRITE_TIMEOUT = $config['search_write_timeout']
SEARCH_READ_TIMEOUT = $config['search_read_timeout']
post '/search/?' do
query = params[:q].to_s.strip
redirect query.blank? ? '/search' : "/search?#{Rack::Utils.build_query(q: query)}"
end
get '/search/?' do
@query = params[:q].to_s.strip
@title = @query.blank? ? 'Neocities Search' : 'Site Search'
@description = 'Search websites hosted on Neocities.'
if !@query.blank?
@start = params[:start].to_i
@start = 0 if @start < 0
@items = []
@total_results = 0
begin
@resp = JSON.parse HTTP.timeout(connect: SEARCH_CONNECT_TIMEOUT, write: SEARCH_WRITE_TIMEOUT, read: SEARCH_READ_TIMEOUT).get(SEARCH_URL, params: {
num: 100,
start: @start,
q: Rack::Utils.escape(@query) + ' -filetype:pdf -filetype:txt site:*.neocities.org'
}).to_s
rescue HTTP::Error, JSON::ParserError
@resp = {}
end
if @resp.is_a?(Hash) && @resp['error'].nil? && @resp.dig('searchInformation', 'totalResults').to_i != 0
@total_results = @resp['searchInformation']['totalResults'].to_i
@resp['items'].each do |item|
link = Addressable::URI.parse(item['link'])
path = link.path || '/'
unencoded_path = begin
Rack::Utils.unescape(Rack::Utils.unescape(path)) # Yes, it needs to be decoded twice
rescue ArgumentError
path # Fall back when the path includes invalid %-encoding
end
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
item['username'] = username if site.profile_enabled
screenshot_path = unencoded_path
screenshot_path << 'index' if screenshot_path[-1] == '/'
if ENV['RACK_ENV'] == 'development'
screenshot_path += '.html'
screenshot_found = true
else
['.html', '.htm'].each do |ext|
if site.screenshot_exists?(screenshot_path + ext, '540x405')
screenshot_path += ext
screenshot_found = true
break
end
end
end
item['screenshot_url'] = site.screenshot_url(screenshot_path, '540x405')
@items << item if screenshot_found
end
end
else
@items = nil
@total_results = 0
end
erb :'search'
end

446
app/settings.rb Normal file
View file

@ -0,0 +1,446 @@
require 'socket'
require 'ipaddr'
get '/settings/?' do
require_login
@site = parent_site
erb :'settings/account'
end
def require_ownership_for_settings
@site = Site[username: params[:username]]
not_found if @site.nil?
unless @site.owned_by? parent_site
flash[:error] = 'Cannot edit this site, you do not have permission.'
redirect request.referrer
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|
# This is for the email_unsubscribe below
pass if Site.select(:id).where(username: username).first.nil?
require_login
require_ownership_for_settings
@title = "Site settings for #{username}"
erb :'settings/site'
end
post '/settings/:username/delete' do
require_login
require_ownership_for_settings
if params[:confirm_username] != @site.username
flash[:error] = 'Site user name and entered user name did not match.'
redirect "/settings/#{@site.username}#delete"
end
@site.deleted_reason = params[:deleted_reason]
@site.save validate: false
@site.destroy
flash[:success] = 'Site deleted.'
if @site.username == current_site.username
signout
redirect '/'
end
redirect '/settings#sites'
end
post '/settings/:username/profile' do
require_login
require_ownership_for_settings
@site.update(
profile_comments_enabled: params[:site][:profile_comments_enabled],
profile_enabled: params[:site][:profile_enabled]
)
flash[:success] = 'Profile settings changed.'
redirect "/settings/#{@site.username}#profile"
end
post '/settings/:username/tags/add' do
require_login
require_ownership_for_settings
@site.new_tags_string = params[:tags]
if @site.valid?
@site.save_tags
flash[:success] = 'Tags updated.'
else
flash[:error] = @site.errors.first.last.first
end
redirect "/settings/#{@site.username}#tags"
end
post '/settings/:username/tags/remove' do
require_login
require_ownership_for_settings
removed = false
if params[:tags].is_a?(Array)
DB.transaction {
params[:tags].each do |tag|
tag_to_remove = @site.tags.detect {|t| t.name == tag }
if tag_to_remove
@site.remove_tag(tag_to_remove)
removed = true
end
end
}
end
flash[:success] = removed ? 'Tags updated.' : 'No tags selected.'
redirect "/settings/#{@site.username}#tags"
end
post '/settings/:username/change_name' do
require_login
require_ownership_for_settings
old_site = Site[username: @site.username]
old_username = @site.username
if params[:name] == nil || params[:name] == ''
flash[:error] = 'Name cannot be blank.'
redirect "/settings/#{@site.username}#username"
end
if old_username.downcase == params[:name].downcase
flash[:error] = 'You already have this name.'
redirect "/settings/#{@site.username}#username"
end
old_host = @site.host
old_site_file_paths = @site.site_files.collect {|site_file| site_file.path}
@site.username = params[:name]
if @site.valid?
DB.transaction {
@site.save_changes
@site.move_files_from old_username
}
old_site.delete_all_thumbnails_and_screenshots
old_site.purge_all_cache
@site.purge_all_cache
@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>"
redirect "/settings/#{@site.username}#username"
else
flash[:error] = @site.errors.first.last.first
redirect "/settings/#{old_username}#username"
end
end
post '/settings/:username/tipping' do
require_login
require_ownership_for_settings
current_site.tipping_enabled = params[:site][:tipping_enabled]
current_site.tipping_paypal = params[:site][:tipping_paypal]
current_site.tipping_bitcoin = params[:site][:tipping_bitcoin]
if current_site.valid?
current_site.save_changes
flash[:success] = "Tip settings have been updated."
else
flash[:error] = current_site.errors.first.last.first
end
redirect "/settings/#{current_site.username}#tipping"
end
post '/settings/:username/change_nsfw' do
require_login
require_ownership_for_settings
redirect "/settings/#{@site.username}" if @site.admin_nsfw == true
@site.is_nsfw = params[:is_nsfw]
@site.save_changes validate: false
flash[:success] = @site.is_nsfw ? 'Marked 18+' : 'Unmarked 18+'
redirect "/settings/#{@site.username}#nsfw"
end
post '/settings/:username/custom_domain' do
require_login
require_ownership_for_settings
original_domain = @site.domain
@site.domain = params[:domain]
if params[:domain] =~ /^www\..+$/i
flash[:error] = 'Cannot begin with www - please only enter the domain name.'
redirect "/settings/#{@site.username}/#custom_domain"
end
begin
addr = IPAddr.new @site.values[:domain]
if addr.ipv4? || addr.ipv6?
flash[:error] = 'IP addresses are not allowed. Please enter a valid domain name.'
redirect "/settings/#{@site.username}#custom_domain"
end
rescue IPAddr::InvalidAddressError
end
unless self.class.test?
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?
@site.save_changes
if @site.domain != original_domain
LetsEncryptWorker.perform_async @site.id
# Sometimes the www record isn't ready for some reason, so try a delay to fix that.
LetsEncryptWorker.perform_in 40.minutes, @site.id
end
flash[:success] = 'The domain has been successfully updated! Make sure your configuration with the domain registrar is correct. It could take a while for the changes to take effect (15-40 minutes), please be patient.'
redirect "/settings/#{@site.username}#custom_domain"
else
flash[:error] = @site.errors.first.last.first
redirect "/settings/#{@site.username}#custom_domain"
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
require_login
require_ownership_for_settings
is_new = @site.api_key.nil?
@site.generate_api_key!
msg = is_new ? "New API key has been generated." : "API key has been regenerated."
flash[:success] = msg
redirect "/settings/#{@site.username}#api_key"
end
post '/settings/change_password' do
require_login
if !current_site.password_reset_confirmed && !Site.valid_login?(parent_site.username, params[:current_password])
flash[:error] = 'Your provided password does not match the current one.'
redirect "/settings#password"
end
parent_site.password = params[:new_password]
parent_site.valid?
if params[:new_password] != params[:new_password_confirm]
parent_site.errors.add :password, 'New passwords do not match.'
end
parent_site.password_reset_token = nil
parent_site.password_reset_confirmed = false
if parent_site.errors.empty?
parent_site.save_changes
parent_site.send_email(
subject: "[Neocities] Your password has been changed",
body: Tilt.new('./views/templates/email/password_changed.erb', pretty: true).render(self)
)
flash[:success] = 'Successfully changed password.'
redirect "/settings#password"
else
flash[:error] = current_site.errors.first.last.first
redirect '/settings#password'
end
end
post '/settings/change_email' do
require_login
if params[:from_confirm]
redirect_url = "/site/#{parent_site.username}/confirm_email"
else
redirect_url = '/settings#email'
end
if params[:email] == parent_site.email
flash[:error] = 'You are already using this email address for this account.'
redirect redirect_url
end
previous_email = parent_site.email
parent_site.email = params[:email]
parent_site.email_confirmation_token = SecureRandom.hex 3
parent_site.email_confirmed = false
parent_site.password_reset_token = nil
if parent_site.valid?
parent_site.save_changes
send_confirmation_email
parent_site.send_email(
subject: "[Neocities] Your email address has been changed",
body: Tilt.new('./views/templates/email/email_changed.erb', pretty: true).render(self, site: parent_site, previous_email: previous_email)
)
if !parent_site.supporter?
session[:fromsettings] = true
redirect "/site/#{parent_site.email}/confirm_email"
else
flash[:success] = 'Email address changed.'
redirect '/settings#email'
end
end
flash[:error] = parent_site.errors.first.last.first
redirect redirect_url
end
post '/settings/change_email_notification' do
require_login
owner = current_site.owner
owner.send_emails = params[:send_emails]
owner.send_comment_emails = params[:send_comment_emails]
owner.send_follow_emails = params[:send_follow_emails]
owner.email_invoice = params[:email_invoice]
owner.save_changes validate: false
flash[:success] = 'Email notification settings have been updated.'
redirect '/settings#email'
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
require_login
if !current_site.plan_feature(:unlimited_site_creation)
flash[:error] = 'Cannot create a new site with your current plan, please become a supporter.'
redirect '/settings#sites'
end
site = Site.new
site.parent_site_id = parent_site.id
site.username = params[:username]
if site.valid?
site.save
flash[:success] = 'Your new site has been created! To manage it, click your username in the top right and go to "Switch Site".'
redirect '/settings#sites'
else
flash[:error] = site.errors.first.last.first
redirect '/settings#sites'
end
end
get '/settings/unsubscribe_email/?' do
redirect "/settings/#email" if signed_in?
if params[:email] && params[:token] && params[:email] != '' && Site.valid_email_unsubscribe_token?(params[:email], params[:token])
Site.where(email: params[:email]).all.each do |site|
site.send_emails = false
site.save_changes validate: false
end
@message = "You have been successfully unsubscribed from future emails to #{params[:email]}. Our apologies for the inconvenience."
else
@message = 'There was an error unsubscribing your email address. Please contact support.'
end
erb :'settings/account/unsubscribe'
end
post '/settings/update_card' do
require_login
customer = Stripe::Customer.retrieve parent_site.stripe_customer_id
old_card_ids = customer.sources.collect {|s| s.id}
begin
customer.sources.create source: params[:stripe_token]
rescue Stripe::InvalidRequestError, Stripe::CardError => e
case e.message
when /cannot use a.+token more than once/
flash[:error] = 'This card is already being used.'
when /Your card has expired/
flash[:error] = 'Your card has expired. Please try a different card.'
when /Your card was declined/
flash[:error] = 'Your card was declined. Please contact your bank or try a different card.'
when /security code is incorrect/
flash[:error] = 'The security code (CVV) you entered is incorrect. Please try again.'
when /card number is incorrect/
flash[:error] = 'The card number you entered is incorrect. Please check and try again.'
when /insufficient funds/
flash[:error] = 'Your card has insufficient funds. Please try a different card.'
when /does not support this type of purchase/
flash[:error] = 'Your card does not support online purchases. Please try a different card.'
else
# Fallback to Stripe's message for uncommon errors
flash[:error] = e.message
end
redirect '/settings#billing'
end
old_card_ids.each do |card_id|
customer.sources.retrieve(card_id).delete
end
flash[:success] = 'Card information updated.'
redirect '/settings#billing'
end

82
app/signin.rb Normal file
View file

@ -0,0 +1,82 @@
get '/signin/?' do
dashboard_if_signed_in
@title = 'Sign In'
@description = 'Sign in to your Neocities account.'
erb :'signin/index'
end
post '/signin' do
dashboard_if_signed_in
if Site.valid_login? params[:username], params[:password]
site = Site.get_with_identifier params[:username]
if site.is_banned
flash[:error] = 'Invalid login.'
flash[:username] = params[:username]
redirect '/signin'
end
if site.is_deleted && !site.is_banned
session[:deleted_site_id] = site.id
redirect '/signin/restore'
end
session[:id] = site.id
redirect '/'
else
flash[:error] = 'Invalid login.'
flash[:username] = params[:username]
redirect '/signin'
end
end
get '/signin/restore' do
redirect '/' unless session[:deleted_site_id]
@site = Site[session[:deleted_site_id]]
redirect '/' if @site.nil?
@title = 'Restore Deleted Site'
erb :'signin/restore'
end
get '/signin/cancel_restore' do
session[:deleted_site_id] = nil
flash[:success] = 'Site restore was cancelled.'
redirect '/'
end
post '/signin/restore' do
redirect '/' unless session[:deleted_site_id]
@site = Site[session[:deleted_site_id]]
session[:deleted_site_id] = nil
if @site.is_banned || !@site.undelete!
flash[:error] = "Sorry, we cannot restore this account."
else
session[:id] = @site.id
end
redirect '/'
end
get '/signin/:username' do
require_login
@site = Site[username: params[:username]]
not_found if @site.nil?
if @site.owned_by? current_site
session[:id] = @site.id
redirect request.referrer
end
flash[:error] = 'You do not have permission to switch to this site.'
redirect request.referrer
end
post '/signout' do
require_login
signout
redirect '/'
end

403
app/site.rb Normal file
View file

@ -0,0 +1,403 @@
get '/site/:username.rss' do |username|
site = Site[username: username]
halt 404 if site.nil? || (current_site && site.is_blocking?(current_site))
content_type :xml
site.to_rss
end
get '/site/:username/?' do |username|
site = Site[username: username]
# TODO: There should probably be a "this site was deleted" page.
not_found if site.nil? || site.is_banned || site.is_deleted || (current_site && site.is_blocking?(current_site))
redirect '/' if site.is_education
redirect site.uri unless site.profile_enabled
@title = site.title
@description = "#{site.username}'s Neocities site profile, updates, and comments."
@page = params[:page]
@page = 1 if @page.not_an_integer?
if params[:event_id]
not_found if params[:event_id].not_an_integer?
not_found if params[:event_id].to_i > 2_147_483_647 # max integer
event = Event.where(id: params[:event_id]).exclude(is_deleted: true).first
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)
else
events_dataset = site.latest_events(@page, current_site)
end
@page_count = events_dataset.page_count || 1
@pagination_dataset = events_dataset
@latest_events = events_dataset.all
meta_robots 'noindex, follow'
erb :'site', locals: {site: site, is_current_site: site == current_site}
end
MAX_STAT_POINTS = 30
get '/site/:username/stats' do
@default_stat_points = 7
@site = Site[username: params[:username]]
not_found if @site.nil? || @site.is_banned || @site.is_deleted || (current_site && @site.is_blocking?(current_site))
@title = "Site stats for #{@site.host}"
@description = "Traffic and engagement stats for #{@site.username}'s Neocities site."
@stats = {}
%i{referrers locations paths}.each do |stat|
@stats[stat] = @site.send("stat_#{stat}_dataset".to_sym).order(:views.desc).limit(100).all
end
@stats[:locations].collect! do |location|
location_name = ''
location_name += location.city_name if location.city_name
if location.region_name
# Some of the region names are numbers for some reason.
begin
Integer(location.region_name)
rescue
location_name += ', ' unless location_name == ''
location_name += location.region_name
end
end
if location.country_code2 && !$country_codes[location.country_code2].nil?
location_name += ', ' unless location_name == ''
location_name += $country_codes[location.country_code2]
end
location_hash = {name: location_name, views: location.views}
if location.latitude && location.longitude
location_hash.merge! latitude: location.latitude, longitude: location.longitude
end
location_hash
end
stats_dataset = @site.stats_dataset.order(:created_at.desc).exclude(created_at: Date.today)
if @site.supporter?
unless params[:days].to_s == 'sincethebigbang'
unless params[:days].not_an_integer?
days_param = params[:days].to_i
if days_param < 9000
stats_dataset = stats_dataset.limit days_param
else
params[:days] = 'sincethebigbang'
end
else
params[:days] = @default_stat_points
stats_dataset = stats_dataset.limit @default_stat_points
end
end
else
stats_dataset = stats_dataset.limit @default_stat_points
end
stats = stats_dataset.all.reverse
if current_site && @site.owned_by?(current_site) && params[:format] == 'csv'
content_type 'application/csv'
attachment "#{current_site.username}-stats.csv"
return CSV.generate do |csv|
csv << ['day', 'hits', 'views', 'bandwidth']
stats.each do |s|
csv << [s[:created_at].to_s, s[:hits], s[:views], s[:bandwidth]]
end
end
end
if stats.length > MAX_STAT_POINTS
stats = stats.select.with_index {|a, i| (i % (stats.length / MAX_STAT_POINTS.to_f).round) == 0}
end
@stats[:stat_days] = stats
@multi_tooltip_template = "<%= datasetLabel %> - <%= value %>"
erb :'site/stats', locals: {site: @site}
end
post '/site/:username/set_editor_theme' do
require_login
current_site.editor_theme = params[:editor_theme]
current_site.save_changes validate: false
'ok'
end
get '/site/:username/follows' do |username|
@title = "Sites #{username} follows"
@description = "See which Neocities sites #{username} follows."
@site = Site[username: username]
not_found if @site.nil? || @site.is_deleted || (current_site && (@site.is_blocking?(current_site) || current_site.is_blocking?(@site)))
params[:page] ||= "1"
@pagination_dataset = @site.followings_dataset.paginate(params[:page].to_i, Site::FOLLOW_PAGINATION_LIMIT)
erb :'site/follows'
end
get '/site/:username/followers' do |username|
@title = "Sites that follow #{username}"
@description = "See which Neocities sites follow #{username}."
@site = Site[username: username]
not_found if @site.nil? || @site.is_deleted || (current_site && (@site.is_blocking?(current_site) || current_site.is_blocking?(@site)))
params[:page] ||= "1"
@pagination_dataset = @site.follows_dataset.paginate(params[:page].to_i, Site::FOLLOW_PAGINATION_LIMIT)
erb :'site/followers'
end
post '/site/:username/comment' do |username|
require_login
site = Site[username: username]
message = normalize_comment_message(params[:message])
message_error = comment_message_error(message)
if current_site && (site.is_blocking?(current_site) || current_site.is_blocking?(site))
flash[:error] = comment_unavailable_message(site)
redirect request.referer
end
last_comment = site.profile_comments_dataset.order(:created_at.desc).first
if last_comment && last_comment.message == message && last_comment.created_at > 2.hours.ago
flash[:error] = 'You already posted that comment.'
redirect request.referer
end
if site.profile_comments_enabled == false ||
message_error ||
site.is_blocking?(current_site) ||
current_site.is_blocking?(site) ||
current_site.commenting_allowed? == false ||
(current_site.is_a_jerk? && site.id != current_site.id && !site.is_following?(current_site))
flash[:error] = message_error || comment_unavailable_message(site)
redirect request.referrer
end
site.add_profile_comment(
actioning_site_id: current_site.id,
message: message
)
redirect request.referrer
end
post '/site/:site_id/toggle_follow' do |site_id|
require_login
content_type :json
site = Site[id: site_id]
return 404 if site.nil?
return 403 if site.is_blocking?(current_site)
{result: (current_site.toggle_follow(site) ? 'followed' : 'unfollowed')}.to_json
end
get '/site/:username/confirm_email/:token' do
@title = 'Confirm email'
if current_site && current_site.email_confirmed
return erb(:'site_email_confirmed')
end
site = Site[username: params[:username]]
if site.nil?
return erb(:'site_email_not_confirmed')
end
if site.email_confirmed
return erb(:'site_email_confirmed')
end
if site.email_confirmation_token == params[:token]
site.email_confirmation_token = nil
site.email_confirmation_count = 0
site.email_confirmed = true
site.save_changes
erb :'site_email_confirmed'
else
erb :'site_email_not_confirmed'
end
end
get '/site/:username/confirm_email' do
require_login
@title = 'Confirm your Email Address'
@fromsettings = session[:fromsettings]
redirect '/' if current_site.username != params[:username] || !current_site.parent? || current_site.email_confirmed
erb :'site/confirm_email'
end
post '/site/:username/confirm_email/resend' do
require_login
redirect '/' if current_site.username != params[:username] || !current_site.parent? || current_site.email_confirmed
request.env['HTTP_REFERER'] ||= "/site/#{current_site.username}/confirm_email"
send_confirmation_email current_site
flash[:success] = 'Confirmation email re-sent.'
redirect "/site/#{current_site.username}/confirm_email"
end
post '/site/:username/confirm_email' do
require_login
redirect '/' if current_site.username != params[:username] || !current_site.parent? || current_site.email_confirmed
# Update email, resend token
if params[:email]
send_confirmation_email @site
end
if params[:token].blank?
flash[:error] = 'You must enter a valid token.'
redirect "/site/#{current_site.username}/confirm_email"
end
if current_site.email_confirmation_token == params[:token]
current_site.email_confirmation_token = nil
current_site.email_confirmation_count = 0
current_site.email_confirmed = true
current_site.save_changes
if session[:fromsettings]
session[:fromsettings] = nil
flash[:success] = 'Email address changed.'
redirect '/settings#email'
end
redirect '/tutorial'
else
flash[:error] = 'You must enter a valid token.'
redirect "/site/#{current_site.username}/confirm_email"
end
end
post '/site/:username/block' do |username|
require_login
site = Site[username: username]
redirect request.referer if current_site.id == site.id
current_site.block! site
if request.referer.match /\/site\/#{Regexp.quote(username)}/i
redirect '/'
else
redirect request.referer
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

166
app/site_files.rb Normal file
View file

@ -0,0 +1,166 @@
post '/site_files/rename' do
require_login
path = HTMLEntities.new.decode params[:path]
new_path = HTMLEntities.new.decode params[:new_path]
site_file = current_site.site_files.select {|s| s.path == path}.first
escaped_path = Rack::Utils.escape_html path
escaped_new_path = Rack::Utils.escape_html new_path
if site_file.nil?
flash[:error] = "File #{escaped_path} does not exist."
else
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
dirname = Pathname(path).dirname
dir_query = dirname.nil? || dirname.to_s == '.' ? '' : "?dir=#{Rack::Utils.escape dirname}"
redirect "/dashboard#{dir_query}"
end
get '/site_files/download' do
require_login
if !current_site.dl_queued_at.nil? && current_site.dl_queued_at > 1.hour.ago
flash[:error] = 'Site downloads are currently limited to once per hour, please try again later.'
redirect request.referer
end
content_type 'application/zip'
attachment "neocities-#{current_site.username}.zip"
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
get %r{\/site_files\/download\/(.+)} do
require_login
dont_browser_cache
not_found if params[:captures].nil? || params[:captures].length != 1
filename = params[:captures].first
attachment filename
send_file current_site.current_files_path(filename)
end
get %r{\/site_files\/text_editor\/(.+)} do
require_login
dont_browser_cache
@filename = params[:captures].first
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]
if @filename.nil? || @filename.strip.empty?
flash[:error] = 'No filename specified.'
redirect '/dashboard'
end
extname = File.extname @filename
@ace_mode = case extname
when /htm|html/ then 'html'
when /js/ then 'javascript'
when /md/ then 'markdown'
when /css/ then 'css'
else
nil
end
file_path = current_site.current_files_path @filename
if File.directory? file_path
flash[:error] = 'Cannot edit a directory.'
redirect '/dashboard'
end
if !File.exist?(file_path)
flash[:error] = 'We could not find the requested file.'
redirect '/dashboard'
end
@title = "Editing #{@filename}"
erb :'site_files/text_editor'
end
get '/site_files/allowed_types' do
@title = 'Allowed File Types'
@description = 'See which file types can be uploaded to Neocities sites.'
erb :'site_files/allowed_types'
end
get '/site_files/hotlinking' do
@title = 'Hotlinking Information'
@description = 'Learn how hotlinking works on Neocities.'
erb :'site_files/hotlinking'
end
get '/site_files/mount_info' do
@title = 'Site Mount Information'
@description = 'Read how to mount and manage your Neocities site files.'
erb :'site_files/mount_info'
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

23
app/supporter.rb Normal file
View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
get '/supporter/?' do
@title = 'Supporter'
erb :'welcome'
end
post '/supporter/update' do
require_login
# Everyone has supporter features nowwwwwwwwwwww yayyayayayy
redirect '/supporter/thanks'
end
get '/supporter/thanks' do
@title = 'Supporter Confirmation'
require_login
erb :'supporter/thanks'
end
def is_special_upgrade
false
end

85
app/sysops.rb Normal file
View file

@ -0,0 +1,85 @@
require 'base64'
require 'zlib'
require 'rubygems/package'
get '/sysops/proxy/map.txt' do
require_proxy_auth
domains = ''
Site.exclude(domain: nil).
exclude(domain: '').
select(:username,:domain).
all.
collect do |s|
domains << "#{s.domain} #{s.username};\n"
end
content_type :text
domains
end
get '/sysops/proxy/sslcerts.tar.gz' do
require_proxy_auth
sites = Site.ssl_sites
nginx_config = ''
tar = StringIO.new
Gem::Package::TarWriter.new(tar) do |writer|
writer.mkdir 'sslcerts', 0740
writer.mkdir 'sslcerts/certs', 0740
sites.each do |site|
writer.add_file "sslcerts/certs/#{site.username}.key", 0640 do |f|
f.write site.ssl_key
end
writer.add_file "sslcerts/certs/#{site.username}.crt", 0640 do |f|
f.write site.ssl_cert
end
nginx_config << %{
server {
listen 443 ssl;
server_name *.#{site.domain};
ssl_certificate sslsites/certs/#{site.username}.crt;
ssl_certificate_key sslsites/certs/#{site.username}.key;
location / {
proxy_http_version 1.1;
proxy_set_header Host #{site.username}.femslash.city;
proxy_pass http://web:9292$request_uri;
}
}
}.unindent
end
writer.add_file "sslcerts/sslsites.conf", 0640 do |f|
f.write nginx_config
end
end
tar.rewind
package = StringIO.new 'b'
package.set_encoding 'binary'
gzip = Zlib::GzipWriter.new package
gzip.write tar.read
tar.close
gzip.finish
package.rewind
attachment
package.read
end
class ProxyAccessViolation < StandardError; end
def require_proxy_auth
begin
auth = request.env['HTTP_AUTHORIZATION']
user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':')
raise ProxyAccessViolation unless pass == $config['proxy_pass']
rescue
raise ProxyAccessViolation, "Violator: #{request.ip}" unless pass == $config['proxy_pass']
end
end

3
app/tags.rb Normal file
View file

@ -0,0 +1,3 @@
get '/tags/autocomplete/:name.json' do |name|
Tag.autocomplete(name).collect {|t| t[:name]}.to_json
end

53
app/tutorial.rb Normal file
View file

@ -0,0 +1,53 @@
def default_tutorial_html
<<-EOT.strip
<!DOCTYPE html>
<html>
<body>
Hello World!
</body>
</html>
EOT
end
get '/tutorials' do
@description = 'Start web development tutorials on Neocities.'
erb :'tutorials'
end
get '/tutorial/?' do
require_login
erb :'tutorial/index'
end
get '/tutorial/:section/?' do
require_login
not_found unless %w{html}.include?(params[:section])
redirect "/tutorial/#{params[:section]}/1"
end
get '/tutorial/:section/:page/?' do
require_login
@page = params[:page]
not_found unless @page.match?(/\A[1-9]\z|\A10\z/)
not_found unless %w{html}.include?(params[:section])
@section = params[:section]
@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
end

221
app/webhooks.rb Normal file
View file

@ -0,0 +1,221 @@
post '/webhooks/paypal' do
return 403 unless valid_paypal_webhook_source?
if paypal_subscription_ended_ipn?
site = paypal_ipn_site
if site
stripe_active = site.stripe_paying_supporter?
site.paypal_active = false
site.paypal_profile_id = nil
site.paypal_token = nil
unless stripe_active
site.plan_type = nil
site.plan_ended = true
end
site.save_changes validate: false
unless stripe_active
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: site.email,
subject: '[Neocities] Supporter plan has ended',
body: Tilt.new('./views/templates/email/supporter_ended.erb', pretty: true).render(self)
})
end
end
end
'ok'
end
def paypal_subscription_ended_ipn?
%w[
recurring_payment_profile_cancel
recurring_payment_expired
recurring_payment_suspended
recurring_payment_suspended_due_to_max_failed_payment
subscr_cancel
subscr_eot
].include?(params[:txn_type].to_s)
end
def paypal_ipn_site
profile_id = params[:recurring_payment_id] ||
params[:recurring_payment_profile_id] ||
params[:rp_profile_id] ||
params[:profile_id] ||
params[:subscr_id]
return nil if profile_id.blank?
Site.where(paypal_profile_id: profile_id).first
end
def valid_paypal_webhook_source?
return true if self.class.test?
return true if request.ip == '127.0.0.1'
# Use DNS to resolve notify.paypal.com as PayPal recommends
begin
resolved_ips = Resolv.getaddresses 'notify.paypal.com'
return resolved_ips.include?(request.ip)
rescue Resolv::ResolvError
return false
end
end
post '/webhooks/paypal/tipping_notify' do
return 403 unless valid_paypal_webhook_source?
# Handle missing custom parameter (this seems to happen with recurring donations)
if params[:custom].nil? || params[:custom].strip.empty?
# Try to extract username from product_name like "Site Donation for Two-reeler (USERNAME_HERE)"
if params[:product_name] && params[:product_name].match(/\((.+)\)$/)
username = params[:product_name].match(/\((.+)\)$/)[1]
site = Site.where(username: username).first
end
# If no site found by username, try to find by payer email
if params[:payer_email]
actioning_site = Site.where(email: params[:payer_email]).first
end
# Create payload structure for anonymous tip
payload = {
site_id: site.id,
actioning_site_id: (actioning_site ? actioning_site : nil)
}
else
payload = JSON.parse Base64.strict_decode64(params[:custom]), symbolize_names: true
site = Site[payload[:site_id]]
end
@tip_hash = {
message: (params[:memo] ? params[:memo] : nil),
amount: params[:mc_gross],
currency: params[:mc_currency],
fee: params[:mc_fee],
actioning_site: (payload[:actioning_site_id] ? Site[payload[:actioning_site_id]] : nil),
paypal_payer_email: params[:payer_email],
paypal_receiver_email: params[:receiver_email],
paypal_txn_id: params[:txn_id],
created_at: DateTime.strptime(params[:payment_date], "%H:%M:%S %b %e, %Y %Z").to_time
}
@tip = site.add_tip @tip_hash
Event.create(
site_id: @tip.site.id,
actioning_site_id: (@tip.actioning_site ? @tip.actioning_site.id : nil),
tip_id: @tip.id
)
if @tip.actioning_site
subject = "You received a #{@tip.amount_string} tip from #{@tip.actioning_site.username}!"
else
subject = "You received a #{@tip.amount_string} tip!"
end
@tip.site.send_email(
subject: subject,
body: Tilt.new('./views/templates/email/tip_received.erb', pretty: true).render(self)
)
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: params[:payer_email],
subject: "You sent a #{@tip.amount_string} tip!",
body: Tilt.new('./views/templates/email/tip_sent.erb', pretty: true).render(self)
})
end
post '/webhooks/stripe' do
event = JSON.parse request.body.read
if event['type'] == 'charge.failed'
site = stripe_get_site_from_event event
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: site.email,
subject: "[Neocities] There was an issue charging your card",
body: Tilt.new('./views/templates/email/charge_failure.erb', pretty: true).render(self)
})
end
if event['type'] == 'customer.subscription.deleted'
site = stripe_get_site_from_event event
site.stripe_subscription_id = nil
site.plan_type = nil
site.plan_ended = true
site.save_changes validate: false
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: site.email,
subject: "[Neocities] Supporter plan has ended",
body: Tilt.new('./views/templates/email/supporter_ended.erb', pretty: true).render(self)
})
end
if event['type'] == 'invoice.payment_succeeded'
site = stripe_get_site_from_event event
if site.email_invoice && site.stripe_paying_supporter?
invoice_obj = event['data']['object']
EmailWorker.perform_async({
from: Site::FROM_EMAIL,
to: site.email,
subject: "[Neocities] Invoice",
body: Tilt.new('./views/templates/email/invoice.erb', pretty: true).render(
self,
site: site,
amount: invoice_obj['amount_due'],
period_start: Time.at(invoice_obj['period_start']),
period_end: Time.at(invoice_obj['period_end']),
date: Time.at(invoice_obj['date'])
)
})
end
end
'ok'
end
def stripe_get_site_from_event(event)
customer_id = event['data']['object']['customer']
halt 'ok' if customer_id.nil? # Likely a fraudulent card report
retries = 0
begin
customer = Stripe::Customer.retrieve customer_id
rescue Stripe::APIConnectionError, Stripe::RateLimitError => e
retries += 1
if retries <= 3
sleep(2 ** retries) # exponential backoff: 2s, 4s, 8s
retry
else
raise e
end
end
# Some old accounts only have a username for the desc
desc_split = customer.description.split(' - ')
if desc_split.length == 1
site_where = {username: desc_split.first}
end
if desc_split.last.not_an_integer?
site_where = {username: desc_split.first}
else
site_where = {id: desc_split.last}
end
Site.where(site_where).first
end

228
app_helpers.rb Normal file
View file

@ -0,0 +1,228 @@
def dashboard_if_signed_in
redirect '/dashboard' if signed_in?
end
def csrf_safe?
csrf_token == params[:csrf_token] || csrf_token == request.env['HTTP_X_CSRF_TOKEN']
end
def csrf_token
session[:_csrf_token] ||= SecureRandom.base64(32)
end
def is_education?
current_site && current_site.is_education
end
def require_login
redirect '/' unless signed_in? && current_site
end
def signed_in?
return false if current_site.nil?
true
end
def signout
@_site = nil
@_parent_site = nil
session[:id] = nil
session.clear
response.delete_cookie 'neocities', path: '/'
request.env['rack.session.options'][:drop] = true
end
def current_site
return nil if session[:id].nil?
@_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
def parent_site
@_parent_site || current_site
end
def meta_robots(newtag=nil)
if newtag
@_meta_robots = newtag
end
@_meta_robots
end
def meta_description(new_description=nil)
if new_description
@_meta_description = new_description
end
description = @_meta_description || @description
return nil if description.nil? || description.strip.empty?
description
end
def title
out = "Neocities"
return out if request.path == '/'
full_title = if @title
"#{out} - #{@title}"
else
path_parts = request.path.split('/').reject(&:empty?)
formatted_parts = path_parts.map do |part|
part.split(/[_\-]/).map(&:capitalize).join(' ')
end
"#{out} - #{formatted_parts.join(' - ')}"
end
if full_title.length >= 70
full_title[0..65] + "..."
else
full_title
end
end
def encoding_fix(file)
begin
Rack::Utils.escape_html file
rescue ArgumentError => e
if e.message =~ /invalid byte sequence in UTF-8/ ||
e.message =~ /incompatible character encodings/
return Rack::Utils.escape_html(file.force_encoding('BINARY'))
end
fail
end
end
def send_confirmation_email(site=current_site)
# Child sites don't need email confirmation - they use parent site's email
return if !site.parent?
if site.email_confirmation_count > Site::MAXIMUM_EMAIL_CONFIRMATIONS
flash[:error] = 'You sent too many email confirmation requests, cannot continue.'
redirect request.referrer
end
DB['UPDATE sites set email_confirmation_count=email_confirmation_count+1 WHERE id=?', site.id].first
EmailWorker.perform_async({
from: 'web@neocities.org',
reply_to: 'noreply@neocities.org',
to: site.email,
subject: "[Neocities] Confirm your email address",
body: Tilt.new('./views/templates/email/confirm.erb', pretty: true).render(self, site: site)
})
end
def dont_browser_cache
headers['Cache-Control'] = 'private, no-store, max-age=0, no-cache, must-revalidate, post-check=0, pre-check=0'
headers['Pragma'] = 'no-cache'
headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT'
@dont_browser_cache = true
end
def sanitize_comment(text)
Rinku.auto_link Sanitize.fragment(text), :all, 'target="_blank" rel="nofollow"'
end
def flash_message_keys
flash.keys.select {|k| [:success, :error, :errors].include?(k) && !flash[k].to_s.empty?}
end
def flash_message_entries
entries = []
Array(@error).each do |message|
entries << {type: 'error', message: message} unless message.to_s.empty?
end
flash_message_keys.each do |key|
type = key == :success ? 'success' : 'error'
Array(flash[key]).each do |message|
entries << {type: type, message: message} unless message.to_s.empty?
end
end
entries
end
def normalize_comment_message(message)
message.to_s.gsub(/\r\n?/, "\n").gsub(/\n{3,}/, "\n\n").strip
end
def comment_message_error(message)
return 'Comment cannot be empty.' if message.empty?
return "Comments must be #{Site::MAX_COMMENT_SIZE} characters or fewer." if message.length > Site::MAX_COMMENT_SIZE
lines = message.split("\n")
return "Comments must be #{Site::MAX_COMMENT_LINES} lines or fewer." if lines.length > Site::MAX_COMMENT_LINES
return nil if lines.length < Site::MULTILINE_COMMENT_AVERAGE_LINE_THRESHOLD
nonblank_lines = lines.map(&:strip).reject(&:empty?)
return 'Comment cannot be empty.' if nonblank_lines.empty?
average_line_length = nonblank_lines.sum(&:length).to_f / nonblank_lines.length
return nil if average_line_length >= Site::MIN_MULTILINE_COMMENT_AVERAGE_LINE_LENGTH
'Multiline comments need a little more text on each line.'
end
def valid_comment_message?(message)
comment_message_error(message).nil?
end
def comment_unavailable_message(site=nil)
return 'Comments are disabled for this site.' if site && site.profile_comments_enabled == false
if current_site && !current_site.commenting_allowed?
if current_site.commenting_too_much?
return "To prevent spam, comments are limited to #{Site::MAX_COMMENTS_PER_DAY} per day. Please try again tomorrow."
end
return "To prevent spam, you cannot comment until you have updated your site #{Site::COMMENTING_ALLOWED_UPDATED_COUNT} times on separate days, and your account is one week old."
end
'You cannot comment on this right now.'
end
def flash_display(opts={})
erb :'_flash', layout: false, locals: {opts: opts}
end
def hcaptcha_valid?
return true if ENV['RACK_ENV'] == 'test' || ENV['CI']
return false unless params[:'h-captcha-response']
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
else
false
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

9
code-of-conduct.txt Normal file
View file

@ -0,0 +1,9 @@
These guidelines apply to Neocities contributor communications, including
messages in code repositories, collaboration tools, outreach channels, etc.
* Language and actions must be free of personal attacks, harassment,
discrimination, and NSFW content.
Instances of unacceptable behavior can be reported by contacting the project
team at abuse@neocities.org. All complaints will be investigated and reporter
confidentiality will be maintained.

377
config.ru Normal file
View file

@ -0,0 +1,377 @@
require 'rubygems'
require './app.rb'
require 'sidekiq/web'
require 'airbrake/sidekiq'
require 'rack/mime'
require 'rack/utils'
require 'time'
use Airbrake::Rack::Middleware
class HostedSiteMiddleware
def initialize(app)
@app = app
end
def call(env)
host = env['HTTP_HOST'].to_s.downcase.split(':').first
site = Site.find_by_host(host)
return @app.call(env) unless site
path = env['PATH_INFO'].to_s
begin
path = Rack::Utils.unescape_path(path)
scrubbed_path = site.scrubbed_path(path)
rescue ArgumentError
return [400, { 'Content-Type' => 'text/plain' }, ['Invalid path']]
end
if scrubbed_path.end_with?('.html')
redirect_path =
if scrubbed_path == 'index.html'
'/'
else
"/#{scrubbed_path.sub(/\.html\z/, '')}"
end
return [
301,
{
'Location' => redirect_path,
'Content-Type' => 'text/plain'
},
['Moved Permanently']
]
end
if scrubbed_path.empty?
scrubbed_path = 'index.html'
elsif site.is_directory?(scrubbed_path)
scrubbed_path = File.join(scrubbed_path, 'index.html')
elsif !site.file_exists?(scrubbed_path)
html_path = "#{scrubbed_path}.html"
scrubbed_path = html_path if site.file_exists?(html_path)
end
return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] unless site.file_exists?(scrubbed_path)
file_path = site.files_path(scrubbed_path)
begin
stat = File.stat(file_path)
rescue Errno::ENOENT
return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
end
headers = {
'Content-Type' => Rack::Mime.mime_type(
File.extname(scrubbed_path),
'application/octet-stream'
),
'Content-Length' => stat.size.to_s,
'Last-Modified' => stat.mtime.httpdate
}
return [200, headers, []] if env['REQUEST_METHOD'] == 'HEAD'
[200, headers, [File.binread(file_path)]]
end
end
use HostedSiteMiddleware
map('/') do
run Sinatra::Application
end
map '/webdav' do
run lambda { |env|
auth = Rack::Auth::Basic::Request.new(env)
unless auth.provided? && auth.basic? && auth.credentials
return [401, { 'WWW-Authenticate' => 'Basic realm="Restricted Area"' }, ['Not authorized']]
end
username, password = auth.credentials
site = Site.get_site_from_login(username, password)
unless site
return [401, { 'WWW-Authenticate' => 'Basic realm="Restricted Area"' }, ['Not authorized']]
end
request_method = env['REQUEST_METHOD']
begin
path = Rack::Utils.unescape_path(env['PATH_INFO'])
rescue ArgumentError
return [400, {}, ['Invalid path']]
end
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'}, ['']]
when 'GET', 'HEAD'
begin
scrubbed_path = site.scrubbed_path(path)
rescue ArgumentError
return [400, {}, ['Invalid path']]
end
if scrubbed_path.empty? || site.is_directory?(scrubbed_path)
return [403, {}, ['Cannot download a directory']]
end
return [404, {}, ['']] unless site.file_exists?(scrubbed_path)
site_file = site.site_files_dataset.where(path: scrubbed_path).first
file_path = site.files_path(scrubbed_path)
begin
file_stat = File.stat(file_path)
rescue Errno::ENOENT
return [404, {}, ['']]
end
content_length = site_file&.size || file_stat.size
last_modified =
if site_file&.updated_at
site_file.updated_at
elsif site_file&.created_at
site_file.created_at
else
file_stat.mtime
end
headers = {
'Content-Type' => Rack::Mime.mime_type(File.extname(scrubbed_path), 'application/octet-stream'),
'Content-Length' => content_length.to_s,
'Last-Modified' => last_modified.httpdate
}
if site_file&.sha1_hash
headers['ETag'] = %("#{site_file.sha1_hash}")
end
if request_method == 'HEAD'
return [200, headers, []]
end
file_io = File.open(file_path, 'rb')
return [200, headers, file_io]
when 'PROPFIND'
begin
scrubbed_path = site.scrubbed_path(path)
rescue ArgumentError
return [400, {}, ['Invalid path']]
end
target_is_root = scrubbed_path.empty?
site_file =
unless target_is_root
site.site_files_dataset.where(path: scrubbed_path).first
end
if !target_is_root && site_file.nil?
return [404, {}, ['']]
end
if site_file && !site_file.is_directory && !site.file_exists?(scrubbed_path)
return [404, {}, ['']]
end
depth_header = env['HTTP_DEPTH']
depth = depth_header == '0' ? 0 : 1
responses = []
build_response_info = lambda do |relative_path, file_info|
is_directory = file_info[:is_directory]
href_path_segments = relative_path.split('/').reject(&:empty?).map { |segment| Rack::Utils.escape_path(segment) }
href = '/webdav'
href += '/' unless href_path_segments.empty?
href += href_path_segments.join('/')
href += '/' if is_directory && !href.end_with?('/')
display_name =
if relative_path.empty?
'/'
else
relative_path.split('/').last
end
updated_at = file_info[:updated_at] || file_info[:created_at]
<<~XML
<D:response>
<D:href>#{Rack::Utils.escape_html(href)}</D:href>
<D:propstat>
<D:prop>
<D:displayname>#{Rack::Utils.escape_html(display_name)}</D:displayname>
<D:resourcetype>#{is_directory ? '<D:collection/>' : ''}</D:resourcetype>
#{file_info[:created_at] ? "<D:creationdate>#{file_info[:created_at].utc.iso8601}</D:creationdate>" : ''}
#{updated_at ? "<D:getlastmodified>#{updated_at.httpdate}</D:getlastmodified>" : ''}
#{is_directory ? '' : "<D:getcontentlength>#{file_info[:size]}</D:getcontentlength>"}
#{file_info[:content_type] ? "<D:getcontenttype>#{Rack::Utils.escape_html(file_info[:content_type])}</D:getcontenttype>" : ''}
#{file_info[:etag] ? "<D:getetag>#{Rack::Utils.escape_html(file_info[:etag])}</D:getetag>" : ''}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
XML
end
add_response_for = lambda do |relative_path, file_info|
responses << build_response_info.call(relative_path, file_info)
end
target_info =
if target_is_root
{
is_directory: true,
size: 0,
created_at: site.created_at,
updated_at: site.site_updated_at || site.updated_at,
content_type: nil,
etag: nil
}
else
{
is_directory: site_file.is_directory,
size: site_file.is_directory ? 0 : site_file.size.to_i,
created_at: site_file.created_at,
updated_at: site_file.updated_at,
content_type: site_file.is_directory ? nil : Rack::Mime.mime_type(File.extname(scrubbed_path), 'application/octet-stream'),
etag: site_file.sha1_hash ? %("#{site_file.sha1_hash}") : nil
}
end
add_response_for.call(scrubbed_path, target_info)
if depth > 0 && (target_is_root || target_info[:is_directory])
site.file_list(scrubbed_path).each do |entry|
child_info = {
is_directory: entry[:is_directory],
size: entry[:is_directory] ? 0 : entry[:size].to_i,
created_at: entry[:created_at],
updated_at: entry[:updated_at],
content_type: entry[:is_directory] ? nil : Rack::Mime.mime_type(File.extname(entry[:path]), 'application/octet-stream'),
etag: entry[:sha1_hash] ? %("#{entry[:sha1_hash]}") : nil
}
add_response_for.call(entry[:path], child_info)
end
end
xml = <<~XML
<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
#{responses.join}
</D:multistatus>
XML
return [207, {'Content-Type' => 'application/xml; charset=utf-8', 'DAV' => '1'}, [xml]]
when 'PUT'
tmpfile = Tempfile.new('davfile', encoding: 'binary')
tmpfile.write(env['rack.input'].read)
tmpfile.close
result = site.store_files([{ filename: path, tempfile: tmpfile }])
if result.is_a?(Hash) && result[:error]
# Map error types to appropriate HTTP status codes
status_code = case result[:error_type]
when 'too_large', 'file_too_large', 'too_many_files'
507 # Insufficient Storage
when 'directory_exists'
409 # Conflict
when 'invalid_file_type'
415 # Unsupported Media Type
else
400 # Bad Request
end
return [status_code, {}, [result[:message]]]
end
return [201, {}, ['']]
when 'MKCOL'
return [400, {}, ['Invalid path']] if site.invalid_path?(path)
return [400, {}, ['Path too long']] if SiteFile.path_too_long?(path)
return [409, {}, ['Already exists']] if site.file_exists?(path)
site.create_directory(path)
return [201, {}, ['']]
when 'MOVE'
destination = env['HTTP_DESTINATION'][/\/webdav(.+)$/i, 1]
return [400, {}, ['Bad Request']] unless destination
begin
destination = Rack::Utils.unescape_path(destination)
rescue ArgumentError
return [400, {}, ['Invalid destination path']]
end
# Remove leading and trailing slashes if present
path.sub!(/^\//, '')
path.sub!(/\/$/, '')
site_file = site.site_files.find { |s| s.path == path }
return [404, {}, ['']] unless site_file
return [400, {}, ['Invalid destination path']] if site.invalid_path?(destination)
return [400, {}, ['Destination path too long']] if SiteFile.path_too_long?(destination)
return [400, {}, ['Destination filename too long']] if SiteFile.name_too_long?(destination)
return [403, {}, ['Cannot rename to index.html at root']] if destination == '/index.html' && path != '/index.html'
res = site_file.rename(destination)
if res.first == true
return [201, {}, ['']]
else
return [400, {}, [res.last]]
end
when 'DELETE'
return [403, {}, ['Cannot delete index.html']] if path == '/index.html' || path == 'index.html'
return [403, {}, ['Cannot delete root directory']] if site.files_path(path) == site.files_path
return [404, {}, ['File not found']] unless site.file_exists?(path)
site.delete_file(path)
return [201, {}, ['']]
else
return [501, {}, ['Not Implemented']]
end
}
end
map '/sidekiq' do
use Rack::Auth::Basic, "Protected Area" do |username, password|
raise 'missing sidekiq auth' unless $config['sidekiq_user'] && $config['sidekiq_pass']
username == $config['sidekiq_user'] && password == $config['sidekiq_pass']
end
use Rack::Session::Cookie, key: 'sidekiq.session', secret: Base64.strict_decode64($config['session_secret'])
use Rack::Protection::AuthenticityToken
run Sidekiq::Web
end

37
config.yml.ci Normal file
View file

@ -0,0 +1,37 @@
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
support_email: derp@example.com
moderation_blur_categories: [ cats, dogs ]
search_url: https://example.com/customsearch/v1
search_connect_timeout: 1
search_read_timeout: 1
search_write_timeout: 1

82
config.yml.template Normal file
View file

@ -0,0 +1,82 @@
development:
database: 'postgres://localhost/neocities'
database_pool: 1
redis_url: "redis://localhost"
session_secret: "SSBqdXN0IHdhbnRlZCB0byBzZWUgd2hhdCB5b3UgbG9va2VkIGxpa2UgaW4gYSBkcmVzcywgRGFkZSBNdXJwaHk="
hcaptcha_site_key: "10000000-ffff-ffff-ffff-000000000001"
hcaptcha_secret_key: "0x0000000000000000000000000000000000000000"
sidekiq_user: "ENTER USER HERE"
sidekiq_pass: "ENTER PASS HERE"
stripe_publishable_key: "ENTER KEY HERE"
stripe_api_key: "ENTER KEY HERE"
ip_hash_salt: "400$8$1$fc21863da5d531c1"
proxy_pass: 'somethinglongandrandom'
email_unsubscribe_token: 'somethingrandom'
logs_path: /path/to/nginx/logs
paypal_api_username: derp
paypal_api_password: ing
paypal_api_signature: tonz
letsencrypt_key: ./tests/files/letsencrypt.key
letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
minfraud_account_id: 696969420
minfraud_license_key: DERPDERPDERP
proxy_ips:
- 10.0.0.1
- 10.0.0.2
education_tag_whitelist:
- mrteacher
screenshot_urls:
- http://screenshots:derp@127.0.0.1:12345
stop_forum_spam_api_key: testkey
google_custom_search_key: herpderp
google_custom_search_cx: herpderp
google_custom_search_query_limit: 69
support_email: derp@example.com
moderation_blur_categories: [ cats, dogs ]
search_url: https://example.com/customsearch/v1
search_connect_timeout: 1
search_read_timeout: 1
search_write_timeout: 1
test:
database: 'postgres://localhost/neocities_test'
database_pool: 1
session_secret: "SSBqdXN0IHdhbnRlZCB0byBzZWUgd2hhdCB5b3UgbG9va2VkIGxpa2UgaW4gYSBkcmVzcywgRGFkZSBNdXJwaHk="
hcaptcha_site_key: "10000000-ffff-ffff-ffff-000000000001"
hcaptcha_secret_key: "0x0000000000000000000000000000000000000000"
sidekiq_user: "ENTER USER HERE"
sidekiq_pass: "ENTER PASS HERE"
stripe_publishable_key: "ENTER KEY HERE"
stripe_api_key: "ENTER KEY HERE"
ip_hash_salt: "400$8$1$fc21863da5d531c1"
proxy_pass: 'somethinglongandrandom'
email_unsubscribe_token: 'somethingrandom'
paypal_api_username: derp
paypal_api_password: ing
paypal_api_signature: tonz
letsencrypt_key: ./tests/files/letsencrypt.key
letsencrypt_endpoint: https://acme-staging.api.letsencrypt.org/
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
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
support_email: derp@example.com
moderation_blur_categories: [ cats, dogs ]
search_url: https://example.com/customsearch/v1
search_connect_timeout: 1
search_read_timeout: 1
search_write_timeout: 1

106
docker-compose.yml Normal file
View file

@ -0,0 +1,106 @@
services:
web:
container_name: ${WEB_INTERFACE_CONTAINER}
image: baldcap/neocities
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "9292:9292"
volumes:
- .:/app
- ./docker/config.yml.docker:/app/config.yml:ro
- site_files:/app/public/sites
- clamav_data:/var/lib/clamav
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/9292' 2>/dev/null"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
environment:
RACK_ENV: production
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
worker:
image: neocities
build:
context: .
dockerfile: docker/Dockerfile
command: worker
volumes:
- .:/app
- ./docker/config.yml.docker:/app/config.yml:ro
- site_files:/app/public/sites
depends_on:
web:
condition: service_healthy
environment:
RACK_ENV: production
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
nginx:
image: nginx:alpine
ports:
- "8081:80"
- "4433:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
# - ./docker/sslcerts:/etc/nginx/sslcerts:ro
depends_on:
web:
condition: service_healthy
db:
container_name: ${DATABASE_CONTAINER}
image: postgres:16
environment:
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres-init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER}"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "5432:5432"
redis:
container_name: ${REDIS_CONTAINER}
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "6379:6379"
# ClamAV is opt-in for dev: docker compose --profile clamav up
clamav:
image: clamav/clamav:stable
volumes:
- clamav_data:/var/lib/clamav
profiles:
- clamav
volumes:
postgres_data:
redis_data:
site_files:
clamav_data:

221
environment.rb Normal file
View file

@ -0,0 +1,221 @@
# frozen_string_literal: true
RubyVM::YJIT.enable
ENV['RACK_ENV'] ||= 'production'
ENV['TZ'] = 'UTC'
DIR_ROOT = File.expand_path File.dirname(__FILE__)
Encoding.default_internal = 'UTF-8'
Encoding.default_external = 'UTF-8'
require 'yaml'
require 'json'
Bundler.require
Bundler.require :development if ENV['RACK_ENV'] == 'development'
require 'logger'
require 'tilt/erubi'
require 'active_support'
require 'active_support/time'
ActiveSupport.to_time_preserves_timezone = :zone
class File
def self.exists?(val)
self.exist?(val)
end
end
Dir['./ext/**/*.rb'].each {|f| require f}
# :nocov:
if ENV['CI']
$config = YAML.load_file File.join(DIR_ROOT, 'config.yml.ci')
else
begin
$config = YAML.load_file(File.join(DIR_ROOT, 'config.yml'))[ENV['RACK_ENV']]
rescue Errno::ENOENT
puts "ERROR: Please provide a config.yml file."
exit
end
end
# :nocov:
DB = Sequel.connect $config['database'], sslmode: 'disable', max_connections: $config['database_pool']
DB.extension :pagination
DB.extension :auto_literal_strings
Sequel.split_symbols = true
Sidekiq.strict_args!(false)
require 'will_paginate/sequel'
# :nocov:
=begin
if defined?(Pry)
Pry.commands.alias_command 'c', 'continue'
Pry.commands.alias_command 's', 'step'
Pry.commands.alias_command 'n', 'next'
Pry.commands.alias_command 'f', 'finish'
end
=end
# :nocov:
unless ENV['RACK_ENV'] == 'production'
Sidekiq.configure_server do |config|
config.logger = nil
end
end
sidekiq_redis_config = {}
sidekiq_redis_config[:url] = $config['sidekiq_url'] if $config['sidekiq_url']
# :nocov:
Sidekiq.configure_server do |config|
config.redis = sidekiq_redis_config
end
# :nocov:
Sidekiq.configure_client do |config|
config.logger = nil
config.redis = sidekiq_redis_config
end
if ENV['RACK_ENV'] == 'test'
$redis = MockRedis.new
else
$redis = Redis.new(
url: $config['redis_url'],
read_timeout: 5.0,
write_timeout: 5.0,
connect_timeout: 5.0
)
end
$redis_cache = Redis::Namespace.new :cache, redis: $redis
if ENV['RACK_ENV'] == 'test'
$redis_proxy = MockRedis.new
else
$redis_proxy = Redis.new(
url: $config['redis_proxy'],
read_timeout: 5.0,
write_timeout: 5.0,
connect_timeout: 5.0
)
end
# :nocov:
if ENV['RACK_ENV'] == 'development'
# Run async jobs immediately in development.
=begin
module Sidekiq
module Worker
module ClassMethods
def perform_async(*args)
Thread.new {
self.new.perform *args
}
end
end
end
end
=end
end
# :nocov:
Sequel.datetime_class = Time
Sequel.extension :core_extensions
Sequel.extension :migration
Sequel::Model.plugin :validation_helpers
Sequel::Model.plugin :force_encoding, 'UTF-8'
Sequel::Model.plugin :defaults_setter
Sequel::Model.plugin :create_timestamp
Sequel.default_timezone = :utc
Sequel::Migrator.apply DB, './migrations'
Stripe.api_key = $config['stripe_api_key']
Dir.glob('models/*.rb').each {|m| require File.join(DIR_ROOT, "#{m}") }
Dir.glob('workers/*.rb').each {|w| require File.join(DIR_ROOT, "/#{w}") }
DB.loggers << Logger.new(STDOUT) if ENV['RACK_ENV'] == 'development'
#Mail.defaults do
# options = {}
#delivery_method :sendmail, options
#end
Mail.defaults do
options = {
address: ENV.fetch("SMTP_ADDRESS"),
port: ENV.fetch("SMTP_PORT", 587).to_i,
user_name: ENV.fetch("SMTP_USERNAME"),
password: ENV.fetch("SMTP_PASSWORD"),
authentication: :login,
enable_starttls_auto: true
}
delivery_method :smtp, options
end
Sinatra::Application.set :erb, escape_html: true
require 'sass/plugin/rack'
Sinatra::Application.use Sass::Plugin::Rack
Sass::Plugin.options[:template_location] = 'sass'
Sass::Plugin.options[:css_location] = './public/css'
Sass::Plugin.options[:style] = :nested
if ENV['RACK_ENV'] != 'development'
Sass::Plugin.options[:style] = :compressed
# Sass::Plugin.options[:never_update] = true
Sass::Plugin.options[:full_exception] = false
end
PayPalRecurring.configure do |config|
config.sandbox = false
config.username = $config['paypal_api_username']
config.password = $config['paypal_api_password']
config.signature = $config['paypal_api_signature']
end
require 'csv'
$country_codes = {}
CSV.foreach("./files/country_codes.csv") do |row|
$country_codes[row.last] = row.first
end
gandi_opts = {}
gandi_opts[:env] = :test # unless ENV['RACK_ENV'] == 'production'
$gandi = Gandi::Session.new $config['gandi_api_key'], gandi_opts
$image_optim = ImageOptim.new pngout: false, svgo: false
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

13
ext/NilClass.rb Normal file
View file

@ -0,0 +1,13 @@
class NilClass
def empty?
true
end
def blank?
true
end
def not_an_integer?
true
end
end

169
ext/ago.rb Normal file
View file

@ -0,0 +1,169 @@
module Ago
module VERSION
MAJOR = 0
MINOR = 1
TINY = 5
class << self
def pretty
"#{MAJOR}.#{MINOR}.#{TINY}"
end
alias_method :print, :pretty
end
end
Ago::Order = [:year, :month, :week, :day, :hour, :minute, :second]
Ago::Units = {
:year => {
:basic => 60 * 60 * 24 * 365,
:gregorian => 86400 * 365.2425,
},
:month => {
:basic => 60 * 60 * 24 * 30,
:gregorian => 86400 * 30.436875,
},
:week => {
:basic => 60 * 60 * 24 * 7,
:gregorian => 86400 * 7.02389423076923,
},
:day => {
:basic => 60 * 60 * 24
},
:hour => {
:basic => 60 * 60
},
:minute => {
:basic => 60
},
:second => {
:basic => 1
}
}
def Ago.calendar_check(calendar)
error = ":calendar => value must be either :basic or :gregorian."
unless calendar == :basic || calendar == :gregorian
raise ArgumentError, error
end
end
module Ago::TimeAgo
# Generate List of valid unit :symbols
valids = ""
Ago::Order.each do |u|
unless u == :second
valids += ":#{u.to_s}, "
else
valids += "and :#{u.to_s}."
end
end
Valids = valids
def ago_in_words(opts={})
# Process options {hash}
focus = opts[:focus] ? opts[:focus] : 0
start_at = opts[:start_at] ? opts[:start_at] : :year
now = opts[:now] ? opts[:now] : Time.now
in_time = opts[:in_time] ? opts[:in_time] : :past
calendar = opts[:calendar] ? opts[:calendar] : :basic
# Filter out invalid arguments for :in_time
in_time_error = ":in_time => value must be either :past or :future, " \
+ "depending on whether the Time object is before or after Time.now."
unless in_time == :past || in_time == :future
raise ArgumentError, in_time_error
end
# Filter out invalid arguments for :calendar
Ago.calendar_check(calendar)
# Filter out invalid arguments for :start_at and :focus
base_error = " => value must either be a number " +
"between 0 and 6 (inclusive),\nor one of the following " +
"symbols: " + Valids
{:focus => focus, :start_at => start_at}.each do |key, opt|
opt_error = ":" + key.to_s + base_error
if opt.class == Integer
raise ArgumentError, opt_error unless opt >= 0 && opt <= 6
elsif opt.class == Symbol
raise ArgumentError, opt_error unless Ago::Units[opt]
else
raise ArgumentError, opt_error
end
end
# Create Variables necessary for processing.
frags = []
output = ""
count = 0
now = calendar == :basic ? now.to_i : now.to_f
my_time = calendar == :basic ? self.to_i : self.to_f
if now > my_time
diff = now - my_time
tail = " ago"
elsif my_time > now
diff = my_time - now
tail = " from now"
else
diff = 0
tail = "just now"
end
# Begin Ago.ago processing
Ago::Order.each do |u|
if calendar == :gregorian && Ago::Units[u][:gregorian]
value = Ago::Units[u][:gregorian]
else
value = Ago::Units[u][:basic]
end
count += 1
# Move further ahead in the Ago::Units array if start_at is farther back than
# the current point in the array.
if start_at.class == Integer
next if count <= start_at
elsif start_at.class == Symbol
next if Ago::Order.index(u) < Ago::Order.index(start_at)
end
n = (diff/value).floor
if n > 0
plural = n > 1 ? "s" : ""
frags << "#{n} #{u.to_s + plural}"
# If the argument passed into ago() is a symbol, focus the ago statement
# down to the level specified in the symbol
if focus.class == Symbol
break if u == focus || u == :second
elsif focus.class == Integer
if focus == 0 || u == :second
break
else
focus -= 1
end
end
diff -= n * value
end
end
# Der Kommissar
frags.size.times do |n|
output += frags[n]
output += ", " unless n == frags.size - 1
end
return output + "#{tail}"
end
def from_now_in_words(opts={})
ago_in_words(opts)
end
end
end
class Time
include Ago::TimeAgo
end

13
ext/float.rb Normal file
View file

@ -0,0 +1,13 @@
class Float
def round_to(x)
(self * 10**x).round.to_f / 10**x
end
def ceil_to(x)
(self * 10**x).ceil.to_f / 10**x
end
def floor_to(x)
(self * 10**x).floor.to_f / 10**x
end
end

83
ext/numeric.rb Normal file
View file

@ -0,0 +1,83 @@
class Numeric
ONE_MEGABYTE = 1000000
def roundup(nearest=10)
self % nearest == 0 ? self : self + nearest - (self % nearest)
end
def to_mb
self/ONE_MEGABYTE.to_f
end
def to_bytes_pretty
computed = nil
unit = nil
{
'B' => 1000,
'KB' => 1000 * 1000,
'MB' => 1000 * 1000 * 1000,
'GB' => 1000 * 1000 * 1000 * 1000,
'TB' => 1000 * 1000 * 1000 * 1000 * 1000
}.each_pair { |e, s|
if self < s
computed = (self.to_f / (s / 1000)).round(2)
unit = e
break
end
}
computed = computed.to_i if computed.modulo(1) == 0.0
"#{computed} #{unit}"
end
def to_gigabytes_pretty
self.to_gigabytes.to_s + ' GB'
end
def to_gigabytes
self / (1000**3)
end
def to_comma_separated
self.to_i.to_s.chars.to_a.reverse.each_slice(3).map(&:join).join(",").reverse
end
def format_large_number
return self.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
if self > 9999
if self > 999999999
unit_char = 'B' #billion
unit_amount = 1000000000.0
elsif self > 999999
unit_char = 'M' #million
unit_amount = 1000000.0
elsif self > 9999
unit_char = 'K' #thousand
unit_amount = 1000.0
end
self_divided = self.to_f / unit_amount
self_rounded = self_divided.round(1)
if self_rounded.denominator == 1
return sprintf ("%.0f" + unit_char), self_divided
else
return sprintf ("%.1f" + unit_char), self_divided
end
else
if self > 999
return self.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
else
return self
end
end
end
def to_space_pretty
to_bytes_pretty
end
def not_an_integer?
!self.integer?
end
end

View file

@ -0,0 +1,8 @@
module Sequel::Plugins::CreateTimestamp
module InstanceMethods
def before_create
self.created_at = Time.now if respond_to?(:created_at) && !self.created_at
super
end
end
end

View file

@ -0,0 +1,33 @@
module Sequel
module ParanoidDelete
def self.included(base)
base.extend(ClassMethods)
end
# Instead of actually deleting, we just set is_deleted to true,
# and look for it with our default dataset filter.
def delete
self.is_deleted = true
save :validate => false
true
end
module ClassMethods
# There's no hook for setting default filters after inheritance (that I'm aware of),
# so this adds the filter for the first time the class' dataset is accessed for the new model.
def dataset
if @_is_deleted_filter_set.nil?
#KD: I turned this off because I think it's easier to do this manually.
#@dataset.filter! is_deleted: false
@_is_deleted_filter_set = true
end
super
end
end
end
# Model.include ParanoidDelete
end

7
ext/sinatra/base.rb Normal file
View file

@ -0,0 +1,7 @@
class Sinatra::Base
alias_method :render_original, :render
def render(engine, data, options = {}, locals = {}, &block)
options.merge!(pretty: self.class.development?) if engine == :slim && options[:pretty].nil?
render_original engine, data, options, locals, &block
end
end

View file

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

26
ext/string.rb Normal file
View file

@ -0,0 +1,26 @@
class String
def shorten(length, usedots=true)
if usedots
return self if self.length < length
"#{self[0..length-3]}..."
else
self[0..length]
end
end
def unindent
gsub /^#{Regexp.quote(scan(/^\s*/).min_by{|l|l.length})}/, ""
end
def blank?
return true if self == ''
false
end
def not_an_integer?
Integer(self)
false
rescue ArgumentError
true
end
end

7
ext/tempfile.rb Normal file
View file

@ -0,0 +1,7 @@
class Tempfile
alias_method :size_original, :size
def size
s = size_original
s.nil? ? 0 : s
end
end

9
ext/time.rb Normal file
View file

@ -0,0 +1,9 @@
class Time
def ago
self.ago_in_words
end
def from_now
self.from_now_in_words
end
end

BIN
files/GeoLiteCity.dat Normal file

Binary file not shown.

250
files/country_codes.csv Normal file
View file

@ -0,0 +1,250 @@
Afghanistan,AF
Åland Islands,AX
Albania,AL
Algeria,DZ
American Samoa,AS
Andorra,AD
Angola,AO
Anguilla,AI
Antarctica,AQ
Antigua and Barbuda,AG
Argentina,AR
Armenia,AM
Aruba,AW
Australia,AU
Austria,AT
Azerbaijan,AZ
Bahamas,BS
Bahrain,BH
Bangladesh,BD
Barbados,BB
Belarus,BY
Belgium,BE
Belize,BZ
Benin,BJ
Bermuda,BM
Bhutan,BT
"Bolivia, Plurinational State of",BO
"Bonaire, Sint Eustatius and Saba",BQ
Bosnia and Herzegovina,BA
Botswana,BW
Bouvet Island,BV
Brazil,BR
British Indian Ocean Territory,IO
Brunei Darussalam,BN
Bulgaria,BG
Burkina Faso,BF
Burundi,BI
Cambodia,KH
Cameroon,CM
Canada,CA
Cape Verde,CV
Cayman Islands,KY
Central African Republic,CF
Chad,TD
Chile,CL
China,CN
Christmas Island,CX
Cocos (Keeling) Islands,CC
Colombia,CO
Comoros,KM
Congo,CG
"Congo, the Democratic Republic of the",CD
Cook Islands,CK
Costa Rica,CR
Côte d'Ivoire,CI
Croatia,HR
Cuba,CU
Curaçao,CW
Cyprus,CY
Czech Republic,CZ
Denmark,DK
Djibouti,DJ
Dominica,DM
Dominican Republic,DO
Ecuador,EC
Egypt,EG
El Salvador,SV
Equatorial Guinea,GQ
Eritrea,ER
Estonia,EE
Ethiopia,ET
Falkland Islands (Malvinas),FK
Faroe Islands,FO
Fiji,FJ
Finland,FI
France,FR
French Guiana,GF
French Polynesia,PF
French Southern Territories,TF
Gabon,GA
Gambia,GM
Georgia,GE
Germany,DE
Ghana,GH
Gibraltar,GI
Greece,GR
Greenland,GL
Grenada,GD
Guadeloupe,GP
Guam,GU
Guatemala,GT
Guernsey,GG
Guinea,GN
Guinea-Bissau,GW
Guyana,GY
Haiti,HT
Heard Island and McDonald Mcdonald Islands,HM
Holy See (Vatican City State),VA
Honduras,HN
Hong Kong,HK
Hungary,HU
Iceland,IS
India,IN
Indonesia,ID
"Iran, Islamic Republic of",IR
Iraq,IQ
Ireland,IE
Isle of Man,IM
Israel,IL
Italy,IT
Jamaica,JM
Japan,JP
Jersey,JE
Jordan,JO
Kazakhstan,KZ
Kenya,KE
Kiribati,KI
"Korea, Democratic People's Republic of",KP
"Korea, Republic of",KR
Kuwait,KW
Kyrgyzstan,KG
Lao People's Democratic Republic,LA
Latvia,LV
Lebanon,LB
Lesotho,LS
Liberia,LR
Libya,LY
Liechtenstein,LI
Lithuania,LT
Luxembourg,LU
Macao,MO
"Macedonia, the Former Yugoslav Republic of",MK
Madagascar,MG
Malawi,MW
Malaysia,MY
Maldives,MV
Mali,ML
Malta,MT
Marshall Islands,MH
Martinique,MQ
Mauritania,MR
Mauritius,MU
Mayotte,YT
Mexico,MX
"Micronesia, Federated States of",FM
"Moldova, Republic of",MD
Monaco,MC
Mongolia,MN
Montenegro,ME
Montserrat,MS
Morocco,MA
Mozambique,MZ
Myanmar,MM
Namibia,NA
Nauru,NR
Nepal,NP
Netherlands,NL
New Caledonia,NC
New Zealand,NZ
Nicaragua,NI
Niger,NE
Nigeria,NG
Niue,NU
Norfolk Island,NF
Northern Mariana Islands,MP
Norway,NO
Oman,OM
Pakistan,PK
Palau,PW
"Palestine, State of",PS
Panama,PA
Papua New Guinea,PG
Paraguay,PY
Peru,PE
Philippines,PH
Pitcairn,PN
Poland,PL
Portugal,PT
Puerto Rico,PR
Qatar,QA
Réunion,RE
Romania,RO
Russian Federation,RU
Rwanda,RW
Saint Barthélemy,BL
"Saint Helena, Ascension and Tristan da Cunha",SH
Saint Kitts and Nevis,KN
Saint Lucia,LC
Saint Martin (French part),MF
Saint Pierre and Miquelon,PM
Saint Vincent and the Grenadines,VC
Samoa,WS
San Marino,SM
Sao Tome and Principe,ST
Saudi Arabia,SA
Senegal,SN
Serbia,RS
Seychelles,SC
Sierra Leone,SL
Singapore,SG
Sint Maarten (Dutch part),SX
Slovakia,SK
Slovenia,SI
Solomon Islands,SB
Somalia,SO
South Africa,ZA
South Georgia and the South Sandwich Islands,GS
South Sudan,SS
Spain,ES
Sri Lanka,LK
Sudan,SD
Suriname,SR
Svalbard and Jan Mayen,SJ
Swaziland,SZ
Sweden,SE
Switzerland,CH
Syrian Arab Republic,SY
"Taiwan, Province of China",TW
Tajikistan,TJ
"Tanzania, United Republic of",TZ
Thailand,TH
Timor-Leste,TL
Togo,TG
Tokelau,TK
Tonga,TO
Trinidad and Tobago,TT
Tunisia,TN
Turkey,TR
Turkmenistan,TM
Turks and Caicos Islands,TC
Tuvalu,TV
Uganda,UG
Ukraine,UA
United Arab Emirates,AE
United Kingdom,GB
United States,US
United States Minor Outlying Islands,UM
Uruguay,UY
Uzbekistan,UZ
Vanuatu,VU
"Venezuela, Bolivarian Republic of",VE
Viet Nam,VN
"Virgin Islands, British",VG
"Virgin Islands, U.S.",VI
Wallis and Futuna,WF
Western Sahara,EH
Yemen,YE
Zambia,ZM
Zimbabwe,ZW
European Union, EU
1 Afghanistan AF
2 Åland Islands AX
3 Albania AL
4 Algeria DZ
5 American Samoa AS
6 Andorra AD
7 Angola AO
8 Anguilla AI
9 Antarctica AQ
10 Antigua and Barbuda AG
11 Argentina AR
12 Armenia AM
13 Aruba AW
14 Australia AU
15 Austria AT
16 Azerbaijan AZ
17 Bahamas BS
18 Bahrain BH
19 Bangladesh BD
20 Barbados BB
21 Belarus BY
22 Belgium BE
23 Belize BZ
24 Benin BJ
25 Bermuda BM
26 Bhutan BT
27 Bolivia, Plurinational State of BO
28 Bonaire, Sint Eustatius and Saba BQ
29 Bosnia and Herzegovina BA
30 Botswana BW
31 Bouvet Island BV
32 Brazil BR
33 British Indian Ocean Territory IO
34 Brunei Darussalam BN
35 Bulgaria BG
36 Burkina Faso BF
37 Burundi BI
38 Cambodia KH
39 Cameroon CM
40 Canada CA
41 Cape Verde CV
42 Cayman Islands KY
43 Central African Republic CF
44 Chad TD
45 Chile CL
46 China CN
47 Christmas Island CX
48 Cocos (Keeling) Islands CC
49 Colombia CO
50 Comoros KM
51 Congo CG
52 Congo, the Democratic Republic of the CD
53 Cook Islands CK
54 Costa Rica CR
55 Côte d'Ivoire CI
56 Croatia HR
57 Cuba CU
58 Curaçao CW
59 Cyprus CY
60 Czech Republic CZ
61 Denmark DK
62 Djibouti DJ
63 Dominica DM
64 Dominican Republic DO
65 Ecuador EC
66 Egypt EG
67 El Salvador SV
68 Equatorial Guinea GQ
69 Eritrea ER
70 Estonia EE
71 Ethiopia ET
72 Falkland Islands (Malvinas) FK
73 Faroe Islands FO
74 Fiji FJ
75 Finland FI
76 France FR
77 French Guiana GF
78 French Polynesia PF
79 French Southern Territories TF
80 Gabon GA
81 Gambia GM
82 Georgia GE
83 Germany DE
84 Ghana GH
85 Gibraltar GI
86 Greece GR
87 Greenland GL
88 Grenada GD
89 Guadeloupe GP
90 Guam GU
91 Guatemala GT
92 Guernsey GG
93 Guinea GN
94 Guinea-Bissau GW
95 Guyana GY
96 Haiti HT
97 Heard Island and McDonald Mcdonald Islands HM
98 Holy See (Vatican City State) VA
99 Honduras HN
100 Hong Kong HK
101 Hungary HU
102 Iceland IS
103 India IN
104 Indonesia ID
105 Iran, Islamic Republic of IR
106 Iraq IQ
107 Ireland IE
108 Isle of Man IM
109 Israel IL
110 Italy IT
111 Jamaica JM
112 Japan JP
113 Jersey JE
114 Jordan JO
115 Kazakhstan KZ
116 Kenya KE
117 Kiribati KI
118 Korea, Democratic People's Republic of KP
119 Korea, Republic of KR
120 Kuwait KW
121 Kyrgyzstan KG
122 Lao People's Democratic Republic LA
123 Latvia LV
124 Lebanon LB
125 Lesotho LS
126 Liberia LR
127 Libya LY
128 Liechtenstein LI
129 Lithuania LT
130 Luxembourg LU
131 Macao MO
132 Macedonia, the Former Yugoslav Republic of MK
133 Madagascar MG
134 Malawi MW
135 Malaysia MY
136 Maldives MV
137 Mali ML
138 Malta MT
139 Marshall Islands MH
140 Martinique MQ
141 Mauritania MR
142 Mauritius MU
143 Mayotte YT
144 Mexico MX
145 Micronesia, Federated States of FM
146 Moldova, Republic of MD
147 Monaco MC
148 Mongolia MN
149 Montenegro ME
150 Montserrat MS
151 Morocco MA
152 Mozambique MZ
153 Myanmar MM
154 Namibia NA
155 Nauru NR
156 Nepal NP
157 Netherlands NL
158 New Caledonia NC
159 New Zealand NZ
160 Nicaragua NI
161 Niger NE
162 Nigeria NG
163 Niue NU
164 Norfolk Island NF
165 Northern Mariana Islands MP
166 Norway NO
167 Oman OM
168 Pakistan PK
169 Palau PW
170 Palestine, State of PS
171 Panama PA
172 Papua New Guinea PG
173 Paraguay PY
174 Peru PE
175 Philippines PH
176 Pitcairn PN
177 Poland PL
178 Portugal PT
179 Puerto Rico PR
180 Qatar QA
181 Réunion RE
182 Romania RO
183 Russian Federation RU
184 Rwanda RW
185 Saint Barthélemy BL
186 Saint Helena, Ascension and Tristan da Cunha SH
187 Saint Kitts and Nevis KN
188 Saint Lucia LC
189 Saint Martin (French part) MF
190 Saint Pierre and Miquelon PM
191 Saint Vincent and the Grenadines VC
192 Samoa WS
193 San Marino SM
194 Sao Tome and Principe ST
195 Saudi Arabia SA
196 Senegal SN
197 Serbia RS
198 Seychelles SC
199 Sierra Leone SL
200 Singapore SG
201 Sint Maarten (Dutch part) SX
202 Slovakia SK
203 Slovenia SI
204 Solomon Islands SB
205 Somalia SO
206 South Africa ZA
207 South Georgia and the South Sandwich Islands GS
208 South Sudan SS
209 Spain ES
210 Sri Lanka LK
211 Sudan SD
212 Suriname SR
213 Svalbard and Jan Mayen SJ
214 Swaziland SZ
215 Sweden SE
216 Switzerland CH
217 Syrian Arab Republic SY
218 Taiwan, Province of China TW
219 Tajikistan TJ
220 Tanzania, United Republic of TZ
221 Thailand TH
222 Timor-Leste TL
223 Togo TG
224 Tokelau TK
225 Tonga TO
226 Trinidad and Tobago TT
227 Tunisia TN
228 Turkey TR
229 Turkmenistan TM
230 Turks and Caicos Islands TC
231 Tuvalu TV
232 Uganda UG
233 Ukraine UA
234 United Arab Emirates AE
235 United Kingdom GB
236 United States US
237 United States Minor Outlying Islands UM
238 Uruguay UY
239 Uzbekistan UZ
240 Vanuatu VU
241 Venezuela, Bolivarian Republic of VE
242 Viet Nam VN
243 Virgin Islands, British VG
244 Virgin Islands, U.S. VI
245 Wallis and Futuna WF
246 Western Sahara EH
247 Yemen YE
248 Zambia ZM
249 Zimbabwe ZW
250 European Union EU

View file

@ -0,0 +1,85 @@
raise 'nope'
Sequel.migration do
up {
raise 'derp'
DB.drop_table :stats
DB.drop_table :stat_referrers
DB.drop_table :stat_paths
DB.drop_table :stat_locations
DB.create_table! :hits do
primary_key :id
Integer :site_id, index: true
Integer :hit_referrer_id
Integer :hit_path_id
Integer :hit_location_id
Bignum :bandwidth
Time :accessed_at, index: true
end
DB.create_table! :hit_referrers do
primary_key :id
String :uri, index: {unique: true}
end
DB.create_table! :hit_locations do
primary_key :id
String :country_code2
String :region_name
String :city_name
Float :latitude
Float :longitude
end
DB.create_table! :hit_paths do
primary_key :id
String :path, index: {unique: true}
end
}
down {
raise 'No.' if ENV['RACK_ENV'] == 'production'
%i{hits hit_referrers hit_locations hit_paths}.each do |t|
DB.drop_table t
end
DB.create_table! :stats do
primary_key :id
Integer :site_id
Date :created_at
Integer :hits
Integer :views
Integer :comments
Integer :follows
Integer :site_updates
end
DB.create_table! :stat_referrers do
primary_key :id
Integer :stat_id
String :url
String :views
end
DB.create_table! :stat_locations do
primary_key :id
Integer :stat_id
String :country_code2
String :region_name
String :city_name
Decimal :latitude
Decimal :longitude
Integer :views
end
DB.create_table :stat_paths do
primary_key :id
Integer :stat_id
String :name
Integer :views
end
}
end

View file

@ -0,0 +1,56 @@
var page = require('webpage').create()
var system = require('system')
var maxTimeout = 15000
if (system.args.length === 1) {
console.log('required args: <siteURL> <outputFilePath>');
phantom.exit(1)
}
var address = system.args[1]
var outputPath = system.args[2]
page.viewportSize = { width: 1280, height: 960 }
page.clipRect = { top: 0, left: 0, width: 1280, height: 960}
/*
In development, not working yet.
page.settings.scriptTimeout = 1000
page.onLongRunningScript = function() {
page.stopJavaScript()
phantom.exit(4)
}
*/
var t = Date.now()
console.log('Loading ' + address)
setTimeout(function() {
console.log('timeout')
phantom.exit(62)
}, maxTimeout)
page.settings.resourceTimeout = maxTimeout
page.onResourceTimeout = function(e) {
console.log(e.errorCode)
console.log(e.errorString)
console.log(e.url)
phantom.exit(3)
}
page.open(address, function(status) {
if(status !== 'success') {
console.log('failed')
phantom.exit(2)
}
page.render(outputPath)
console.log('Loading time ' + (Date.now() - t) + ' msec');
phantom.exit()
})

24
files/root_site_uris.txt Normal file
View file

@ -0,0 +1,24 @@
1,weekly,https://femslash.city
0.9,daily,https://femslash.city/browse
0.9,monthly,https://femslash.city/signin
0.9,hourly,https://femslash.city/activity
0.9,monthly,https://femslash.city/tutorials
0.9,monthly,https://femslash.city/supporter
0.9,monthly,https://femslash.city/cli
0.9,monthly,https://femslash.city/about
0.9,monthly,https://femslash.city/donate
0.9,monthly,https://femslash.city/api
0.9,monthly,https://femslash.city/press
0.8,monthly,https://femslash.city/terms
0.8,weekly,https://blog.femslash.city
0.7,monthly,https://femslash.city/contact
0.6,daily,https://femslash.city/browse?sort_by=special_sauce&tag=
0.6,hourly,https://femslash.city/browse?sort_by=last_updated&tag=
0.6,daily,https://femslash.city/browse?sort_by=supporters&tag=
0.6,weekly,https://femslash.city/browse?sort_by=featured&tag=
0.6,daily,https://femslash.city/browse?sort_by=tipping_enabled&tag=
0.6,weekly,https://femslash.city/browse?sort_by=views&tag=
0.6,weekly,https://femslash.city/browse?sort_by=hits&tag=
0.6,hourly,https://femslash.city/browse?sort_by=newest&tag=
0.6,monthly,https://femslash.city/browse?sort_by=oldest&tag=
0.6,always,https://femslash.city/browse?sort_by=random&tag=

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View file

@ -0,0 +1,9 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body style="background:#E9E0D5 url(homepage01bg.png) repeat-x;text-align:center; margin:0;padding:0">
<img src="homepage01x.png">
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View file

@ -0,0 +1,17 @@
Sequel.migration do
up {
DB.create_table! :sites do
primary_key :id
String :username
String :email
String :password
Integer :server_id
DateTime :created_at
DateTime :updated_at
end
}
down {
DB.drop_table :sites
}
end

View file

@ -0,0 +1,15 @@
Sequel.migration do
up {
DB.create_table! :servers do
primary_key :id
String :ip
Integer :slots_available
DateTime :created_at
DateTime :updated_at
end
}
down {
DB.drop_table :servers
}
end

View file

@ -0,0 +1,12 @@
Sequel.migration do
up {
DB.create_table! :tags do
primary_key :id
String :name
end
}
down {
DB.drop_table :tags
}
end

View file

@ -0,0 +1,12 @@
Sequel.migration do
up {
DB.create_table! :sites_tags do
foreign_key :site_id, :sites
foreign_key :tag_id, :tags
end
}
down {
DB.drop_table :sites_tags
}
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
Sequel.migration do
up {
DB.add_column :sites, :password_reset_token, :text
DB.add_index :sites, :password_reset_token
}
down {
DB.drop_column :sites, :password_reset_token
}
end

View file

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

View file

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

View file

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

View file

@ -0,0 +1,90 @@
Sequel.migration do
up {
DB.add_column :sites, :title, :text, default: nil
DB.add_column :sites, :twitter_handle, :text, default: nil
DB.add_column :sites, :views, :integer, default: 0
DB.add_column :sites, :stripe_token, :text, default: nil
DB.create_table! :follows do
primary_key :id
Integer :site_id, index: true
Integer :actioning_site_id, index: true
DateTime :created_at, index: true
end
DB.create_table! :tips do
primary_key :id
Integer :site_id, index: true
Integer :actioning_site_id, index: true
DateTime :created_at, index: true
BigDecimal :amount
Integer :stripe_charge_id
end
DB.create_table! :changes do
primary_key :id
Integer :site_id, index: true
DateTime :created_at, index: true
end
DB.create_table! :events do
primary_key :id
Integer :site_id, index: true
Integer :follow_id
Integer :tip_id
Integer :tag_id
Integer :site_update_id
Integer :comment_id
Boolean :notification_seen, default: false
Integer :created_at, index: true
end
DB.create_table! :comments do
primary_key :id
Integer :event_id, index: true
Integer :actioning_site_id
Integer :parent_comment_id
Text :message
DateTime :created_at
DateTime :updated_at
end
DB.create_table! :likes do
primary_key :id
Integer :event_id, index: true
Integer :actioning_site_id
DateTime :created_at
end
DB.create_table! :stats do
primary_key :id
Integer :site_id, index: true
Integer :hits, default: 0
Integer :views, default: 0
DateTime :created_at, index: true
end
DB.create_table! :blocks do
primary_key :id
Integer :site_id, index: true
Integer :actioning_site_id, index: true
DateTime :created_at
end
}
down {
DB.drop_column :sites, :title
DB.drop_column :sites, :twitter_handle
DB.drop_column :sites, :views
DB.drop_column :sites, :stripe_token
DB.drop_table :follows
DB.drop_table :tips
DB.drop_table :changes
DB.drop_table :events
DB.drop_table :comments
DB.drop_table :likes
DB.drop_table :stats
DB.drop_table :blocks
}
end

View file

@ -0,0 +1,11 @@
Sequel.migration do
up {
DB.drop_column :sites, :stripe_token
DB.add_column :sites, :stripe_customer_id, :text, default: nil
}
down {
DB.drop_column :sites, :stripe_customer_id
DB.add_column :sites, :stripe_token, :text, default: nil
}
end

View file

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

View file

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

View file

@ -0,0 +1,9 @@
Sequel.migration do
up {
DB.rename_table :changes, :site_changes
}
down {
DB.rename_table :site_changes, :changes
}
end

View file

@ -0,0 +1,19 @@
Sequel.migration do
up {
DB.rename_column :events, :comment_id, :profile_comment_id
DB.create_table! :profile_comments do
primary_key :id
Integer :site_id
Integer :actioning_site_id
Text :message
DateTime :created_at
DateTime :updated_at
end
}
down {
DB.rename_column :events, :profile_comment_id, :comment_id
DB.drop_table :profile_comments
}
end

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
Sequel.migration do
up {
DB.create_table! :comment_likes do
primary_key :id
Integer :comment_id
Integer :site_id
Integer :actioning_site_id
DateTime :created_at
end
}
down {
DB.drop_table :comment_likes
}
end

View file

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

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