diff --git a/server/harp_blog.rb b/server/harp_blog.rb index c002607..83a4693 100644 --- a/server/harp_blog.rb +++ b/server/harp_blog.rb @@ -1,5 +1,6 @@ require 'fileutils' require 'json' +require 'securerandom' require './web_title_finder' require './web_version_finder' @@ -20,7 +21,7 @@ class HarpBlog end class Post - PERSISTENT_FIELDS = %w[author title date timestamp link url tags].map(&:to_sym) + PERSISTENT_FIELDS = %w[id author title date timestamp link url tags].map(&:to_sym) TRANSIENT_FIELDS = %w[time slug body draft].map(&:to_sym) FIELDS = PERSISTENT_FIELDS + TRANSIENT_FIELDS FIELDS.each { |f| attr_accessor f } @@ -83,10 +84,19 @@ class HarpBlog @timestamp = timestamp end + def id + @id ||= + if draft? + SecureRandom.uuid + else + slug + end + end + def url @url ||= if draft? - "/posts/drafts/#{slug}" + "/posts/drafts/#{id}" else "/posts/#{time.year}/#{padded_month}/#{slug}" end @@ -94,7 +104,7 @@ class HarpBlog def slug # TODO: be intelligent about unicode ... \p{Word} might help. negated char class with it? - if title + if !draft? && title @slug ||= title.downcase. gsub(/'/, ''). gsub(/[^[:alpha:]\d_]/, '-'). @@ -184,12 +194,12 @@ class HarpBlog read_posts('drafts', draft: true) end - def get_post(year, month, slug) - read_post(File.join(year, month), slug) + def get_post(year, month, id) + read_post(File.join(year, month), id) end - def get_draft(slug) - read_post('drafts', slug, draft: true) + def get_draft(id) + read_post('drafts', id, draft: true) end def create_post(title, body, url, extra_fields = nil) @@ -208,15 +218,15 @@ class HarpBlog post = Post.new(fields) begin - existing_post = read_post(post.dir, post.slug, extra_fields) + existing_post = read_post(post.dir, post.id, extra_fields) rescue InvalidDataError => e $stderr.puts "[HarpBlog#create_post] deleting post with invalid data: #{e.message}" - delete_post_from_dir(post.dir, post.slug) + delete_post_from_dir(post.dir, post.id) existing_post = nil end if existing_post - raise PostExistsError.new("post exists: #{post.dir}/#{post.slug}") + raise PostExistsError.new("post exists: #{post.dir}/#{post.id}") else save_post('create post', post) end @@ -229,36 +239,36 @@ class HarpBlog save_post('update post', post) end - def delete_post(year, month, slug) - delete_post_from_dir(File.join(year, month), slug) + def delete_post(year, month, id) + delete_post_from_dir(File.join(year, month), id) end - def delete_draft(slug) - delete_post_from_dir('drafts', slug) + def delete_draft(id) + delete_post_from_dir('drafts', id) end def publish_post(post) if post.draft? new_post = create_post(post.title, post.body, post.link) - delete_post_from_dir('drafts', post.slug) + delete_post_from_dir('drafts', post.id) new_post else - raise PostAlreadyPublishedError.new("post is already published: #{post.dir}/#{post.slug}") + raise PostAlreadyPublishedError.new("post is already published: #{post.dir}/#{post.id}") end end def unpublish_post(post) if post.draft? - raise PostNotPublishedError.new("post is not published: #{post.dir}/#{post.slug}") + raise PostNotPublishedError.new("post is not published: #{post.dir}/#{post.id}") else new_post = create_post(post.title, post.body, post.link, draft: true) - delete_post_from_dir(post.dir, post.slug) + delete_post_from_dir(post.dir, post.id) new_post end end - def publish(production = false) - target = production ? 'publish' : 'publish_beta' + def publish(env) + target = env.to_s == 'production' ? 'publish' : 'publish_beta' run("make #{target}") end @@ -282,26 +292,40 @@ class HarpBlog end def read_posts(post_dir, extra_fields = nil) + extra_fields ||= {} post_data = read_post_data(post_path(post_dir)) post_data.sort_by do |k, v| (v['timestamp'] || Time.now).to_i - end.map do |slug, fields| - Post.new(fields.merge(extra_fields || {}).merge(slug: slug)) + end.map do |id, fields| + fields[:id] = id + unless extra_fields[:draft] + fields[:slug] = id + end + post_filename = post_path(post_dir, "#{id}.md") + fields[:body] = File.read(post_filename) + Post.new(fields.merge(extra_fields)) end end - def read_post(post_dir, slug, extra_fields = nil) - post_filename = post_path(post_dir, "#{slug}.md") + def read_post(post_dir, id, extra_fields = nil) + post_filename = post_path(post_dir, "#{id}.md") post_data = read_post_data(post_path(post_dir)) - if File.exist?(post_filename) && fields = post_data[slug] + if File.exist?(post_filename) && fields = post_data[id] fields[:body] = File.read(post_filename) - Post.new(fields.merge(extra_fields || {}).merge(slug: slug)) + if extra_fields + fields.merge!(extra_fields) + end + fields[:id] = id + unless fields[:draft] + fields[:slug] = id + end + Post.new(fields) elsif fields - message = "missing post body for #{post_dir}/#{slug}: #{post_filename}" + message = "missing post body for #{post_dir}/#{id}: #{post_filename}" $stderr.puts "[HarpBlog#read_post] #{message}" raise InvalidDataError.new(message) elsif File.exist?(post_filename) - message = "missing metadata for #{post_dir}/#{slug}: #{post_dir}/_data.json" + message = "missing metadata for #{post_dir}/#{id}: #{post_dir}/_data.json" $stderr.puts "[HarpBlog#read_post] #{message}" raise InvalidDataError.new(message) end @@ -330,43 +354,43 @@ class HarpBlog unless post.draft? ensure_post_dir_exists(post_dir) end - write_post_body(post_dir, post.slug, post.body) + write_post_body(post_dir, post.id, post.body) begin - write_post_index(post_dir, post.slug, post.persistent_fields) + write_post_index(post_dir, post.id, post.persistent_fields) rescue => e $stderr.puts "#{e.class}: #{e.message}" $stderr.puts e.backtrace - delete_post_body(post_dir, post.slug) + delete_post_body(post_dir, post.id) raise e end end - def delete_post_from_dir(post_dir, slug) + def delete_post_from_dir(post_dir, id) post_dir = post_path(post_dir) - delete_post_body(post_dir, slug) - delete_post_index(post_dir, slug) + delete_post_body(post_dir, id) + delete_post_index(post_dir, id) end - def write_post_body(dir, slug, body) - post_filename = File.join(dir, "#{slug}.md") + def write_post_body(dir, id, body) + post_filename = File.join(dir, "#{id}.md") write_file(post_filename, body) end - def delete_post_body(dir, slug) - post_filename = File.join(dir, "#{slug}.md") + def delete_post_body(dir, id) + post_filename = File.join(dir, "#{id}.md") delete_file(post_filename) end - def write_post_index(dir, slug, fields) + def write_post_index(dir, id, fields) post_data = read_post_data(dir) - post_data[slug] = fields + post_data[id] = fields write_post_data(dir, post_data) end - def delete_post_index(dir, slug) + def delete_post_index(dir, id) post_data = read_post_data(dir) - if post_data[slug] - post_data.delete(slug) + if post_data[id] + post_data.delete(id) write_post_data(dir, post_data) end end diff --git a/server/server.rb b/server/server.rb index 4e1fce4..aaffccb 100755 --- a/server/server.rb +++ b/server/server.rb @@ -67,11 +67,36 @@ set :port, $config[:port] blog = HarpBlog.new($config[:path], $config[:dry_run]) +before do + if request.body.size > 0 + type = request['HTTP_CONTENT_TYPE'] + @fields = + case + when type =~ /^application\/json\b/ + request.body.rewind + JSON.parse(request.body.read) + else + params + end + end +end + # status get '/status' do status 200 headers 'Content-Type' => 'application/json' - JSON.generate(blog.status) + JSON.generate(status: blog.status) +end + +# publish the site +post '/publish' do + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + blog.publish(@fields['env']) + status 204 end # list years @@ -102,6 +127,17 @@ get '/posts/:year/?:month?' do |year, month| JSON.generate(posts: posts.map(&:fields)) end +# list all published posts +get '/posts' do + posts = blog.months.map do |year, month| + blog.posts_for_month(year, month) + end.flatten + + status 200 + headers 'Content-Type' => 'application/json' + JSON.generate(posts: posts.map(&:fields)) +end + # list drafts get '/drafts' do posts = blog.drafts @@ -112,9 +148,9 @@ get '/drafts' do end # get a post -get '/posts/:year/:month/:slug' do |year, month, slug| +get '/posts/:year/:month/:id' do |year, month, id| begin - post = blog.get_post(year, month, slug) + post = blog.get_post(year, month, id) rescue HarpBlog::InvalidDataError => e status 500 return "Failed to get post, invalid data on disk: #{e.message}" @@ -140,9 +176,9 @@ get '/posts/:year/:month/:slug' do |year, month, slug| end # get a draft -get '/drafts/:slug' do |slug| +get '/drafts/:id' do |id| begin - post = blog.get_draft(slug) + post = blog.get_draft(id) rescue HarpBlog::InvalidDataError => e status 500 return "Failed to get draft, invalid data on disk: #{e.message}" @@ -174,16 +210,25 @@ post '/drafts' do return 'forbidden' end + id, title, body, link = @fields.values_at('id', 'title', 'body', 'link') begin - post = blog.create_post(params[:title], params[:body], params[:link], draft: true) + if post = blog.create_post(title, body, link, id: id, draft: true) + 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 rescue HarpBlog::PostExistsError => e post = HarpBlog::Post.new({ - title: params[:title], - body: params[:body], - link: params[:link], + title: title, + body: body, + link: link, }) status 409 - return "refusing to clobber existing draft, update it instead: #{post.url}" + "refusing to clobber existing draft, update it instead: #{post.url}" rescue HarpBlog::PostSaveError => e status 500 if orig_err = e.original_error @@ -192,28 +237,19 @@ post '/drafts' do "Failed to create draft: #{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| +put '/posts/:year/:month/:id' do |year, month, id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end + title, body, link = @field.values_at('title', 'body', 'link') begin - if post = blog.get_post(year, month, slug) - blog.update_post(post, params[:title], params[:body], params[:link]) + if post = blog.get_post(year, month, id) + blog.update_post(post, title, body, link) status 204 else status 404 @@ -233,15 +269,16 @@ put '/posts/:year/:month/:slug' do |year, month, slug| end # update a draft -put '/drafts/:slug' do |slug| +put '/drafts/:id' do |id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end + title, body, link = @field.values_at('title', 'body', 'link') begin - if post = blog.get_draft(slug) - blog.update_post(post, params[:title], params[:body], params[:link]) + if post = blog.get_draft(id) + blog.update_post(post, title, body, link) status 204 else status 404 @@ -261,35 +298,35 @@ put '/drafts/:slug' do |slug| end # delete a post -delete '/posts/:year/:month/:slug' do |year, month, slug| +delete '/posts/:year/:month/:id' do |year, month, id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end - blog.delete_post(year, month, slug) + blog.delete_post(year, month, id) status 204 end # delete a draft -delete '/drafts/:slug' do |slug| +delete '/drafts/:id' do |id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end - blog.delete_draft(slug) + blog.delete_draft(id) status 204 end # publish a post -post '/drafts/:slug/publish' do |slug| +post '/drafts/:id/publish' do |id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end - if post = blog.get_draft(slug) + if post = blog.get_draft(id) new_post = blog.publish_post(post) status 201 headers 'Location' => url_for(new_post.url), 'Content-Type' => 'application/json' @@ -301,13 +338,13 @@ post '/drafts/:slug/publish' do |slug| end # unpublish a post -post '/posts/:year/:month/:slug/unpublish' do |year, month, slug| +post '/posts/:year/:month/:id/unpublish' do |year, month, id| unless authenticated?(request['Auth']) status 403 return 'forbidden' end - if post = blog.get_post(year, month, slug) + if post = blog.get_post(year, month, id) new_post = blog.unpublish_post(post) status 201 headers 'Location' => url_for(new_post.url), 'Content-Type' => 'application/json' @@ -317,15 +354,3 @@ post '/posts/:year/:month/:slug/unpublish' do |year, month, slug| 'not found' end end - -# publish the site -post '/publish' do - unless authenticated?(request['Auth']) - status 403 - return 'forbidden' - end - - production = params[:env] == 'production' - blog.publish(production) - status 204 -end diff --git a/server/spec/harp_blog_spec.rb b/server/spec/harp_blog_spec.rb index 9650b24..9cff694 100644 --- a/server/spec/harp_blog_spec.rb +++ b/server/spec/harp_blog_spec.rb @@ -11,21 +11,28 @@ end RSpec.describe HarpBlog::Post do - # Persistent fields: author, title, date, timestamp, link, url, tags + # Persistent fields: id, author, title, date, timestamp, link, url, tags # Transient fields: time, slug, body before :all do - @default_fields = { + @post_fields = { title: 'samhuri.net', link: 'http://samhuri.net', body: 'this site is sick', } - @default_slug = 'samhuri-net' + @post_slug = 'samhuri-net' + @draft_fields = { + title: 'reddit.com', + link: 'http://reddit.com', + body: 'hi reddit', + draft: true, + id: 'dummy-draft-id', + } end describe '#new' do it "takes a Hash of fields" do - fields = @default_fields + fields = @post_fields post = HarpBlog::Post.new(fields) expect(post.title).to eq(fields[:title]) expect(post.link).to eq(fields[:link]) @@ -61,7 +68,7 @@ RSpec.describe HarpBlog::Post do describe '#link?' do it "returns true for link posts" do - post = HarpBlog::Post.new(link: @default_fields[:link]) + post = HarpBlog::Post.new(link: @post_fields[:link]) expect(post.link?).to be_truthy end @@ -101,18 +108,38 @@ RSpec.describe HarpBlog::Post do describe '#url' do it "should be derived from the time and slug if necessary" do - post = HarpBlog::Post.new(@default_fields) + post = HarpBlog::Post.new(@post_fields) year = post.time.year.to_s month = post.time.month - padded_month = month < 10 ? " #{month}" : "#{month}" - expect(post.url).to eq("/posts/#{year}/#{padded_month}/#{@default_slug}") + padded_month = month < 10 ? "0#{month}" : "#{month}" + expect(post.url).to eq("/posts/#{year}/#{padded_month}/#{@post_slug}") + end + end + + describe '#id' do + it "should be generated for drafts if necessary" do + draft = HarpBlog::Post.new(@draft_fields) + expect(draft.id).to eq(@draft_fields[:id]) + + draft = HarpBlog::Post.new(@draft_fields.merge(id: nil)) + expect(draft.id).to_not eq(@draft_fields[:id]) + end + + it "should be the slug for posts" do + post = HarpBlog::Post.new(@post_fields) + expect(post.id).to eq(post.slug) end end describe '#slug' do it "should be derived from the title if necessary" do - post = HarpBlog::Post.new(@default_fields) - expect(post.slug).to eq(@default_slug) + post = HarpBlog::Post.new(@post_fields) + expect(post.slug).to eq(@post_slug) + end + + it "should be nil for drafts" do + draft = HarpBlog::Post.new(@draft_fields) + expect(draft.slug).to be_nil end it "should strip apostrophes" do @@ -303,20 +330,21 @@ RSpec.describe HarpBlog do describe '#get_draft' do it "should return complete posts" do + id = 'some-draft-id' title = 'new draft' body = "blah blah blah\n" - @blog.create_post(title, body, nil, draft: true) - post = @blog.get_draft('new-draft') - expect(post).to be_truthy - expect(post.title).to eq(title) - expect(post.url).to eq('/posts/drafts/new-draft') - expect(post.draft?).to be_truthy - expect(post.body).to eq(body) + @blog.create_post(title, body, nil, id: id, draft: true) + draft = @blog.get_draft(id) + expect(draft).to be_truthy + expect(draft.title).to eq(title) + expect(draft.url).to eq("/posts/drafts/#{id}") + expect(draft.draft?).to be_truthy + expect(draft.body).to eq(body) end it "should return nil if the post does not exist" do - post = @blog.get_draft('does-not-exist') - expect(post).to be(nil) + draft = @blog.get_draft('does-not-exist') + expect(draft).to be(nil) end end @@ -369,13 +397,14 @@ RSpec.describe HarpBlog do end it "should create a draft that can be fetched immediately" do + id = 'another-draft-id' title = 'fetch now' body = 'blah blah blah' - post = @blog.create_post(title, body, nil, draft: true) - expect(post).to be_truthy + draft = @blog.create_post(title, body, nil, id: id, draft: true) + expect(draft).to be_truthy - fetched_post = @blog.get_draft(post.slug) - expect(post.url).to eq(fetched_post.url) + fetched_draft = @blog.get_draft(draft.id) + expect(draft.url).to eq(fetched_draft.url) end it "should fetch titles if necessary" do @@ -434,49 +463,54 @@ RSpec.describe HarpBlog do describe '#delete_draft' do it "should delete existing drafts" do + id = 'bunk-draft-id' title = 'new draft' body = 'blah blah blah' - existing_post = @blog.create_post(title, body, nil, draft: true) - post = @blog.get_draft(existing_post.slug) - expect(post).to be_truthy + existing_draft = @blog.create_post(title, body, nil, id: id, draft: true) + draft = @blog.get_draft(existing_draft.id) + expect(draft).to be_truthy - @blog.delete_draft(post.slug) + @blog.delete_draft(draft.id) - post = @blog.get_draft(post.slug) - expect(post).to eq(nil) + draft = @blog.get_draft(draft.id) + expect(draft).to eq(nil) end it "should do nothing for non-existent posts" do + id = 'missing-draft-id' title = 'new draft' body = 'blah blah blah' - existing_post = @blog.create_post(title, body, nil, draft: true) + existing_draft = @blog.create_post(title, body, nil, id: id, draft: true) - post = @blog.get_draft(existing_post.slug) - expect(post).to be_truthy + draft = @blog.get_draft(existing_draft.id) + expect(draft).to be_truthy - @blog.delete_draft(post.slug) - @blog.delete_draft(post.slug) + @blog.delete_draft(draft.id) + expect(@blog.get_draft(existing_draft.id)).to be_nil + @blog.delete_draft(draft.id) end end describe '#publish_post' do it "should publish drafts" do + id = 'this-draft-is-a-keeper' title = 'a-shiny-new-post' body = 'blah blah blah' link = 'http://samhuri.net' - post = @blog.create_post(title, body, link, draft: true) - new_post = @blog.publish_post(post) - expect(new_post).to be_truthy - expect(new_post.draft?).to be_falsy - expect(new_post.title).to eq(title) - expect(new_post.body).to eq(body) - expect(new_post.link).to eq(link) - - draft = @blog.get_draft(post.slug) - expect(draft).to eq(nil) - - post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug) + draft = @blog.create_post(title, body, link, id: id, draft: true) + post = @blog.publish_post(draft) expect(post).to be_truthy + expect(post.id).to eq(post.slug) + expect(post.draft?).to be_falsy + expect(post.title).to eq(title) + expect(post.body).to eq(body) + expect(post.link).to eq(link) + + missing_draft = @blog.get_draft(draft.id) + expect(missing_draft).to eq(nil) + + fetched_post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug) + expect(fetched_post).to be_truthy end it "should raise an error for published posts" do @@ -488,18 +522,19 @@ RSpec.describe HarpBlog do describe '#unpublish_post' do it "should unpublish posts" do post = @blog.get_post('2006', '02', 'first-post') - new_post = @blog.unpublish_post(post) - expect(new_post).to be_truthy - expect(new_post.draft?).to be_truthy - expect(new_post.title).to eq(post.title) - expect(new_post.body).to eq(post.body) - expect(new_post.link).to eq(post.link) - - post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug) - expect(post).to eq(nil) - - draft = @blog.get_draft(new_post.slug) + draft = @blog.unpublish_post(post) expect(draft).to be_truthy + expect(draft.id).to be_truthy + expect(draft.draft?).to be_truthy + expect(draft.title).to eq(post.title) + expect(draft.body).to eq(post.body) + expect(draft.link).to eq(post.link) + + missing_post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug) + expect(missing_post).to eq(nil) + + fetched_draft = @blog.get_draft(draft.id) + expect(fetched_draft).to be_truthy end it "should raise an error for drafts" do