diff --git a/.gitignore b/.gitignore
index a609b413..a8f5d2a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,19 +7,25 @@
*.orig
*.tmp
*.md5
+*.swp
+.fseventsd
+db
*.*.py
.\#*
\#*.*\#
.hg
.hgignore
+.rvmrc
lighttpd.**
development.ini
development_*.ini
-production.ini
+lesswrong.com*.ini
r2/r2/public/static/frame.js
r2/r2/public/static/reddit.js
r2/r2/public/static/vote.js
r2/r2/public/static/reddit_rtl.css
+r2/r2/public/static/lesswrong.js
+public/files/wiki.lesswrong.xml*
r2/r2admin
r2/reddit_i18n
r2/data/*
@@ -27,7 +33,18 @@ r2/count.pickle
r2/srcount.pickle
r2/myproduction.ini
.DS_Store
+.fseventsd/*
r2/r2.egg-info/**
r2/lighttpd_access.log
r2/lighttpd_error.log
-r2/paster.log
\ No newline at end of file
+r2/paster.log
+r2/paster.pid
+memcached.*.pid
+paster.*.pid
+config/deploy/aws-test.rb
+exporter/*
+notes/*
+attic/*
+.dir-locals.el
+.Trashes/*
+spec/selenium-override.rb
diff --git a/.gitmodules b/.gitmodules
index 85afd7ff..13412252 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
[submodule "config/cap-tasks"]
path = config/cap-tasks
url = git@git.trike.com.au:capistrano.git
+[submodule "tasks/manual_test_script"]
+ path = tasks/manual_test_script
+ url = git://github.com/tricycle/manual_test_script.git
diff --git a/.rvmrc.sample b/.rvmrc.sample
new file mode 100644
index 00000000..707ae177
--- /dev/null
+++ b/.rvmrc.sample
@@ -0,0 +1 @@
+rvm --create ree@lesswrong
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 00000000..0feb58a9
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,13 @@
+source :rubygems
+
+gem 'capistrano', '~>2.5.19'
+gem 'capistrano-ext', '~>1.2.1'
+gem 'right_aws', '~>2.0.0'
+gem 'erubis', '~>2.6.6'
+gem 'activesupport', '~>3.0.0'
+gem 'rake', '~>0.8.7'
+gem 'treetop', '~>1.4.8'
+gem 'capybara'
+gem 'selenium-webdriver'
+gem 'rspec'
+
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 00000000..1aac1ca4
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,82 @@
+GEM
+ remote: http://rubygems.org/
+ specs:
+ abstract (1.0.0)
+ activesupport (3.0.7)
+ capistrano (2.5.21)
+ highline
+ net-scp (>= 1.0.0)
+ net-sftp (>= 2.0.0)
+ net-ssh (>= 2.0.14)
+ net-ssh-gateway (>= 1.0.0)
+ capistrano-ext (1.2.1)
+ capistrano (>= 1.0.0)
+ capybara (0.4.1.2)
+ celerity (>= 0.7.9)
+ culerity (>= 0.2.4)
+ mime-types (>= 1.16)
+ nokogiri (>= 1.3.3)
+ rack (>= 1.0.0)
+ rack-test (>= 0.5.4)
+ selenium-webdriver (>= 0.0.27)
+ xpath (~> 0.1.3)
+ celerity (0.8.9)
+ childprocess (0.1.9)
+ ffi (~> 1.0.6)
+ culerity (0.2.15)
+ diff-lcs (1.1.2)
+ erubis (2.6.6)
+ abstract (>= 1.0.0)
+ ffi (1.0.9)
+ highline (1.6.2)
+ json_pure (1.5.1)
+ mime-types (1.16)
+ net-scp (1.0.4)
+ net-ssh (>= 1.99.1)
+ net-sftp (2.0.5)
+ net-ssh (>= 2.0.9)
+ net-ssh (2.1.4)
+ net-ssh-gateway (1.1.0)
+ net-ssh (>= 1.99.1)
+ nokogiri (1.4.4)
+ polyglot (0.3.1)
+ rack (1.3.0)
+ rack-test (0.6.0)
+ rack (>= 1.0)
+ rake (0.8.7)
+ right_aws (2.0.0)
+ right_http_connection (>= 1.2.1)
+ right_http_connection (1.3.0)
+ rspec (2.6.0)
+ rspec-core (~> 2.6.0)
+ rspec-expectations (~> 2.6.0)
+ rspec-mocks (~> 2.6.0)
+ rspec-core (2.6.3)
+ rspec-expectations (2.6.0)
+ diff-lcs (~> 1.1.2)
+ rspec-mocks (2.6.0)
+ rubyzip (0.9.4)
+ selenium-webdriver (0.2.0)
+ childprocess (>= 0.1.7)
+ ffi (>= 1.0.7)
+ json_pure
+ rubyzip
+ treetop (1.4.9)
+ polyglot (>= 0.3.1)
+ xpath (0.1.4)
+ nokogiri (~> 1.3)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activesupport (~> 3.0.0)
+ capistrano (~> 2.5.19)
+ capistrano-ext (~> 1.2.1)
+ capybara
+ erubis (~> 2.6.6)
+ rake (~> 0.8.7)
+ right_aws (~> 2.0.0)
+ rspec
+ selenium-webdriver
+ treetop (~> 1.4.8)
diff --git a/README.textile b/README.textile
index 67136b8e..661fd6cf 100644
--- a/README.textile
+++ b/README.textile
@@ -1,5 +1,11 @@
-The code behind the rationalists community at "lesswrong.com":http://lesswrong.com/.
-Source hosted on "Github":http://github.com/tricycle/lesswrong.
-Issues tracked on "Google Code":http://code.google.com/p/lesswrong/.
+h1. Less Wrong
-A fork of the "Reddit codebase":http://code.reddit.com/.
+The code behind the rationalists community at "lesswrong.com":http://lesswrong.com/.
+
+h2. Resources
+
+* The source code is hosted on "Github":http://github.com/tricycle/lesswrong
+* Documentation is available via the "GitHub wiki":http://wiki.github.com/tricycle/lesswrong
+* Issues are tracked on "Google Code":http://code.google.com/p/lesswrong/
+* Mailing list (lesswrong-dev) on "Google Groups":http://groups.google.com/group/lesswrong-dev
+* The code is a fork of the "Reddit":http://reddit.com/ "codebase":http://code.reddit.com/
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 00000000..7f056853
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,362 @@
+require 'fileutils'
+require 'tempfile'
+require 'pathname'
+require 'shellwords'
+
+begin
+ require 'rspec/core/rake_task'
+rescue LoadError
+end
+
+namespace :test do
+ desc "Interactively run through the deployment test script."
+ task :manual do
+ # These are in here so they aren't required on the production server
+ $:.unshift 'tasks/manual_test_script/lib'
+ require 'active_support'
+ require 'manual_test_script'
+ ManualTestScript.run('test/manual.txt')
+ end
+end
+
+# Borrowed from capistrano
+def sudo(command, options = {})
+ user = options[:as] && "-u #{options.delete(:as)}"
+
+ sudo_prompt_option = "-p 'sudo password: '"
+ sudo_command = ["sudo", sudo_prompt_option, user, command].compact.join(" ")
+ run sudo_command
+end
+
+def run(command)
+ puts "running `#{command}'"
+ unless system(command)
+ raise RuntimeError.new("Error running command: '#{command}'")
+ end
+end
+
+def basepath
+ Pathname(__FILE__).dirname.realpath
+end
+
+def shared_path
+ basepath.parent.parent + 'shared'
+end
+
+def r2_path
+ basepath + "r2"
+end
+
+def db_dump_path
+ path = basepath + "db" + "dumps"
+ path.mkpath unless path.exist?
+ path
+end
+
+def inifile
+ ENV['INI'] || (r2_path + "#{environment}.ini")
+end
+
+def user
+ ENV['APPLICATION_USER'] || raise("APPLICATION_USER environment variable must be set to run this task")
+end
+
+def application
+ ENV['APPLICATION'] || raise("APPLICATION environment variable variable must be set to run this task")
+end
+
+def environment
+ ENV['APPLICATION_ENV'] || raise("APPLICATION_ENV environment variable must be set to run this task")
+end
+
+def databases
+ @databases ||= begin
+ dbs = ENV['DATABASES'] || raise("DATABASES environment variable must be set to run this task")
+ dbs.split(/\s*,\s*/)
+ end
+end
+
+def db_dump_prefix
+ ENV['DB_DUMP_PREFIX'] || ''
+end
+
+def app_server(action)
+ return unless [:start, :stop, :restart].include?(action)
+ sudo "#{action} paster"
+end
+
+# These tasks assume they are running in a capistrano managed directory structure.
+namespace :app do
+ desc "Start the Application"
+ task :start do
+ app_server(:start)
+ end
+
+ desc "Stop the Application"
+ task :stop do
+ app_server(:stop)
+ end
+
+ desc "Restart the Application"
+ task :restart do
+ app_server(:restart)
+ end
+end
+
+namespace :deploy do
+ desc 'Run Reddit setup routine'
+ task :setup do
+ Dir.chdir r2_path
+ sudo "python setup.py install"
+ Dir.chdir basepath
+ sudo "chown -R #{user} #{r2_path}"
+ end
+
+ desc "Symlink the INI files into the release path"
+ task :symlink_ini do
+ Dir["/usr/local/etc/reddit/#{application}.*.ini"].each do |ini|
+ if File.basename(ini) =~ /#{Regexp.escape(application)}\.([^\.]+)\.ini/
+ target = "#{r2_path}/#{$1}.ini"
+ FileUtils.ln_sf(ini, target, :verbose => true)
+ end
+ end
+ end
+
+ desc 'Compress and concetenate JS and generate MD5 files'
+ task :process_static_files do
+ Dir.chdir r2_path
+ run "./compress_js.sh"
+ end
+
+ # For compatibilty
+ desc "Restart the Application"
+ task :restart do
+ Rake::Task['app:stop'].invoke
+ Rake::Task['app:start'].invoke
+ end
+
+ desc "Copy the lesswrong crontab to /etc/cron.d in production. Requires root permissions"
+ task :crontab do
+ crontab = basepath + 'config' + 'crontab'
+ target = "/etc/cron.d/lesswrong"
+ if environment == "production"
+ sudo "/bin/cp #{crontab} #{target}"
+ else
+ # Don't want the cron jobs running in non-production environments
+ sudo "/bin/rm -f #{target}"
+ end
+ end
+
+end
+
+desc "Hook for tasks that should run after code update"
+task :after_update_code => %w[
+ deploy:symlink_ini
+ deploy:setup
+ deploy:process_static_files
+ deploy:crontab
+]
+
+def conf
+ @conf ||= begin
+ conf = {}
+ File.open(inifile.to_s) do |ini|
+ ini.each_line do |line|
+ next if line =~ /^\s*#/ # skip comments
+ next if line =~ /^\s*\[[^\]]+\]/ # skip sections
+
+ if line =~ /\s*([^\s=]+)\s*=\s*(.*)$/
+ conf[$1] = $2
+ end
+ end
+ end
+ conf
+ end
+end
+
+
+# Set the databases variable in your local deploy configuration
+# expects an array of PostgreSQL database names
+# Example:
+# set :databases, %w[reddit change query_queue]
+
+namespace :postgresql do
+
+ def db_conf(db, var)
+ key = [db, 'db', var].join('_')
+ conf[key]
+ end
+
+ # Common options
+ def postgresql_opts(database)
+ opts = []
+ opts << "--host=#{db_conf database, 'host'}"
+ opts << "--username=#{db_conf database, 'user'}"
+ opts << "--no-password" # Never prompt for password, its read from the file below
+ opts.join(" ")
+ end
+
+ def with_pgpass(db)
+ # Setup the pgpass file
+ pgpass = Tempfile.new("pgpass")
+ ENV['PGPASSFILE'] = pgpass.path
+ pgpass.puts [
+ db_conf(db, 'host'),
+ '*', # port
+ db_conf(db, 'name'),
+ db_conf(db, 'user'),
+ db_conf(db, 'pass')
+ ].join(':')
+ pgpass.close
+ begin
+ yield
+ ensure
+ pgpass.unlink
+ end
+ end
+
+ def dump_file_path(db)
+ (db_dump_path + "#{db_dump_prefix}#{db}.psql").to_s.shellescape
+ end
+
+ desc 'Dump the database'
+ task :dump do
+ databases.each do |db|
+ with_pgpass(db) do
+ run "pg_dump #{postgresql_opts(db)} -f #{dump_file_path(db)} -Fc #{db_conf(db, 'name')}"
+ end
+ end
+ end
+
+ desc 'Restore the latest database dump'
+ task :restore do
+ databases.each do |db|
+ with_pgpass(db) do
+ run "pg_restore #{postgresql_opts(db)} --no-owner --clean -d #{db_conf(db, 'name')} #{dump_file_path(db)} || true"
+ end
+ end
+ end
+end
+
+namespace :memcached do
+ def memcached_pid_path
+ basepath + "memcached.#{environment}.pid"
+ end
+
+ task :start do
+ port = conf['memcaches'].split(':').last
+ pid = fork do
+ exec('memcached','-p',port)
+ end
+ File.open(memcached_pid_path, "w") do |f|
+ f.puts pid
+ end
+ end
+ task :stop do
+ pid = File.read(memcached_pid_path).to_i
+ Process.kill("TERM", pid)
+ File.unlink(memcached_pid_path)
+ end
+end
+
+namespace :db do
+ namespace :test do
+ desc "Clean the test db, and re-populate it"
+ task :prepare do
+ ENV['APPLICATION_ENV'] = 'test'
+ ENV['DATABASES'] = 'main'
+ ENV['DB_DUMP_PREFIX'] = 'test-'
+ Rake::Task['db:test:truncate'].invoke
+ Rake::Task['postgresql:restore'].invoke
+ end
+
+ task :truncate do
+ databases.each do |db|
+ with_pgpass(db) do
+ tables = `psql -t -A #{postgresql_opts(db)} -d #{db_conf(db, 'name')} -c "select tablename from pg_tables where schemaname='public'"`
+ tables.lines.each do |table|
+ run "psql #{postgresql_opts(db)} -d #{db_conf(db, 'name')} -c 'TRUNCATE TABLE #{table.chomp}'"
+ end
+
+ sequences = `psql -t -A #{postgresql_opts(db)} -d #{db_conf(db, 'name')} -c "select sequence_name from information_schema.sequences where sequence_schema='public'"`
+ sequences.lines.each do |seq|
+ run %{psql #{postgresql_opts(db)} -d #{db_conf(db, 'name')} -c "SELECT setval('#{seq.chomp}', 1, false)"}
+ end
+
+ end
+ end
+ end
+ end
+end
+
+namespace :test do
+ def paster_pid_path
+ basepath + "paster.#{environment}.pid"
+ end
+
+ def paster_log_file_path
+ basepath + "paster.#{environment}.log"
+ end
+
+ namespace :paster do
+ task :start do
+ ENV['APPLICATION_ENV'] = 'test'
+ FileUtils.cd r2_path do |d|
+ system(
+ 'paster','serve',inifile.to_s,
+ '--pid-file',paster_pid_path.to_s,
+ '--log-file',paster_log_file_path.to_s,
+ '--daemon','--reload'
+ )
+ end
+ end
+ task :stop do
+ ENV['APPLICATION_ENV'] = 'test'
+ FileUtils.cd r2_path do |d|
+ system('paster','serve',inifile.to_s,'--pid-file',paster_pid_path.to_s,'--stop-daemon')
+ end
+ end
+ end
+
+ desc "Start the server in test mode for specs"
+ task :start do
+ ENV['APPLICATION_ENV'] = 'test'
+ Rake::Task['db:test:prepare'].invoke
+ Rake::Task['memcached:start'].invoke
+ Rake::Task['test:paster:start'].invoke
+ end
+ desc "Stop the test server"
+ task :stop do
+ ENV['APPLICATION_ENV'] = 'test'
+ Rake::Task['test:paster:stop'].invoke
+ Rake::Task['memcached:stop'].invoke
+ end
+
+ desc "Start+stop test server, and run selenium spec tests"
+ task :run do
+ begin
+ Rake::Task['test:start'].invoke
+ Rake::Task['spec:setup'].invoke
+ Rake::Task['spec:test'].invoke
+ ensure
+ Rake::Task['test:stop'].invoke
+ end
+ end
+end
+
+if defined?(RSpec)
+ namespace :spec do
+ desc "Run the setup selenium spec"
+ RSpec::Core::RakeTask.new(:setup) do |t|
+ t.rspec_opts = ['--options', "\"#{basepath}/spec/spec.opts\""]
+ t.pattern = 'spec/selenium-setup.rb'
+ end
+
+ desc "Run the selenium spec"
+ RSpec::Core::RakeTask.new(:test) do |t|
+ t.rspec_opts = ['--options', "\"#{basepath}/spec/spec.opts\""]
+ t.pattern = 'spec/**/*_spec.rb'
+ end
+ end
+end
+
diff --git a/config/cap-tasks b/config/cap-tasks
index 899a7f33..49a7311f 160000
--- a/config/cap-tasks
+++ b/config/cap-tasks
@@ -1 +1 @@
-Subproject commit 899a7f33d6950d65e21675dc6e8f4d22c7bb27d1
+Subproject commit 49a7311f88244b7407937d765c5f9d09412c5144
diff --git a/config/crontab b/config/crontab
new file mode 100644
index 00000000..945b474f
--- /dev/null
+++ b/config/crontab
@@ -0,0 +1,5 @@
+# IF YOU STUMBLE UPON THIS FILE YOU MAY NOT HAVE NOTICED THAT IT IS SYMLINKED
+# INTO THE APP REPO. MAKE SURE THAT IT GETS COMMITED IF YOU MAKE ANY CHANGES.
+
+23 * * * * www-data /srv/www/lesswrong.com/current/scripts/sync_wiki_export.sh
+
diff --git a/config/db.rb b/config/db.rb
new file mode 100644
index 00000000..b35db4f3
--- /dev/null
+++ b/config/db.rb
@@ -0,0 +1,19 @@
+set :db_dump_filename do
+ "#{stage}.psql.gz"
+end
+
+namespace :db do
+
+ desc 'Fetches the latest PostgeSQL dump from the backup server'
+ task :fetch_dump, :roles => :backups do
+ host = roles[:app].servers.first.host
+ source = fetch(:remote_db_dump_location, File.join(['', 'srv', 'backup', 'sql', host.sub(/\..*$/, ''), 'all_databases.psql.gz']))
+ destination = fetch(:db_dump_location, File.join('db', 'dumps', host))
+ unless File.directory?(destination) || File.symlink?(destination)
+ FileUtils.mkdir_p destination
+ File.chmod 0775, destination # Ensure group has write access
+ end
+ get source, File.join(destination, db_dump_filename)
+ end
+
+end
diff --git a/config/deploy.rb b/config/deploy.rb
index fbae92bf..288afcde 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -1,57 +1,68 @@
-stages_glob = File.join(File.dirname(__FILE__), "deploy", "*")
-stages = Dir[stages_glob].collect {|f| File.basename(f) }.sort
+stages_glob = File.join(File.dirname(__FILE__), "deploy", "*.rb")
+stages = Dir[stages_glob].collect { |f| File.basename(f, ".rb") }.sort
set :stages, stages
require 'capistrano/ext/multistage'
-load 'config/cap-tasks/trike-tasks.rb'
+load 'config/cap-tasks/common.rb'
+load 'config/cap-tasks/test.rb'
+load 'config/cap-tasks/console.rb'
+load 'config/cap-tasks/console.rb'
+load 'config/cap-tasks/rake.rb'
+load 'config/cap-tasks/postgresql_dump.rb'
+load 'config/db.rb'
set :scm, 'git'
set :repository, "git@github.com:tricycle/lesswrong.git"
set :git_enable_submodules, 1
set :deploy_via, :remote_cache
set :repository_cache, 'cached-copy'
+set :engine, "paster"
# Be sure to change these in your application-specific files
-set :branch, 'master'
-
+set :branch, 'stable'
+set :rails_env, nil
set :user, "www-data" # defaults to the currently logged in user
-default_run_options[:pty] = true
+set :public_path, lambda { "#{current_path}/r2/r2/public" }
+set :databases, %w[main change email query_queue]
namespace :deploy do
- task :after_update_code, :roles => [:web, :app] do
+ after "deploy:update_code", :roles => [:web, :app] do
%w[files assets].each {|dir| link_shared_dir(dir) }
end
+ def rake_options
+ {
+ 'APPLICATION' => application,
+ 'APPLICATION_USER' => user,
+ 'APPLICATION_ENV' => environment
+ }.map { |k, v| "#{k}=#{v}" }.join(" ")
+ end
+
def link_shared_dir(dir)
- shared_subdir = "#{deploy_to}/shared/#{dir}"
+ shared_subdir = "#{shared_path}/#{dir}"
public_dir = "#{release_path}/public/#{dir}"
run "mkdir -p #{shared_subdir}" # make sure the shared dir exists
run "if [ -e #{public_dir} ]; then rm -rf #{public_dir} && echo '***\n*** #{public_dir} removed (in favour of a symlink to the shared version) ***\n***'; fi"
run "ln -sv #{shared_subdir} #{public_dir}"
end
-
- desc 'Link to a reddit ini file stored on the server (/usr/local/etc/reddit/#{application}.ini'
- task :symlink_remote_reddit_ini, :roles => [:app, :db] do
- run "ln -sf /usr/local/etc/reddit/#{application}.ini #{release_path}/r2/#{application}.ini"
- end
- desc 'Run Reddit setup routine'
- task :setup_reddit, :roles => [:app] do
- sudo "/bin/bash -c \"cd #{release_path}/r2 && python ./setup.py install\""
- sudo "/bin/bash -c \"cd #{release_path} && chown -R #{user} .\""
+ desc 'Symlink all the INI files into the release dir'
+ task :symlink_remote_reddit_ini, :roles => :app do
+ # Not using remote rake because need to cd to release path not current
+ run "cd #{release_path} && rake --trace deploy:symlink_ini #{rake_options}"
end
desc "Restart the Application"
task :restart, :roles => :app do
- pid_file = "#{shared_dir}/pids/paster.pid"
- run "cd #{deploy_to}/current/r2 && paster serve --stop-daemon --pid-file #{pid_file} #{application}.ini || true"
- run "cd #{deploy_to}/current/r2 && paster serve --daemon --pid-file #{pid_file} #{application}.ini"
+ remote_rake "--trace deploy:restart #{rake_options}"
+ end
+
+ desc "Run after update code rake task"
+ task :rake_after_update_code, :roles => :app do
+ remote_rake "--trace after_update_code #{rake_options}", :path => release_path
end
end
-#before 'deploy:update_code', 'git:ensure_pushed'
-#before 'deploy:update_code', 'git:ensure_deploy_branch'
-#after "deploy:update_code", "deploy:symlink_remote_db_yaml"
-#after "deploy:symlink", "deploy:apache:config"
-after "deploy:update_code", "deploy:setup_reddit"
-after "deploy:update_code", "deploy:symlink_remote_reddit_ini"
+before 'deploy:update_code', 'git:ensure_pushed'
+after "deploy:update_code", "deploy:rake_after_update_code"
+
diff --git a/config/deploy/beta b/config/deploy/beta
deleted file mode 100644
index 6770aacc..00000000
--- a/config/deploy/beta
+++ /dev/null
@@ -1,11 +0,0 @@
-set :application, "moreor.lesswrong.org"
-set :domains, %w[ lesswrong.org ]
-set :deploy_to, "/srv/www/#{application}"
-set :shared_dir, "#{deploy_to}/shared"
-set :engine, "paster"
-set :rails_env, "NA"
-
-role :app, "serpent.trike.com.au"
-role :web, "serpent.trike.com.au"
-role :db, "serpent.trike.com.au", :primary => true
-
diff --git a/config/deploy/staging b/config/deploy/dev.rb
similarity index 50%
rename from config/deploy/staging
rename to config/deploy/dev.rb
index bd543e34..c3334db7 100644
--- a/config/deploy/staging
+++ b/config/deploy/dev.rb
@@ -1,11 +1,10 @@
set :application, "lesswrong.org"
set :domains, %w[ lesswrong.org ]
set :deploy_to, "/srv/www/#{application}"
-set :shared_dir, "#{deploy_to}/shared"
-set :engine, "paster"
-set :rails_env, "NA"
+set :branch, 'staging-disable-cron'
+set :environment, 'dev'
-role :app, "polly.trike.com.au"
-role :web, "polly.trike.com.au"
+role :app, "polly.trike.com.au", :primary => true
+role :web, "polly.trike.com.au", :primary => true
role :db, "polly.trike.com.au", :primary => true
diff --git a/config/deploy/prod b/config/deploy/prod
deleted file mode 100644
index e0983bea..00000000
--- a/config/deploy/prod
+++ /dev/null
@@ -1,11 +0,0 @@
-set :application, "lesswrong.org"
-set :domains, %w[ lesswrong.org ]
-set :deploy_to, "/srv/www/#{application}"
-set :shared_dir, "#{deploy_to}/shared"
-set :engine, "paster"
-set :rails_env, "NA"
-
-role :app, "serpent.trike.com.au"
-role :web, "serpent.trike.com.au"
-role :db, "serpent.trike.com.au", :primary => true
-
diff --git a/config/deploy/production.rb b/config/deploy/production.rb
new file mode 100644
index 00000000..2df31b81
--- /dev/null
+++ b/config/deploy/production.rb
@@ -0,0 +1,36 @@
+load 'config/cap-tasks/trike-aws.rb'
+
+set :application, "lesswrong.com"
+set :domains, %w[ lesswrong.com ]
+set :elb_name, 'python'
+set :hosts, lambda { AWS.elb_hosts(elb_name) }
+set :environment, 'production'
+
+set :primary_host, hosts.shift
+
+role :app, primary_host, :primary => true
+role :app, *hosts
+role :web, primary_host, :primary => true
+role :web, *hosts
+role :db, "db.aws.trike.com.au", :primary => true, :no_release => true
+role :backups, "backup.trike.com.au", :user => 'backup', :no_release => true
+
+before "deploy:update_code", "tests_check:manual_tests_executed?"
+
+after 'multistage:ensure', :check_hostname
+after 'deploy:cleanup', :check_hostname
+
+task :check_hostname, :roles => :app, :only => :primary do
+ balancers = AWS.elb.describe_load_balancers(elb_name)
+ unless balancers.empty?
+ domain = domains.first
+ balancer = balancers.first
+ _, _, _, domain_ip = TCPSocket.gethostbyname(domain)
+ _, _, _, balancer_ip = TCPSocket.gethostbyname(balancer[:dns_name])
+ if domain_ip != balancer_ip
+ warn "\033[31mWARNING:\033[0m #{domain} is not resolving to the IP for the #{elb_name} elb.\n" +
+ "Do you have an entry in /etc/hosts that needs removing?"
+ end
+ end
+end
+
diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb
new file mode 100644
index 00000000..f9726fd2
--- /dev/null
+++ b/config/deploy/staging.rb
@@ -0,0 +1,40 @@
+require 'socket'
+load 'config/cap-tasks/trike-aws.rb'
+
+set :application, "lesswrong.com"
+set :domains, %w[ lesswrong.com ]
+set :deploy_to, "/srv/www/#{application}"
+set :branch, 'staging'
+set :environment, 'staging'
+set :security_group, 'webserver_python_staging'
+
+set :instance, lambda {
+ AWS.find_or_start_host_for_security_group(
+ security_group,
+ AWS.auto_scaler_ami('python'),
+ 120,
+ File.join('config', "user_data_#{environment}.sh.erb"),
+ :instance_type => 'c1.medium'
+ )
+}
+
+role :app, instance, :primary => true
+role :web, instance, :primary => true
+role :db, "db.aws.trike.com.au", :primary => true, :no_release => true
+
+after 'multistage:ensure', :check_hostname
+after 'deploy:cleanup', :check_hostname
+
+task :check_hostname, :roles => :app, :only => :primary do
+ hosts = AWS.security_group_hosts(security_group)
+ unless hosts.empty?
+ _, _, _, domain_ip = TCPSocket.gethostbyname(domains.first)
+ _, _, _, host_ip = TCPSocket.gethostbyname(hosts.first)
+ if domain_ip != host_ip
+ warn "\033[31mWARNING:\033[0m #{domains.first} is not resolving to #{host_ip}. " +
+ "You should add an entry to /etc/hosts:\n" +
+ "#{host_ip} #{domains.first}"
+ end
+ end
+end
+
diff --git a/config/user_data_staging.sh.erb b/config/user_data_staging.sh.erb
new file mode 100644
index 00000000..a218deb1
--- /dev/null
+++ b/config/user_data_staging.sh.erb
@@ -0,0 +1,25 @@
+#!/bin/bash
+/usr/local/sys_scripts/bin/git-clone-update git@git.trike.com.au:sys_scripts.git /usr/local/sys_scripts --branch master
+/usr/local/sys_scripts/bin/git-clone-update git@git.trike.com.au:munin.git /etc/munin --branch master
+
+/usr/local/sys_scripts/bin/aws-update_hosts 75.101.144.164 \
+ db.aws.trike.com.au \
+ sultana.trike.com.au \
+ memcache.aws.trike.com.au
+
+export APPLICATION=lesswrong.com
+export APPLICATION_USER=www-data
+export APPLICATION_ENV=staging
+APP_REPO='git://github.com/tricycle/lesswrong.git'
+APP_DIR=/srv/www/lesswrong.com
+
+sudo -u $APPLICATION_USER /usr/local/sys_scripts/bin/git-clone-update $APP_REPO $APP_DIR/current --branch staging
+cd $APP_DIR/current && sudo -u $APPLICATION_USER env APPLICATION=$APPLICATION APPLICATION_USER=$APPLICATION_USER APPLICATION_ENV=$APPLICATION_ENV rake after_update_code > $APP_DIR/shared/log/after_update_code.log 2>&1
+cd $APP_DIR/current && sudo -u $APPLICATION_USER env APPLICATION=$APPLICATION APPLICATION_USER=$APPLICATION_USER APPLICATION_ENV=$APPLICATION_ENV rake deploy:restart > $APP_DIR/shared/log/deploy-restart.log 2>&1
+sudo -u $APPLICATION_USER $APP_DIR/current/scripts/sync_wiki_export.sh
+
+<% if time_to_live > 0 %>
+# This server will self destruct in <%= time_to_live %> minutes
+# run shutdown -c to cancel
+shutdown -h +<%= time_to_live %> &
+<% end %>
diff --git a/db/dumps/test-main.psql b/db/dumps/test-main.psql
new file mode 100644
index 00000000..67c2ae36
Binary files /dev/null and b/db/dumps/test-main.psql differ
diff --git a/r2/compress_js.sh b/r2/compress_js.sh
index 1a7a5282..7929103e 100755
--- a/r2/compress_js.sh
+++ b/r2/compress_js.sh
@@ -8,64 +8,43 @@
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
-#
+#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
-#
+#
# The Original Code is Reddit.
-#
+#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
-#
+#
# All portions of the code written by CondeNet are Copyright (c) 2006-2008
# CondeNet, Inc. All Rights Reserved.
################################################################################
-files=( psrs.js utils.js animate.js link.js comments.js subreddit.js vote_piece.js reddit_piece.js organic.js )
+files=( event.simulate.js utils.js animate.js link.js comments.js subreddit.js vote_piece.js reddit_piece.js organic.js main.js map.js )
wd=`pwd`
-redditjs='reddit.js'
+redditjs='lesswrong.js'
framejs='frame.js'
votejs='vote.js'
-compressor=" $wd/r2/lib/contrib/jsjam -g -i"
+function compressor {
+ yui-compressor --type js $@
+}
-echo "generating rtl style sheet"
-
-./rtl.sh
-
-
-echo "Generating reddit.js..."
-
+echo "Generating ${redditjs}..."
cd r2/public/static
[ -e $redditjs ] && rm $redditjs
[ -e $redditjs-big ] && rm $redditjs-big
-cat json.js > $redditjs.tmp
-for f in "${files[@]}"
-do
- $compressor $f >> $redditjs.tmp
+cat /dev/null > $redditjs.tmp
+for f in "${files[@]}"; do
+ echo "Compressing ${f}"
+ compressor $f >> $redditjs
done;
-sed 's/\$/ \$/g' $redditjs.tmp > $redditjs
-
-echo "Generating vote.js..."
-# compress the votes alone (for buttons)
-cat psrs.js | $compressor | sed 's/\$/ \$/g' > $votejs
-cat utils.js vote_piece.js | $compressor >> $votejs
-
-echo "Generating frame.js..."
-# compress frame alone (for the toolbar)
-cat psrs.js > $framejs
-cat vote_piece.js utils.js frame_piece.js | $compressor >> $framejs
-
-echo "droppping md5s..."
-for file in *.js
-do
- cat $file | openssl md5 > $file.md5
-done
-for file in *.css
-do
+echo "Droppping md5s..."
+for file in *.{js,css,gif,png,jpg}; do
cat $file | openssl md5 > $file.md5
done
diff --git a/r2/example.ini b/r2/example.ini
index 36ecffbf..47d7abdf 100644
--- a/r2/example.ini
+++ b/r2/example.ini
@@ -8,6 +8,7 @@ debug = true
template_debug = true
uncompressedJS = true
translator = true
+sqlprinting = false
proxy_addr =
log_path =
@@ -64,35 +65,45 @@ enable_doquery = False
use_query_cache = False
write_query_queue = False
-stylesheet = reddit.css
+stylesheet = lesswrong.css
stylesheet_rtl = reddit_rtl.css
allowed_css_linked_domains = my.domain.com, my.otherdomain.com
+authorized_cnames = lesswrong.local
css_killswitch = False
max_sr_images = 20
login_cookie = reddit_session
-domain = localhost
+domain = lesswrong.local
domain_prefix =
-authorized_cnames = lesswrong.local
default_sr = lesswrong
blessed_sr = overcomingbias
front_page_title = Less Wrong
admins =
sponsors =
page_cache_time = 30
+wiki_page_cache_time = 86400
static_path = /static/
useragent = Mozilla/5.0 (compatible; bot/1.0; ChangeMe)
+feedbox_urls = http://www.overcomingbias.com/feed
+recent_edits_feed = http://wiki.lesswrong.com/mediawiki/index.php?title=Special:RecentChanges&feed=atom&hideminor=1
+# Your sitemeter.com username/codename
+site_meter_codename =
+geoip_db_path = /usr/share/GeoIP/GeoIP.dat
+# The radius in km for a meetup to be considered nearby
+meetups_radius = 100
+
solr_url =
+google_api_key = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbb_cccccccccccccccccc-d
SECRET = abcdefghijklmnopqrstuvwxyz0123456789
MODSECRET = abcdefghijklmnopqrstuvwxyz0123456789
tracking_secret = abcdefghijklmnopqrstuvwxyz0123456789
ip_hash =
S3KEY_ID = ABCDEFGHIJKLMNOP1234
S3SECRET_KEY = aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCd
-s3_thumb_bucket = /your.bucket.here/
+s3_thumb_bucket = /lesswrong.dev/
default_thumb = /static/noimage.png
MIN_DOWN_LINK = 0
@@ -110,7 +121,7 @@ media_period = 10 minutes
rising_period = 12 hours
# time of ratelimit purgatory (min)
-RATELIMIT = 0
+RATELIMIT = 1
num_comments = 200
max_comments = 500
@@ -125,6 +136,18 @@ agents =
feedback_email = abuse@localhost
+about_post_id = 1
+issues_post_id = 2
+
+karma_to_post = 40
+post_karma_multiplier = 10
+
+side_meetups_max_age = 60
+side_comments_max_age = 60
+side_posts_max_age = 300
+side_tags_max_age = 43200
+side_contributors_max_age = 3600
+article_navigation_max_age = 0
[server:main]
use = egg:Paste#http
diff --git a/r2/r2/config/databases.py b/r2/r2/config/databases.py
index be26f5a0..14d09b01 100644
--- a/r2/r2/config/databases.py
+++ b/r2/r2/config/databases.py
@@ -74,6 +74,8 @@
dbm.thing('account', main_engine, main_engine)
dbm.thing('message', main_engine, main_engine)
dbm.thing('tag', main_engine, main_engine)
+dbm.thing('edit', main_engine, main_engine)
+dbm.thing('meetup', main_engine, main_engine)
dbm.relation('savehide', 'account', 'link', main_engine)
dbm.relation('click', 'account', 'link', main_engine)
diff --git a/r2/r2/config/middleware.py b/r2/r2/config/middleware.py
index 46a6767a..127113f8 100644
--- a/r2/r2/config/middleware.py
+++ b/r2/r2/config/middleware.py
@@ -41,7 +41,7 @@
#middleware stuff
from r2.lib.html_source import HTMLValidationParser
from cStringIO import StringIO
-import sys, tempfile, urllib, re, os, sha
+import sys, tempfile, urllib, re, os, hashlib
#from pylons.middleware import error_mapper
@@ -181,7 +181,7 @@ def __init__(self, app):
auth_cnames = [x.strip() for x in auth_cnames.split(',')]
# we are going to be matching with endswith, so make sure there
# are no empty strings that have snuck in
- self.auth_cnames = [x for x in auth_cnames if x]
+ self.auth_cnames = filter(None, auth_cnames)
def is_auth_cname(self, domain):
return any((domain == cname or domain.endswith('.' + cname))
@@ -291,8 +291,8 @@ class ExtensionMiddleware(object):
extensions = {'rss' : ('xml', 'text/xml; charset=UTF-8'),
'xml' : ('xml', 'text/xml; charset=UTF-8'),
'js' : ('js', 'text/javascript; charset=UTF-8'),
- 'png' : ('png', 'image/png'),
- 'css' : ('css', 'text/css'),
+ #'png' : ('png', 'image/png'),
+ #'css' : ('css', 'text/css'),
'api' : (api_type(), 'application/json; charset=UTF-8'),
'json' : (api_type(), 'application/json; charset=UTF-8'),
'json-html' : (api_type('html'), 'application/json; charset=UTF-8')}
@@ -354,7 +354,7 @@ def __init__(self, log_path, process_iden, app):
def __call__(self, environ, start_response):
request = '\n'.join('%s: %s' % (k,v) for k,v in environ.iteritems()
if k.isupper())
- iden = self.process_iden + '-' + sha.new(request).hexdigest()
+ iden = self.process_iden + '-' + hashlib.sha1(request).hexdigest()
fname = os.path.join(self.log_path, iden)
f = open(fname, 'w')
@@ -371,6 +371,10 @@ def __call__(self, environ, start_response):
return r
class LimitUploadSize(object):
+ """
+ Middleware for restricting the size of uploaded files (such as
+ image files for the CSS editing capability).
+ """
def __init__(self, app, max_size=1024*500):
self.app = app
self.max_size = max_size
@@ -404,7 +408,29 @@ def start_response_wrapper(status, headers, exc_info=None):
return self.app(environ, start_response_wrapper)
-#god this stuff is disorganized and confusing
+class CleanupMiddleware(object):
+ """
+ Put anything here that should be called after every other bit of
+ middleware. This currently includes the code for removing
+ duplicate headers (except multiple cookie setting). The behavior
+ here is to disregard all but the last record.
+ """
+ def __init__(self, app):
+ self.app = app
+
+ def __call__(self, environ, start_response):
+ def custom_start_response(status, headers, exc_info = None):
+ fixed = []
+ seen = set()
+ for head, val in reversed(headers):
+ head = head.title()
+ if head == 'Set-Cookie' or head not in seen:
+ fixed.insert(0, (head, val))
+ seen.add(head)
+ return start_response(status, fixed, exc_info)
+ return self.app(environ, custom_start_response)
+
+#god this shit is disorganized and confusing
class RedditApp(PylonsBaseWSGIApp):
def find_controller(self, controller):
if controller in self.controller_classes:
@@ -475,14 +501,15 @@ def make_app(global_conf, full_stack=True, **app_conf):
# Static files
javascripts_app = StaticJavascripts()
- static_app = StaticURLParser(config['pylons.paths']['static_files'])
+ # Set cache headers indicating the client should cache for 7 days
+ static_app = StaticURLParser(config['pylons.paths']['static_files'], cache_max_age=604800)
app = Cascade([static_app, javascripts_app, app])
- app = make_gzip_middleware(app, app_conf)
-
app = AbsoluteRedirectMiddleware(app)
#add the rewrite rules
app = RewriteMiddleware(app)
+ app = CleanupMiddleware(app)
+
return app
diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py
index 0eea312e..7e6c4279 100644
--- a/r2/r2/config/routing.py
+++ b/r2/r2/config/routing.py
@@ -6,16 +6,16 @@
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
-#
+#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
-#
+#
# The Original Code is Reddit.
-#
+#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
-#
+#
# All portions of the code written by CondeNet are Copyright (c) 2006-2008
# CondeNet, Inc. All Rights Reserved.
################################################################################
@@ -25,13 +25,14 @@
import os
from routes import Mapper
import admin_routes
+from wiki_pages_embed import allWikiPagesCached
def make_map(global_conf={}, app_conf={}):
map = Mapper()
mc = map.connect
admin_routes.add(mc)
-
+
mc('/login', controller='front', action='login')
mc('/logout', controller='front', action='logout')
mc('/adminon', controller='front', action='adminon')
@@ -47,7 +48,7 @@ def make_map(global_conf={}, app_conf={}):
mc('/search/results', controller='front', action='search_results')
- mc('/about/:location', controller='front',
+ mc('/about/:location', controller='front',
action='editreddit', location = 'about')
mc('/categories/create', controller='front', action='newreddit')
@@ -60,70 +61,89 @@ def make_map(global_conf={}, app_conf={}):
requirements=dict(where='subscriber|contributor|moderator'))
#mc('/stats', controller='front', action='stats')
-
+
mc('/user/:username/:where', controller='user', action='listing',
where='overview')
-
+
mc('/prefs/:location', controller='front',
action='prefs', location='options')
-
+
mc('/related/:article/:title', controller='front',
action = 'related', title=None)
mc('/lw/:article/:title/:comment', controller='front',
action= 'comments', title=None, comment = None)
mc('/edit/:article', controller='front', action="editarticle")
+
+
+ mc('/stylesheet', controller = 'front', action = 'stylesheet')
+
+ mc('/', controller='promoted', action='listing')
- mc('/', controller='root', action='listing')
-
- listing_controllers = "hot|saved|toplinks|new|recommended|randomrising|comments|blessed|recentposts"
+ for name,page in allWikiPagesCached.items():
+ if page.has_key('route'):
+ mc("/wiki/"+page['route'], controller='wikipage', action='wikipage', name=name)
+
+ mc('/invalidate_cache/:name', controller='wikipage', action='invalidate_cache')
+
+ listing_controllers = "hot|saved|toplinks|topcomments|new|recommended|randomrising|comments|blessed|recentposts|edits|promoted"
mc('/:controller', action='listing',
requirements=dict(controller=listing_controllers))
- mc('/tag/:tag', controller='tag', action='listing')
+ # Can't use map.resource because the version of the Routing module we're
+ # using doesn't support the controller_action kw arg
+ #map.resource('meetup', 'meetups', collection_actions=['create', 'new'])
+ mc('/meetups/create', action='create', controller='meetups')
+ mc('/meetups', action='listing', controller='meetupslisting')
+ mc('/meetups/new', action='new', controller='meetups')
+ mc('/meetups/:id/edit', action='edit', controller='meetups')
+ mc('/meetups/:id/update', action='update', controller='meetups')
+ mc('/meetups/:id', action='show', controller='meetups')
+
+ mc('/tag/:tag', controller='tag', action='listing', where='tag')
mc('/by_id/:names', controller='byId', action='listing')
mc('/:sort', controller='browse', sort='top', action = 'listing',
requirements = dict(sort = 'top|controversial'))
-
+
mc('/message/compose', controller='message', action='compose')
mc('/message/:where', controller='message', action='listing')
-
+
mc('/:action', controller='front',
requirements=dict(action="password|random|framebuster"))
mc('/:action', controller='embed',
requirements=dict(action="help|blog"))
-
+
mc('/:action', controller='toolbar',
requirements=dict(action="goto|toolbar"))
-
+
mc('/resetpassword/:key', controller='front',
action='resetpassword')
mc('/resetpassword', controller='front',
action='resetpassword')
mc('/post/:action', controller='post',
- requirements=dict(action="options|over18|unlogged_options|optout|optin|login|reg"))
-
+ requirements=dict(action="options|over18|optout|optin|login|reg"))
+
mc('/api/:action', controller='api')
-
+
mc('/captcha/:iden', controller='captcha', action='captchaimg')
mc('/doquery', controller='query', action='doquery')
mc('/code', controller='redirect', action='redirect',
dest='http://code.google.com/p/lesswrong/')
-
+
mc('/about-less-wrong', controller='front', action='about')
mc('/issues', controller='front', action='issues')
# Google webmaster tools verification page
mc('/googlea26ba8329f727095.html', controller='front', action='blank')
- # This route handles displaying the error page and
+ # This route handles displaying the error page and
# graphics used in the 404/500
- # error pages. It should likely stay at the top
+ # error pages. It should likely stay at the top
# to ensure that the error page is
# displayed properly.
mc('/error/document/:id', controller='error', action="document")
diff --git a/r2/r2/controllers/__init__.py b/r2/r2/controllers/__init__.py
index d1446a3d..bdf83d41 100644
--- a/r2/r2/controllers/__init__.py
+++ b/r2/r2/controllers/__init__.py
@@ -6,16 +6,16 @@
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
-#
+#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
-#
+#
# The Original Code is Reddit.
-#
+#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
-#
+#
# All portions of the code written by CondeNet are Copyright (c) 2006-2008
# CondeNet, Inc. All Rights Reserved.
################################################################################
@@ -23,6 +23,7 @@
from listingcontroller import HotController
from listingcontroller import SavedController
from listingcontroller import ToplinksController
+from listingcontroller import PromotedController
from listingcontroller import NewController
from listingcontroller import BrowseController
from listingcontroller import RecommendedController
@@ -32,10 +33,12 @@
from listingcontroller import RandomrisingController
from listingcontroller import UserController
from listingcontroller import CommentsController
-from listingcontroller import RootController
+from listingcontroller import TopcommentsController
from listingcontroller import BlessedController
from listingcontroller import TagController
from listingcontroller import RecentpostsController
+from listingcontroller import EditsController
+from listingcontroller import MeetupslistingController
from listingcontroller import MyredditsController
@@ -49,6 +52,8 @@
from toolbar import ToolbarController
from i18n import I18nController
from promotecontroller import PromoteController
+from meetupscontroller import MeetupsController
+from wikipagecontroller import WikipageController
from querycontroller import QueryController
diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py
index 40a59d2e..0ed4ebca 100644
--- a/r2/r2/controllers/api.py
+++ b/r2/r2/controllers/api.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
@@ -6,24 +7,26 @@
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
-#
+#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
-#
+#
# The Original Code is Reddit.
-#
+#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
-#
+#
# All portions of the code written by CondeNet are Copyright (c) 2006-2008
# CondeNet, Inc. All Rights Reserved.
################################################################################
from reddit_base import RedditController
from pylons.i18n import _
-from pylons import c, request
+from pylons import c, request, response
+from pylons.controllers.util import etag_cache
+import hashlib
from validator import *
from r2.models import *
@@ -32,11 +35,16 @@
from r2.controllers import ListingController
-from r2.lib.utils import get_title, sanitize_url, timeuntil, set_last_modified
+from r2.lib.utils import get_title, sanitize_url, timeuntil, \
+ set_last_modified, remote_addr
from r2.lib.utils import query_string, to36, timefromnow
from r2.lib.wrapped import Wrapped
-from r2.lib.pages import FriendList, ContributorList, ModList, \
- BannedList, BoringPage, FormPage, NewLink, CssError, UploadedImage
+from r2.lib.pages import FriendList, ContributorList, ModList, EditorList, \
+ BannedList, BoringPage, FormPage, NewLink, CssError, UploadedImage, \
+ RecentArticles, RecentComments, TagCloud, TopContributors, TopMonthlyContributors, WikiPageList, \
+ ArticleNavigation, UpcomingMeetups, RecentPromotedArticles, \
+ MeetupsMap
+
from r2.lib.menus import CommentSortMenu
from r2.lib.translation import Translator
@@ -55,9 +63,8 @@
from r2.lib.media import force_thumbnail, thumbnail_url
from r2.lib.comment_tree import add_comment, delete_comment
-from simplejson import dumps
-
from datetime import datetime, timedelta
+from simplejson import dumps
from md5 import md5
from r2.lib.promote import promote, unpromote, get_promoted
@@ -70,13 +77,13 @@ def link_listing_by_url(url, count = None):
links = links[:count]
except NotFound:
links = ()
-
+
names = [l._fullname for l in links]
builder = IDBuilder(names, num = 25)
listing = LinkListing(builder).listing()
return listing
-
+
class ApiController(RedditController):
def response_func(self, **kw):
return self.sendstring(dumps(kw))
@@ -86,7 +93,7 @@ def ajax_login_redirect(self, res, dest):
res._redirect("/login" + query_string(dict(dest=dest)))
def link_exists(self, url, sr, message = False):
- try:
+ try:
l = Link._by_url(url, sr)
if message:
return l.already_submitted_link()
@@ -162,7 +169,7 @@ def POST_compose(self, res, to, subject, body, ip):
spam = (c.user._spam or
errors.BANNED_IP in c.errors or
errors.BANNED_DOMAIN in c.errors)
-
+
m, inbox_rel = Message._new(c.user, to, subject, body, ip, spam)
res._update('success',
innerHTML=_("Your message has been delivered"))
@@ -175,46 +182,6 @@ def POST_compose(self, res, to, subject, body, ip):
else:
res._update('success', innerHTML='')
-
- @validate(VUser(),
- VSRSubmitPage(),
- url = VRequired('url', None),
- title = VRequired('title', None))
- def GET_submit(self, url, title):
- if url and not request.get.get('resubmit'):
- listing = link_listing_by_url(url)
- redirect_link = None
- if listing.things:
- if len(listing.things) == 1:
- redirect_link = listing.things[0]
- else:
- subscribed = [l for l in listing.things
- if c.user_is_loggedin
- and l.subreddit.is_subscriber_defaults(c.user)]
-
- #if there is only 1 link to be displayed, just go there
- if len(subscribed) == 1:
- redirect_link = subscribed[0]
- else:
- infotext = strings.multiple_submitted % \
- listing.things[0].resubmit_link()
- res = BoringPage(_("Seen it"),
- content = listing,
- infotext = infotext).render()
- return res
-
- if redirect_link:
- return self.redirect(redirect_link.already_submitted_link)
-
- captcha = Captcha() if c.user.needs_captcha() else None
- srs = Subreddit.submit_sr(c.user) if c.default_sr else ()
-
- return FormPage(_("Submit"),
- content=NewLink(url=url or '',
- title=title or '',
- subreddits = srs,
- captcha=captcha)).render()
-
@Json
@validate(VAdmin(),
link = VByName('id'))
@@ -231,6 +198,7 @@ def POST_unbless(self, res, link):
@validate(VUser(),
VCaptcha(),
VRatelimit(rate_user = True, rate_ip = True, prefix='rate_submit_'),
+ VModhash(),
ip = ValidIP(),
sr = VSubmitSR('sr'),
title = VTitle('title'),
@@ -241,8 +209,8 @@ def POST_unbless(self, res, link):
tags = VTags('tags'))
def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip, tags):
res._update('status', innerHTML = '')
- should_ratelimit = sr.should_ratelimit(c.user, 'link')
-
+ should_ratelimit = sr.should_ratelimit(c.user, 'link') if sr else True
+
#remove the ratelimit error if the user's karma is high
if not should_ratelimit:
c.errors.remove(errors.RATELIMIT)
@@ -259,6 +227,9 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
res._focus('title')
elif res._chk_captcha(errors.BAD_CAPTCHA):
pass
+ elif res._chk_error(errors.SUBREDDIT_FORBIDDEN):
+ pass
+
if res.error or not title: return
@@ -266,7 +237,7 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
spam = (c.user._spam or
errors.BANNED_IP in c.errors or
errors.BANNED_DOMAIN in c.errors)
-
+
if not new_content:
new_content = ''
@@ -275,12 +246,6 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
# print "\n".join(request.post.va)
if not l:
l = Link._submit(request.post.title, new_content, c.user, sr, ip, tags, spam)
- if l.url.lower() == 'self':
- l.url = l.make_permalink_slow()
- l.is_self = True
- l._commit()
- l.set_url_cache()
- v = Vote.vote(c.user, l, True, ip, spam)
if save == 'on':
r = l._save(c.user)
if g.write_query_queue:
@@ -292,8 +257,10 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
#update the queries
if g.write_query_queue:
queries.new_link(l)
- queries.new_vote(v)
else:
+ edit = None
+ if c.user._id != l.author_id:
+ edit = Edit._new(l,c.user,new_content)
old_url = l.url
l.title = request.post.title
l.article = new_content
@@ -301,14 +268,16 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
l._commit()
l.set_tags(tags)
l.update_url_cache(old_url)
+ if edit:
+ edit._commit()
#update the modified flags
set_last_modified(c.user, 'overview')
set_last_modified(c.user, 'submitted')
-
+
# flag search indexer that something has changed
tc.changed(l)
-
+
if continue_editing:
path = "/edit/%s" % l._id36
else:
@@ -317,7 +286,8 @@ def POST_submit(self, res, l, new_content, title, save, continue_editing, sr, ip
# cname.
cname = c.cname
c.cname = False
- path = l.make_permalink_slow()
+ #path = l.make_permalink_slow()
+ path = l.make_permalink(sr, sr_path = not sr.name == g.default_sr)
c.cname = cname
res._redirect(path)
@@ -361,6 +331,12 @@ def POST_register(self, res, name, email, password, op, dest, rem, reason):
res._update('status_' + op, innerHTML='')
if res._chk_error(errors.BAD_USERNAME, op):
res._focus('user_reg')
+ elif res._chk_error(errors.BAD_USERNAME_SHORT, op):
+ res._focus('user_reg')
+ elif res._chk_error(errors.BAD_USERNAME_LONG, op):
+ res._focus('user_reg')
+ elif res._chk_error(errors.BAD_USERNAME_CHARS, op):
+ res._focus('user_reg')
elif res._chk_error(errors.USERNAME_TAKEN, op):
res._focus('user_reg')
elif res._chk_error(errors.BAD_PASSWORD, op):
@@ -394,7 +370,7 @@ def POST_register(self, res, name, email, password, op, dest, rem, reason):
d = c.user._dirties.copy()
user._commit()
-
+
c.user = user
# Create a drafts subredit for this user
@@ -419,7 +395,7 @@ def POST_register(self, res, name, email, password, op, dest, rem, reason):
self._subscribe(sr, sub)
self._login(res, user, dest, rem)
-
+
@Json
@validate(VUser(),
@@ -441,7 +417,7 @@ def POST_leave(self, res, container, type):
redirect = nop('redirect'),
friend = VExistingUname('name'),
container = VByName('container'),
- type = VOneOf('type', ('friend', 'moderator', 'contributor', 'banned')))
+ type = VOneOf('type', ('friend', 'moderator', 'editor', 'contributor', 'banned')))
def POST_friend(self, res, ip, friend, action, redirect, container, type):
res._update('status', innerHTML='')
@@ -451,6 +427,8 @@ def POST_friend(self, res, ip, friend, action, redirect, container, type):
and (type in ('moderator','contributer','banned')
and not c.site.is_moderator(c.user))):
+ abort(403,'forbidden')
+ elif type == 'editor' and not c.user_is_admin:
abort(403,'forbidden')
elif action == 'add':
if res._chk_errors((errors.USER_DOESNT_EXIST,
@@ -460,10 +438,11 @@ def POST_friend(self, res, ip, friend, action, redirect, container, type):
new = fn(friend)
cls = dict(friend=FriendList,
moderator=ModList,
+ editor=EditorList,
contributor=ContributorList,
banned=BannedList).get(type)
res._update('name', value = '')
-
+
#subscribing doesn't need a response
if new and cls:
res.object = cls().ajax_user(friend).for_ajax('add')
@@ -474,7 +453,7 @@ def POST_friend(self, res, ip, friend, action, redirect, container, type):
if msg and subj and friend.name != c.user.name:
# fullpath with domain needed or the markdown link
# will break
- d = dict(url = container.path,
+ d = dict(url = container.path,
title = container.title)
msg = msg % d
subj = subj % d
@@ -497,7 +476,7 @@ def POST_update(self, res, email, curpass, password, newpass, verpass):
if res._chk_error(errors.WRONG_PASSWORD):
res._focus('curpass')
res._update('curpass', value='')
- return
+ return
updated = False
if res._chk_error(errors.BAD_EMAILS):
res._focus('email')
@@ -505,10 +484,10 @@ def POST_update(self, res, email, curpass, password, newpass, verpass):
or c.user.email != email):
c.user.email = email
c.user._commit()
- res._update('status',
+ res._update('status',
innerHTML=_('Your email has been updated'))
updated = True
-
+
if newpass or verpass:
if res._chk_error(errors.BAD_PASSWORD):
res._focus('newpass')
@@ -518,10 +497,10 @@ def POST_update(self, res, email, curpass, password, newpass, verpass):
else:
change_password(c.user, password)
if updated:
- res._update('status',
+ res._update('status',
innerHTML=_('Your email and password have been updated'))
else:
- res._update('status',
+ res._update('status',
innerHTML=_('Your password has been updated'))
self.login(c.user)
@@ -532,11 +511,11 @@ def POST_update(self, res, email, curpass, password, newpass, verpass):
areyousure2 = nop('areyousure2'),
areyousure3 = nop('areyousure3'))
def POST_delete_user(self, res, areyousure1, areyousure2, areyousure3):
- if areyousure1 == areyousure2 == areyousure3 == 'yes':
+ if areyousure1 == areyousure2 == areyousure3 == 'Yes':
c.user.delete()
res._redirect('/?deleted=true')
else:
- res._update('status',
+ res._update('status',
innerHTML = _("See? you don't really want to leave"))
@Json
@@ -545,6 +524,13 @@ def POST_delete_user(self, res, areyousure1, areyousure2, areyousure3):
thing = VByNameIfAuthor('id'))
def POST_del(self, res, thing):
'''for deleting all sorts of things'''
+
+ # Special check if comment can be deleted
+ if isinstance(thing, Comment) and (not thing.can_delete()):
+ c.errors.add(errors.CANNOT_DELETE)
+ res._chk_error(errors.CANNOT_DELETE)
+ return
+
thing._deleted = True
thing._commit()
@@ -565,6 +551,20 @@ def POST_del(self, res, thing):
if g.use_query_cache:
queries.new_comment(thing, None)
+ @Json
+ @validate(VUser(),
+ VModhash(),
+ thing = VByNameIfAuthor('id'))
+ def POST_retract(self, res, thing):
+ '''for retracting comments'''
+
+ if isinstance(thing, Comment):
+ thing.retracted = True
+ thing._commit()
+ if g.use_query_cache:
+ queries.new_comment(thing, None)
+
+
@Json
@validate(VUser(), VModhash(),
thing = VByName('id'))
@@ -610,6 +610,7 @@ def POST_comment(self, res, parent, comment, ip):
if isinstance(parent, Message):
is_message = True
should_ratelimit = False
+ link = None
else:
is_message = False
is_comment = True
@@ -623,7 +624,7 @@ def POST_comment(self, res, parent, comment, ip):
if not sr.should_ratelimit(c.user, 'comment'):
should_ratelimit = False
- if not link.comments_enabled:
+ if link and not link.comments_enabled:
return abort(403,'forbidden')
#remove the ratelimit error if the user's karma is high
@@ -633,17 +634,17 @@ def POST_comment(self, res, parent, comment, ip):
if res._chk_errors((errors.BAD_COMMENT,errors.COMMENT_TOO_LONG, errors.RATELIMIT),
parent._fullname):
res._focus("comment_reply_" + parent._fullname)
- return
+ return
res._show('reply_' + parent._fullname)
- res._update("comment_reply_" + parent._fullname, rows = 2)
+ res._update("comment_reply_" + parent._fullname, rows = '2')
spam = (c.user._spam or
errors.BANNED_IP in c.errors)
-
+
if is_message:
to = Account._byID(parent.author_id)
subject = parent.subject
- re = "re: "
+ re = "Re: "
if not subject.startswith(re):
subject = re + subject
item, inbox_rel = Message._new(c.user, to, subject, comment, ip, spam)
@@ -652,21 +653,10 @@ def POST_comment(self, res, parent, comment, ip):
else:
item, inbox_rel = Comment._new(c.user, link, parent_comment, comment,
ip, spam)
- Vote.vote(c.user, item, True, ip)
- res._update("comment_reply_" + parent._fullname,
+ res._update("comment_reply_" + parent._fullname,
innerHTML='', value='')
res._send_things(item)
res._hide('noresults')
- # flag search indexer that something has changed
- tc.changed(item)
-
- #update last modified
- set_last_modified(c.user, 'overview')
- set_last_modified(c.user, 'commented')
- set_last_modified(link, 'comments')
-
- #update the comment cache
- add_comment(item)
#update the queries
if g.write_query_queue:
@@ -688,8 +678,8 @@ def POST_comment(self, res, parent, comment, ip):
prefix = "rate_share_"),
share_from = VLength('share_from', length = 100),
emails = ValidEmails("share_to"),
- reply_to = ValidEmails("replyto", num = 1),
- message = VLength("message", length = 1000),
+ reply_to = ValidEmails("replyto", num = 1),
+ message = VLength("message", length = 1000),
thing = VByName('id'))
def POST_share(self, res, emails, thing, share_from, reply_to,
message):
@@ -730,7 +720,7 @@ def POST_share(self, res, emails, thing, share_from, reply_to,
innerHTML=_('Shared'))
res._update("sharelink_" + thing._fullname,
- innerHTML=("
%s
" % + innerHTML=("%s
" % _("Your link has been shared."))) emailer.share(thing, emails, from_name = share_from or "", @@ -739,9 +729,9 @@ def POST_share(self, res, emails, thing, share_from, reply_to, #set the ratelimiter if should_ratelimit: VRatelimit.ratelimit(rate_user=True, rate_ip = True, prefix = "rate_share_") - - - + + + @Json @validate(VUser(), VModhash(), @@ -755,28 +745,38 @@ def POST_vote(self, res, dir, thing, ip, vote_type): spam = (c.user._spam or errors.BANNED_IP in c.errors or errors.CHEATER in c.errors) + dir = (True if dir > 0 + else False if dir < 0 + else None) - if thing: - dir = (True if dir > 0 - else False if dir < 0 - else None) - organic = vote_type == 'organic' - v = Vote.vote(user, thing, dir, ip, spam, organic) + isRetracted = thing.retracted if thing and isinstance(thing,Comment) else False - #update relevant caches - if isinstance(thing, Link): - sr = thing.subreddit_slow - set_last_modified(c.user, 'liked') - set_last_modified(c.user, 'disliked') + # Ensure authors can't vote on their own posts / comments + # Cannot vote on retracted comments + if thing and thing.author_id != c.user._id and not isRetracted: + try: + organic = vote_type == 'organic' + v = Vote.vote(user, thing, dir, ip, spam, organic) - if v.valid_thing: - expire_hot(sr) + #update relevant caches + if isinstance(thing, Link): + sr = thing.subreddit_slow + set_last_modified(c.user, 'liked') + set_last_modified(c.user, 'disliked') - if g.write_query_queue: - queries.new_vote(v) + if v.valid_thing: + expire_hot(sr) - # flag search indexer that something has changed - tc.changed(thing) + if g.write_query_queue: + queries.new_vote(v) + + # flag search indexer that something has changed + tc.changed(thing) + + except NotEnoughKarma, e: + # User is downvoting and does not have enough karma. + res._update('status_'+thing._fullname, innerHTML = e.message) + res._show('status_'+thing._fullname) @Json @validate(VUser(), @@ -795,7 +795,7 @@ def POST_subreddit_stylesheet(self, res, stylesheet_contents = '', op='save'): if report.errors: error_items = [ CssError(x).render(style='html') for x in sorted(report.errors) ] - + res._update('status', innerHTML = _('Validation errors')) res._update('validation-errors', innerHTML = ''.join(error_items)) res._show('error-header') @@ -893,7 +893,6 @@ def POST_delete_link_img(self, res, link, name): # reset the status res._update('img-status', innerHTML = _("Deleted")) - @Json @validate(VSrModerator(), VModhash()) @@ -916,7 +915,78 @@ def POST_delete_sr_header(self, res): # reset the status boxes res._update('img-status', innerHTML = _("Deleted")) res._update('status', innerHTML = "") - + + def render_cached(self, cache_key, render_cls, max_age, cache_time=0, *args, **kwargs): + """Render content using client caching and server caching.""" + + # Default the cache to be the same as our max age if not + # supplied. + cache_time = cache_time or max_age + + # Postfix the cache key with the subreddit name + # This scopes all the caches by subreddit + cache_key = cache_key + '-' + c.site.name + + # Get the etag and content from the cache. + hit = g.rendercache.get(cache_key) + if hit: + etag, content = hit + else: + # Generate and cache the content along with an etag. + content = render_cls(*args, **kwargs).render() + etag = '"%s"' % datetime.utcnow().isoformat() + g.rendercache.set(cache_key, (etag, content), time=cache_time) + + # Check if the client already has the correct content and + # throw 304 if so. Note that we want to set the max age in the + # 304 response, we can only do this by using the + # pylons.response object just like the etag_cache fn does + # within pylons (it sets the etag header). Setting it on the + # c.response won't work as c.response isn't used when an + # exception is thrown. Note also that setting it on the + # pylons.response will send the max age in the 200 response + # (just like the etag header is sent in the response). + response.headers['Cache-Control'] = 'max-age=%d' % max_age + etag_cache(etag) + + # Return full response using our cached info. + c.response.content = content + return c.response + + TWELVE_HOURS = 3600 * 12 + + def GET_side_posts(self, *a, **kw): + """Return HTML snippet of the recent posts for the side bar.""" + # Server side cache is also invalidated when new article is posted + return self.render_cached('side-posts', RecentArticles, g.side_posts_max_age) + + def GET_side_comments(self, *a, **kw): + """Return HTML snippet of the recent comments for the side bar.""" + # Server side cache is also invalidated when new comment is posted + return self.render_cached('side-comments', RecentComments, g.side_comments_max_age, self.TWELVE_HOURS) + + def GET_side_tags(self, *a, **kw): + """Return HTML snippet of the tags for the side bar.""" + return self.render_cached('side-tags', TagCloud, g.side_tags_max_age) + + def GET_side_contributors(self, *a, **kw): + """Return HTML snippet of the top contributors for the side bar.""" + return self.render_cached('side-contributors', TopContributors, g.side_contributors_max_age) + + def GET_side_meetups(self, *a, **kw): + """Return HTML snippet of the upcoming meetups for the side bar.""" + ip = remote_addr(c.environ) + location = Meetup.geoLocateIp(ip) + # Key to group cached meetup pages with + invalidating_key = g.rendercache.get_key_group_value(Meetup.group_cache_key()) + cache_key = "%s-side-meetups-%s" % (invalidating_key,ip) + return self.render_cached(cache_key, UpcomingMeetups, g.side_meetups_max_age, + cache_time=self.TWELVE_HOURS, location=location, + max_distance=g.meetups_radius) + + def GET_side_monthly_contributors(self, *a, **kw): + """Return HTML snippet of the top monthly contributors for the side bar.""" + return self.render_cached('side-monthly-contributors', TopMonthlyContributors, g.side_contributors_max_age) def GET_upload_sr_img(self, *a, **kw): """ @@ -935,6 +1005,30 @@ def GET_upload_link_img(self, *a, **kw): """ return "nothing to see here." + def GET_front_recent_posts(self, *a, **kw): + """Return HTML snippet of the recent promoted posts for the front page.""" + # Server side cache is also invalidated when new article is posted + return self.render_cached('recent-promoted', RecentPromotedArticles, g.side_posts_max_age) + + def GET_front_meetups_map(self, *a, **kw): + ip = remote_addr(c.environ) + location = Meetup.geoLocateIp(ip) + invalidating_key = g.rendercache.get_key_group_value(Meetup.group_cache_key()) + cache_key = "%s-front-meetups-%s" % (invalidating_key,ip) + return self.render_cached(cache_key, MeetupsMap, g.side_meetups_max_age, + cache_time=self.TWELVE_HOURS, location=location, + max_distance=g.meetups_radius) + + @validate(link = VLink('article_id', redirect=False)) + def GET_article_navigation(self, link, *a, **kw): + """Returns the article navigation fragment for the article specified""" + author = Account._byID(link.author_id, data=True) if link else None + return self.render_cached( + 'article_navigation_%s' % (link._id36 if link else None), + ArticleNavigation, g.article_navigation_max_age, + link=link, author=author + ) + @validate(VModhash(), file = VLength('file', length=1024*500), name = VCssName("name"), @@ -981,7 +1075,7 @@ def POST_upload_sr_img(self, file, header, name): if any(errors.values()): return UploadedImage("", "", "", errors = errors).render() - else: + else: # with the image num, save the image an upload to s3. the # header image will be of the form "${c.site._fullname}.png" # while any other image will be ${c.site._fullname}_${num}.png @@ -989,10 +1083,10 @@ def POST_upload_sr_img(self, file, header, name): if header: c.site.header = new_url c.site._commit() - - return UploadedImage(_('Saved'), new_url, name, + + return UploadedImage(_('Saved'), new_url, name, errors = errors).render() - + @validate(VModhash(), link = VLink('article_id'), @@ -1104,12 +1198,12 @@ def POST_site_admin(self, res, name ='', sr = None, **kw): #editting an existing reddit elif sr.is_moderator(c.user) or c.user_is_admin: #assume sr existed, or was just built - clear_memo('subreddit._by_domain', + clear_memo('subreddit._by_domain', Subreddit, _force_unicode(sr.domain)) for k, v in kw.iteritems(): setattr(sr, k, v) sr._commit() - clear_memo('subreddit._by_domain', + clear_memo('subreddit._by_domain', Subreddit, _force_unicode(sr.domain)) # flag search indexer that something has changed @@ -1205,7 +1299,7 @@ def _children(cur_items): cm.child = None else: items.append(cm.child) - + return items # assumes there is at least one child # a = _children(items[0].child.things) @@ -1216,7 +1310,7 @@ def _children(cur_items): a.extend(_children(item.child.things)) item.child = None - # the result is not always sufficient to replace the + # the result is not always sufficient to replace the # morechildren link if mc_id not in [x._fullname for x in a]: res._hide('thingrow_' + str(mc_id)) @@ -1246,7 +1340,7 @@ def GET_bookmarklet(self, action, uh, links): Subreddit.load_subreddits(links, return_dict = False) user = c.user if c.user_is_loggedin else None links = [l for l in links if l.subreddit_slow.can_view(user)] - + if links: if action in ['like', 'dislike']: #vote up all of the links @@ -1274,7 +1368,7 @@ def POST_password(self, res, user): else: emailer.password_email(user) res._success() - + @Json @validate(user = VCacheKey('reset', ('key', 'name')), key= nop('key'), @@ -1339,7 +1433,7 @@ def POST_deltranslator(self, res, l): sr = VByName('sr')) def POST_subscribe(self, res, action, sr): self._subscribe(sr, action == 'sub') - + def _subscribe(self, sr, sub): Subreddit.subscribe_defaults(c.user) @@ -1359,7 +1453,7 @@ def POST_disable_lang(self, res, lang): if lang and Translator.exists(lang): tr = Translator(locale = lang) tr._is_enabled = False - + @Json @validate(VAdmin(), @@ -1371,7 +1465,7 @@ def POST_enable_lang(self, res, lang): def action_cookie(action): s = action + request.ip + request.user_agent - return sha.new(s).hexdigest() + return hashlib.sha1(s).hexdigest() @Json @@ -1444,7 +1538,7 @@ def POST_edit_promo(self, res, ip, elif (not l or url != l.url) and res._chk_error(errors.ALREADY_SUB): #if url == l.url, we're just editting something else res._focus('url') - elif res._chk_error(errors.SUBREDDIT_NOEXIST): + elif res._chk_error(errors.SUBREDDIT_NOEXIST) or res._chk_error(errors.SUBREDDIT_FORBIDDEN): res._focus('sr') elif expire == 'expirein' and res._chk_error(errors.BAD_NUMBER): res._focus('timelimitlength') @@ -1476,7 +1570,7 @@ def POST_edit_promo(self, res, ip, promote(l, subscribers_only = subscribers_only, promote_until = promote_until, disable_comments = disable_comments) - + res._redirect('/promote/edit_promo/%s' % to36(l._id)) def GET_link_thumb(self, *a, **kw): @@ -1502,7 +1596,7 @@ def POST_link_thumb(self, link=None, file=None): else: return UploadedImage(_('Saved'), thumbnail_url(link), "upload", errors = errors).render() - + @Json @validate(ids = VLinkFullnames('ids')) diff --git a/r2/r2/controllers/captcha.py b/r2/r2/controllers/captcha.py index 20989a7a..b654a546 100644 --- a/r2/r2/controllers/captcha.py +++ b/r2/r2/controllers/captcha.py @@ -22,10 +22,12 @@ from reddit_base import RedditController import StringIO import r2.lib.captcha as captcha +import re from pylons import c class CaptchaController(RedditController): def GET_captchaimg(self, iden): + iden = re.sub("\.png$", "", iden, re.IGNORECASE) image = captcha.get_image(iden) f = StringIO.StringIO() image.save(f, "PNG") diff --git a/r2/r2/controllers/error.py b/r2/r2/controllers/error.py index bd62add9..0dc86339 100644 --- a/r2/r2/controllers/error.py +++ b/r2/r2/controllers/error.py @@ -57,9 +57,7 @@
- %s -