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