├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── config.ru ├── config └── syndication_targets.json ├── lib ├── transformative.rb └── transformative │ ├── auth.rb │ ├── authorship.rb │ ├── cache.rb │ ├── card.rb │ ├── cite.rb │ ├── context.rb │ ├── entry.rb │ ├── event.rb │ ├── file_system.rb │ ├── media.rb │ ├── micropub.rb │ ├── notification.rb │ ├── post.rb │ ├── redis.rb │ ├── server.rb │ ├── store.rb │ ├── syndication.rb │ ├── twitter.rb │ ├── utils.rb │ ├── view_helper.rb │ └── webmention.rb ├── public ├── barryfrost-favicon.png ├── barryfrost.jpg ├── css │ ├── font-awesome.min.css │ └── moof.css ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── main.js ├── manifest.json └── sw.js ├── scripts ├── migrate_contexts.rb ├── migrate_entries.rb └── migrate_webmentions.rb └── views ├── 404.erb ├── 410.erb ├── 500.erb ├── _categories.erb ├── _contexts.erb ├── _deleted.erb ├── _indie_actions.erb ├── _location.erb ├── _meta.erb ├── _name.erb ├── _photos.erb ├── _photos_all.erb ├── _search_form.erb ├── _sources.erb ├── _syndications.erb ├── _timestamp.erb ├── _webmention_counts.erb ├── _webmentions.erb ├── entry.erb ├── event.erb ├── index.erb ├── layout.erb ├── rss.builder └── static.erb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | .env 15 | 16 | ## Environment normalization: 17 | /.bundle/ 18 | /vendor/bundle 19 | /lib/bundler/man/ 20 | 21 | # for a library or gem, you might want to ignore these files since the code is 22 | # intended to run in multiple environments; otherwise, check them in: 23 | # Gemfile.lock 24 | # .ruby-version 25 | # .ruby-gemset 26 | 27 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 28 | .rvmrc 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.6.5' 4 | 5 | gem 'sinatra' 6 | gem 'sinatra-contrib' 7 | gem 'rack-contrib' 8 | gem 'rack-ssl' 9 | gem 'puma' 10 | gem 'httparty' 11 | gem 'nokogiri' 12 | gem 'octokit' 13 | gem 'microformats' 14 | gem 'redcarpet' 15 | gem 'sanitize' 16 | gem 'builder' 17 | gem 'webmention' 18 | gem 'sequel_pg', require: 'sequel' 19 | gem 'will_paginate' 20 | gem 's3' 21 | gem 'twitter' 22 | gem 'indieweb-authorship' 23 | gem 'hashie' 24 | gem 'redis' 25 | 26 | group :production do 27 | gem 'sentry-raven' 28 | end 29 | 30 | group :development do 31 | gem 'dotenv' 32 | gem 'shotgun' 33 | end 34 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | absolutely (5.1.0) 5 | addressable (~> 2.7) 6 | addressable (2.8.0) 7 | public_suffix (>= 2.0.2, < 5.0) 8 | buftok (0.2.0) 9 | builder (3.2.4) 10 | crass (1.0.6) 11 | domain_name (0.5.20190701) 12 | unf (>= 0.0.5, < 1.0.0) 13 | dotenv (2.7.6) 14 | equalizer (0.0.11) 15 | faraday (1.7.1) 16 | faraday-em_http (~> 1.0) 17 | faraday-em_synchrony (~> 1.0) 18 | faraday-excon (~> 1.1) 19 | faraday-httpclient (~> 1.0.1) 20 | faraday-net_http (~> 1.0) 21 | faraday-net_http_persistent (~> 1.1) 22 | faraday-patron (~> 1.0) 23 | faraday-rack (~> 1.0) 24 | multipart-post (>= 1.2, < 3) 25 | ruby2_keywords (>= 0.0.4) 26 | faraday-em_http (1.0.0) 27 | faraday-em_synchrony (1.0.0) 28 | faraday-excon (1.1.0) 29 | faraday-httpclient (1.0.1) 30 | faraday-net_http (1.0.1) 31 | faraday-net_http_persistent (1.2.0) 32 | faraday-patron (1.0.0) 33 | faraday-rack (1.0.0) 34 | ffi (1.15.4) 35 | ffi-compiler (1.0.1) 36 | ffi (>= 1.0.0) 37 | rake 38 | hashie (4.1.0) 39 | http (4.4.1) 40 | addressable (~> 2.3) 41 | http-cookie (~> 1.0) 42 | http-form_data (~> 2.2) 43 | http-parser (~> 1.2.0) 44 | http-cookie (1.0.4) 45 | domain_name (~> 0.5) 46 | http-form_data (2.3.0) 47 | http-parser (1.2.3) 48 | ffi-compiler (>= 1.0, < 2.0) 49 | http_parser.rb (0.6.0) 50 | httparty (0.19.0) 51 | mime-types (~> 3.0) 52 | multi_xml (>= 0.5.2) 53 | indieweb-authorship (0.2.2) 54 | microformats (~> 4.0, >= 4.2.1) 55 | indieweb-endpoints (5.0.0) 56 | absolutely (~> 5.0) 57 | addressable (~> 2.7) 58 | http (~> 4.4) 59 | link-header-parser (~> 2.1) 60 | nokogiri (~> 1.10) 61 | json (2.5.1) 62 | link-header-parser (2.2.0) 63 | absolutely (~> 5.1) 64 | memoizable (0.4.2) 65 | thread_safe (~> 0.3, >= 0.3.1) 66 | microformats (4.3.1) 67 | json (~> 2.2) 68 | nokogiri (~> 1.10) 69 | mime-types (3.3.1) 70 | mime-types-data (~> 3.2015) 71 | mime-types-data (3.2021.0901) 72 | mini_portile2 (2.8.0) 73 | multi_json (1.15.0) 74 | multi_xml (0.6.0) 75 | multipart-post (2.1.1) 76 | mustermann (1.1.1) 77 | ruby2_keywords (~> 0.0.1) 78 | naught (1.1.0) 79 | nio4r (2.5.8) 80 | nokogiri (1.13.2) 81 | mini_portile2 (~> 2.8.0) 82 | racc (~> 1.4) 83 | octokit (4.21.0) 84 | faraday (>= 0.9) 85 | sawyer (~> 0.8.0, >= 0.5.3) 86 | pg (1.2.3) 87 | proxies (0.2.3) 88 | public_suffix (4.0.6) 89 | puma (5.6.2) 90 | nio4r (~> 2.0) 91 | racc (1.6.0) 92 | rack (2.2.3) 93 | rack-contrib (2.3.0) 94 | rack (~> 2.0) 95 | rack-protection (2.1.0) 96 | rack 97 | rack-ssl (1.4.1) 98 | rack 99 | rake (13.0.6) 100 | redcarpet (3.5.1) 101 | redis (4.4.0) 102 | ruby2_keywords (0.0.5) 103 | s3 (0.3.29) 104 | addressable 105 | proxies 106 | sanitize (6.0.0) 107 | crass (~> 1.0.2) 108 | nokogiri (>= 1.12.0) 109 | sawyer (0.8.2) 110 | addressable (>= 2.3.5) 111 | faraday (> 0.8, < 2.0) 112 | sentry-raven (3.1.2) 113 | faraday (>= 1.0) 114 | sequel (5.48.0) 115 | sequel_pg (1.14.0) 116 | pg (>= 0.18.0, != 1.2.0) 117 | sequel (>= 4.38.0) 118 | shotgun (0.9.2) 119 | rack (>= 1.0) 120 | simple_oauth (0.3.1) 121 | sinatra (2.1.0) 122 | mustermann (~> 1.0) 123 | rack (~> 2.2) 124 | rack-protection (= 2.1.0) 125 | tilt (~> 2.0) 126 | sinatra-contrib (2.1.0) 127 | multi_json 128 | mustermann (~> 1.0) 129 | rack-protection (= 2.1.0) 130 | sinatra (= 2.1.0) 131 | tilt (~> 2.0) 132 | thread_safe (0.3.6) 133 | tilt (2.0.10) 134 | twitter (7.0.0) 135 | addressable (~> 2.3) 136 | buftok (~> 0.2.0) 137 | equalizer (~> 0.0.11) 138 | http (~> 4.0) 139 | http-form_data (~> 2.0) 140 | http_parser.rb (~> 0.6.0) 141 | memoizable (~> 0.4.0) 142 | multipart-post (~> 2.0) 143 | naught (~> 1.0) 144 | simple_oauth (~> 0.3.0) 145 | unf (0.1.4) 146 | unf_ext 147 | unf_ext (0.0.7.7) 148 | webmention (5.0.0) 149 | absolutely (~> 5.0) 150 | addressable (~> 2.7) 151 | http (~> 4.4) 152 | indieweb-endpoints (~> 5.0) 153 | nokogiri (~> 1.10) 154 | will_paginate (3.3.1) 155 | 156 | PLATFORMS 157 | ruby 158 | 159 | DEPENDENCIES 160 | builder 161 | dotenv 162 | hashie 163 | httparty 164 | indieweb-authorship 165 | microformats 166 | nokogiri 167 | octokit 168 | puma 169 | rack-contrib 170 | rack-ssl 171 | redcarpet 172 | redis 173 | s3 174 | sanitize 175 | sentry-raven 176 | sequel_pg 177 | shotgun 178 | sinatra 179 | sinatra-contrib 180 | twitter 181 | webmention 182 | will_paginate 183 | 184 | RUBY VERSION 185 | ruby 2.6.5p114 186 | 187 | BUNDLED WITH 188 | 1.17.3 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: rackup -s puma -p $PORT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Transformative 2 | 3 | Transformative is a microblogging engine that powered my personal website 2016-2021. It's written in Ruby and supports several key [IndieWeb][] technologies as detailed below. 4 | 5 | All my notes, articles, bookmarks, photos and more are hosted on my personal domain rather than in someone else's silo. I can choose how it looks and works while having fun building it for myself. 6 | 7 | ## IndieWeb 8 | 9 | - [Microformats2][] (h-entry, h-event, h-cite, h-card) 10 | - [Webmention][] sending and receiving 11 | - [Micropub][] create, update and delete/undelete 12 | - [POSSE][] syndication to Twitter and Pinboard 13 | - Reply-/repost-/like- [contexts][] fetched and displayed above posts 14 | - [Backfeed][] of replies, reposts and likes imported via [Brid.gy][bridgy] 15 | - Authorisation via [IndieAuth][] and [`rel=me`][relme] 16 | - [WebSub][websub] hub pinging 17 | - [`person-tag`][persontag] support 18 | 19 | Implementation reports are available showing Transformative's compliance with the [Webmention][wm-ir] and [Micropub][mp-ir] specifications. 20 | 21 | ## How it works 22 | 23 | Transformative has several parts. The most obvious is a [Sinatra][] web app that serves content from a database cache. It also exposes APIs so that compatible clients can create and edit content. Posts are first stored as flat JSON files in a git repository and then sucked down into the database and cached. Any external contexts, replies, likes and reposts are also imported. 24 | 25 | ### Storage 26 | 27 | All content is stored on GitHub in my [content][] repo. Whenever a file is added or changed, either via a Micropub post to my endpoint or via a git push, GitHub notifies Transformative via a webhook. 28 | 29 | The post is then pulled from GitHub and copied to a local Postgres database as a full [Microformats2 JSON][mf2json] document. Note: this database is a cache that can be rebuilt from the content repo at any time; the canonical store for all content is the GitHub repo. 30 | 31 | Images and other media files are also stored in the content repo but are copied to and served from an Amazon S3 bucket. 32 | 33 | ### Micropub 34 | 35 | Rather than build an admin system for creating and modifying posts, I've attempted to do without. I've exposed an API endpoint that's compliant with the Micropub specification for posting and editing using a compatible third-party app. 36 | 37 | Using a tool like [Quill][] or [Micropublish][] I can log in and then post notes, bookmarks and likes to my site. A successful post is stored on GitHub and cached on my server which then fires off any webmentions, syndicates to silos like Twitter and fetches reply/repost/like contexts for display if appropriate. 38 | 39 | Alternatively, I can write (or edit) a post as a file and simply push via git to my GitHub content repo and Transformative pulls it down and does the rest. 40 | 41 | ### Webmentions 42 | 43 | Instead of a comments form, my site supports replies, reposts, likes or mentions via Webmention from another IndieWeb site. If someone wants to respond to a post they can write a note on their own site using Microformats2 h-entry markup and send a webmention ping to my endpoint. 44 | 45 | The commenter's h-entry will then be parsed, stored and added underneath my post with an icon indicating its type. Further webmentions from the same permalink will update or remove it if the link (or the whole post) is gone. 46 | 47 | And using the magic of [Bridgy][], responses to my tweets, Facebook posts and Instagram photos that have been syndicated from my site are pulled back in as webmentions. 48 | 49 | ## Requirements 50 | 51 | - Ruby 2.3.1 or newer 52 | - PostgreSQL 9.4 or newer -- required for its JSONB support 53 | - GitHub account -- post canonical storage 54 | - AWS S3 bucket -- media file hosting 55 | 56 | ## Installation 57 | 58 | Transformative currently powers my personal site but should be considered experimental and likely to change at any time. You're welcome to fork and hack on it but its primary purpose is to evolve based on my needs. Use at your own risk! 59 | 60 | ### Hosting 61 | 62 | I recommend hosting Transformative with [Heroku][]. I started building a new VPS with this setup and realised I could save myself the time by using a relatively cheap Heroku instance. 63 | 64 | ### Database 65 | 66 | Create a fresh database instance with one table named `posts`: 67 | 68 | ``` 69 | # CREATE TABLE posts (url VARCHAR(255) PRIMARY KEY, data JSONB); 70 | ``` 71 | 72 | ### GitHub 73 | 74 | - Create a public repo in GitHub (suggested name: "content") 75 | - Create a new webhook under this repo 76 | - Payload URL: your Micropub endpoint, e.g. https://barryfrost.com/micropub 77 | - Content type: `application/json` 78 | - Secret: Generate/decide on a secure password or token to use when setting `GITHUB_SECRET` 79 | - Select just the `push` event 80 | - Generate a Personal Access Token with repo permissions and keep a note of it to use when setting `GITHUB_ACCESS_TOKEN `. 81 | 82 | ### Environment variables 83 | 84 | You will need to define the following environment variables: 85 | 86 | - `SITE_URL` e.g. https://barryfrost.com/ 87 | - `MEDIA_URL` e.g. https://barryfrost-media.s3.amazonaws.com/ 88 | - `GITHUB_USER` e.g. barryf 89 | - `GITHUB_REPO` -- name of an empty repo to use as your content store 90 | - `GITHUB_ACCESS_TOKEN` -- a personal access token generated from your GitHub account 91 | - `GITHUB_SECRET` -- a (strong) random password/token you've generated for the webhook 92 | - `SILOPUB_TWITTER_TOKEN` -- a token generated by SiloPub when syndicating to Twitter 93 | - `DATABASE_URL` -- your Postgres connection (Heroku will create this for you on deploy) 94 | 95 | Optional variables: 96 | 97 | - `CAMO_KEY` -- your [Camo][] instance private key 98 | - `CAMO_URL` -- your [Camo][] instance root URL 99 | - `PUSHOVER_USER`, `PUSHOVER_TOKEN` -- account details for use with Pushover 100 | - `PINBOARD_AUTH_TOKEN` -- Pinboard API key 101 | - `PUBSUBHUBBUB_HUB` e.g. https://barryfrost.superfeedr.com 102 | 103 | --- 104 | 105 | _This README also appears on my site as its [Colophon][]._ 106 | 107 | [bf]: https://barryfrost.com 108 | [indieweb]: https://indieweb.org 109 | [microformats2]: http://microformats.org/wiki/microformats2 110 | [webmention]: https://webmention.net 111 | [micropub]: https://micropub.net 112 | [backfeed]: http://indieweb.org/backfeed 113 | [posse]: http://indieweb.org/POSSE 114 | [silopub]: https://silo.pub 115 | [contexts]: http://indieweb.org/reply-context 116 | [indieauth]: https://indieauth.com 117 | [relme]: http://indieweb.org/rel-me 118 | [websub]: http://indieweb.org/websub 119 | [persontag]: http://indieweb.org/person-tag 120 | [wm-ir]: https://github.com/w3c/webmention/blob/master/implementation-reports/transformative.md 121 | [mp-ir]: https://micropub.rocks/implementation-report/server/30/Qr4kVp0CSxFGY9Zfpsfh 122 | [sinatra]: sinatrarb.com 123 | [content]: https://github.com/barryf/content 124 | [mf2json]: http://microformats.org/wiki/microformats2-parsing 125 | [quill]: https://quill.p3k.io 126 | [micropublish]: https://micropublish.net 127 | [bridgy]: https://brid.gy 128 | [heroku]: https://www.heroku.com 129 | [colophon]: https://barryfrost.com/2016/11/colophon 130 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # create table posts (url varchar(255) primary key, data jsonb); 2 | 3 | require "bundler/setup" 4 | Bundler.require(:default, :development) 5 | 6 | require 'json' 7 | require 'time' 8 | 9 | require 'dotenv' 10 | Dotenv.load 11 | 12 | require_relative 'lib/transformative.rb' 13 | 14 | DB = Sequel.connect(ENV['DATABASE_URL']) 15 | 16 | CONTENT_PATH = "#{File.dirname(__FILE__)}/../content" 17 | 18 | def parse(file) 19 | data = File.read(file) 20 | post = JSON.parse(data) 21 | url = file.sub(CONTENT_PATH,'').sub(/\.json$/,'') 22 | 23 | DB[:posts].insert(url: url, data: data) 24 | 25 | print "." 26 | end 27 | 28 | desc "Rebuild database cache from all content JSON files." 29 | task :rebuild do 30 | DB[:posts].truncate 31 | Dir.glob("#{CONTENT_PATH}/**/*.json").each do |file| 32 | parse(file) 33 | end 34 | end 35 | 36 | desc "Rebuild database cache from this month's content JSON files." 37 | task :recent do 38 | DB[:posts].truncate 39 | year_month = Time.now.strftime('%Y/%m') 40 | files = Dir.glob("#{CONTENT_PATH}/#{year_month}/**/*.json") 41 | # need to rebuild all cites and cards because they're not organised by month 42 | files += Dir.glob("#{CONTENT_PATH}/cite/**/*.json") 43 | files += Dir.glob("#{CONTENT_PATH}/card/**/*.json") 44 | files.each do |file| 45 | parse(file) 46 | end 47 | end 48 | 49 | desc "Fetch a context and store." 50 | task :context_fetch, :url do |t, args| 51 | url = args[:url] 52 | Transformative::Context.fetch(url) 53 | end 54 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) 3 | 4 | env = ENV['RACK_ENV'].to_sym 5 | 6 | require "bundler/setup" 7 | Bundler.require(:default, env) 8 | 9 | Dotenv.load unless env == :production 10 | 11 | # optionally use sentry in production 12 | if env == :production && ENV.key?('SENTRY_DSN') 13 | Raven.configure do |config| 14 | config.dsn = ENV['SENTRY_DSN'] 15 | config.processors -= [Raven::Processor::PostData] 16 | end 17 | use Raven::Rack 18 | end 19 | 20 | # automatically parse json in the body 21 | use Rack::JSONBodyParser 22 | 23 | require 'will_paginate/sequel' 24 | Sequel::Database.extension(:pagination, :pg_json) 25 | Sequel.extension(:pg_array, :pg_json_ops) 26 | 27 | require 'transformative' 28 | run Transformative::Server 29 | -------------------------------------------------------------------------------- /config/syndication_targets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uid": "https://twitter.com/barryf", 4 | "name": "Twitter" 5 | }, 6 | { 7 | "uid": "https://pinboard.in/barryf", 8 | "name": "Pinboard" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /lib/transformative.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | 3 | class TransformativeError < StandardError 4 | attr_reader :type, :status 5 | def initialize(type, message, status=500) 6 | @type = type 7 | @status = status.to_i 8 | super(message) 9 | end 10 | end 11 | 12 | end 13 | 14 | %w( utils post card cite entry event auth context media micropub notification 15 | syndication authorship notification webmention view_helper cache twitter 16 | file_system store redis server ).each do |file| 17 | require_relative "transformative/#{file}.rb" 18 | end 19 | -------------------------------------------------------------------------------- /lib/transformative/auth.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Auth 3 | module_function 4 | 5 | def verify_token_and_scope(token, scope) 6 | token_data = get_cached_token(token) 7 | unless token_data 8 | response = get_token_response(token, ENV['TOKEN_ENDPOINT']) 9 | unless response.code.to_i == 200 10 | raise ForbiddenError.new 11 | end 12 | token_data = CGI.parse(response.parsed_response) 13 | set_cached_token(token, token_data) 14 | end 15 | 16 | if token_data.key?('scope') && token_data['scope'].is_a?(Array) 17 | scopes = token_data['scope'][0].split(' ') 18 | return if scopes.include?(scope) 19 | # if we want to post and are allowed to create then go ahead 20 | return if scope == 'post' && scopes.include?('create') 21 | end 22 | raise InsufficientScope.new 23 | end 24 | 25 | def get_token_response(token, token_endpoint) 26 | HTTParty.get( 27 | token_endpoint, 28 | headers: { 29 | 'Accept' => 'application/x-www-form-urlencoded', 30 | 'Authorization' => "Bearer #{token}" 31 | }) 32 | end 33 | 34 | def verify_github_signature(body, header_signature) 35 | signature = 'sha1=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), 36 | ENV['GITHUB_SECRET'], body) 37 | unless Rack::Utils.secure_compare(signature, header_signature) 38 | raise ForbiddenError.new("GitHub webhook signatures did not match.") 39 | end 40 | end 41 | 42 | def set_cached_token(token, token_data) 43 | Redis.set(token, token_data) 44 | Redis.expire(token, 3600) # expire after one hour 45 | end 46 | 47 | def get_cached_token(token) 48 | Redis.get(token) 49 | end 50 | 51 | class NoTokenError < TransformativeError 52 | def initialize(message="Micropub endpoint did not return an access token.") 53 | super("unauthorized", message, 401) 54 | end 55 | end 56 | 57 | class InsufficientScope < TransformativeError 58 | def initialize(message="The user does not have sufficient scope to perform this action.") 59 | super("insufficient_scope", message, 401) 60 | end 61 | end 62 | 63 | class ForbiddenError < TransformativeError 64 | def initialize(message="The authenticated user does not have permission" + 65 | " to perform this request.") 66 | super("forbidden", message, 403) 67 | end 68 | end 69 | 70 | end 71 | end -------------------------------------------------------------------------------- /lib/transformative/authorship.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Authorship 3 | module_function 4 | 5 | def fetch(url) 6 | author = Indieweb::Authorship.identify(url) 7 | return unless author 8 | properties = { 9 | 'url' => [author['url']] 10 | } 11 | properties['name'] = [author['name']] if author['name'] 12 | properties['photo'] = [author['photo']] if author['photo'] 13 | Card.new(properties) 14 | end 15 | 16 | end 17 | end -------------------------------------------------------------------------------- /lib/transformative/cache.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Cache 3 | module_function 4 | 5 | MAX_POSTS = 20 6 | 7 | def db 8 | @db ||= Sequel.connect(ENV['DATABASE_URL']) 9 | end 10 | 11 | def row_to_post(row) 12 | klass = Post.class_from_type(row[:data]['type'][0]) 13 | klass.new(row[:data]['properties'], row[:url]) 14 | end 15 | 16 | def data 17 | Sequel.pg_jsonb_op(:data) 18 | end 19 | 20 | def put(post) 21 | json = post.data.to_json 22 | if db[:posts].where(url: post.url).count > 0 23 | db[:posts].where(url: post.url).update(data: json) 24 | else 25 | db[:posts].insert(url: post.url, data: json) 26 | end 27 | end 28 | 29 | def order_by_published_desc 30 | data['properties']['published'].get_text(0).desc 31 | end 32 | 33 | def get(url) 34 | row = db[:posts].where(url: url).first 35 | return if row.nil? 36 | klass = Post.class_from_type(row[:data]['type'][0]) 37 | klass.new(row[:data]['properties'], url) 38 | end 39 | 40 | def get_json(url) 41 | data = db[:posts].where(url: url).first[:data] 42 | JSON.pretty_generate(data) 43 | end 44 | 45 | def get_by_properties_url(url) 46 | row = db[:posts].where(data['properties']['url'].get_text(0) => url).first 47 | return if row.nil? 48 | klass = Post.class_from_type(row[:data]['type'][0]) 49 | klass.new(row[:data]['properties'], row[:url]) 50 | end 51 | 52 | def get_first_by_slug(slug) 53 | db[:posts] 54 | .where(data['properties']['slug'].get_text(0) => slug) 55 | .map { |row| row_to_post(row) } 56 | .first 57 | end 58 | 59 | def find_via_syndication(syndication) 60 | db[:posts] 61 | .where(data['properties']['syndication'].contain_any(syndication)) 62 | .map { |row| row_to_post(row) } 63 | end 64 | 65 | def authors_from_cites(*cites) 66 | author_urls = cites.compact.flatten.map do |cite| 67 | if cite.properties.key?('author') && 68 | Utils.valid_url?(cite.properties['author'][0]) 69 | cite.properties['author'][0] 70 | end 71 | end 72 | 73 | cards = db[:posts] 74 | .where(data['properties']['url'].get_text(0) => author_urls) 75 | .map { |row| row_to_post(row) } 76 | 77 | authors = {} 78 | cards.each do |card| 79 | authors[card.properties['url'][0]] = card 80 | end 81 | authors 82 | end 83 | 84 | def authors_from_categories(posts) 85 | author_urls = [] 86 | Array(posts).each do |post| 87 | next unless post.properties.key?('category') 88 | post.properties['category'].each do |category| 89 | if Utils.valid_url?(category) 90 | author_urls << category 91 | end 92 | end 93 | end 94 | return {} if author_urls.empty? 95 | 96 | cards = db[:posts] 97 | .where(data['properties']['url'].get_text(0) => author_urls) 98 | .map { |row| row_to_post(row) } 99 | 100 | authors = {} 101 | cards.each do |card| 102 | authors[card.properties['url'][0]] = card 103 | end 104 | authors 105 | end 106 | 107 | def stream(types, page=1) 108 | db[:posts] 109 | .where(data['type'].get_text(0) => 'h-entry') 110 | .where(data['properties']['entry-type'].get_text(0) => types) 111 | .order(order_by_published_desc) 112 | .paginate(page.to_i, MAX_POSTS) 113 | end 114 | 115 | def stream_all(page=1, max=MAX_POSTS) 116 | db[:posts] 117 | .where(data['type'].get_text(0) => 'h-entry') 118 | .order(order_by_published_desc) 119 | .paginate(page.to_i, max.to_i) 120 | end 121 | 122 | def stream_tagged(tags, page=1) 123 | db[:posts] 124 | .where(data['type'].get_text(0) => 'h-entry') 125 | .where(data['properties']['category'].contain_all(tags.split('+'))) 126 | .order(order_by_published_desc) 127 | .paginate(page.to_i, MAX_POSTS) 128 | end 129 | 130 | def stream_all_by_month(y, m) 131 | return unless Array(1..12).include?(m.to_i) 132 | return unless Array(2000..2050).include?(y.to_i) 133 | start_date = Date.new(y.to_i, m.to_i, 1) 134 | end_date = start_date.next_month 135 | db[:posts] 136 | .where(data['type'].get_text(0) => 'h-entry') 137 | .where(data['properties']['published'].get_text(0) => 138 | (start_date..end_date)) 139 | .order(order_by_published_desc) 140 | end 141 | 142 | def contexts(posts) 143 | posts = Array(posts) 144 | entry_type_property = { 145 | 'reply' => 'in-reply-to', 146 | 'repost' => 'repost-of', 147 | 'like' => 'like-of', 148 | 'rsvp' => 'in-reply-to' 149 | } 150 | urls = posts.map do |post| 151 | post.properties[entry_type_property[post.properties['entry-type'][0]]] 152 | end 153 | urls.flatten! 154 | 155 | cites = db[:posts] 156 | .where(data['type'].get_text(0) => 'h-cite') 157 | .where(data['properties']['url'].get_text(0) => urls) 158 | .order(data['properties']['published'].get_text(0)) 159 | .map{ |row| row_to_post(row) } 160 | end 161 | 162 | def webmention_counts(posts) 163 | post_urls = posts.map { |post| post.absolute_url } 164 | 165 | replies = db[:posts] 166 | .where(data['properties']['in-reply-to'].contain_any(post_urls)) 167 | .where(data['type'].get_text(0) => 'h-cite') 168 | .map { |row| row_to_post(row) } 169 | reposts = db[:posts] 170 | .where(data['properties']['repost-of'].contain_any(post_urls)) 171 | .where(data['type'].get_text(0) => 'h-cite') 172 | .map { |row| row_to_post(row) } 173 | likes = db[:posts] 174 | .where(data['properties']['like-of'].contain_any(post_urls)) 175 | .where(data['type'].get_text(0) => 'h-cite') 176 | .map { |row| row_to_post(row) } 177 | 178 | counts = {} 179 | posts.each do |post| 180 | counts[post.absolute_url] = { replies: 0, reposts: 0, likes: 0 } 181 | end 182 | replies.each do |reply| 183 | counts[reply.properties['in-reply-to'][0]][:replies] += 1 184 | end 185 | reposts.each do |repost| 186 | counts[repost.properties['repost-of'][0]][:reposts] += 1 187 | end 188 | likes.each do |like| 189 | counts[like.properties['like-of'][0]][:likes] += 1 190 | end 191 | counts 192 | end 193 | 194 | def webmentions(post) 195 | url = post.absolute_url 196 | db[:posts] 197 | .where(data['type'].get_text(0) => 'h-cite') 198 | .where( 199 | data['properties']['in-reply-to'].has_key?(url) | 200 | data['properties']['repost-of'].has_key?(url) | 201 | data['properties']['like-of'].has_key?(url) | 202 | data['properties']['mention-of'].has_key?(url) 203 | ) 204 | .order(data['properties']['published'].get_text(0)) 205 | .map{ |row| row_to_post(row) } 206 | end 207 | 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/transformative/card.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Card < Post 3 | 4 | def initialize(properties, url=nil) 5 | super(properties, url) 6 | end 7 | 8 | def h_type 9 | 'h-card' 10 | end 11 | 12 | def generate_url 13 | generate_url_slug('/card/') 14 | end 15 | 16 | end 17 | end -------------------------------------------------------------------------------- /lib/transformative/cite.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Cite < Post 3 | 4 | def initialize(properties, url=nil) 5 | super(properties, url) 6 | end 7 | 8 | def h_type 9 | 'h-cite' 10 | end 11 | 12 | def generate_url 13 | generate_url_slug('/cite/') 14 | end 15 | 16 | def webmention_type 17 | if @properties.key?('in-reply-to') 18 | 'Reply' 19 | elsif @properties.key?('repost-of') 20 | 'Repost' 21 | elsif @properties.key?('like-of') 22 | 'Like' 23 | else 24 | 'Mention' 25 | end 26 | end 27 | 28 | end 29 | end -------------------------------------------------------------------------------- /lib/transformative/context.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Context 3 | module_function 4 | 5 | def fetch_contexts(post) 6 | if post.properties.key?('in-reply-to') 7 | post.properties['in-reply-to'].each { |url| fetch(url) } 8 | elsif post.properties.key?('repost-of') 9 | post.properties['repost-of'].each { |url| fetch(url) } 10 | elsif post.properties.key?('like-of') 11 | post.properties['like-of'].each { |url| fetch(url) } 12 | end 13 | end 14 | 15 | def fetch(url) 16 | parsed = if url.match(/^https?:\/\/twitter\.com/) || 17 | url.match(/^https?:\/\/mobile\.twitter\.com/) 18 | parse_twitter(url) 19 | elsif url.match(/instagram\.com/) 20 | parse_instagram(url) 21 | else 22 | parse_mf2(url) 23 | end 24 | return if parsed.nil? 25 | # create author h-card 26 | unless parsed[1].nil? 27 | Store.save(parsed[1]) 28 | end 29 | # create h-cite 30 | Store.save(parsed[0]) 31 | end 32 | 33 | def parse_mf2(url) 34 | json = Microformats.parse(url).to_json 35 | items = JSON.parse(json)['items'] 36 | item = find_first_hentry_or_hevent(items) 37 | return if item.nil? 38 | # get author first 39 | author = Authorship.fetch(url) 40 | # construct entry 41 | properties = item['properties'] 42 | published = properties.key?('published') ? 43 | Time.parse(properties['published'][0]) : Time.now 44 | post_url = properties.key?('url') ? properties['url'][0] : url 45 | hash = { 46 | 'url' => [URI.join(url, post_url).to_s], 47 | 'published' => [published.utc.iso8601], 48 | 'content' => [properties['content'][0]['value']], 49 | 'author' => [author.properties['url'][0]] 50 | } 51 | if properties.key?('name') 52 | hash['name'] = [properties['name'][0].strip] 53 | end 54 | if properties.key?('photo') 55 | hash['photo'] = properties['photo'] 56 | end 57 | if properties.key?('start') 58 | hash['start'] = properties['start'] 59 | end 60 | if properties.key?('end') 61 | hash['end'] = properties['end'] 62 | end 63 | if properties.key?('location') 64 | hash['location'] = properties['location'] 65 | end 66 | cite = Cite.new(hash) 67 | [cite, author] 68 | end 69 | 70 | def parse_twitter(url) 71 | tweet_id = url.split('/').last 72 | tweet = twitter_client.status(tweet_id) 73 | cite_properties = { 74 | 'url' => [url], 75 | 'content' => [tweet.text.dup], 76 | 'author' => ["https://twitter.com/#{tweet.user.screen_name}"], 77 | 'published' => [Time.parse(tweet.created_at.to_s).utc] 78 | } 79 | # does the tweet have photo(s)? 80 | if tweet.media.any? 81 | cite_properties['photo'] = tweet.media.map { |m| m.media_url.to_s } 82 | end 83 | # replace t.co links with expanded versions 84 | tweet.urls.each do |u| 85 | cite_properties['content'][0].sub!(u.url.to_s, u.expanded_url.to_s) 86 | end 87 | cite = Cite.new(cite_properties) 88 | author_properties = { 89 | 'url' => ["https://twitter.com/#{tweet.user.screen_name}"], 90 | 'name' => [tweet.user.name], 91 | 'photo' => ["#{tweet.user.profile_image_url.scheme}://" + 92 | tweet.user.profile_image_url.host + 93 | tweet.user.profile_image_url.path] 94 | } 95 | # TODO: copy the photo somewhere else and reference it 96 | author = Card.new(author_properties) 97 | [cite, author] 98 | end 99 | 100 | def parse_instagram(url) 101 | url = tidy_instagram_url(url) 102 | json = HTTParty.get( 103 | "http://api.instagram.com/oembed?url=#{CGI::escape(url)}").body 104 | body = JSON.parse(json) 105 | cite_properties = { 106 | 'url' => [url], 107 | 'author' => [body['author_url']], 108 | 'photo' => [body['thumbnail_url']], 109 | 'content' => [body['title']] 110 | } 111 | cite = Cite.new(cite_properties) 112 | author_properties = { 113 | 'url' => [body['author_url']], 114 | 'name' => [body['author_name']] 115 | } 116 | author = Card.new(author_properties) 117 | [cite, author] 118 | end 119 | 120 | # strip cruft from URL, e.g. #liked-by-xxxx or modal=true from instagram 121 | def tidy_instagram_url(url) 122 | uri = URI.parse(url) 123 | uri.fragment = nil 124 | uri.query = nil 125 | uri.to_s 126 | end 127 | 128 | def find_first_hentry_or_hevent(items) 129 | items.extend Hashie::Extensions::DeepLocate 130 | found = items.deep_locate -> (k, v, o) {k == 'type' && (v == ['h-entry'] || v == ['h-event'])} 131 | found.first if found 132 | end 133 | 134 | def twitter_client 135 | Utils.twitter_client 136 | end 137 | 138 | end 139 | end -------------------------------------------------------------------------------- /lib/transformative/entry.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Entry < Post 3 | 4 | def initialize(properties, url=nil) 5 | super(properties, url) 6 | end 7 | 8 | def h_type 9 | 'h-entry' 10 | end 11 | 12 | def generate_url 13 | generate_url_published 14 | end 15 | 16 | def entry_type 17 | if @properties.key?('rsvp') && 18 | %w( yes no maybe interested ).include?(@properties['rsvp'][0]) 19 | 'rsvp' 20 | elsif @properties.key?('in-reply-to') && 21 | Utils.valid_url?(@properties['in-reply-to'][0]) 22 | 'reply' 23 | elsif @properties.key?('repost-of') && 24 | Utils.valid_url?(@properties['repost-of'][0]) 25 | 'repost' 26 | elsif @properties.key?('like-of') && 27 | Utils.valid_url?(@properties['like-of'][0]) 28 | 'like' 29 | elsif @properties.key?('video') && 30 | Utils.valid_url?(@properties['video'][0]) 31 | 'video' 32 | elsif @properties.key?('photo') && 33 | Utils.valid_url?(@properties['photo'][0]) 34 | 'photo' 35 | elsif @properties.key?('bookmark-of') && 36 | Utils.valid_url?(@properties['bookmark-of'][0]) 37 | 'bookmark' 38 | elsif @properties.key?('name') && !@properties['name'].empty? && 39 | !content_start_with_name? 40 | 'article' 41 | elsif @properties.key?('checkin') 42 | 'checkin' 43 | else 44 | 'note' 45 | end 46 | end 47 | 48 | def content_start_with_name? 49 | return unless @properties.key?('content') && @properties.key?('name') 50 | content = @properties['content'][0].is_a?(Hash) && 51 | @properties['content'][0].key?('html') ? 52 | @properties['content'][0]['html'] : @properties['content'][0] 53 | content_tidy = content.gsub(/\s+/, " ").strip 54 | name_tidy = @properties['name'][0].gsub(/\s+/, " ").strip 55 | content_tidy.start_with?(name_tidy) 56 | end 57 | 58 | def cite_belongs_to_post?(cite) 59 | property = case @properties['entry-type'][0] 60 | when 'reply', 'rsvp' 61 | 'in-reply-to' 62 | when 'repost' 63 | 'repost-of' 64 | when 'like' 65 | 'like-of' 66 | else 67 | return 68 | end 69 | @properties[property].include?(cite.properties['url'][0]) 70 | end 71 | 72 | def self.new_from_form(params) 73 | # wrap each non-array value in an array 74 | props = Hash[ params.map { |k, v| [k, Array(v)] } ] 75 | 76 | if props.key?('photo') 77 | props['photo'] = if params['photo'].is_a?(Array) 78 | Media.upload_files(params['photo']) 79 | else 80 | [Media.upload_file(params['photo'])] 81 | end 82 | end 83 | 84 | self.new(props) 85 | end 86 | 87 | end 88 | end -------------------------------------------------------------------------------- /lib/transformative/event.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Event < Post 3 | 4 | def initialize(properties, url=nil) 5 | super(properties, url) 6 | end 7 | 8 | def h_type 9 | 'h-event' 10 | end 11 | 12 | def generate_url 13 | generate_url_published 14 | end 15 | 16 | end 17 | end -------------------------------------------------------------------------------- /lib/transformative/file_system.rb: -------------------------------------------------------------------------------- 1 | # for local development and testing, fake the github api 2 | module Transformative 3 | class FileSystem 4 | 5 | def contents(repo, opts) 6 | path = File.join(content_path, opts[:path]) 7 | begin 8 | content = File.read(path) 9 | rescue 10 | return 11 | end 12 | content_encoded = Base64.encode64(content) 13 | OpenStruct.new({ 14 | 'content' => content_encoded, 15 | 'sha' => 'fake_sha' 16 | }) 17 | end 18 | 19 | def create_contents(repo, filename, message, content) 20 | path = File.join(content_path, filename) 21 | FileUtils.mkdir_p(File.dirname(path)) 22 | File.write(path, content) 23 | end 24 | 25 | def update_contents(repo, filename, message, sha, content) 26 | create_contents(repo, filename, message, content) 27 | end 28 | 29 | def upload(filename, file) 30 | path = File.join(content_path, filename) 31 | FileUtils.mkdir_p(File.dirname(path)) 32 | File.write(path, file) 33 | end 34 | 35 | def content_path 36 | "#{File.dirname(__FILE__)}/../../../content" 37 | end 38 | 39 | end 40 | end -------------------------------------------------------------------------------- /lib/transformative/media.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Media 3 | module_function 4 | 5 | def save(file) 6 | filename = "#{Time.now.strftime('%Y/%m/%d')}-#{SecureRandom.hex.to_s}" 7 | ext = file[:filename].match(/\./) ? '.' + 8 | file[:filename].split('.').last : ".jpg" 9 | filepath = "file/#{filename}#{ext}" 10 | content = file[:tempfile].read 11 | 12 | if ENV['RACK_ENV'] == 'production' 13 | # upload to github (canonical store) 14 | Store.upload(filepath, content) 15 | # upload to s3 (serves file) 16 | s3_upload(filepath, content, ext, file[:type]) 17 | else 18 | rootpath = "#{File.dirname(__FILE__)}/../../../content/" 19 | FileSystem.new.upload(rootpath + filepath, content) 20 | end 21 | 22 | URI.join(ENV['MEDIA_URL'], filepath).to_s 23 | end 24 | 25 | def upload_files(files) 26 | files.map do |file| 27 | upload_file(file) 28 | end 29 | end 30 | 31 | def upload_file(file) 32 | if Utils.valid_url?(file) 33 | # TODO extract file from url and store? 34 | file 35 | else 36 | save(file) 37 | end 38 | end 39 | 40 | def s3_upload(filepath, content, ext, content_type) 41 | object = bucket.objects.build(filepath) 42 | object.content = content 43 | object.content_type = content_type 44 | object.acl = :public_read 45 | object.save 46 | end 47 | 48 | def s3 49 | @s3 ||= S3::Service.new( 50 | access_key_id: ENV['AWS_ACCESS_KEY_ID'], 51 | secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] 52 | ) 53 | end 54 | 55 | def bucket 56 | @bucket ||= s3.bucket(ENV['AWS_BUCKET']) 57 | end 58 | 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/transformative/micropub.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Micropub 3 | module_function 4 | 5 | def create(params) 6 | if params.key?('h') 7 | safe_properties = sanitise_properties(params) 8 | # TODO support other types? 9 | post = Entry.new_from_form(safe_properties) 10 | services = params.key?('mp-syndicate-to') ? 11 | Array(params['mp-syndicate-to']) : [] 12 | else 13 | check_if_syndicated(params['properties']) 14 | safe_properties = sanitise_properties(params['properties']) 15 | klass = Post.class_from_type(params['type'][0]) 16 | post = klass.new(safe_properties) 17 | services = params['properties'].key?('mp-syndicate-to') ? 18 | params['properties']['mp-syndicate-to'] : [] 19 | end 20 | 21 | post.set_slug(params) 22 | post.syndicate(services) if services.any? 23 | Store.save(post) 24 | end 25 | 26 | def action(properties) 27 | post = Store.get_url(properties['url']) 28 | 29 | case properties['action'].to_sym 30 | when :update 31 | if properties.key?('replace') 32 | verify_hash(properties, 'replace') 33 | post.replace(properties['replace']) 34 | end 35 | if properties.key?('add') 36 | verify_hash(properties, 'add') 37 | post.add(properties['add']) 38 | end 39 | if properties.key?('delete') 40 | verify_array_or_hash(properties, 'delete') 41 | post.remove(properties['delete']) 42 | end 43 | when :delete 44 | post.delete 45 | when :undelete 46 | post.undelete 47 | end 48 | 49 | if properties.key?('mp-syndicate-to') && properties['mp-syndicate-to'].any? 50 | post.syndicate(properties['mp-syndicate-to']) 51 | end 52 | post.set_updated 53 | Store.save(post) 54 | end 55 | 56 | def verify_hash(properties, key) 57 | unless properties[key].is_a?(Hash) 58 | raise InvalidRequestError.new( 59 | "Invalid request: the '#{key}' property should be a hash.") 60 | end 61 | end 62 | 63 | def verify_array_or_hash(properties, key) 64 | unless properties[key].is_a?(Array) || properties[key].is_a?(Hash) 65 | raise InvalidRequestError.new( 66 | "Invalid request: the '#{key}' property should be an array or hash.") 67 | end 68 | end 69 | 70 | # has this post already been syndicated, perhaps via a pesos method? 71 | def check_if_syndicated(properties) 72 | if properties.key?('syndication') && 73 | Cache.find_via_syndication(properties['syndication']).any? 74 | raise ConflictError.new 75 | end 76 | end 77 | 78 | def sanitise_properties(properties) 79 | Hash[ 80 | properties.map { |k, v| 81 | unless k.start_with?('mp-') || k == 'access_token' || k == 'h' || 82 | k == 'syndicate-to' 83 | [k, v] 84 | end 85 | }.compact 86 | ] 87 | end 88 | 89 | class ForbiddenError < TransformativeError 90 | def initialize(message="The authenticated user does not have permission to perform this request.") 91 | super("forbidden", message, 403) 92 | end 93 | end 94 | 95 | class InsufficientScopeError < TransformativeError 96 | def initialize(message="The scope of this token does not meet the requirements for this request.") 97 | super("insufficient_scope", message, 401) 98 | end 99 | end 100 | 101 | class InvalidRequestError < TransformativeError 102 | def initialize(message="The request is missing a required parameter, or there was a problem with a value of one of the parameters.") 103 | super("invalid_request", message, 400) 104 | end 105 | end 106 | 107 | class NotFoundError < TransformativeError 108 | def initialize(message="The post with the requested URL was not found.") 109 | super("not_found", message, 400) 110 | end 111 | end 112 | 113 | class ConflictError < TransformativeError 114 | def initialize( 115 | message="The post has already been created and syndicated.") 116 | super("conflict", message, 409) 117 | end 118 | end 119 | 120 | end 121 | end 122 | 123 | -------------------------------------------------------------------------------- /lib/transformative/notification.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Notification 3 | module_function 4 | 5 | def send(title, message, url) 6 | if ENV.key?('PUSHOVER_USER') && ENV.key?('PUSHOVER_TOKEN') 7 | pushover(title, message, url) 8 | end 9 | end 10 | 11 | def pushover(title, message, url) 12 | response = HTTParty.post('https://api.pushover.net/1/messages.json', { 13 | body: { 14 | token: ENV['PUSHOVER_TOKEN'], 15 | user: ENV['PUSHOVER_USER'], 16 | title: title, 17 | message: message, 18 | url: url 19 | } 20 | }) 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/transformative/post.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Post 3 | 4 | attr_reader :properties, :url 5 | 6 | def initialize(properties, url=nil) 7 | @properties = properties 8 | @url = url 9 | end 10 | 11 | def data 12 | { 'type' => [h_type], 'properties' => @properties } 13 | end 14 | 15 | def filename 16 | "#{url}.json" 17 | end 18 | 19 | def slug 20 | @slug ||= slugify 21 | end 22 | 23 | def url 24 | @url ||= generate_url 25 | end 26 | 27 | def absolute_url 28 | URI.join(ENV['SITE_URL'], url).to_s 29 | end 30 | 31 | def is_deleted? 32 | @properties.key?('deleted') && 33 | Time.parse(@properties['deleted'][0]) < Time.now 34 | end 35 | 36 | def content 37 | if @properties.key?('content') 38 | if @properties['content'][0].is_a?(Hash) && 39 | @properties['content'][0].key?('html') 40 | @properties['content'][0]['html'] 41 | else 42 | @properties['content'][0] 43 | end 44 | elsif @properties.key?('summary') 45 | @properties['summary'][0] 46 | end 47 | end 48 | 49 | def generate_url_published 50 | unless @properties.key?('published') 51 | @properties['published'] = [Time.now.utc.iso8601] 52 | end 53 | "/#{Time.parse(@properties['published'][0]).strftime('%Y/%m')}/#{slug}" 54 | end 55 | 56 | def generate_url_slug(prefix='/') 57 | slugify_url = Utils.slugify_url(@properties['url'][0]) 58 | "#{prefix}#{slugify_url}" 59 | end 60 | 61 | def slugify 62 | content = if @properties.key?('name') 63 | @properties['name'][0] 64 | elsif @properties.key?('summary') 65 | @properties['summary'][0] 66 | elsif @properties.key?('content') 67 | if @properties['content'][0].is_a?(Hash) && 68 | @properties['content'][0].key?('html') 69 | @properties['content'][0]['html'] 70 | else 71 | @properties['content'][0] 72 | end 73 | end 74 | return Time.now.utc.strftime('%d-%H%M%S') if content.nil? 75 | 76 | content.downcase.gsub(/[^\w-]/, ' ').strip.gsub(' ', '-'). 77 | gsub(/[-_]+/,'-').split('-')[0..5].join('-') 78 | end 79 | 80 | def replace(props) 81 | props.keys.each do |prop| 82 | @properties[prop] = props[prop] 83 | end 84 | end 85 | 86 | def add(props) 87 | props.keys.each do |prop| 88 | unless @properties.key?(prop) 89 | @properties[prop] = props[prop] 90 | else 91 | @properties[prop] += props[prop] 92 | end 93 | end 94 | end 95 | 96 | def remove(props) 97 | if props.is_a?(Hash) 98 | props.keys.each do |prop| 99 | @properties[prop] -= props[prop] 100 | if @properties[prop].empty? 101 | @properties.delete(prop) 102 | end 103 | end 104 | else 105 | props.each do |prop| 106 | @properties.delete(prop) 107 | end 108 | end 109 | end 110 | 111 | def delete 112 | @properties['deleted'] = [Time.now.utc.iso8601] 113 | end 114 | 115 | def undelete 116 | @properties.delete('deleted') 117 | end 118 | 119 | def set_updated 120 | @properties['updated'] = [Time.now.utc.iso8601] 121 | end 122 | 123 | def set_slug(params) 124 | if params.key?('properties') 125 | return unless params['properties'].key?('mp-slug') 126 | mp_slug = params['properties']['mp-slug'][0] 127 | else 128 | return unless params.key?('mp-slug') 129 | mp_slug = params['mp-slug'] 130 | end 131 | @slug = mp_slug.strip.downcase.gsub(/[^\w-]/, '-') 132 | end 133 | 134 | def syndicate(services) 135 | # only syndicate if this is an entry or event 136 | return unless ['h-entry','h-event'].include?(h_type) 137 | 138 | # iterate over the mp-syndicate-to services 139 | new_syndications = services.map { |service| 140 | # have we already syndicated to this service? 141 | unless @properties.key?('syndication') && 142 | @properties['syndication'].map { |s| 143 | s.start_with?(service) 144 | }.include?(true) 145 | Syndication.send(self, service) 146 | end 147 | }.compact 148 | 149 | return if new_syndications.empty? 150 | # add to syndication list 151 | @properties['syndication'] ||= [] 152 | @properties['syndication'] += new_syndications 153 | end 154 | 155 | def self.class_from_type(type) 156 | case type 157 | when 'h-card' 158 | Card 159 | when 'h-cite' 160 | Cite 161 | when 'h-entry' 162 | Entry 163 | when 'h-event' 164 | Event 165 | end 166 | end 167 | 168 | def self.valid_types 169 | %w( h-card h-cite h-entry h-event ) 170 | end 171 | 172 | end 173 | end -------------------------------------------------------------------------------- /lib/transformative/redis.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Redis 3 | module_function 4 | 5 | def client 6 | raise "No REDIS_URL environment variable was found." unless ENV.key?('REDIS_URL') 7 | @client ||= ::Redis.new(url: ENV['REDIS_URL']) 8 | end 9 | 10 | def set(key, value) 11 | json = value.to_json 12 | client.set(key, json) 13 | end 14 | 15 | def get(key) 16 | data = client.get(key) 17 | return unless data 18 | begin 19 | JSON.parse(data) 20 | rescue JSON::ParserError 21 | end 22 | end 23 | 24 | def expire(key, seconds) 25 | client.expire(key, seconds) 26 | end 27 | 28 | end 29 | end -------------------------------------------------------------------------------- /lib/transformative/server.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | class Server < Sinatra::Application 3 | helpers Sinatra::LinkHeader 4 | helpers ViewHelper 5 | 6 | configure do 7 | use Rack::SSL if settings.production? 8 | 9 | root_path = "#{File.dirname(__FILE__)}/../../" 10 | set :config_path, "#{root_path}config/" 11 | set :syndication_targets, 12 | JSON.parse(File.read("#{settings.config_path}syndication_targets.json")) 13 | set :markdown, layout_engine: :erb 14 | set :server, :puma 15 | end 16 | 17 | before do 18 | headers \ 19 | "Referrer-Policy" => "no-referrer", 20 | "Content-Security-Policy" => "script-src 'self'" 21 | end 22 | 23 | get '/' do 24 | @posts_rows = Cache.stream(%w( note article photo repost ), 25 | params[:page] || 1) 26 | @show_announcement = params.fetch(:page, 1).to_i == 1 27 | index_page 28 | end 29 | 30 | get '/all' do 31 | @posts_rows = Cache.stream_all(params[:page] || 1) 32 | @title = "All" 33 | index_page 34 | end 35 | 36 | get %r{/tags?/([A-Za-z0-9\-\+]+)/?} do |tags| 37 | tags.downcase! 38 | @title = "Tagged ##{tags.split('+').join(' #')}" 39 | @page_title = @title 40 | @posts_rows = Cache.stream_tagged(tags, params[:page] || 1) 41 | index_page 42 | end 43 | 44 | get %r{/(note|article|bookmark|photo|checkin|repost|like|replie)s/?} do |type| 45 | @title = "#{type}s".capitalize 46 | type = 'reply' if type == 'replie' 47 | @posts_rows = Cache.stream([type], params[:page] || 1) 48 | index_page 49 | end 50 | 51 | get %r{/([0-9]{4})/([0-9]{2})/?} do |y, m| 52 | @posts_rows = Cache.stream_all_by_month(y, m) 53 | @title = "#{Date::MONTHNAMES[m.to_i]} #{y}" 54 | @page_title = @title 55 | index_page 56 | end 57 | 58 | get %r{/([0-9]{4})/([0-9]{2})/([a-z0-9-]+)/?} do |y, m, slug| 59 | url = "/#{y}/#{m}/#{slug}" 60 | @post = Cache.get(url) 61 | return not_found if @post.nil? 62 | return deleted if @post.is_deleted? 63 | @title = page_title(@post) 64 | @webmentions = Cache.webmentions(@post) 65 | @contexts = Cache.contexts(@post) 66 | @authors = Cache.authors_from_cites(@webmentions, @contexts) 67 | @authors.merge!(Cache.authors_from_categories(@post)) 68 | @post_page = true 69 | link "#{ENV['SITE_URL']}webmention", rel: 'webmention' 70 | cache_unless_new 71 | if @post.h_type == 'h-entry' 72 | erb :entry 73 | else 74 | erb :event 75 | end 76 | end 77 | 78 | get %r{/([0-9]{4})/([0-9]{2})/([a-z0-9-]+)\.json} do |y, m, slug| 79 | url = "/#{y}/#{m}/#{slug}" 80 | json = Cache.get_json(url) 81 | etag Digest::SHA1.hexdigest(json) 82 | cache_control :s_maxage => 300, :max_age => 600 83 | content_type :json, charset: 'utf-8' 84 | json 85 | end 86 | 87 | get %r{/(index|posts|rss|feed)(\.xml)?} do 88 | posts_rows = Cache.stream(%w( note article photo ), 1) 89 | @posts = posts_rows.map { |row| Cache.row_to_post(row) } 90 | xml = builder :rss 91 | etag Digest::SHA1.hexdigest(xml) 92 | cache_control :s_maxage => 300, :max_age => 600 93 | content_type :xml 94 | xml 95 | end 96 | 97 | get '/feed.json' do 98 | posts_rows = Cache.stream(%w( note article photo ), 1) 99 | posts = posts_rows.map { |row| Cache.row_to_post(row) } 100 | json = jsonfeed(posts) 101 | etag Digest::SHA1.hexdigest(json) 102 | cache_control :s_maxage => 300, :max_age => 600 103 | content_type :json, charset: 'utf-8' 104 | json 105 | end 106 | 107 | get '/archives/?' do 108 | posts = Cache.stream_all(1, 99999).map { |row| Cache.row_to_post(row) } 109 | year = 0 110 | month = 0 111 | months_content = "" 112 | @content = "
" 113 | posts.each do |post| 114 | published = Time.parse(post.properties['published'][0]) 115 | if published.strftime('%Y') != year 116 | year = published.strftime('%Y') 117 | @content += "#{months_content}\n
#{year}
\n" 118 | months_content = "" 119 | end 120 | if published.strftime('%m') != month 121 | month = published.strftime('%m') 122 | mon = Date::MONTHNAMES[month.to_i][0...3] 123 | months_content = "
#{mon}
\n" + 124 | months_content 125 | end 126 | end 127 | @content += "#{months_content}\n
" 128 | @title = "Archives" 129 | erb :static 130 | end 131 | 132 | # legacy redirects from old sites (baker) 133 | get %r{/posts/([0-9]+)/?} do |baker_id| 134 | post = Cache.get_first_by_slug("baker-#{baker_id}") 135 | if post.nil? 136 | not_found 137 | else 138 | redirect post.url, 301 139 | end 140 | end 141 | get %r{/([0-9]{1,3})/?} do |baker_id| 142 | post = Cache.get_first_by_slug("baker-#{baker_id}") 143 | if post.nil? 144 | not_found 145 | else 146 | redirect post.url, 301 147 | end 148 | end 149 | get %r{/articles/([a-z0-9-]+)/?} do |slug| 150 | post = Cache.get_first_by_slug(slug) 151 | not_found if post.nil? 152 | redirect post.url, 301 153 | end 154 | get '/feed' do 155 | redirect '/rss', 302 156 | end 157 | get '/posts' do 158 | redirect '/', 301 159 | end 160 | get '/about' do 161 | redirect '/2015/01/about', 302 162 | end 163 | get '/colophon' do 164 | redirect '/2016/11/colophon', 302 165 | end 166 | get '/2015/01/colophon' do 167 | redirect '/2016/11/colophon', 302 168 | end 169 | get '/contact' do 170 | redirect '/2015/01/contact', 302 171 | end 172 | 173 | post '/webhook' do 174 | puts "Webhook params=#{params}" 175 | return not_found unless params.key?('commits') 176 | commits = params[:commits] 177 | 178 | request.body.rewind 179 | Auth.verify_github_signature(request.body.read, 180 | request.env['HTTP_X_HUB_SIGNATURE']) 181 | 182 | Store.webhook(commits) 183 | status 204 184 | end 185 | 186 | post '/micropub' do 187 | puts "Micropub params=#{params}" 188 | # start by assuming this is a non-create action 189 | if params.key?('action') 190 | verify_action 191 | require_auth 192 | verify_url 193 | post = Micropub.action(params) 194 | status 204 195 | elsif params.key?('file') 196 | # assume this a file (photo) upload 197 | require_auth 198 | url = Media.save(params[:file]) 199 | headers 'Location' => url 200 | status 201 201 | else 202 | # assume this is a create 203 | require_auth 204 | verify_create 205 | post = Micropub.create(params) 206 | headers 'Location' => post.absolute_url 207 | status 202 208 | end 209 | end 210 | 211 | get '/micropub' do 212 | if params.key?('q') 213 | require_auth 214 | content_type :json 215 | case params[:q] 216 | when 'source' 217 | render_source 218 | when 'config' 219 | render_config 220 | when 'syndicate-to' 221 | render_syndication_targets 222 | else 223 | # Silently fail if query method is not supported 224 | end 225 | else 226 | 'Micropub endpoint' 227 | end 228 | end 229 | 230 | get '/webmention' do 231 | "Webmention endpoint" 232 | end 233 | 234 | post '/webmention' do 235 | puts "Webmention params=#{params}" 236 | Webmention.receive(params[:source], params[:target]) 237 | headers 'Location' => params[:target] 238 | status 202 239 | end 240 | 241 | get '/cv' do 242 | redirect ENV['CV_URL'] 243 | end 244 | 245 | not_found do 246 | status 404 247 | erb :'404' 248 | end 249 | 250 | error TransformativeError do 251 | e = env['sinatra.error'] 252 | json = { 253 | error: e.type, 254 | error_description: e.message 255 | }.to_json 256 | halt(e.status, { 'Content-Type' => 'application/json' }, json) 257 | end 258 | 259 | error do 260 | erb :'500', layout: false 261 | end 262 | 263 | def deleted 264 | status 410 265 | erb :'410' 266 | end 267 | 268 | private 269 | 270 | def index_page 271 | not_found if @posts_rows.nil? || @posts_rows.empty? 272 | cache_control :s_maxage => 300, :max_age => 600 273 | @posts = @posts_rows.map { |row| Cache.row_to_post(row) } 274 | @contexts = Cache.contexts(@posts) 275 | @authors = Cache.authors_from_cites(@contexts) 276 | @authors.merge!(Cache.authors_from_categories(@posts)) 277 | @webmention_counts = Cache.webmention_counts(@posts) 278 | @footer = true 279 | erb :index 280 | end 281 | 282 | def require_auth 283 | return unless settings.production? 284 | token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || "" 285 | token.sub!(/^Bearer /,'') 286 | if token.empty? 287 | raise Auth::NoTokenError.new 288 | end 289 | scope = params.key?('action') ? params['action'] : 'post' 290 | Auth.verify_token_and_scope(token, scope) 291 | end 292 | 293 | def verify_create 294 | if params.key?('h') && Post.valid_types.include?("h-#{params[:h]}") 295 | return 296 | elsif params.key?('type') && Post.valid_types.include?(params[:type][0]) 297 | return 298 | else 299 | raise Micropub::InvalidRequestError.new( 300 | "You must specify a Microformats 'h-' type to create a new post. " + 301 | "Valid post types are: #{Post.valid_types.join(' ')}." 302 | ) 303 | end 304 | end 305 | 306 | def verify_action 307 | valid_actions = %w( create update delete undelete ) 308 | unless valid_actions.include?(params[:action]) 309 | raise Micropub::InvalidRequestError.new( 310 | "The specified action ('#{params[:action]}') is not supported. " + 311 | "Valid actions are: #{valid_actions.join(' ')}." 312 | ) 313 | end 314 | end 315 | 316 | def verify_url 317 | unless params.key?('url') && !params[:url].empty? && 318 | Store.exists_url?(params[:url]) 319 | raise Micropub::InvalidRequestError.new( 320 | "The specified URL ('#{params[:url]}') could not be found." 321 | ) 322 | end 323 | end 324 | 325 | def render_syndication_targets 326 | content_type :json 327 | { "syndicate-to" => settings.syndication_targets }.to_json 328 | end 329 | 330 | def render_config 331 | content_type :json 332 | { 333 | "media-endpoint" => "#{ENV['SITE_URL']}micropub", 334 | "syndicate-to" => settings.syndication_targets 335 | }.to_json 336 | end 337 | 338 | def render_source 339 | content_type :json, charset: 'utf-8' 340 | return render_source_post if params.key?('url') 341 | types = %w(note article bookmark photo checkin repost like reply) 342 | posts_rows = if params.key?('post-type') && types.include?(params['post-type']) 343 | Cache.stream([params['post-type']], params[:page] || 1) 344 | else 345 | Cache.stream_all(params[:page] || 1) 346 | end 347 | items = posts_rows.to_a.map do |post| 348 | { 349 | url: post[:url], 350 | type: post[:data]['type'], 351 | properties: post[:data]['properties'] 352 | } 353 | end 354 | { items: items }.to_json 355 | end 356 | 357 | def render_source_post 358 | verify_url 359 | relative_url = Utils.relative_url(params[:url]) 360 | not_found unless post = Store.get("#{relative_url}.json") 361 | data = if params.key?('properties') 362 | properties = {} 363 | Array(params[:properties]).each do |property| 364 | if post.properties.key?(property) 365 | properties[property] = post.properties[property] 366 | end 367 | end 368 | { 'type' => [post.h_type], 'properties' => properties } 369 | else 370 | post.data 371 | end 372 | data.to_json 373 | end 374 | 375 | # don't cache posts for the first 10 mins (to allow editing) 376 | def cache_unless_new 377 | published = Time.parse(@post.properties['published'][0]) 378 | if Time.now - published > 600 379 | cache_control :s_maxage => 300, :max_age => 600 380 | else 381 | cache_control :max_age => 0 382 | end 383 | end 384 | end 385 | end 386 | -------------------------------------------------------------------------------- /lib/transformative/store.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Store 3 | module_function 4 | 5 | def save(post) 6 | # ensure entry posts always have an entry-type 7 | if post.h_type == 'h-entry' 8 | post.properties['entry-type'] ||= [post.entry_type] 9 | end 10 | # trim initial slash from path (if exists) 11 | filename = post.filename.start_with?('/') ? post.filename[1..-1] : post.filename 12 | put(filename, post.data) 13 | post 14 | end 15 | 16 | def put(filename, data) 17 | puts "put: filename=#{filename}" 18 | content = JSON.pretty_generate(data) 19 | if existing = get_file(filename) 20 | unless Base64.decode64(existing['content']) == content 21 | update(existing['sha'], filename, content) 22 | end 23 | else 24 | create(filename, content) 25 | end 26 | end 27 | 28 | def create(filename, content) 29 | octokit.create_contents( 30 | github_full_repo, 31 | filename, 32 | "Adding new post using Transformative", 33 | content 34 | ) 35 | end 36 | 37 | def update(sha, filename, content) 38 | octokit.update_contents( 39 | github_full_repo, 40 | filename, 41 | "Updating post using Transformative", 42 | sha, 43 | content 44 | ) 45 | end 46 | 47 | def upload(filename, file) 48 | octokit.create_contents( 49 | github_full_repo, 50 | filename, 51 | "Adding new file using Transformative", 52 | file 53 | ) 54 | end 55 | 56 | def get(filename) 57 | file_content = get_file_content(filename) 58 | data = JSON.parse(file_content) 59 | url = filename.sub(/\.json$/, '') 60 | klass = Post.class_from_type(data['type'][0]) 61 | klass.new(data['properties'], url) 62 | end 63 | 64 | def get_url(url) 65 | relative_url = Utils.relative_url(url) 66 | get("#{relative_url}.json") 67 | end 68 | 69 | def exists_url?(url) 70 | relative_url = Utils.relative_url(url) 71 | get_file("#{relative_url}.json") != nil 72 | end 73 | 74 | def get_file(filename) 75 | begin 76 | octokit.contents(github_full_repo, { path: filename }) 77 | rescue Octokit::NotFound 78 | end 79 | end 80 | 81 | def get_file_content(filename) 82 | base64_content = octokit.contents( 83 | github_full_repo, 84 | { path: filename } 85 | ).content 86 | Base64.decode64(base64_content) 87 | end 88 | 89 | def webhook(commits) 90 | commits.each do |commit| 91 | process_files(commit['added']) if commit['added'].any? 92 | process_files(commit['modified'], true) if commit['modified'].any? 93 | end 94 | end 95 | 96 | def process_files(files, modified=false) 97 | files.each do |file| 98 | file_content = get_file_content(file) 99 | url = "/" + file.sub(/\.json$/,'') 100 | data = JSON.parse(file_content) 101 | klass = Post.class_from_type(data['type'][0]) 102 | post = klass.new(data['properties'], url) 103 | 104 | if %w( h-entry h-event ).include?(data['type'][0]) 105 | if modified 106 | existing_webmention_client = 107 | ::Webmention::Client.new(post.absolute_url) 108 | begin 109 | existing_webmention_client.mentioned_urls 110 | rescue OpenURI::HTTPError 111 | end 112 | Cache.put(post) 113 | existing_webmention_client.send_all_mentions 114 | else 115 | Cache.put(post) 116 | end 117 | ::Webmention::Client.new(post.absolute_url).send_all_mentions 118 | Utils.ping_pubsubhubbub 119 | Context.fetch_contexts(post) 120 | else 121 | Cache.put(post) 122 | end 123 | end 124 | end 125 | 126 | def github_full_repo 127 | "#{ENV['GITHUB_USER']}/#{ENV['GITHUB_REPO']}" 128 | end 129 | 130 | def octokit 131 | @octokit ||= case (ENV['RACK_ENV'] || 'development').to_sym 132 | when :production 133 | Octokit::Client.new(access_token: ENV['GITHUB_ACCESS_TOKEN']) 134 | else 135 | FileSystem.new 136 | end 137 | end 138 | 139 | end 140 | 141 | class StoreError < TransformativeError 142 | def initialize(message) 143 | super("store", message) 144 | end 145 | end 146 | 147 | end -------------------------------------------------------------------------------- /lib/transformative/syndication.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Syndication 3 | module_function 4 | 5 | def send(post, service) 6 | if service.start_with?('https://twitter.com/') 7 | send_twitter(post) 8 | elsif service.start_with?('https://pinboard.in/') 9 | send_pinboard(post) 10 | end 11 | end 12 | 13 | def send_pinboard(post) 14 | # no person-tags (or other urls) 15 | tags = if post.properties.key?('category') 16 | post.properties['category'].map { |tag| 17 | tag unless Utils.valid_url?(tag) 18 | }.compact.join(',') 19 | else 20 | "" 21 | end 22 | opts = { 23 | 'auth_token' => ENV['PINBOARD_AUTH_TOKEN'], 24 | 'url' => post.properties['bookmark-of'][0], 25 | 'description' => post.properties['name'][0], 26 | 'extended' => post.content, 27 | 'tags' => tags, 28 | 'dt' => post.properties.key?('published') ? 29 | post.properties['published'][0] : Time.now.utc.iso8601 30 | } 31 | pinboard_url = "https://api.pinboard.in/v1/posts/add" 32 | HTTParty.get(pinboard_url, query: opts) 33 | return 34 | end 35 | 36 | def send_twitter(post) 37 | # we can only send entries to twitter so ignore anything else 38 | # TODO syndicate other objects 39 | return unless post.h_type == 'h-entry' 40 | 41 | case post.entry_type 42 | when 'repost' 43 | Twitter.retweet(post.properties['repost-of']) 44 | when 'like' 45 | Twitter.favorite(post.properties['like-of']) 46 | else 47 | Twitter.update(post) 48 | end 49 | end 50 | 51 | end 52 | 53 | class SyndicationError < TransformativeError 54 | def initialize(message) 55 | super("syndication", message) 56 | end 57 | end 58 | 59 | end -------------------------------------------------------------------------------- /lib/transformative/twitter.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Twitter 3 | module_function 4 | 5 | TWITTER_STATUS_REGEX = 6 | /^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)$/ 7 | 8 | def update(post) 9 | status = get_status(post) 10 | return if status.empty? 11 | 12 | options = {} 13 | if reply_tweet_id = get_reply(post) 14 | options[:in_reply_to_status_id] = reply_tweet_id 15 | end 16 | if media = get_media(post) 17 | media_ids = media.map { |file| client.upload(file) } 18 | options[:media_ids] = media_ids.join(',') 19 | end 20 | 21 | tweet = client.update(status, options) 22 | "https://twitter.com/#{tweet.user.screen_name}/status/#{tweet.id}" 23 | end 24 | 25 | def get_status(post) 26 | # prioritise summary, name (+ url) then content 27 | # TODO: ellipsize 28 | if post.properties.key?('summary') 29 | post.properties['summary'][0] 30 | elsif post.properties.key?('name') 31 | "#{post.properties['name'][0]}: #{post.absolute_url}" 32 | elsif post.properties.key?('content') 33 | if post.properties['content'][0].is_a?(Hash) && 34 | post.properties['content'][0].key?('html') 35 | Sanitize.fragment(post.properties['content'][0]['html']).strip 36 | else 37 | post.properties['content'][0] 38 | end 39 | else 40 | "" 41 | end 42 | end 43 | 44 | def get_reply(post) 45 | # use first twitter url from in-reply-to list 46 | if post.properties.key?('in-reply-to') && 47 | post.properties['in-reply-to'].is_a?(Array) 48 | post.properties['in-reply-to'].each do |url| 49 | if tweet_id = tweet_id_from_url(url) 50 | return tweet_id 51 | end 52 | end 53 | end 54 | end 55 | 56 | def get_media(post) 57 | if post.properties.key?('photo') && 58 | post.properties['photo'].is_a?(Array) 59 | post.properties['photo'].map do |photo| 60 | if photo.is_a?(Hash) 61 | open(photo['value']) 62 | else 63 | open(photo) 64 | end 65 | end 66 | end 67 | end 68 | 69 | def retweet(urls) 70 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 71 | tweet = client.retweet(tweet_id) 72 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 73 | end 74 | 75 | def favorite(urls) 76 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 77 | tweet = client.favorite(tweet_id) 78 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 79 | end 80 | 81 | def tweet_id_from_url(url) 82 | return unless tweet_parts = url.match(TWITTER_STATUS_REGEX) 83 | tweet_parts[3] 84 | end 85 | 86 | def find_first_tweet_id_from_urls(urls) 87 | urls.each do |url| 88 | if tweet_id = tweet_id_from_url(url) 89 | return tweet_id 90 | end 91 | end 92 | end 93 | 94 | def client 95 | Utils.twitter_client 96 | end 97 | 98 | end 99 | end -------------------------------------------------------------------------------- /lib/transformative/utils.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Utils 3 | module_function 4 | 5 | def valid_url?(url) 6 | begin 7 | uri = URI.parse(url) 8 | uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) 9 | rescue URI::InvalidURIError 10 | end 11 | end 12 | 13 | def slugify_url(url) 14 | url.to_s.downcase.gsub(/[^a-z0-9\-]/,' ').strip.gsub(/\s+/,'/') 15 | end 16 | 17 | def relative_url(url) 18 | url.sub!(ENV['SITE_URL'], '') 19 | url.start_with?('/') ? url : "/#{url}" 20 | end 21 | 22 | def ping_pubsubhubbub 23 | HTTParty.post(ENV['PUBSUBHUBBUB_HUB'], { 24 | body: { 25 | "hub.mode": "publish", 26 | "hub.url": ENV['SITE_URL'] 27 | } 28 | }) 29 | end 30 | 31 | def twitter_client 32 | @twitter_client ||= ::Twitter::REST::Client.new do |config| 33 | config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] 34 | config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] 35 | config.access_token = ENV['TWITTER_ACCESS_TOKEN'] 36 | config.access_token_secret = ENV['TWITTER_ACCESS_TOKEN_SECRET'] 37 | end 38 | end 39 | 40 | end 41 | end -------------------------------------------------------------------------------- /lib/transformative/view_helper.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Transformative 4 | module ViewHelper 5 | 6 | def h(text) 7 | Rack::Utils.escape_html(text) 8 | end 9 | 10 | def filter_markdown(content) 11 | return "" if content.nil? 12 | Redcarpet::Render::SmartyPants.render( 13 | Redcarpet::Markdown.new( 14 | Redcarpet::Render::HTML, autolink: true 15 | ).render(content) 16 | ) 17 | end 18 | 19 | def post_type_icon(post) 20 | icon = case post.properties['entry-type'][0] 21 | when 'article' 22 | "file-text" 23 | when 'bookmark' 24 | "bookmark" 25 | when 'photo' 26 | "camera" 27 | when 'reply' 28 | "reply" 29 | when 'repost' 30 | "retweet" 31 | when 'like' 32 | "heart" 33 | when 'rsvp' 34 | if post.properties.key?('rsvp') 35 | case post.properties['rsvp'][0] 36 | when 'yes', true 37 | 'calendar-check-o' 38 | when 'no', false 39 | 'calendar-times-o' 40 | else 41 | 'calendar-o' 42 | end 43 | else 44 | "calendar" 45 | end 46 | when 'checkin' 47 | "compass" 48 | else 49 | "comment" 50 | end 51 | "" 52 | end 53 | 54 | def context_prefix(entry) 55 | if entry.properties.key?('in-reply-to') 56 | "In reply to" 57 | elsif entry.properties.key?('repost-of') 58 | "Reposted" 59 | elsif entry.properties.key?('like-of') 60 | "Liked" 61 | elsif entry.properties.key?('rsvp') 62 | "RSVP to" 63 | end 64 | end 65 | 66 | def syndication_text(syndication) 67 | host = URI.parse(syndication).host 68 | case host 69 | when 'twitter.com', 'mobile.twitter.com' 70 | " Twitter" 71 | when 'instagram.com', 'www.instagram.com' 72 | " Instagram" 73 | when 'facebook.com', 'www.facebook.com' 74 | " Facebook" 75 | when 'swarmapp.com', 'www.swarmapp.com' 76 | " Swarm" 77 | when 'news.indieweb.org' 78 | "IndieNews" 79 | when 'medium.com' 80 | " Medium" 81 | when 'linkedin.com', 'www.linkedin.com' 82 | " LinkedIn" 83 | else 84 | host 85 | end 86 | end 87 | 88 | def webmention_type(post) 89 | if post.properties.key?('in-reply-to') 90 | 'reply' 91 | elsif post.properties.key?('repost-of') 92 | 'repost' 93 | elsif post.properties.key?('like-of') 94 | 'like' 95 | else 96 | 'mention' 97 | end 98 | end 99 | 100 | def webmention_type_p_class(type) 101 | case type 102 | when "reply","comment" 103 | return "p-comment" # via http://microformats.org/wiki/comment-brainstorming#microformats2_p-comment_h-entry 104 | when "like" 105 | return "p-like" 106 | when "repost" 107 | return "p-repost" 108 | when "mention" 109 | "p-mention" 110 | end 111 | end 112 | def post_type_u_class(type) 113 | case type 114 | when "reply", "rsvp" 115 | return "u-in-reply-to" 116 | when "repost" 117 | return "u-repost-of u-repost" 118 | when "like" 119 | return "u-like-of u-like" 120 | end 121 | end 122 | def context_class(post) 123 | if post.properties.key?('in-reply-to') 124 | "u-in-reply-to" 125 | elsif post.properties.key?('repost-of') 126 | "u-repost-of" 127 | elsif post.properties.key?('like-of') 128 | "u-like-of" 129 | end 130 | end 131 | def post_type_p_class(post) 132 | if post.properties.key?('in-reply-to') 133 | "p-in-reply-to" 134 | elsif post.properties.key?('repost-of') 135 | "p-repost-of" 136 | elsif post.properties.key?('like-of') 137 | "p-like-of" 138 | elsif post.properties.key?('rsvp') 139 | "p-rsvp" 140 | end 141 | end 142 | def context_tag(post) 143 | if post.h_type == 'h-entry' 144 | case post.entry_type 145 | when "reply", "rsvp" 146 | property = "in-reply-to" 147 | klass = "u-in-reply-to" 148 | when "repost" 149 | property = "repost-of" 150 | klass = "u-repost-of" 151 | when "like" 152 | property = "like-of" 153 | klass = "u-like-of" 154 | when "bookmark" 155 | property = "bookmark-of" 156 | klass = "u-bookmark-of" 157 | else 158 | return 159 | end 160 | tags = post.properties[property].map do |url| 161 | "" 162 | end 163 | tags.join('') 164 | end 165 | end 166 | 167 | def webmention_type_icon(type) 168 | case type 169 | when 'reply' 170 | return "" 171 | when 'repost' 172 | return "" 173 | when 'like' 174 | return "" 175 | else 176 | return "" 177 | end 178 | end 179 | 180 | def webmention_type_text(type) 181 | case type 182 | when 'reply' 183 | return "replied to this" 184 | when 'repost' 185 | return "reposted this" 186 | when 'like' 187 | return "liked this" 188 | else 189 | return "mentioned this" 190 | end 191 | end 192 | 193 | def host_link(url) 194 | host = URI.parse(url).host.downcase 195 | case host 196 | when "twitter.com","mobile.twitter.com" 197 | host_text = " Twitter" 198 | when "instagram.com", "www.instagram.com" 199 | host_text = " Instagram" 200 | else 201 | host_text = host 202 | end 203 | "#{host_text}" 204 | end 205 | 206 | def post_summary(content, num=200) 207 | summary = Sanitize.clean(content).to_s.strip[0...num] 208 | if summary.size == num 209 | summary = summary.strip + "…" 210 | end 211 | summary 212 | end 213 | 214 | def post_split(content) 215 | paragraph_ends = content.split(/\n\n/) 216 | return content unless paragraph_ends.size > 3 217 | paragraph_ends.first + " " + 218 | "Read full post…" 219 | end 220 | 221 | def filter_all(content) 222 | content = link_twitter(content) 223 | content = link_hashtags(content) 224 | content 225 | end 226 | 227 | def link_urls(content) 228 | content.gsub /((https?:\/\/|www\.)([-\w\.]+)+(:\d+)?(\/([\w\/_\.\+\-]*(\?\S+)?)?)?)/, %Q{\\1} 229 | end 230 | def link_twitter(content) 231 | content.gsub /\B@(\w*[a-zA-Z0-9_-]+)\w*/i, %Q{@\\1\\2} 232 | end 233 | def link_hashtags(content) 234 | return content 235 | # hashtags => link internally 236 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 } 237 | end 238 | def link_hashtags_twitter(content) 239 | # hashtags => link to twitter search 240 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 } 241 | end 242 | 243 | def force_https_author_profile(photo_url, base_url) 244 | url = URI.join(base_url, photo_url).to_s 245 | # use unavatar.now.sh to avoid avatar rot 246 | if url.match(/pbs\.twimg\.com/) 247 | screen_name = base_url.split('/').last 248 | return "https://unavatar.now.sh/twitter/#{screen_name}" 249 | elsif url.match(/cdninstagram\.com/) 250 | screen_name = base_url.split('/')[3] 251 | return "https://unavatar.now.sh/instagram/#{screen_name}" 252 | end 253 | https_image(url) 254 | end 255 | 256 | def https_image(url) 257 | unless url.start_with?('https') 258 | camo_image(url) 259 | else 260 | url 261 | end 262 | end 263 | 264 | # from https://github.com/atmos/camo/blob/master/test/proxy_test.rb 265 | def hexenc(image_url) 266 | image_url.to_enum(:each_byte).map { |byte| "%02x" % byte }.join 267 | end 268 | def camo_image(image_url) 269 | hexdigest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), 270 | ENV['CAMO_KEY'], image_url) 271 | encoded_image_url = hexenc(image_url) 272 | "#{ENV['CAMO_URL']}#{hexdigest}/#{encoded_image_url}" 273 | end 274 | 275 | def is_tweet?(post) 276 | url = post.properties['url'][0] 277 | url.start_with?('https://twitter.com') || 278 | url.start_with?('https://mobile.twitter.com') 279 | end 280 | 281 | def host_link(url) 282 | host = URI.parse(url).host.downcase 283 | case host 284 | when "twitter.com","mobile.twitter.com" 285 | host_text = " Twitter" 286 | when "instagram.com", "www.instagram.com" 287 | host_text = " Instagram" 288 | else 289 | host_text = host 290 | end 291 | "#{host_text}" 292 | end 293 | 294 | def valid_url?(url) 295 | Utils.valid_url?(url) 296 | end 297 | 298 | def nav(path, text) 299 | if path == request.path_info 300 | "
  • #{text}
  • " 301 | else 302 | "
  • #{text}
  • " 303 | end 304 | end 305 | 306 | def page_title(post) 307 | if post.h_type == 'h-event' 308 | return post.properties['name'][0] || 'Event' 309 | end 310 | 311 | case post.properties['entry-type'][0] 312 | when 'article' 313 | post.properties['name'][0] || 'Article' 314 | when 'bookmark' 315 | post.properties['name'][0] || 'Bookmark' 316 | when 'repost' 317 | "Repost #{post.url}" 318 | when 'like' 319 | "Like #{post.url}" 320 | when 'rsvp' 321 | "RSVP #{post.url}" 322 | when 'checkin' 323 | "Check-in" 324 | else 325 | post_summary(post.content, 100) 326 | end 327 | end 328 | 329 | def rss_description(post) 330 | content = "" 331 | if post.properties.key?('photo') 332 | post.properties['photo'].each do |photo| 333 | src = photo.is_a?(Hash) ? photo['value'] : photo 334 | content += "

    " 335 | end 336 | end 337 | unless post.content.nil? 338 | content += markdown(post.content) 339 | end 340 | content 341 | end 342 | 343 | def jsonfeed(posts) 344 | feed = { 345 | "version" => "https://jsonfeed.org/version/1", 346 | "title" => "Barry Frost", 347 | "home_page_url" => ENV['SITE_URL'], 348 | "feed_url" => "#{ENV['SITE_URL']}feed.json", 349 | "author" => { 350 | "name" => "Barry Frost", 351 | "url" => "https://barryfrost.com/", 352 | "avatar" => "#{ENV['SITE_URL']}barryfrost.jpg" 353 | }, 354 | "items" => [] 355 | } 356 | posts.each do |post| 357 | item = { 358 | "id" => post.url, 359 | "url" => URI.join(ENV['SITE_URL'], post.url), 360 | "date_published" => post.properties['published'][0] 361 | } 362 | if post.properties.key?('updated') 363 | item["date_modified"] = post.properties['updated'][0] 364 | end 365 | if post.properties.key?('name') 366 | item["title"] = post.properties['name'][0] 367 | end 368 | if post.properties['entry-type'][0] == 'bookmark' 369 | item["title"] = "Bookmark: " + item["title"] 370 | item["external_url"] = post.properties['bookmark-of'][0] 371 | end 372 | if post.properties.key?('content') 373 | if post.properties['content'][0].is_a?(Hash) 374 | item["content_html"] = post.properties['content'][0]['html'] 375 | elsif !post.properties['content'][0].empty? 376 | item["content_html"] = filter_markdown(post.properties['content'][0]) 377 | end 378 | end 379 | if post.properties.key?('photo') 380 | photo = post.properties['photo'][0] 381 | item["image"] = photo.is_a?(Hash) ? photo['value'] : photo 382 | end 383 | if post.properties.key?('category') 384 | item["tags"] = post.properties['category'] 385 | end 386 | feed["items"] << item 387 | end 388 | feed.to_json 389 | end 390 | 391 | end 392 | end -------------------------------------------------------------------------------- /lib/transformative/webmention.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Webmention 3 | module_function 4 | 5 | def receive(source, target) 6 | return if source == target 7 | 8 | # ignore swarm comment junk 9 | return if source.start_with?('https://ownyourswarm.p3k.io') 10 | 11 | verify_source(source) 12 | 13 | verify_target(target) 14 | 15 | check_site_matches_target(target) 16 | 17 | check_target_is_valid_post(target) 18 | 19 | return unless source_links_to_target?(source, target) 20 | 21 | author = store_author(source) 22 | 23 | cite = store_cite(source, author.properties['url'][0], target) 24 | 25 | send_notification(cite, author, target) 26 | end 27 | 28 | def verify_source(source) 29 | unless Utils.valid_url?(source) 30 | raise WebmentionError.new("invalid_source", 31 | "The specified source URI is not a valid URI.") 32 | end 33 | end 34 | 35 | def verify_target(target) 36 | unless Utils.valid_url?(target) 37 | raise WebmentionError.new("invalid_target", 38 | "The specified target URI is not a valid URI.") 39 | end 40 | end 41 | 42 | def check_site_matches_target(target) 43 | unless URI.parse(ENV['SITE_URL']).host == URI.parse(target).host 44 | raise WebmentionError.new("target_not_supported", 45 | "The specified target URI does not exist on this server.") 46 | end 47 | end 48 | 49 | def check_target_is_valid_post(target) 50 | target_path = URI.parse(target).path 51 | # check there is a webmention link tag/header at target 52 | response = HTTParty.get(target) 53 | unless response.code.to_i == 200 54 | raise WebmentionError.new("invalid_source", 55 | "The specified target URI could not be retrieved.") 56 | end 57 | unless Nokogiri::HTML(response.body).css("link[rel=webmention]").any? || 58 | response.headers['Link'].match('rel=webmention') 59 | raise WebmentionError.new("target_not_supported", 60 | "The specified target URI is not a Webmention-enabled resource.") 61 | end 62 | end 63 | 64 | def source_links_to_target?(source, target) 65 | response = HTTParty.get(source) 66 | case response.code.to_i 67 | when 410 68 | # the post has been deleted so remove any existing webmentions 69 | remove_webmention_if_exists(source) 70 | # source no longer links to target but no need for error 71 | return false 72 | when 200 73 | # that's fine - continue... 74 | else 75 | raise WebmentionError.new("invalid_source", 76 | "The specified source URI could not be retrieved.") 77 | end 78 | doc = Nokogiri::HTML(response.body) 79 | unless doc.css("a[href=\"#{target}\"]").any? || 80 | doc.css("img[src=\"#{target}\"]").any? || 81 | doc.css("video[src=\"#{target}\"]").any? || 82 | doc.css("audio[src=\"#{target}\"]").any? 83 | # there is no match so remove any existing webmentions 84 | remove_webmention_if_exists(source) 85 | raise WebmentionError.new("no_link_found", 86 | "The source URI does not contain a link to the target URI.") 87 | end 88 | true 89 | end 90 | 91 | def store_author(source) 92 | author_post = Authorship.fetch(source) 93 | Store.save(author_post) 94 | end 95 | 96 | def store_cite(source, author_url, target) 97 | json = Microformats.parse(source).to_json 98 | properties = JSON.parse(json)['items'][0]['properties'] 99 | published = properties.key?('published') ? 100 | Time.parse(properties['published'][0]) : 101 | Time.now 102 | name = properties.key?('name') ? properties['name'][0] : "" 103 | hash = { 104 | 'url' => [properties['url'][0]], 105 | 'name' => [name.strip], 106 | 'published' => [published.utc.iso8601], 107 | 'author' => [author_url], 108 | webmention_property(source, target) => [target] 109 | } 110 | if properties.key?('content') 111 | hash['content'] = [properties['content'][0]['value']] 112 | end 113 | if properties.key?('photo') && properties['photo'].any? 114 | hash['photo'] = properties['photo'] 115 | end 116 | cite = Cite.new(hash) 117 | Store.save(cite) 118 | end 119 | 120 | def webmention_property(source, target) 121 | hash = Microformats.parse(source).to_h 122 | # use first entry 123 | entries = hash['items'].map { |i| i if i['type'].include?('h-entry') } 124 | entries.compact! 125 | return 'mention-of' unless entries.any? 126 | entry = entries[0] 127 | # find the webmention type, just one, in order of importance 128 | %w( in-reply-to repost-of like-of ).each do |type| 129 | if entry['properties'].key?(type) 130 | urls = entry['properties'][type].map do |t| 131 | if t.is_a?(Hash) 132 | if t.key?('type') && 133 | t['type'].is_a?(Array) && 134 | t['type'].any? && 135 | t['type'][0] == 'h-cite' && 136 | t['properties'].key?('url') && 137 | t['properties']['url'].is_a?(Array) && 138 | t['properties']['url'].any? && 139 | Utils.valid_url?(t['properties']['url'][0]) 140 | t['properties']['url'][0] 141 | end 142 | elsif t.is_a?(String) && Utils.valid_url?(t) 143 | t 144 | end 145 | end 146 | return type if urls.include?(target) 147 | end 148 | end 149 | # no type found so assume it was a simple mention 150 | 'mention-of' 151 | end 152 | 153 | def remove_webmention_if_exists(url) 154 | return unless cite = Cache.get_by_properties_url(url) 155 | %w( in-reply-to repost-of like-of mention-of ).each do |property| 156 | if cite.properties.key?(property) 157 | cite.properties.delete(property) 158 | end 159 | end 160 | Store.save(cite) 161 | end 162 | 163 | def send_notification(cite, author, target) 164 | author_name = author.properties['name'][0] 165 | author_url = author.properties['url'][0] 166 | type = cite.webmention_type 167 | Notification.send( 168 | "New #{type}", 169 | "New #{type} of #{target} from #{author_name} - #{author_url}", 170 | target) 171 | end 172 | 173 | end 174 | 175 | class WebmentionError < TransformativeError 176 | def initialize(type, message) 177 | super(type, message, 400) 178 | end 179 | end 180 | 181 | end 182 | -------------------------------------------------------------------------------- /public/barryfrost-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/barryfrost-favicon.png -------------------------------------------------------------------------------- /public/barryfrost.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/barryfrost.jpg -------------------------------------------------------------------------------- /public/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} 5 | -------------------------------------------------------------------------------- /public/css/moof.css: -------------------------------------------------------------------------------- 1 | /*** RESET ***/ 2 | 3 | html, body, div, span, applet, object, iframe, 4 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 5 | a, abbr, acronym, address, big, cite, code, 6 | del, dfn, em, font, img, ins, kbd, q, s, samp, 7 | small, strike, strong, sub, sup, tt, var, 8 | dl, dt, dd, ol, ul, li, 9 | fieldset, form, label, legend, 10 | table, caption, tbody, tfoot, thead, tr, th, td, 11 | figure { 12 | margin: 0; 13 | padding: 0; 14 | border: 0; 15 | outline: 0; 16 | font-weight: inherit; 17 | font-style: inherit; 18 | font-size: 100%; 19 | font-family: inherit; 20 | vertical-align: baseline; 21 | } 22 | /* remember to define focus styles! */ 23 | :focus { 24 | outline: 0; 25 | } 26 | body { 27 | line-height: 1; 28 | color: black; 29 | background: white; 30 | } 31 | ol, ul { 32 | list-style: none; 33 | } 34 | /* tables still need 'cellspacing="0"' in the markup */ 35 | table { 36 | border-collapse: separate; 37 | border-spacing: 0; 38 | } 39 | caption, th, td { 40 | text-align: left; 41 | font-weight: normal; 42 | } 43 | blockquote:before, blockquote:after, 44 | q:before, q:after { 45 | content: ""; 46 | } 47 | blockquote, q { 48 | quotes: "" ""; 49 | } 50 | 51 | /*** MAIN ***/ 52 | 53 | body { 54 | font-family: 'Avenir Next', Avenir, 'Helvetica Neue', Helvetica, Arial, sans-serif; 55 | background: #f3f3f0; 56 | color: #222; 57 | font-size: 20px; 58 | font-weight: 500; 59 | line-height: 30px; 60 | } 61 | a { 62 | color: #f60; 63 | text-decoration: none; 64 | } 65 | a:hover, .metadata a:hover, .pagination a:hover, #how-to-comment a:hover { 66 | color: #222; 67 | } 68 | 69 | body>header>div, #container { 70 | max-width: 680px; 71 | } 72 | 73 | body>header { 74 | padding: 20px 0; 75 | margin-bottom: 80px; 76 | background: #fffffc; 77 | border: #eee solid 1px; 78 | border-radius: 0 0 15px 15px; 79 | } 80 | body>header>div { 81 | margin-left: auto; 82 | margin-right: auto; 83 | } 84 | body>header h1 { 85 | color: #777; 86 | } 87 | body>header li { 88 | font-size: 16px; 89 | font-weight: 400; 90 | color: #bbb; 91 | display: inline; 92 | margin-right: 5px; 93 | } 94 | body>header li a { 95 | color: #bbb; 96 | } 97 | body>header li.active { 98 | color: #222; 99 | font-weight: 500; 100 | } 101 | body>header>div>a { 102 | position: absolute; 103 | margin: 0 0 0 -73px; 104 | } 105 | body>header img { 106 | width: 60px; 107 | height: 60px; 108 | border-radius: 60px; 109 | } 110 | 111 | body>footer { 112 | font-size: 15px; 113 | line-height: 30px; 114 | padding: 20px; 115 | color: #999; 116 | text-align: center; 117 | } 118 | body>footer li { 119 | display: inline; 120 | margin-right: 5px; 121 | } 122 | input { 123 | margin-top: 10px; 124 | width: 200px; 125 | } 126 | 127 | #container { 128 | margin-left: auto; 129 | margin-right: auto; 130 | padding: 0 20px; 131 | overflow-x: hidden; 132 | } 133 | .static { 134 | margin-bottom: 80px; 135 | } 136 | 137 | .post { 138 | padding-bottom: 80px; 139 | } 140 | .post .content, .post p, .post h1, .post h2, .post h3, .post pre, .post ul, .post ol, .post .content li { 141 | margin-bottom: 20px; 142 | } 143 | .post-full.article { 144 | font-weight: 400; 145 | } 146 | .post-full.article .metadata { 147 | padding-top: 25px; 148 | } 149 | .post-type { 150 | position: absolute; 151 | margin: 0 0 0 -30px; 152 | color: #999; 153 | } 154 | span.post-type { 155 | color: #222; 156 | } 157 | .metadata { 158 | font-size: 15px; 159 | line-height: 25px; 160 | } 161 | .metadata, .metadata a { 162 | color: #999; 163 | } 164 | .metadata ul, .metadata li { 165 | display: inline; 166 | } 167 | .metadata .actions li span, .metadata .actions li a { 168 | margin-left: 12px; 169 | } 170 | .metadata .webmention-counts li { 171 | margin-right: 10px; 172 | } 173 | .metadata .tags, .metadata .syndications, .metadata .webmention-counts { 174 | display: block; 175 | } 176 | .metadata .tags li { 177 | margin-right: 5px; 178 | } 179 | .metadata abbr { 180 | font-size: 80%; 181 | } 182 | .metadata .location { 183 | display: block; 184 | } 185 | .post pre, .post code { 186 | font-family: Monaco, Menlo, Consolas, "Courier New", monospace; 187 | background: #e9e9e6; 188 | } 189 | .post pre { 190 | padding: 10px; 191 | overflow-x: scroll; 192 | border-radius: 10px; 193 | line-height: 19px; 194 | } 195 | .post code { 196 | font-size: 18px; 197 | } 198 | .post pre code { 199 | font-size: 14px; 200 | } 201 | .post.like .content { 202 | display: none; 203 | } 204 | .post .content figure { 205 | max-width: 320px; 206 | float: left; 207 | margin-right: 10px; 208 | } 209 | .post .content figure img { 210 | width: 100%; 211 | } 212 | .post .content figure.thumb { 213 | width: 50px; 214 | } 215 | .post blockquote { 216 | border-left: #ccc solid 5px; 217 | padding-left: 15px; 218 | color: #666; 219 | font-weight: 400; 220 | } 221 | .post .content ul { 222 | list-style: square outside; 223 | } 224 | .post .content ol { 225 | list-style: decimal inside; 226 | } 227 | .embeds li { 228 | max-width: 560px; 229 | list-style: none; 230 | } 231 | .embeds li iframe { 232 | width: 100%; 233 | } 234 | 235 | #container>h1, .post-full h1 { 236 | font-size: 150%; 237 | font-weight: 600; 238 | padding-bottom: 15px; 239 | } 240 | .post-full h2 { 241 | font-weight: 600; 242 | padding-top: 20px; 243 | } 244 | .post-full h3 { 245 | padding-top: 20px; 246 | font-weight: 600; 247 | } 248 | .post-full strong { 249 | font-weight: 600; 250 | } 251 | .post .content em { 252 | font-style: italic; 253 | } 254 | .title { 255 | padding-bottom: 80px; 256 | color: #999; 257 | font-weight: 400; 258 | } 259 | .post-full small { 260 | font-size: 80%; 261 | } 262 | 263 | .external { 264 | font-weight: 500; 265 | color: #555; 266 | font-size: 15px; 267 | line-height: 25px; 268 | padding-bottom: 10px; 269 | } 270 | .external li { 271 | margin-top: 15px; 272 | } 273 | .external dt img { 274 | width: 25px; 275 | height: 25px; 276 | border-radius: 25px; 277 | float: left; 278 | margin: 0 5px 0 0; 279 | } 280 | .webmentions.external dt img { 281 | margin-left: 5px; 282 | } 283 | .external dd img { 284 | max-width: 100px; 285 | max-height: 100px; 286 | } 287 | .external span.fa { 288 | float: left; 289 | line-height: 25px; 290 | } 291 | .external dt { 292 | margin-bottom: 5px; 293 | } 294 | .webmentions dd { 295 | margin-left: 50px; 296 | } 297 | .context dd { 298 | margin-left: 36px; 299 | border-left: #ccc solid 5px; 300 | padding-left: 15px; 301 | margin-left: 21px; 302 | color: #666; 303 | } 304 | .context { 305 | margin-bottom: 10px; 306 | } 307 | .context .prefix { 308 | float: left; 309 | margin-right: 5px; 310 | } 311 | .webmentions time { 312 | color: #999; 313 | } 314 | 315 | #archives { 316 | margin-bottom: 80px; 317 | } 318 | #archives dt { 319 | clear: left; 320 | padding-top: 20px; 321 | } 322 | #archives dt:first-child { 323 | padding-top: 0; 324 | } 325 | #archives dd { 326 | float: left; 327 | width: 2.8em; 328 | } 329 | 330 | .pagination { 331 | margin-bottom: 80px; 332 | font-weight: 400; 333 | } 334 | .pagination:after { 335 | /* clearfix */ 336 | content: ""; 337 | display: table; 338 | clear: both; 339 | } 340 | .pagination a { 341 | color: #999; 342 | display: block; 343 | padding: 5px 0; 344 | } 345 | .pagination .previous_page { 346 | float: left; 347 | } 348 | .pagination .next_page { 349 | float: right; 350 | } 351 | .pagination span { 352 | color: #ddd; 353 | } 354 | 355 | #how-to-comment, #how-to-comment a { 356 | font-weight: 400; 357 | color: #999; 358 | font-size: 15px; 359 | line-height: 25px; 360 | } 361 | 362 | .clearfix::after { 363 | content: ""; 364 | clear: both; 365 | display: table; 366 | } 367 | 368 | @media screen and (max-width: 740px) { 369 | body { 370 | font-size: 17px; 371 | line-height: 25px; 372 | } 373 | body>header { 374 | margin-bottom: 20px; 375 | } 376 | .post, #container .title { 377 | padding-bottom: 50px; 378 | } 379 | .post-type { 380 | position: relative; 381 | margin: 0 10px 0 0; 382 | float: left; 383 | } 384 | .external, .metadata { 385 | font-size: 14px; 386 | line-height: 23px; 387 | } 388 | .external li { 389 | margin-top: 10px; 390 | } 391 | .context dd { 392 | } 393 | .post .content ul { 394 | list-style-position: inside; 395 | } 396 | .post .content ol { 397 | list-style-position: inside; 398 | } 399 | } 400 | @media screen and (max-width: 820px) { 401 | body>header { 402 | padding: 20px 20px 20px 73px; 403 | } 404 | body>header>div>a { 405 | position: absolute; 406 | margin: 0 0 0 -53px; 407 | } 408 | body>header img { 409 | width: 40px; 410 | height: 40px; 411 | border-radius: 40px; 412 | } 413 | body>header li { 414 | font-size: 15px; 415 | line-height: 20px; 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /public/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/transformative/9892a32c29022edac214f9b1cf0704474966055d/public/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | // register sw script in supporting browsers 2 | if ('serviceWorker' in navigator) { 3 | navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(() => { 4 | console.log('Service Worker registered successfully.'); 5 | }).catch(error => { 6 | console.log('Service Worker registration failed:', error); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en", 3 | "dir": "ltr", 4 | "name": "Barry Frost's personal website", 5 | "short_name": "Barry Frost", 6 | "icons": [ 7 | { 8 | "src": "https://barryfrost.com/barryfrost-favicon.png", 9 | "sizes": "192x192", 10 | "type": "image/jpeg" 11 | } 12 | ], 13 | "theme_color": "#f60", 14 | "background_color": "#f60", 15 | "start_url": "/", 16 | "display": "standalone", 17 | "orientation": "natural" 18 | } -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', e => { 2 | e.waitUntil( 3 | // after the service worker is installed, 4 | // open a new cache 5 | caches.open('barryfrost-com-cache').then(cache => { 6 | // add all URLs of resources we want to cache 7 | return cache.addAll([ 8 | '/' 9 | ]); 10 | }) 11 | ); 12 | }); -------------------------------------------------------------------------------- /scripts/migrate_contexts.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'time' 3 | require 'json' 4 | 5 | path_moof = "/Users/barry/data-2016-11-08/contexts" 6 | path_new = "/Users/barry/Dropbox/barryfrost.com/content/cite" 7 | path_card = "/Users/barry/Dropbox/barryfrost.com/content/card" 8 | 9 | Dir.glob("#{path_moof}/**/*.md").each do |file| 10 | 11 | puts "Reading #{file}" 12 | 13 | raw = File.read(file) 14 | raw_parts = raw.split(/---\n/) 15 | 16 | properties = {} 17 | properties['content'] = [raw_parts[2].to_s] unless raw_parts[2].to_s.empty? 18 | 19 | begin 20 | data = YAML.load(raw_parts[1]) 21 | rescue 22 | puts raw 23 | raise 24 | end 25 | 26 | properties = { 27 | 'url' => [data['url']], 28 | 'published' => [Time.parse(data['published']).utc.iso8601.to_s], 29 | } 30 | 31 | content = raw_parts[2].to_s 32 | unless content.empty? 33 | properties['content'] = [content] 34 | end 35 | 36 | #post_url = "https://barryfrost.com#{data['post_permalink']}" 37 | 38 | if data.key?('photo') 39 | properties['photo'] = [data['photo']] 40 | end 41 | 42 | if data['url'].start_with?('https://twitter.com') 43 | properties['author'] = [data['url'].split('/')[0..3].join('/')] 44 | else 45 | properties['author'] = [data['author_url']] 46 | end 47 | 48 | # create card 49 | card_slug = properties['author'][0].to_s.downcase.gsub(/[^a-z0-9\-]/,' '). 50 | strip.gsub(/\s+/,'/') 51 | next if card_slug.nil? 52 | cp = {} 53 | cp['name'] = [data['author_name']] if data.key?('author_name') 54 | cp['photo'] = [data['author_photo']] if data.key?('author_photo') 55 | cp['url'] = [data['author_url']] if data.key?('author_url') 56 | card_content = JSON.pretty_generate({ 57 | type: ['h-card'], 58 | properties: cp 59 | }) 60 | filename = "#{path_card}/#{card_slug}.json" 61 | FileUtils.mkdir_p(File.dirname(filename)) 62 | File.write(filename, card_content) 63 | 64 | # create cite 65 | slug = data['url'].to_s.downcase.gsub(/[^a-z0-9\-]/,' ').strip.gsub(/\s+/,'/') 66 | next if slug.nil? 67 | file_content = JSON.pretty_generate({ 68 | type: ['h-cite'], 69 | properties: properties 70 | }) 71 | filename = "#{path_new}/#{slug}.json" 72 | FileUtils.mkdir_p(File.dirname(filename)) 73 | File.write(filename, file_content) 74 | 75 | end 76 | -------------------------------------------------------------------------------- /scripts/migrate_entries.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'time' 3 | require 'json' 4 | 5 | path_moof = "/Users/barry/data-2016-11-08/posts" 6 | path_new = "/Users/barry/Dropbox/barryfrost.com/content" 7 | 8 | Dir.glob("#{path_moof}/**/*.md").each do |file| 9 | 10 | puts "Reading #{file}" 11 | 12 | raw = File.read(file) 13 | raw_parts = raw.split(/---\n/) 14 | 15 | properties = {} 16 | properties['content'] = [raw_parts[2].to_s] unless raw_parts[2].to_s.empty? 17 | 18 | begin 19 | data = YAML.load(raw_parts[1]) 20 | rescue 21 | puts raw 22 | raise 23 | end 24 | 25 | data.keys.each do |key| 26 | case key 27 | when 'syndications' 28 | k = 'syndication' 29 | when 'tags' 30 | k = 'category' 31 | when 'bookmark' 32 | k = 'bookmark-of' 33 | when 'in_reply_to' 34 | k = 'in-reply-to' 35 | when 'like_of' 36 | k = 'like-of' 37 | when 'repost_of' 38 | k = 'repost-of' 39 | when 'place_name' 40 | k = 'place-name' 41 | when 'post_type' 42 | k = 'entry-type' 43 | when 'permalink', 'latitude', 'longitude' 44 | next 45 | else 46 | k = key 47 | end 48 | 49 | properties[k] = Array(data[key]) 50 | 51 | if key == 'published' 52 | properties[key] = [Time.parse(data[key]).utc.iso8601.to_s] 53 | elsif k == 'photo' 54 | properties[key] = ["https://barryfrost-media.s3.amazonaws.com/file/#{data[key]}"] 55 | end 56 | end 57 | 58 | if data.key?('latitude') && data.key?('longitude') 59 | properties['location'] = ["geo:#{data['latitude']},#{data['longitude']}"] 60 | end 61 | 62 | file_content = JSON.pretty_generate({ 63 | type: ['h-entry'], 64 | properties: properties 65 | }) 66 | 67 | date = Time.parse(data['published']).utc 68 | url = date.strftime('/%Y/%m/') + data['slug'] 69 | new_file = "#{path_new}#{url}.json" 70 | 71 | FileUtils.mkdir_p( File.dirname(new_file) ) 72 | 73 | File.write(new_file, file_content) 74 | 75 | end 76 | -------------------------------------------------------------------------------- /scripts/migrate_webmentions.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'time' 3 | require 'json' 4 | 5 | path_moof = "/Users/barry/data-2016-11-08/webmentions" 6 | path_new = "/Users/barry/Dropbox/barryfrost.com/content/cite" 7 | path_card = "/Users/barry/Dropbox/barryfrost.com/content/card" 8 | 9 | Dir.glob("#{path_moof}/**/*.md").each do |file| 10 | 11 | puts "Reading #{file}" 12 | 13 | raw = File.read(file) 14 | raw_parts = raw.split(/---\n/) 15 | 16 | properties = {} 17 | properties['content'] = [raw_parts[2].to_s] unless raw_parts[2].to_s.empty? 18 | 19 | begin 20 | data = YAML.load(raw_parts[1]) 21 | rescue 22 | puts raw 23 | raise 24 | end 25 | 26 | properties = { 27 | 'url' => [data['url']], 28 | 'published' => [Time.parse(data['published']).utc.iso8601.to_s], 29 | } 30 | 31 | content = raw_parts[2].to_s 32 | unless content.empty? 33 | properties['content'] = [content] 34 | end 35 | 36 | post_url = "https://barryfrost.com#{data['post_permalink']}" 37 | case data['webmention_type'] 38 | when 'reply' 39 | properties['in-reply-to'] = [post_url] 40 | when 'repost' 41 | properties['repost-of'] = [post_url] 42 | when 'like' 43 | properties['like-of'] = [post_url] 44 | when 'mention' 45 | properties['mention-of'] = [post_url] 46 | end 47 | 48 | if data.key?('photo') 49 | properties['photo'] = [data['photo']] 50 | end 51 | 52 | if data.key?('author_url') 53 | if data['author_url'].start_with?('https://twitter.com') 54 | properties['author'] = [data['author_url'].split('/')[0..3].join('/')] 55 | else 56 | properties['author'] = [data['author_url']] 57 | end 58 | else 59 | properties['author'] = [data['author']] 60 | end 61 | 62 | # create card 63 | card_slug = properties['author'][0].to_s.downcase.gsub(/[^a-z0-9\-]/,' '). 64 | strip.gsub(/\s+/,'/') 65 | next if card_slug.nil? 66 | cp = {} 67 | cp['name'] = [data['author_name']] if data.key?('author_name') 68 | cp['photo'] = [data['author_photo']] if data.key?('author_photo') 69 | cp['url'] = [data['author_url']] if data.key?('author_url') 70 | card_content = JSON.pretty_generate({ 71 | type: ['h-card'], 72 | properties: cp 73 | }) 74 | filename = "#{path_card}/#{card_slug}.json" 75 | FileUtils.mkdir_p(File.dirname(filename)) 76 | File.write(filename, card_content) 77 | 78 | # create cite 79 | slug = data['url'].to_s.downcase.gsub(/[^a-z0-9\-]/,' ').strip.gsub(/\s+/,'/') 80 | next if slug.nil? 81 | file_content = JSON.pretty_generate({ 82 | type: ['h-cite'], 83 | properties: properties 84 | }) 85 | filename = "#{path_new}/#{slug}.json" 86 | FileUtils.mkdir_p(File.dirname(filename)) 87 | File.write(filename, file_content) 88 | 89 | end 90 | -------------------------------------------------------------------------------- /views/404.erb: -------------------------------------------------------------------------------- 1 |
    2 | 3 |

    404 Not Found

    4 |
    5 | 6 |

    The page or post was not found.

    7 |
    8 | 9 |

    Try searching instead:

    10 | 11 | <%= erb :_search_form %> 12 | 13 |
    -------------------------------------------------------------------------------- /views/410.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 |

    410 Gone

    5 | 6 | <%= erb :'_deleted' %> 7 | 8 |
    9 |
    -------------------------------------------------------------------------------- /views/500.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

    500 Error

    9 |

    <%= h env['sinatra.error'].message %>

    10 | 11 | -------------------------------------------------------------------------------- /views/_categories.erb: -------------------------------------------------------------------------------- 1 | <% if @post.properties.key?('category') && @post.properties['category'].any? %> 2 |
  • 3 | 44 |
  • 45 | <% end %> -------------------------------------------------------------------------------- /views/_contexts.erb: -------------------------------------------------------------------------------- 1 | <% context_found = false %> 2 | <% @contexts.each do |context| %> 3 | <% if @post.cite_belongs_to_post?(context) %> 4 | <% context_found = true %> 5 | 6 |
    7 | 8 |
    9 | 10 |
    11 | <%= context_prefix(@post) %> 12 | 13 | <% if @authors.key?(context.properties['author'][0]) %> 14 | <% author = @authors[context.properties['author'][0]] %> 15 | 16 | <% if author.properties.key?('photo') %> 17 | <%= h author.properties['name'][0] %> 19 | <% end %> 20 | <%= h author.properties['name'][0] %>’s 21 | <% else %> 22 | <%= h context.properties['author'][0] %> 23 | <% end %> 24 | 25 | post 26 | 27 | <% if context.properties.key?('name') %> 28 | “<%= post_summary(context.properties['name'][0], 40) %>” 29 | <% end %> 30 | on <%= host_link(context.properties['url'][0]) %> 31 |
    32 | 33 | <% if context.content || context.properties.key?('photo') %> 34 | <% display_content = link_twitter link_urls post_summary(context.content, 250) %> 35 |
    36 |
    <%= is_tweet?(context) ? link_hashtags_twitter(display_content) : link_hashtags(display_content) %>
    37 | <% if context.properties.key?('photo') && context.properties['photo'].any? %> 38 |
    39 | 40 | 41 | 42 |
    43 | <% end %> 44 |
    45 | <% end %> 46 | 47 | <% if context.properties.key?('start') && context.properties.key?('end') %> 48 | 58 | <% end %> 59 | 60 |
    61 |
    62 | 63 | <% end %> 64 | <% end %> 65 | 66 | <%= context_tag(@post) unless context_found %> 67 | -------------------------------------------------------------------------------- /views/_deleted.erb: -------------------------------------------------------------------------------- 1 |

    This post has been deleted and is no longer available.

    2 | 3 | 14 | -------------------------------------------------------------------------------- /views/_indie_actions.erb: -------------------------------------------------------------------------------- 1 | <% tweet = @post.properties['syndication'].to_s.scan(/twitter.com\/[A-Za-z0-9_]*\/status\/([0-9]*)/) %> 2 | <% tweet_id = tweet.size == 1 ? tweet[0][0] : false %> 3 | 4 |
  • 5 | 37 |
  • 38 | -------------------------------------------------------------------------------- /views/_location.erb: -------------------------------------------------------------------------------- 1 | <% if @post.properties.key?('location') || @post.properties.key?('checkin') %> 2 | 3 | <% if @post.properties.key?('checkin') %> 4 | <% location = @post.properties['checkin'][0] %> 5 | <% p_value = "p-checkin" %> 6 | <% elsif @post.properties.key?('location') %> 7 | <% location = @post.properties['location'][0] %> 8 | <% p_value = "p-location" %> 9 | <% end %> 10 | 11 |
  • 12 | <% if location.is_a?(Hash) && location['type'][0] == 'h-card' %> 13 |
    14 | 15 | 16 | 17 | 18 | <%= location['properties']['name'][0] %><% if location['properties'].key?('locality') %>, 19 | <%= location['properties']['locality'][0] %><% end %><% if location['properties'].key?('region') %>, 20 | <%= location['properties']['region'][0] %><% end %> 21 | 22 |
    23 | 24 | <% elsif location.is_a?(String) && location.start_with?('geo:') %> 25 | <% geo = location.match(/^geo:(\-?[0-9\.]+),(\-?[0-9\.]+)/) %> 26 | <% unless geo.nil? %> 27 |
    28 | 29 | 30 | 31 | 32 | <% if @post.properties.key?('place-name') %> 33 | <%= @post.properties['place-name'][0] %> 34 | <% elsif @post.properties.key?('place_name') %> 35 | <%= @post.properties['place_name'][0] %> 36 | <% else %> 37 | <%= geo[1] %>, <%= geo[2] %> 38 | <% end %> 39 | 40 |
    41 | <% end %> 42 | 43 | <% end %> 44 |
  • 45 | <% end %> 46 | -------------------------------------------------------------------------------- /views/_meta.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <% if @post.properties.key?('name') %> 8 | 9 | 10 | <% end %> 11 | 12 | <% if @post.content %> 13 | 14 | 15 | <% end %> 16 | 17 | <% if @post.properties.key?('photo') %> 18 | <% content = @post.properties['photo'][0].is_a?(Hash) ? 19 | @post.properties['photo'][0]['value'] : @post.properties['photo'][0] %> 20 | 21 | 22 | <% end %> -------------------------------------------------------------------------------- /views/_name.erb: -------------------------------------------------------------------------------- 1 | <% case @post.properties['entry-type'][0] 2 | when 'bookmark' %> 3 |

    4 | 5 | <%= @post.properties['name'][0] %> 6 | 7 | 8 |

    9 | <% when 'article' %> 10 |

    <%= @post.properties['name'][0] %>

    11 | <% when 'like' %> 12 | 13 | <% when 'repost' %> 14 | <% if @post.content.nil? %> 15 | 16 | <% end %> 17 | <% when 'photo' %> 18 | <% if @post.content.nil? %> 19 | 20 | <% end %> 21 | <% end %> 22 | -------------------------------------------------------------------------------- /views/_photos.erb: -------------------------------------------------------------------------------- 1 | <% if @post.properties.key?('photo') %> 2 | <% photos = @post.properties['photo'] %> 3 | <% photo = photos.first %> 4 | 5 |
    6 | <% if photo.is_a?(Hash) %> 7 | 8 | <%= photo['alt'] %> 10 | 11 | <% else %> 12 | 13 | 14 | 15 | <% end %> 16 |
    17 | 18 | <% if photos.size > 1 %> 19 |
    20 | <% photos[1..photos.size].each do |photo| %> 21 |
    22 | <% if photo.is_a?(Hash) %> 23 | 24 | <%= photo['alt'] %> 26 | 27 | <% else %> 28 | 29 | 30 | 31 | <% end %> 32 |
    33 | <% end %> 34 |
    35 | <% end %> 36 | 37 | <% end %> 38 | -------------------------------------------------------------------------------- /views/_photos_all.erb: -------------------------------------------------------------------------------- 1 | <% if @post.properties.key?('photo') %> 2 |
    3 | <% @post.properties['photo'].each do |photo| %> 4 | 5 |
    6 | <% if photo.is_a?(Hash) %> 7 | 8 | <%= photo['alt'] %> 10 | 11 | <% else %> 12 | 13 | 14 | 15 | <% end %> 16 |
    17 | 18 | <% end %> 19 |
    20 | <% end %> 21 | -------------------------------------------------------------------------------- /views/_search_form.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 6 |
    7 |
    8 | -------------------------------------------------------------------------------- /views/_sources.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/_syndications.erb: -------------------------------------------------------------------------------- 1 | <% if @post.properties.key?('syndication') && @post.properties['syndication'].any? %> 2 |
  • 3 | Also on  4 | <% @post.properties['syndication'].each do |syndication| %> 5 | 6 | <%= syndication_text(syndication) %> 7 |   8 | <% end %> 9 |
  • 10 | <% end %> 11 | -------------------------------------------------------------------------------- /views/_timestamp.erb: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 | 6 | UTC 7 | 8 | 9 | <% if @post.properties.key?('updated') %> 10 | 13 | <% end %> 14 |
  • 15 | -------------------------------------------------------------------------------- /views/_webmention_counts.erb: -------------------------------------------------------------------------------- 1 | <% tweet = @post.properties['syndication'].to_s.scan(/twitter.com\/[A-Za-z0-9_]*\/status\/([0-9]*)/) %> 2 | <% tweet_id = tweet.size == 1 ? tweet[0][0] : false %> 3 | 4 |
  • 5 |
      6 |
    • 7 | 8 | <% if tweet_id %> 9 | 11 | <% else %> 12 | 13 | <% end %> 14 | 15 | <% count = @webmention_counts[@post.absolute_url][:replies] %> 16 | <%= count if count > 0 %> 17 |
    • 18 |
    • 19 | 20 | <% if tweet_id %> 21 | 23 | <% else %> 24 | 25 | <% end %> 26 | 27 | <% count = @webmention_counts[@post.absolute_url][:reposts] %> 28 | <%= count if count > 0 %> 29 |
    • 30 |
    • 31 | 32 | <% if tweet_id %> 33 | 35 | <% else %> 36 | 37 | <% end %> 38 | 39 | <% count = @webmention_counts[@post.absolute_url][:likes] %> 40 | <%= count if count > 0 %> 41 |
    • 42 |
    43 |
  • 44 | -------------------------------------------------------------------------------- /views/_webmentions.erb: -------------------------------------------------------------------------------- 1 | <% if @webmentions.any? %> 2 | <% author = nil %> 3 |
      4 | 5 | <% likes_count = @webmentions.map { |w| webmention_type(w) == 'like' ? 1 : nil }.compact.size %> 6 | <% if likes_count > 0 %> 7 | 36 | <% end %> 37 | 38 | <% @webmentions.each do |webmention| %> 39 | <% type = webmention_type(webmention) %> 40 | <% next if type == 'like' %> 41 | 42 |
    • 43 |
      44 | 45 |
      46 | <%= webmention_type_icon(type) %> 47 | 48 | <% if @authors.key?(webmention.properties['author'][0]) %> 49 | <% author = @authors[webmention.properties['author'][0]] %> 50 | 51 | <% if author.properties.key?('photo') %> 52 | <%= h author.properties['name'][0] %> 54 | <% end %> 55 | <%= h author.properties['name'][0] %> 56 | 57 | <% else %> 58 | <%= h webmention.properties['author'][0] %> 59 | <% end %> 60 | 61 | 62 | <%= webmention_type_text(type) %> 63 | 64 | <% if type == 'reply' && webmention.properties.key?('name') && !webmention.content.nil? && !webmention.content.start_with?(webmention.properties['name'][0]) %> 65 | “<%= post_summary(webmention.properties['name'][0], 40) %>” 66 | <% end %> 67 | on <%= host_link(webmention.properties['url'][0]) %> 68 |
      69 | 70 | <% if type == 'reply' %> 71 |
      72 | <%= link_twitter link_hashtags link_urls post_summary webmention.content %> 73 |
      74 | <% end %> 75 | 76 | <% if type == 'repost' || type == 'mention' %> 77 | <% unless author.nil? %> 78 | 79 | <% else %> 80 | 81 | <% end %> 82 | <% end %> 83 | 84 |
      85 |
    • 86 | 87 | <% end %> 88 | 89 |
    90 | <% end %> -------------------------------------------------------------------------------- /views/entry.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 8 | 9 | <%= erb :'_contexts' unless @contexts.nil? %> 10 | 11 | 12 | <%= post_type_icon(@post) %> 13 | 14 | 15 | <%= erb :'_name' %> 16 | 17 | <% if !@post.content.nil? %> 18 |
    19 |
    <%= filter_all filter_markdown @post.content %>
    21 | <%= erb :'_photos_all' %> 22 |
    23 | <% end %> 24 | 25 | <% if @post.properties.key?('summary') and !@post.properties['summary'][0].empty? %> 26 | 27 | <% end %> 28 | 29 | <% if @post.properties['entry-type'][0] == 'rsvp' %> 30 | 31 | <% end %> 32 | 33 | 40 | 41 | <%= erb :'_webmentions' unless @webmentions.nil? %> 42 | 43 |
    44 |
    45 | 46 | <%= erb :'_sources' %> 47 | -------------------------------------------------------------------------------- /views/event.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 |

    14 | <% if @post.properties.key?('url') %> 15 | 16 | <%= @post.properties['name'][0] %> 17 | 18 | 19 | <% else %> 20 | <%= @post.properties['name'][0] %> 21 | <% end %> 22 |

    23 | 24 |
      25 | <%= erb :'_location' %> 26 |
    27 | 28 |

    29 | Starts at 30 | 34 | UTC. 35 | <% if @post.properties.key?('end') %> 36 | Finishes at 37 | 41 | UTC. 42 | <% end %> 43 |

    44 | 45 | <% if @post.properties.key?('summary') %> 46 |
    47 | <%= @post.properties['summary'][0] %> 48 |
    49 | <% end %> 50 | 51 | <% if @post.properties.key?('description') %> 52 |
    53 | <%= @post.properties['description'][0] %> 54 |
    55 | <% end %> 56 | 57 | 63 | 64 | <%= erb :'_webmentions' unless @webmentions.nil? %> 65 | 66 |
    67 |
    68 | 69 | <%= erb :'_sources' %> -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    14 | 15 | 19 | 20 | <% if @page_title %> 21 |
    <%= @page_title %>
    22 | <% else %> 23 | 26 | <% end %> 27 | 28 | <% @posts.each do |post| %> 29 | <% @post = post %> 30 |
    31 | 32 | <% if @post.is_deleted? %> 33 | 34 | 35 | 36 | <%= erb :'_deleted' %> 37 | <% else %> 38 | 39 | <%= erb :'_contexts' unless @contexts.nil? %> 40 | 41 | 42 | <%= post_type_icon(@post) %> 43 | 44 | 45 | <%= erb :'_name' %> 46 | 47 | <% if !@post.content.nil? %> 48 |
    49 |
    <%= post_split filter_all filter_markdown @post.content %>
    51 | <%= erb :'_photos' %> 52 |
    53 | <% end %> 54 | 55 | <% if @post.properties['entry-type'][0] == 'rsvp' %> 56 | 57 | <% end %> 58 | 59 | 66 | 67 | <% end %> 68 | 69 |
    70 | <% end %> 71 | 72 | <% if @posts_rows.respond_to?(:total_pages) %> 73 | <%= will_paginate @posts_rows, 74 | previous_label: " Newer", 75 | next_label: "Older ", 76 | page_links: false %> 77 | <% end %> 78 |
    79 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% if @title %><%= @title %> · <% end %>Barry Frost 6 | <% if @post_page %> 7 | 8 | 9 | 10 | <% else %> 11 | 12 | <% end %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    32 |
    33 | Barry Frost 34 |

    This is Barry Frost’s personal website.

    35 | 51 |
    52 |
    53 | 54 | <%= yield %> 55 | 56 | <% if @footer %> 57 | 78 | <% end %> 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /views/rss.builder: -------------------------------------------------------------------------------- 1 | xml.instruct! :xml, version: '1.0' 2 | xml.rss version: "2.0" do 3 | xml.channel do 4 | xml.title "Barry Frost" 5 | xml.description "Barry Frost's feed." 6 | xml.link ENV['SITE_URL'] 7 | 8 | xml.link rel: "hub", href: ENV['PUBSUBHUBBUB_HUB'] 9 | xml.link rel: "self", href: "#{ENV['SITE_URL']}rss" 10 | xml.link rel: "alternate", href: ENV['SITE_URL'] 11 | 12 | @posts.each do |post| 13 | xml.item do 14 | xml.title post.properties['name'][0] if post.properties.key?('name') 15 | xml.link post.properties['entry-type'][0] == 'bookmark' ? 16 | post.properties['bookmark-of'][0] : 17 | "#{URI.join(ENV['SITE_URL'], post.url)}" 18 | xml.description rss_description(post) 19 | xml.pubDate Time.parse(post.properties['published'][0]).rfc822() 20 | xml.guid "#{URI.join(ENV['SITE_URL'], post.url)}" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /views/static.erb: -------------------------------------------------------------------------------- 1 |
    2 | 3 | <%= @content %> 4 | 5 |
    --------------------------------------------------------------------------------