diff --git a/Gemfile b/Gemfile index e278773..7028a61 100644 --- a/Gemfile +++ b/Gemfile @@ -3,5 +3,6 @@ source 'https://rubygems.org' gem 'builder' gem 'htmlentities' gem 'rdiscount' +gem 'sinatra' gem 'rspec' gem 'guard-rspec' diff --git a/Gemfile.lock b/Gemfile.lock index a1ba32d..f510b7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -29,6 +29,9 @@ GEM coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) + rack (1.5.2) + rack-protection (1.5.0) + rack rb-fsevent (0.9.4) rb-inotify (0.9.5) ffi (>= 0.5.0) @@ -45,8 +48,13 @@ GEM rspec-mocks (3.0.4) rspec-support (~> 3.0.0) rspec-support (3.0.4) + sinatra (1.3.6) + rack (~> 1.4) + rack-protection (~> 1.3) + tilt (~> 1.3, >= 1.3.3) slop (3.6.0) thor (0.19.1) + tilt (1.4.1) timers (4.0.1) hitimes @@ -59,3 +67,4 @@ DEPENDENCIES htmlentities rdiscount rspec + sinatra diff --git a/server/auth.rb b/server/auth.rb new file mode 100644 index 0000000..ba3ae77 --- /dev/null +++ b/server/auth.rb @@ -0,0 +1,11 @@ +require 'json' + +class Auth + def initialize(filename) + @credentials = JSON.parse(File.read(filename)) + end + + def authenticated?(username, password) + @credentials['username'] == username && @credentials['password'] == password + end +end diff --git a/server/meta_weblog_handler.rb b/server/meta_weblog_handler.rb deleted file mode 100644 index 1a2757b..0000000 --- a/server/meta_weblog_handler.rb +++ /dev/null @@ -1,166 +0,0 @@ -require 'fileutils' -require 'json' -require 'open-uri' -require 'xmlrpc/server' - -class MetaWeblogHandler - - def newPost(site_id, username, password, post_hash, should_publish) - if authenticated?(username, password) - post_hash.each_key do |key| - v = post_hash[key] - if v.to_s.strip == '' - post_hash.delete(key) - end - end - - url = post_hash['link'] - title = post_hash['title'] || find_title(url) - body = post_hash['description'] || 'No description necessary.' - - unless title - raise XMLRPC::FaultException.new(0, "no title given and cannot parse title") - end - - create_link_post(url, title, body) - - 1 # dummy post ID - - else - raise XMLRPC::FaultException.new(0, "username or password invalid") - end - end - - - private - - def find_title(url) - body = open(url).read - lines = body.split(/[\r\n]+/) - title_line = lines.grep(/]*>\s*/, '') - rescue - nil - end - - def create_link_post(url, title, body) - slug = create_slug(title) - time = Time.now - date = time.strftime('%B %d, %Y') - post_dir = File.join(samhuri_net_path, 'public/posts', time.year.to_s, pad(time.month)) - relative_url = "/posts/#{time.year}/#{pad(time.month)}/#{slug}" - - git_pull - - ensure_post_dir_exists(post_dir) - - post_filename = File.join(post_dir, "#{slug}.md") - File.open(post_filename, 'w') do |f| - f.puts(body) - end - - post_data = read_post_data(post_dir) - post_data[slug] = { - title: title, - date: date, - timestamp: time.to_i, - tags: [], - url: relative_url, - link: url - } - write_post_data(post_dir, post_data) - - git_commit(title, post_dir) - git_push - publish - - rescue Exception => e - git_reset - raise e - end - - def samhuri_net_path - '/Users/sjs/Projects/samhuri.net-publish' - end - - def create_slug(title) - # TODO: be intelligent about unicode ... \p{Word} might help. negated char class with it? - title.downcase.gsub(/[^\w]/, '-') - end - - def pad(n) - n.to_i < 10 ? "0#{n}" : "#{n}" - end - - def ensure_post_dir_exists(post_dir) - FileUtils.mkdir_p(post_dir) - - monthly_index_filename = File.join(post_dir, 'index.ejs') - unless File.exists?(monthly_index_filename) - source = File.join(post_dir, '../../2006/02/index.ejs') - FileUtils.cp(source, monthly_index_filename) - end - - yearly_index_filename = File.join(post_dir, '../index.ejs') - unless File.exists?(yearly_index_filename) - source = File.join(post_dir, '../../2006/index.ejs') - FileUtils.cp(source, yearly_index_filename) - end - end - - def read_post_data(dir) - post_data_filename = File.join(dir, '_data.json') - if File.exists?(post_data_filename) - JSON.parse(File.read(post_data_filename)) - else - {} - end - end - - def write_post_data(dir, data) - post_data_filename = File.join(dir, '_data.json') - json = JSON.pretty_generate(data) - File.open(post_data_filename, 'w') do |f| - f.puts(json) - end - end - - def quote(s) - s.gsub('"', '\\"') - end - - def git_commit(title, *files) - quoted_files = files.map { |f| "\"#{quote(f)}\"" } - message = "linked '#{quote(title)}'" - `cd '#{samhuri_net_path}' && git add #{quoted_files.join(' ')} && git commit -m "#{message}"` - end - - def git_pull - `cd '#{samhuri_net_path}' && git pull -f` - end - - def git_push - `cd '#{samhuri_net_path}' && git push` - end - - def git_reset - `cd '#{samhuri_net_path}' && git reset --hard` - end - - def publish - `cd '#{samhuri_net_path}' && make publish` - end - - def auth_json_filename - File.expand_path('../auth.json', __FILE__) - end - - def auth - @auth ||= JSON.parse(File.read(auth_json_filename)) - end - - def authenticated?(username, password) - auth['username'] == username && auth['password'] == password - end - -end diff --git a/server/server.rb b/server/server.rb index b9f516d..4dd1eca 100755 --- a/server/server.rb +++ b/server/server.rb @@ -1,16 +1,215 @@ #!/usr/bin/env ruby -w -# MetaWeblog and Blogger API to post to this site. +# An HTTP interface for my Harp blog. -require 'xmlrpc/server' -require 'xmlrpc/client' -require './meta_weblog_handler' +require 'json' +require 'optparse' +require 'sinatra' +require './auth' +require './harp_blog' -def main - port = (ARGV.shift || 6706).to_i - server = XMLRPC::Server.new(port, '0.0.0.0') - server.add_handler('metaWeblog', MetaWeblogHandler.new) - server.serve +$config = { + auth: false, + dry_run: false, + path: File.expand_path('../spec/test-blog', __FILE__), + host: '127.0.0.1', + port: 6706, +} + +OptionParser.new do |opts| + opts.banner = "Usage: server.rb [options]" + + opts.on("-a", "--[no-]auth", "Enable authentication") do |auth| + $config[:auth] = auth + end + + opts.on("-h", "--host [HOST]", "Host to bind") do |host| + $config[:host] = host + end + + opts.on("-p", "--port [PORT]", "Port to bind") do |port| + $config[:port] = port.to_i + end + + opts.on("-P", "--path [PATH]", "Path to Harp blog") do |path| + $config[:path] = path + end +end.parse! + +unless File.exist?($config[:path]) + raise RuntimeError.new("file not found: #{$config[:path]}") end -main if $0 == __FILE__ +if $config[:host] == '0.0.0.0' && !$config[:auth] + raise RuntimeError.new("cowardly refusing to bind to 0.0.0.0 without authentication") +end + +$auth = Auth.new(File.expand_path('../auth.json', __FILE__)) +def authenticated?(auth) + if $config[:auth] + username, password = auth.split('|') + $auth.authenticated?(username, password) + else + true + end +end + +real_host = $config[:host] == '0.0.0.0' ? 'h.samhuri.net' : $config[:host] +$url_root = "http://#{real_host}:#{$config[:port]}/" +def url_for(*components) + File.join($url_root, *components) +end + +# Server + +set :host, $config[:host] +set :port, $config[:port] + +blog = HarpBlog.new($config[:path], $config[:dry_run]) + +# list years +get '/years' do + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + JSON.generate(years: blog.years) +end + +# list posts +get '/posts/:year/?:month?' do |year, month| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + posts = + if month + blog.posts_for_month(year, month) + else + blog.posts_for_year(year) + end + JSON.generate(posts: posts.map(&:fields)) +end + +# get a post +get '/posts/:year/:month/:slug' do |year, month, slug| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + begin + post = blog.get_post(year, month, slug) + rescue HarpBlog::InvalidDataError => e + status 500 + return "Failed to get post, invalid data on disk: #{e.message}" + end + + if post + if request.accept?('application/json') + status 200 + headers 'Content-Type' => 'application/json' + JSON.generate(post: post.fields) + elsif request.accept?('text/html') + status 200 + headers 'Content-Type' => 'text/html' + blog.render_post(post.fields) + else + status 400 + "content not available in an acceptable format: #{request.accept.join(', ')}" + end + else + status 404 + 'not found' + end +end + +# make a post +post '/posts' do + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + begin + post = blog.create_post(params[:title], params[:body], params[:link]) + rescue HarpBlog::PostExistsError => e + post = HarpBlog::Post.new({ + title: params[:title], + body: params[:body], + link: params[:link], + }) + status 409 + return "refusing to clobber existing post, update it instead: #{post.url}" + rescue HarpBlog::PostSaveError => e + status 500 + if orig_err = e.original_error + "#{e.message} -- #{orig_err.class}: #{orig_err.message}" + else + "Failed to create post: #{e.message}" + end + end + + if post + url = url_for(post.url) + status 201 + headers 'Location' => url, 'Content-Type' => 'application/json' + JSON.generate(post: post.fields) + else + status 500 + 'failed to create post' + end +end + +# update a post +put '/posts/:year/:month/:slug' do |year, month, slug| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + begin + if post = blog.get_post(year, month, slug) + blog.update_post(post, params[:title], params[:body], params[:link]) + status 204 + else + status 404 + 'not found' + end + rescue HarpBlog::InvalidDataError => e + status 500 + "Failed to update post, invalid data on disk: #{e.message}" + rescue HarpBlog::PostSaveError => e + status 500 + if orig_err = e.original_error + "#{e.message} -- #{orig_err.class}: #{orig_err.message}" + else + "Failed to create post: #{e.message}" + end + end +end + +# delete a post +delete '/posts/:year/:month/:slug' do |year, month, slug| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + blog.delete_post(year, month, slug) + status 204 +end + +# publish +post '/publish' do + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + production = params[:env] == 'production' + blog.publish(production) + status 204 +end