├── .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 |
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 | -
49 |
52 | —
53 |
56 | UTC
57 |
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 |
6 | -
7 |
8 | <% if tweet_id %>
9 |
11 | <% else %>
12 |
13 | <% end %>
14 |
15 |
16 | -
17 |
18 | <% if tweet_id %>
19 |
21 | <% else %>
22 |
23 | <% end %>
24 |
25 |
26 | -
27 |
28 | <% if tweet_id %>
29 |
31 | <% else %>
32 |
33 | <% end %>
34 |
35 |
36 |
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 |
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 |
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 |
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 |
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 |
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 |
10 |
11 | <% else %>
12 |
13 |
14 |
15 | <% end %>
16 |
17 |
18 | <% end %>
19 |
20 | <% end %>
21 |
--------------------------------------------------------------------------------
/views/_search_form.erb:
--------------------------------------------------------------------------------
1 |
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 | -
8 |
9 | -
10 |
11 | <% @webmentions.each do |webmention| %>
12 | <% next unless webmention_type(webmention) == 'like' %>
13 |
14 | <% if @authors.key?(webmention.properties['author'][0]) %>
15 | <% author = @authors[webmention.properties['author'][0]] %>
16 |
17 |
18 |
19 |
20 |
21 | <% if author.properties.key?('photo') %>
22 |
23 |
26 |
27 | <% end %>
28 |
29 |
30 | <% end %>
31 | <% end %>
32 |
33 |
34 |
35 |
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 |
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 |
24 | <% if @title %><%= @title %> · <% end %>Barry Frost
25 |
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 |

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 |
--------------------------------------------------------------------------------