├── views
├── gallery.erb
├── 404.erb
├── 500.erb
├── syslog.erb
├── site_settings.erb
├── site_uploading.erb
├── layout.erb
├── store_edit.erb
├── site_posting.erb
├── flow_media.erb
├── tools.erb
├── index.erb
├── site_status.erb
└── flow_edit.erb
├── .ruby-version
├── script
├── server
├── migrate
└── console
├── Procfile
├── .gitignore
├── public
├── favicon.ico
├── images
│ ├── file.svg
│ ├── bookmark.svg
│ ├── reply.svg
│ ├── event.svg
│ ├── article.svg
│ ├── note.svg
│ ├── video.svg
│ ├── photo.svg
│ ├── checkin.svg
│ ├── repost.svg
│ ├── like.svg
│ ├── attachment.svg
│ ├── audio.svg
│ └── setup.svg
├── js
│ └── codemirror
│ │ ├── yaml-frontmatter.js
│ │ ├── yaml.js
│ │ └── multiplex.js
└── css
│ ├── styles.css
│ ├── normalize.css
│ ├── codemirror.css
│ └── milligram.css
├── db
└── migrate
│ ├── 009_add_file_to_log.rb
│ ├── 010_add_photos_to_log.rb
│ ├── 007_add_file_flow_to_sites.rb
│ ├── 001_create_sites.rb
│ ├── 004_add_flow_media_templates.rb
│ ├── 002_create_stores.rb
│ ├── 008_add_settings_to_sites.rb
│ ├── 003_create_flows.rb
│ ├── 005_update_flow_type_to_kind.rb
│ └── 006_create_log.rb
├── routes
├── pages.rb
├── webmention.rb
├── init.rb
├── micropub.rb
└── manage.rb
├── lib
├── posts
│ ├── note.rb
│ ├── audio.rb
│ ├── event.rb
│ ├── repost.rb
│ ├── video.rb
│ ├── photo.rb
│ ├── article.rb
│ ├── like.rb
│ ├── bookmark.rb
│ ├── reply.rb
│ ├── _entry.rb
│ └── checkin.rb
├── init.rb
├── _transformative
│ ├── notification.rb
│ ├── utils.rb
│ ├── media_s3.rb
│ ├── syndication.rb
│ ├── authorship.rb
│ ├── twitter.rb
│ ├── context.rb
│ ├── webmention.rb
│ └── view_helper.rb
├── generators.yml
├── post_types.yml
├── media.rb
├── auth.rb
├── micropub.rb
└── post.rb
├── models
├── site.rb
├── init.rb
├── store.rb
├── file_system.rb
├── flow.rb
└── github.rb
├── Gemfile
├── app.rb
├── LICENSE
├── config.ru
├── README.md
├── Rakefile
└── Gemfile.lock
/views/gallery.erb:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 2.6.5
2 |
--------------------------------------------------------------------------------
/script/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | bundle exec rackup
3 |
--------------------------------------------------------------------------------
/script/migrate:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | bundle exec rake db:migrate
3 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | release: script/migrate
2 | web: rackup -s puma -p $PORT
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Used by dotenv library to load environment variables.
2 | .env
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gerwitz/sitewriter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/db/migrate/009_add_file_to_log.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | alter_table(:log) do
4 | add_column :file, String, size: 255
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/010_add_photos_to_log.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | alter_table(:log) do
4 | add_column :photos, String, text:true
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/routes/pages.rb:
--------------------------------------------------------------------------------
1 | class SiteWriter < Sinatra::Application
2 | get '/' do
3 | erb :index
4 | end
5 |
6 | get '/tools' do
7 | erb :tools
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/007_add_file_flow_to_sites.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | alter_table(:sites) do
4 | add_column :file_flow_id, Integer
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/views/404.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
404 Not Found
4 |
5 |
6 |
The page or post was not found.
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/db/migrate/001_create_sites.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | create_table(:sites) do
4 | primary_key :id
5 | String :domain, null: false
6 | String :url
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/posts/note.rb:
--------------------------------------------------------------------------------
1 | class Note < Post
2 |
3 | VARIABLES_CATALOG = {
4 | }
5 |
6 | def initialize(properties, url=nil)
7 | super(properties, url)
8 | end
9 |
10 | def kind
11 | 'note'
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/lib/posts/audio.rb:
--------------------------------------------------------------------------------
1 | class Audio < Post
2 |
3 | VARIABLES_CATALOG = {
4 | }
5 |
6 | def initialize(properties, url=nil)
7 | super(properties, url)
8 | end
9 |
10 | def kind
11 | 'audio'
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/lib/posts/event.rb:
--------------------------------------------------------------------------------
1 | class Event < Post
2 |
3 | VARIABLES_CATALOG = {
4 | }
5 |
6 | def initialize(properties, url=nil)
7 | super(properties, url)
8 | end
9 |
10 | def kind
11 | 'event'
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/lib/posts/repost.rb:
--------------------------------------------------------------------------------
1 | class Repost < Post
2 |
3 | VARIABLES_CATALOG = {
4 | }
5 |
6 | def initialize(properties, url=nil)
7 | super(properties, url)
8 | end
9 |
10 | def kind
11 | 'repost'
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/lib/posts/video.rb:
--------------------------------------------------------------------------------
1 | class Video < Post
2 |
3 | VARIABLES_CATALOG = {
4 | }
5 |
6 | def initialize(properties, url=nil)
7 | super(properties, url)
8 | end
9 |
10 | def kind
11 | 'video'
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/db/migrate/004_add_flow_media_templates.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | alter_table(:flows) do
4 | add_column :media_path_template, String, size: 255
5 | add_column :media_url_template, String, size: 255
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/views/500.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 500 Error
9 | <%= h env['sinatra.error'].message %>
10 |
11 |
--------------------------------------------------------------------------------
/models/site.rb:
--------------------------------------------------------------------------------
1 | class Site < Sequel::Model
2 | one_to_one :default_store, class: :Store
3 | many_to_one :file_flow, class: :Flow
4 | one_to_many :flows
5 |
6 | def log(count=20)
7 | return DB[:log].where(site_id: id).reverse_order(:started_at).first(count)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/002_create_stores.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | create_table(:stores) do
4 | primary_key :id
5 | foreign_key :site_id, :sites
6 | Int :type_id, null: false
7 | String :location
8 | String :user
9 | String :key
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/migrate/008_add_settings_to_sites.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | alter_table(:sites) do
4 | add_column :timezone, String, size: 255, default: 'Etc/UTC'
5 | add_column :private, TrueClass, default: true
6 | add_column :generator, String, size: 255
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/routes/webmention.rb:
--------------------------------------------------------------------------------
1 | class SiteWriter < Sinatra::Application
2 |
3 | get '/:domain/webmention' do
4 | "Webmention endpoint"
5 | end
6 |
7 | post '/:domain/webmention' do
8 | puts "Webmention params=#{params}"
9 | Webmention.receive(params[:source], params[:target])
10 | headers 'Location' => params[:target]
11 | status 202
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/models/init.rb:
--------------------------------------------------------------------------------
1 | require 'sequel'
2 | # require 'will_paginate/sequel'
3 | Sequel::Database.extension(:pagination, :pg_json)
4 | Sequel.extension(:pg_array, :pg_json, :pg_json_ops)
5 |
6 | DB = Sequel.connect(ENV['DATABASE_URL'])
7 |
8 | require_relative 'site'
9 | require_relative 'flow'
10 | require_relative 'store'
11 | require_relative 'github'
12 | require_relative 'file_system'
13 |
--------------------------------------------------------------------------------
/lib/posts/photo.rb:
--------------------------------------------------------------------------------
1 | class Photo < Post
2 |
3 | VARIABLES_CATALOG = {
4 | syndication: 'original URL'
5 | }
6 |
7 | def initialize(properties, url=nil)
8 | super(properties, url)
9 | end
10 |
11 | def kind
12 | 'photo'
13 | end
14 |
15 | def render_variables
16 | return super().merge({
17 | syndication: @syndication
18 | })
19 | end
20 |
21 | end
22 |
--------------------------------------------------------------------------------
/script/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "irb"
4 | require "irb/completion"
5 |
6 | env = ENV['RACK_ENV'] || 'development'
7 | env = env.to_sym
8 |
9 | require "bundler/setup"
10 | Bundler.require(:default, env)
11 |
12 | Dotenv.load unless env == :production
13 |
14 | # require_relative '../models/init'
15 | require_relative '../app'
16 |
17 | # Suppress the Sinatra `at_exit` hook
18 | set :run, false
19 |
20 | IRB.start
21 |
--------------------------------------------------------------------------------
/models/store.rb:
--------------------------------------------------------------------------------
1 | class Store < Sequel::Model
2 |
3 | TYPES = {
4 | 1 => :Github
5 | }
6 |
7 | plugin :single_table_inheritance, :type_id, model_map: TYPES
8 |
9 | many_to_one :site
10 |
11 | def type_desc
12 | return "Unknown"
13 | end
14 |
15 | def name
16 | return "Invalid"
17 | end
18 |
19 | end
20 |
21 | class StoreError < SitewriterError
22 | def initialize(message)
23 | super("store", message)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/init.rb:
--------------------------------------------------------------------------------
1 | require_relative 'micropub'
2 | require_relative 'auth'
3 |
4 | require_relative 'media'
5 |
6 | # require_relative 'card'
7 | # require_relative 'cite'
8 | # require_relative 'location'
9 |
10 | require_relative 'post'
11 | Dir[File.join(__dir__, 'posts', '*.rb')].each { |file| require file }
12 |
13 | # require_relative 'items/article'
14 | # require_relative 'items/bookmark'
15 | # require_relative 'items/note'
16 | # require_relative 'items/photo'
17 |
--------------------------------------------------------------------------------
/lib/posts/article.rb:
--------------------------------------------------------------------------------
1 | class Article < Post
2 |
3 | VARIABLES_CATALOG = {
4 | title: 'post title'
5 | }
6 |
7 | attr_accessor :title
8 |
9 | def initialize(properties, url=nil)
10 | super(properties, url)
11 | if properties.key?('name')
12 | @title = properties['name'][0]
13 | else
14 | @title = ''
15 | end
16 | end
17 |
18 | def kind
19 | 'article'
20 | end
21 |
22 | def render_variables
23 | return super().merge({
24 | title: @title
25 | })
26 | end
27 |
28 | end
29 |
--------------------------------------------------------------------------------
/db/migrate/003_create_flows.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | create_table(:flows) do
4 | primary_key :id
5 | foreign_key :site_id, :sites
6 | foreign_key :store_id, :stores
7 | foreign_key :media_store_id, :stores
8 | Int :post_type_id
9 | TrueClass :allow_media
10 | TrueClass :allow_meta
11 | String :name, size: 140
12 | String :path_template, size: 255
13 | String :url_template, size: 255
14 | String :content_template, text: true
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/posts/like.rb:
--------------------------------------------------------------------------------
1 | class Like < Post
2 |
3 | VARIABLES_CATALOG = {
4 | like_of: 'liked URL',
5 |
6 | content: nil,
7 | has_photos: nil,
8 | photos: nil
9 | }
10 |
11 | def initialize(properties, url=nil)
12 | super(properties, url)
13 | if properties.key?('like-of')
14 | @like_of = properties['like-of'][0]
15 | else
16 | @like_of = ''
17 | end
18 | end
19 |
20 | def kind
21 | 'like'
22 | end
23 |
24 | def render_variables
25 | return super().merge({
26 | like_of: @like_of
27 | })
28 | end
29 |
30 | end
31 |
--------------------------------------------------------------------------------
/db/migrate/005_update_flow_type_to_kind.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | up do
3 | add_column :flows, :post_kind, String, size: 255
4 | from(:flows).where(post_type_id: 1).update(post_kind: 'article')
5 | from(:flows).where(post_type_id: 2).update(post_kind: 'note')
6 | drop_column :flows, :post_type_id
7 | end
8 |
9 | down do
10 | add_column :flows, :post_type_id, Integer
11 | from(:flows).where(post_kind: 'article').update(post_type_id: 1)
12 | from(:flows).where(post_kind: 'note').update(post_type_id: 2)
13 | drop_column :flows, :post_kind
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/db/migrate/006_create_log.rb:
--------------------------------------------------------------------------------
1 | Sequel.migration do
2 | change do
3 | create_table(:log) do
4 | primary_key :id
5 | foreign_key :site_id, :sites
6 | foreign_key :flow_id, :flows
7 |
8 | column :started_at, 'timestamp without time zone', index: true
9 | column :finished_at, 'timestamp without time zone'
10 | String :ip
11 | String :user_agent
12 | column :properties, :jsonb
13 | String :request, text: true
14 | String :kind
15 | String :url, size: 255
16 | Integer :status_code
17 | column :error, :jsonb
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/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/posts/bookmark.rb:
--------------------------------------------------------------------------------
1 | class Bookmark < Post
2 |
3 | VARIABLES_CATALOG = {
4 | name: 'bookmark name',
5 | url: 'bookmarked url'
6 | }
7 |
8 | def initialize(properties, url=nil)
9 | super(properties, url)
10 | if properties.key?('name')
11 | @name = properties['name'][0]
12 | else
13 | @name = ''
14 | end
15 | if properties.key?('bookmark-of')
16 | @url = properties['bookmark-of'][0]
17 | else
18 | @url = ''
19 | end
20 | end
21 |
22 | def kind
23 | 'bookmark'
24 | end
25 |
26 | def render_variables
27 | return super().merge({
28 | name: @name,
29 | url: @url
30 | })
31 | end
32 |
33 | end
34 |
--------------------------------------------------------------------------------
/routes/init.rb:
--------------------------------------------------------------------------------
1 | configure do
2 | # use Rack::SSL if settings.production?
3 |
4 | # this feels like an odd hack to avoid Sinatra's natural directory structure
5 | root_path = "#{File.dirname(__FILE__)}/../"
6 | set :config_path, "#{root_path}config/"
7 | set :syndication_targets, {}
8 | set :markdown, layout_engine: :erb
9 | set :server, :puma
10 |
11 | set :views, "#{root_path}views/"
12 | end
13 |
14 | before do
15 | headers \
16 | "Referrer-Policy" => "no-referrer",
17 | "Content-Security-Policy" => "script-src 'self' 'unsafe-inline'"
18 | end
19 |
20 | require_relative 'pages'
21 | require_relative 'manage'
22 | require_relative 'micropub'
23 | # require_relative 'webmention'
24 |
--------------------------------------------------------------------------------
/lib/generators.yml:
--------------------------------------------------------------------------------
1 | eleventy:
2 | name: Eleventy
3 | url: https://11ty.io/
4 | destination: github
5 | frontmatter: yaml
6 | body: markdown
7 | jekyll:
8 | name: Jekyll
9 | url: https://jekyllrb.com/
10 | destination: github
11 | frontmatter: yaml
12 | body: markdown
13 | middleman:
14 | name: Middleman
15 | url: https://middlemanapp.com/
16 | destination: github
17 | frontmatter: yaml
18 | body: markdown
19 | hugo:
20 | name: Hugo
21 | url: https://www.staticgen.com/hugo
22 | destination: github
23 | frontmatter: yaml
24 | body: markdown
25 | _blot:
26 | name: Blot
27 | url: https://blot.im/
28 | destination: dropbox
29 | frontmatter: comment
30 | body: markdown
31 |
32 |
33 |
--------------------------------------------------------------------------------
/views/syslog.erb:
--------------------------------------------------------------------------------
1 | Most recent 100 log entries
2 |
3 | <% @log.first(100).each do |entry| %>
4 |
5 |
6 | <%= Site[entry[:site_id]].domain %>
7 |
8 |
9 | <%= entry[:started_at].strftime('%Y-%m-%d %H:%M:%S') %>
10 |
11 | <%= entry[:request] %>
12 |
13 | <% if entry[:url] %>
14 | <%= entry[:url] %>
15 | <% else %>
16 | <%= entry[:error]['error'] %> error
17 | <% end %>
18 |
19 |
20 | <% end %>
21 |
22 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | ruby '2.6.5'
4 |
5 | gem 'rake', ">= 2.0.6"
6 | gem 'sinatra', require: 'sinatra/base'
7 | gem 'sinatra-contrib', require: 'sinatra/link_header'
8 | gem 'rack-contrib'
9 | gem 'rack-ssl'
10 | gem 'moneta'
11 | gem 'redis'
12 | gem 'puma'
13 | gem 'httparty'
14 | gem 'nokogiri'
15 | gem 'mustache'
16 | gem 'mustermann'
17 | gem 'octokit'
18 | gem 'microformats'
19 | gem 'redcarpet'
20 | gem 'sanitize'
21 | gem 'builder'
22 | gem 'webmention'
23 | gem 'sequel_pg', require: 'sequel'
24 | gem 'will_paginate'
25 | gem 's3'
26 | gem 'twitter'
27 | gem 'tzinfo'
28 | gem 'tzinfo-data'
29 |
30 | group :production do
31 | gem 'sentry-raven'
32 | end
33 |
34 | group :development do
35 | gem 'dotenv'
36 | gem 'shotgun'
37 | end
38 |
--------------------------------------------------------------------------------
/lib/posts/reply.rb:
--------------------------------------------------------------------------------
1 | class Reply < Post
2 |
3 | VARIABLES_CATALOG = {
4 | original_url: 'original post URL',
5 | title: 'title',
6 |
7 | has_photos: nil,
8 | photos: nil
9 | }
10 |
11 | def initialize(properties, url=nil)
12 | super(properties, url)
13 | if properties.key?('in-reply-to')
14 | @original_url = properties['in-reply-to'][0]
15 | else
16 | @original_url = ''
17 | end
18 | if properties.key?('name')
19 | @title = properties['name'][0]
20 | else
21 | @title = ''
22 | end
23 | end
24 |
25 | def kind
26 | 'reply'
27 | end
28 |
29 | def render_variables
30 | return super().merge({
31 | original_url: @original_url,
32 | title: @title
33 | })
34 | end
35 |
36 | end
37 |
--------------------------------------------------------------------------------
/app.rb:
--------------------------------------------------------------------------------
1 | require 'sinatra'
2 |
3 | class SiteWriter < Sinatra::Application
4 | # enable :sessions
5 |
6 | configure :production do
7 | set :haml, { :ugly=>true }
8 | set :clean_trace, true
9 | set :show_exceptions, false # supposed to be the default?
10 | end
11 |
12 | configure :development do
13 | # ...
14 | end
15 |
16 | helpers do
17 | include Rack::Utils
18 | alias_method :h, :escape_html
19 | end
20 | end
21 |
22 | class SitewriterError < StandardError
23 | attr_reader :type, :status
24 | def initialize(type, message, status=500)
25 | @type = type
26 | @status = status.to_i
27 | super(message)
28 | end
29 | end
30 |
31 | require_relative 'lib/init'
32 | require_relative 'models/init'
33 | # require_relative 'helpers/init'
34 | require_relative 'routes/init'
35 |
--------------------------------------------------------------------------------
/views/site_settings.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 | Settings
10 |
26 |
--------------------------------------------------------------------------------
/models/file_system.rb:
--------------------------------------------------------------------------------
1 | # for local development and testing, fake the github api
2 | class FileSystem
3 |
4 | def contents(repo, opts)
5 | path = File.join(content_path, opts[:path])
6 | begin
7 | content = File.read(path)
8 | rescue
9 | return
10 | end
11 | content_encoded = Base64.encode64(content)
12 | OpenStruct.new({
13 | 'content' => content_encoded,
14 | 'sha' => 'fake_sha'
15 | })
16 | end
17 |
18 | def create_contents(repo, filename, message, content)
19 |
20 | puts "👀 #{content}"
21 |
22 | path = File.join(content_path, filename)
23 | FileUtils.mkdir_p(File.dirname(path))
24 | File.write(path, content)
25 | end
26 |
27 | def update_contents(repo, filename, message, sha, content)
28 | create_contents(repo, filename, message, content)
29 | end
30 |
31 | def upload(filename, file)
32 | path = File.join(content_path, filename)
33 | FileUtils.mkdir_p(File.dirname(path))
34 | File.write(path, file)
35 | end
36 |
37 | def content_path
38 | "#{File.dirname(__FILE__)}/../../content"
39 | end
40 |
41 | end
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/views/site_uploading.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 | Micropub Uploads
10 | <% if @site.file_flow.nil? || @site.file_flow.media_store.nil? %>
11 |
12 | No file saving destination! Please
set one up now.
13 |
14 | <% else %>
15 |
16 | Files save to <%= @site.file_flow.media_store.name %>. [
edit ]
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | All Files
26 |
27 |
28 | path template: <%= @site.file_flow.media_path_template %>
29 |
30 | url template: <%= @site.file_flow.media_url_template %>
31 |
32 | Edit
33 |
34 |
35 |
36 | <% end %>
37 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/views/layout.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Sitewriter [ALPHA]
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
17 | Sitewriter
18 | ALPHA
19 | <% if session[:domain] %>
20 |
23 | <% end %>
24 |
25 |
26 | <%= yield %>
27 |
28 |
29 |
32 |
33 |
--------------------------------------------------------------------------------
/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 || :development
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::PostBodyContentTypeParser
22 |
23 | # session pool using redis via moneta
24 | # if env != :development
25 | # require 'rack/session/moneta'
26 | # use Rack::Session::Moneta,
27 | # key: 'sitewriter.net',
28 | # path: '/',
29 | # expire_after: 7*24*60*60, # one week
30 | # secret: ENV['SESSION_SECRET_KEY'],
31 |
32 | # store: Moneta.new(:Redis, {
33 | # url: ENV['REDISCLOUD_URL'],
34 | # expires: true,
35 | # threadsafe: true
36 | # })
37 | # end
38 |
39 | root = ::File.dirname(__FILE__)
40 | require ::File.join( root, 'app' )
41 | run SiteWriter.new
42 |
--------------------------------------------------------------------------------
/public/images/file.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to save An icon for "save" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_save by 213.127.53.189 on 2018-05-10. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_save
7 |
--------------------------------------------------------------------------------
/public/images/bookmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to bookmark An icon for "bookmark" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_bookmark by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_bookmark
7 |
--------------------------------------------------------------------------------
/views/store_edit.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 | <% if @flow.nil? %>
10 | Save Destination
11 | <% else %>
12 | Save Destination For <%= @flow.name %>
13 | <% end %>
14 |
15 |
32 |
--------------------------------------------------------------------------------
/public/images/reply.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to chat An icon for "chat" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_chat by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_chat
7 |
--------------------------------------------------------------------------------
/public/images/event.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to schedule An icon for "schedule" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_schedule by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_schedule
7 |
--------------------------------------------------------------------------------
/public/images/article.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to report An icon for "report" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_report by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_report
7 |
--------------------------------------------------------------------------------
/public/images/note.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to write An icon for "write" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_write by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_write
7 |
--------------------------------------------------------------------------------
/public/images/video.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to film An icon for "film" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_film by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_film
7 |
--------------------------------------------------------------------------------
/public/images/photo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to photograph An icon for "photograph" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_photograph by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_photograph
7 |
--------------------------------------------------------------------------------
/views/site_posting.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 | Micropub Posts
10 | <% if @site.default_store.nil? %>s
11 |
12 | No saving destination! Please [
set one up ] now.
13 |
14 | <% else %>
15 |
16 | Posts save to <%= @site.default_store.name %>. [
edit ]
17 |
18 |
19 |
20 | <%
21 | @flows.each do |flow_hash|
22 | post_kind = flow_hash[:kind]
23 | flow = flow_hash[:flow]
24 | %>
25 |
26 |
27 |
28 |
29 |
30 | <%= post_kind.capitalize %>
31 |
32 | <% if flow %>
33 |
34 | file template: <%= flow.path_template %>
35 |
36 | url template: <%= flow.url_template %>
37 |
38 | Edit
39 |
40 | <% else %>
41 |
42 | Enable
43 |
44 |
45 | <% end %>
46 |
47 | <% end %>
48 |
49 | <% end %>
50 |
--------------------------------------------------------------------------------
/public/images/checkin.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to locate An icon for "locate" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_locate by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_locate
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sitewriter
2 |
3 | This is a small Sinatra service that provides publishing endpoints for sites that cannot provide their own.
4 |
5 | The first use case is Micropub posting of notes to [my Middleman site](https://hans.gerwitz.com/) via GitHub.
6 |
7 | Essentially, a configured domain has some _endpoints_, which receive content and apply _templates_ which are then sent to a _store_.
8 |
9 | Authentication for configuration is provided via IndieAuth.com
10 |
11 | I also use IndieAuth for the Micropub tokens. If you'd like to do the same, set the environment variable `TOKEN_ENDPOINT` to `https://tokens.indieauth.com/token`
12 |
13 | ## Post type heuristics
14 |
15 | - If `mp-type` is defined, use it
16 | - If `type` == h-event or `h` == event: event
17 | - If `type` == h-entry or `h` == entry:
18 | - If there is a `in-reply-to` value: reply
19 | - quotation
20 | - If there is a `repost-of` value: repost
21 | - If there is a `bookmark-of` value: bookmark
22 | - If there is a `checkin` or `u-checkin` value: checkin
23 | - If there is a `like-of` value: like
24 | - If there is a `video` value: video
25 | - If there is a `photo` value: photo
26 | - If there is a `audio` value: audio
27 | - If there is a `name` value: article
28 | - Otherwise: note
29 |
30 | N.B. entries are flat. The value of a property may be an entry itself, but the "deeper" values will not be flattened for post type discovery.
31 |
32 | ## Credits
33 |
34 | This was inspired by and initiated as a fork of Barry Frost's [Transformative](https://github.com/barryf/transformative).
35 |
--------------------------------------------------------------------------------
/views/flow_media.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | Media Uploads
12 |
13 |
14 |
15 | Files sent to your Sitewriter media endpoint will be saved with this configuration.
16 |
17 |
18 |
46 |
--------------------------------------------------------------------------------
/lib/_transformative/media_s3.rb:
--------------------------------------------------------------------------------
1 | module Media
2 | module_function
3 |
4 | def save(file)
5 | filename = "#{Time.now.strftime('%Y/%m/%d')}-#{SecureRandom.hex.to_s}"
6 | ext = file[:filename].match(/\./) ? '.' +
7 | file[:filename].split('.').last : ".jpg"
8 | filepath = "file/#{filename}#{ext}"
9 | content = file[:tempfile].read
10 |
11 | if ENV['RACK_ENV'] == 'production'
12 | # upload to github (canonical store)
13 | Store.upload(filepath, content)
14 | # upload to s3 (serves file)
15 | s3_upload(filepath, content, ext, file[:type])
16 | else
17 | rootpath = "#{File.dirname(__FILE__)}/../../../content/"
18 | FileSystem.new.upload(rootpath + filepath, content)
19 | end
20 |
21 | URI.join(ENV['MEDIA_URL'], filepath).to_s
22 | end
23 |
24 | def upload_files(files)
25 | files.map do |file|
26 | upload_file(file)
27 | end
28 | end
29 |
30 | def upload_file(file)
31 | if Utils.valid_url?(file)
32 | # TODO extract file from url and store?
33 | file
34 | else
35 | save(file)
36 | end
37 | end
38 |
39 | def s3_upload(filepath, content, ext, content_type)
40 | object = bucket.objects.build(filepath)
41 | object.content = content
42 | object.content_type = content_type
43 | object.acl = :public_read
44 | object.save
45 | end
46 |
47 | def s3
48 | @s3 ||= S3::Service.new(
49 | access_key_id: ENV['AWS_ACCESS_KEY_ID'],
50 | secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
51 | )
52 | end
53 |
54 | def bucket
55 | @bucket ||= s3.bucket(ENV['AWS_BUCKET'])
56 | end
57 |
58 | end
59 |
--------------------------------------------------------------------------------
/public/images/repost.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to sync An icon for "sync" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_sync by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_sync
7 |
--------------------------------------------------------------------------------
/public/images/like.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to like An icon for "like" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_like by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 |
7 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_like
8 |
--------------------------------------------------------------------------------
/public/images/attachment.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to clip An icon for "clip" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_clip by 213.127.53.189 on 2018-05-10. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_clip
7 |
--------------------------------------------------------------------------------
/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 < SitewriterError
54 | def initialize(message)
55 | super("syndication", message)
56 | end
57 | end
58 |
59 | end
60 |
--------------------------------------------------------------------------------
/lib/post_types.yml:
--------------------------------------------------------------------------------
1 | note:
2 | description: |
3 | A simple text post, usually short, without a title.
4 | link: https://indieweb.org/note
5 | attachments: true
6 | class: Note
7 | article:
8 | description: |
9 | A titled post, usually long.
10 | link: https://indieweb.org/article
11 | attachments: true
12 | class: Article
13 | photo:
14 | description: |
15 | A photo (or photos) with some description.
16 | link: https://indieweb.org/photo
17 | attachments: true
18 | class: Photo
19 | _video:
20 | description: |
21 | A video (or videos) with some description (and optionally thumbnails).
22 | link: https://indieweb.org/video
23 | attachments: true
24 | class: Video
25 | _audio:
26 | description: |
27 | A audio file (or files) with some description.
28 | link: https://indieweb.org/audio
29 | attachments: true
30 | class: Audio
31 | bookmark:
32 | description: |
33 | A bookmark of another URL, often with some comments or metadata.
34 | link: https://indieweb.org/bookmark
35 | attachments: false
36 | class: Bookmark
37 | checkin:
38 | description: |
39 | A location and time, often with some description.
40 | link: https://indieweb.org/checkin
41 | attachments: true
42 | class: Checkin
43 | like:
44 | description: |
45 | A "like" or "favorite" or "star" affirming a post at another URL.
46 | link: https://indieweb.org/like
47 | attachments: false
48 | class: Like
49 | reply:
50 | description: |
51 | A comment on another post, from anywhere.
52 | link: https://indieweb.org/reply
53 | attachments: false
54 | class: Reply
55 | _repost:
56 | description: |
57 | Republication of another post, from anywhere.
58 | link: https://indieweb.org/repost
59 | attachments: true
60 | class: Repost
61 | _event:
62 | description: |
63 | An event with a specific time and location.
64 | link: https://indieweb.org/event
65 | attachments: true
66 | class: Event
67 |
--------------------------------------------------------------------------------
/views/tools.erb:
--------------------------------------------------------------------------------
1 | Nuts and bolts
2 |
3 | Get IndieWeb ready with IndieWebify.Me .
4 |
5 |
6 | Test your Micropub setup at Micropub.rocks .
7 |
8 |
9 | Posting to your site
10 |
11 | Post with Micropub using Omnibear , Quill , or Micropublish .
12 |
13 |
14 | Use older posting tools by emulating Wordpress with Aaron Parecki's MetaWeblog bridge at xmlrpc.p3k.io .
15 |
16 |
17 | Stay social
18 |
19 | Micro.blog is a great place to add a conversation layer to your site. Sitewriter's Micropub endpoint works for posting and it should be easy for you to publish a compatible feed . It's also great for syndicating to Twitter and Facebook .
20 |
21 |
22 | Syndicate your posts to, and receive webmentions from, many social sites with Bridgy .
23 |
24 |
25 | Pelle Wessman's open WebMention Endpoint can received webmentions for you and use client Javascript to display them.
26 |
27 | You might also receive webmentions with Webmention.io and display them with the Jekyll plugin .
28 |
29 |
30 | Own your data
31 |
32 | Import your Instagram posts with OwnYourGram .
33 |
34 |
35 | Import your Swarm (Foursquare) checkins with OwnYourSwarm .
36 |
37 |
--------------------------------------------------------------------------------
/lib/_transformative/authorship.rb:
--------------------------------------------------------------------------------
1 | module Transformative
2 | module Authorship
3 | module_function
4 |
5 | def fetch(url)
6 | return unless Utils.valid_url?(url)
7 | get_author(url)
8 | end
9 |
10 | def get_author(url)
11 | json = Microformats.parse(url).to_json
12 | items = JSON.parse(json)['items']
13 |
14 | # find first h-entry
15 | entry = find_first_hentry(items)
16 | if entry.nil?
17 | raise AuthorshipError.new("No h-entry found at #{url}.")
18 | end
19 |
20 | # find author in the entry or on the page
21 | author = find_author(entry, url)
22 | return if author.nil?
23 |
24 | # find author properties
25 | if author.is_a?(Hash) && author['type'][0] == 'h-card'
26 | Card.new(author['properties'])
27 | elsif author.is_a?(String)
28 | url = if Utils.valid_url?(author)
29 | author
30 | else
31 | begin
32 | URI.join(url, author).to_s
33 | rescue URI::InvalidURIError
34 | end
35 | end
36 | get_author_hcard(url) if url
37 | end
38 | end
39 |
40 | def find_first_hentry(items)
41 | items.each do |item|
42 | if item['type'][0] == 'h-entry'
43 | return item
44 | end
45 | end
46 | end
47 |
48 | def find_author(entry, url)
49 | body = HTTParty.get(url).body
50 | if entry.is_a?(Hash) && entry['properties'].key?('author')
51 | entry['properties']['author'][0]
52 | elsif author_rel = Nokogiri::HTML(body).css("[rel=author]")
53 | author_rel.attribute('href').value
54 | end
55 | end
56 |
57 | def get_author_hcard(url)
58 | json = Microformats.parse(url).to_json
59 | properties = JSON.parse(json)['items'][0]['properties']
60 | # force the url to be this absolute url
61 | properties['url'] = [url]
62 | Card.new(properties)
63 | end
64 |
65 | end
66 | end
--------------------------------------------------------------------------------
/lib/media.rb:
--------------------------------------------------------------------------------
1 | class Media
2 | require 'rack/mime'
3 |
4 | def initialize(file_hash)
5 | @file = file_hash[:tempfile]
6 | @time = Time.now.utc.to_datetime
7 |
8 | type = file_hash[:type]
9 | if filename = file_hash[:filename]
10 | extension = File.extname(filename)[1..-1]
11 | @slug = "#{@time.strftime('%H%M%S')}-#{File.basename(filename, extension)}_#{SecureRandom.hex(2).to_s}"
12 | else
13 | @slug = "#{@time.strftime('%H%M%S')}-#{SecureRandom.hex(8).to_s}"
14 | end
15 | @extension = extension || Rack::Mime::MIME_TYPES.invert[type][1..-1]
16 | end
17 |
18 | def post_slug=(post_slug)
19 | @post_slug = post_slug
20 | end
21 |
22 | def render_variables
23 | vars = {
24 | slug: @slug,
25 | extension: @extension,
26 | date: @time.strftime('%Y-%m-%d'),
27 | year: @time.strftime('%Y'),
28 | month: @time.strftime('%m'),
29 | day: @time.strftime('%d'),
30 | hour: @time.strftime('%H'),
31 | minute: @time.strftime('%M'),
32 | second: @time.strftime('%S'),
33 | year_month: @time.strftime('%Y-%m')
34 | }
35 |
36 | if @post_slug
37 | vars.merge!({
38 | post_slug: @post_slug
39 | })
40 | end
41 |
42 | return vars
43 | end
44 |
45 | def file
46 | @file
47 | end
48 |
49 | def self.variables(is_attachment=false)
50 | vars = {
51 | slug: 'slug (filename)',
52 | extension: 'file extension',
53 |
54 | date: 'upload date (YYYY-MM-DD)',
55 | year: 'upload year (YYYY)',
56 | month: 'upload month (01-12)',
57 | day: 'day of upload month (01-31)',
58 | hour: 'hour of upload (00-23)',
59 | minute: 'minute of upload',
60 | second: 'second of upload',
61 | year_month: 'year and month (YYYY-MM)'
62 | }
63 |
64 | if is_attachment
65 | vars.merge!({
66 | post_slug: 'the post slug'
67 | })
68 | end
69 |
70 | return vars
71 | end
72 |
73 | end
74 |
--------------------------------------------------------------------------------
/views/index.erb:
--------------------------------------------------------------------------------
1 | <% if session[:domain] %>
2 |
3 | Welcome back!
4 |
5 |
6 | You're authenticated as <%= session[:domain] %>
7 |
8 |
11 |
12 | <% else %>
13 |
14 | What is this?
15 |
16 | SiteWriter.net makes it easy to use IndieWeb services with your static site. It currently supports writing Micropub posts to GitHub repositories.
17 |
18 |
19 | To get started, enable IndieAuth on your domain. (IndieAuth.com is a popular provider.) Then enter your domain here:
20 |
26 |
27 |
28 |
29 | If you don't yet have a site, you can get started for free with Jonathan McGlone's guide for GitHub pages . We strongly suggest setting it up with a custom domain of your own.
30 |
31 |
32 | <% end %>
33 |
34 | Sample Posts
35 |
36 |
37 | <% Site.all.map{|s| s.log.first}.select{|e| !e.nil?}.sort_by{|e| e[:started_at]}.first(5).each do |entry| %>
38 |
39 | <% if entry[:kind] %>
40 |
41 | <% else %>
42 |
43 | <% end %>
44 | <%= entry[:url] %>
45 |
46 | <% end %>
47 |
48 |
--------------------------------------------------------------------------------
/public/images/audio.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to sing An icon for "sing" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_sing by 213.127.53.189 on 2018-05-09. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_sing
7 |
--------------------------------------------------------------------------------
/views/site_status.erb:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | <% if @site.default_store.nil? %>
12 |
13 | No saving destination! Please
set one up now.
14 |
15 | <% elsif @site.flows.count == 0 %>
16 |
17 | No posting configured! Please
set some up now.
18 |
19 | <% else %>
20 |
21 | Ready for post types: <%= @site.flows.map{|f| f.name }.join(', ') %>
22 |
23 | <% end %>
24 |
25 | Recent Activity
26 | <% if @site.log.count == 0 %>
27 |
28 | No posts yet. Make sure you have linked to your endpoint by including this tag in the
<head> of your homepage:
29 |
<link href="<%= "#{request.scheme}://#{request.host_with_port}/#{@site.domain}/micropub" %>" rel="micropub">
30 |
31 | <% else %>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | <% @site.log.each do |entry| %>
40 |
41 |
42 | <% if entry[:kind] %>
43 |
44 | <%= entry[:kind] %>
45 | <% end %>
46 |
47 |
48 | <%= entry[:started_at].strftime('%Y-%m-%d %H:%M:%S') %>
49 |
50 |
51 | <% if entry[:file] %>
52 | <%= entry[:file] %>
53 | <% end %>
54 |
55 | <% if entry[:url] %>
56 | <%= entry[:url] %>
57 | <% else %>
58 |
59 | <%= entry[:error]['error'] %> error
60 | <%= entry[:error]['error_description'] %>
61 |
62 | <% end %>
63 |
64 |
65 | <% end %>
66 |
67 | <% end %>
68 |
--------------------------------------------------------------------------------
/lib/posts/_entry.rb:
--------------------------------------------------------------------------------
1 | class Entry < Post
2 |
3 | def initialize(properties, url=nil)
4 | super(properties, url)
5 | end
6 |
7 | def h_type
8 | 'h-entry'
9 | end
10 |
11 | def generate_url
12 | generate_url_published
13 | end
14 |
15 | def entry_type
16 | if @properties.key?('rsvp') &&
17 | %w( yes no maybe interested ).include?(@properties['rsvp'][0])
18 | 'rsvp'
19 | elsif @properties.key?('in-reply-to') &&
20 | Utils.valid_url?(@properties['in-reply-to'][0])
21 | 'reply'
22 | elsif @properties.key?('repost-of') &&
23 | Utils.valid_url?(@properties['repost-of'][0])
24 | 'repost'
25 | elsif @properties.key?('like-of') &&
26 | Utils.valid_url?(@properties['like-of'][0])
27 | 'like'
28 | elsif @properties.key?('video') &&
29 | Utils.valid_url?(@properties['video'][0])
30 | 'video'
31 | elsif @properties.key?('photo') &&
32 | Utils.valid_url?(@properties['photo'][0])
33 | 'photo'
34 | elsif @properties.key?('bookmark-of') &&
35 | Utils.valid_url?(@properties['bookmark-of'][0])
36 | 'bookmark'
37 | elsif @properties.key?('name') && !@properties['name'].empty? &&
38 | !content_start_with_name?
39 | 'article'
40 | elsif @properties.key?('checkin')
41 | 'checkin'
42 | else
43 | 'note'
44 | end
45 | end
46 |
47 | def content_start_with_name?
48 | return unless @properties.key?('content') && @properties.key?('name')
49 | content = @properties['content'][0].is_a?(Hash) &&
50 | @properties['content'][0].key?('html') ?
51 | @properties['content'][0]['html'] : @properties['content'][0]
52 | content_tidy = content.gsub(/\s+/, " ").strip
53 | name_tidy = @properties['name'][0].gsub(/\s+/, " ").strip
54 | content_tidy.start_with?(name_tidy)
55 | end
56 |
57 | def cite_belongs_to_post?(cite)
58 | property = case @properties['entry-type'][0]
59 | when 'reply', 'rsvp'
60 | 'in-reply-to'
61 | when 'repost'
62 | 'repost-of'
63 | when 'like'
64 | 'like-of'
65 | else
66 | return
67 | end
68 | @properties[property].include?(cite.properties['url'][0])
69 | end
70 |
71 | def self.new_from_form(params)
72 | # wrap each non-array value in an array
73 | deep_props = Hash[ params.map { |k, v| [k, Array(v)] } ]
74 | self.new(deep_props)
75 | end
76 |
77 | end
78 |
--------------------------------------------------------------------------------
/public/images/setup.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | to turn An icon for "turn" from the Lines and Angles series on to [icon]. Downloaded from http://www.toicon.com/icons/lines-and-angles_turn by 213.127.53.189 on 2018-05-10. Licensed CC-BY, see http://toicon.com/license/ for details.
5 |
6 | image/svg+xml Shannon E Thomas http://www.toicon.com/icons/lines-and-angles_turn
7 |
--------------------------------------------------------------------------------
/public/js/codemirror/yaml-frontmatter.js:
--------------------------------------------------------------------------------
1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others
2 | // Distributed under an MIT license: https://codemirror.net/LICENSE
3 |
4 | (function (mod) {
5 | if (typeof exports == "object" && typeof module == "object") // CommonJS
6 | mod(require("../../lib/codemirror"), require("../yaml/yaml"))
7 | else if (typeof define == "function" && define.amd) // AMD
8 | define(["../../lib/codemirror", "../yaml/yaml"], mod)
9 | else // Plain browser env
10 | mod(CodeMirror)
11 | })(function (CodeMirror) {
12 |
13 | var START = 0, FRONTMATTER = 1, BODY = 2
14 |
15 | // a mixed mode for Markdown text with an optional YAML front matter
16 | CodeMirror.defineMode("yaml-frontmatter", function (config, parserConfig) {
17 | var yamlMode = CodeMirror.getMode(config, "yaml")
18 | var innerMode = CodeMirror.getMode(config, parserConfig && parserConfig.base || "gfm")
19 |
20 | function curMode(state) {
21 | return state.state == BODY ? innerMode : yamlMode
22 | }
23 |
24 | return {
25 | startState: function () {
26 | return {
27 | state: START,
28 | inner: CodeMirror.startState(yamlMode)
29 | }
30 | },
31 | copyState: function (state) {
32 | return {
33 | state: state.state,
34 | inner: CodeMirror.copyState(curMode(state), state.inner)
35 | }
36 | },
37 | token: function (stream, state) {
38 | if (state.state == START) {
39 | if (stream.match(/---/, false)) {
40 | state.state = FRONTMATTER
41 | return yamlMode.token(stream, state.inner)
42 | } else {
43 | state.state = BODY
44 | state.inner = CodeMirror.startState(innerMode)
45 | return innerMode.token(stream, state.inner)
46 | }
47 | } else if (state.state == FRONTMATTER) {
48 | var end = stream.sol() && stream.match(/---/, false)
49 | var style = yamlMode.token(stream, state.inner)
50 | if (end) {
51 | state.state = BODY
52 | state.inner = CodeMirror.startState(innerMode)
53 | }
54 | return style
55 | } else {
56 | return innerMode.token(stream, state.inner)
57 | }
58 | },
59 | innerMode: function (state) {
60 | return {mode: curMode(state), state: state.inner}
61 | },
62 | blankLine: function (state) {
63 | var mode = curMode(state)
64 | if (mode.blankLine) return mode.blankLine(state.inner)
65 | }
66 | }
67 | })
68 | });
69 |
--------------------------------------------------------------------------------
/public/css/styles.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | }
4 | body {
5 | display: flex;
6 | flex-direction: column;
7 |
8 | margin: 0;
9 |
10 | line-height: 1.5;
11 |
12 | color: black;
13 | background: white;
14 | }
15 |
16 | header {
17 | margin: 0;
18 | padding: 0 16px;
19 | padding-top: 16px;
20 | font-size: 32px;
21 | background-color: #34495E;
22 | color: #FFFFFF;
23 | }
24 | header a {
25 | text-decoration: none;
26 | color: inherit;
27 | }
28 | header .domain {
29 | float: right;
30 | padding-top: 16px;
31 | font-size: 16px;
32 | }
33 |
34 | #content {
35 | flex: 1 0 auto;
36 | margin: 0;
37 | padding: 0;
38 | }
39 | #content>* {
40 | width: 100%;
41 | padding-top: 16px;
42 | padding-bottom: 16px;
43 | padding-left: 16px;
44 | padding-right: 16px;
45 | margin: 0;
46 | }
47 |
48 | footer {
49 | flex-shrink: 0;
50 |
51 | margin: 0;
52 | padding: 16px;
53 | font-size: 16px;
54 | background-color: #34495E;
55 | color: #FFFFFF;
56 | }
57 | footer a {
58 | color: inherit;
59 | }
60 |
61 | h1, h2 {
62 | color: #34495E;
63 | }
64 |
65 | a {
66 | color: #2980B9;
67 | }
68 |
69 | .nav {
70 | background-color: #ECF0F1;
71 | color: #34495E;
72 | }
73 | .nav a {
74 | color: #34495E;
75 | }
76 |
77 | ul.activity li {
78 | list-style: none;
79 | }
80 |
81 | .warning {
82 | font-weight: bold;
83 | background-color: #E67E22;
84 | color: #FFFFFF;
85 | }
86 |
87 | .success {
88 | font-weight: bold;
89 | background-color: #27AE60;
90 | color: #FFFFFF;
91 | }
92 |
93 | .warning a, .success a {
94 | color: #FFFFFF;
95 | }
96 |
97 | .log-error {
98 | font-weight: bold;
99 | color: #E67E22;
100 | }
101 |
102 | /* copied from milligram's textarea */
103 | .CodeMirror {
104 | background-color: transparent;
105 | border: 0.1rem solid #d1d1d1;
106 | border-radius: .4rem;
107 | box-shadow: none;
108 | box-sizing: inherit;
109 | padding: .6rem 1.0rem;
110 | width: 100%;
111 | }
112 | .CodeMirror-focused {
113 | border-color: #2980B9;
114 | }
115 |
116 | ul.template-vars {
117 | padding: 0;
118 | }
119 |
120 | ul.template-vars>li {
121 | display: inline;
122 | list-style-type: none;
123 | margin-right: 8px;
124 |
125 | background-color: #ECF0F1;
126 | font-size: 12px;
127 | height: 20px;
128 | padding: 4px 12px;
129 | border-radius: 10px;
130 | }
131 | ul.template-vars>li:hover {
132 | background-color: #BDC3C7;
133 | }
134 |
135 | .site-types th.icon {
136 | text-align: right;
137 | }
138 | .site-types th.icon img {
139 | width: 32px;
140 | height: 32px;
141 | }
142 |
--------------------------------------------------------------------------------
/models/flow.rb:
--------------------------------------------------------------------------------
1 | class Flow < Sequel::Model
2 |
3 | require 'mustermann'
4 |
5 | many_to_one :site
6 | many_to_one :store
7 | many_to_one :media_store, class: :Store
8 |
9 | def name
10 | if post_kind
11 | return post_kind.capitalize
12 | else
13 | return "Files"
14 | end
15 | end
16 |
17 | def url_pattern
18 | return Mustermann.new(url_template) # type: :sinatra
19 | end
20 | def path_pattern
21 | return Mustermann.new(path_template) # type: :sinatra
22 | end
23 | def media_url_pattern
24 | return Mustermann.new(media_url_template) # type: :sinatra
25 | end
26 | def media_path_pattern
27 | return Mustermann.new(media_path_template) # type: :sinatra
28 | end
29 |
30 | def url_for_post(post)
31 | begin
32 | relative_url = url_pattern.expand(:ignore, post.render_variables)
33 | return URI.join(site.url, relative_url).to_s
34 | rescue => e
35 | puts "#{e.message} #{e.backtrace.join("\n")}"
36 | raise SitewriterError.new("template", "Unable to generate post url: #{e.message}", 500)
37 | end
38 | end
39 |
40 | def file_path_for_post(post)
41 | begin
42 | return path_pattern.expand(:ignore, post.render_variables)
43 | rescue => e
44 | puts "#{e.message} #{e.backtrace.join("\n")}"
45 | raise SitewriterError.new("template", "Unable to generate file path: #{e.message}", 500)
46 | end
47 | end
48 |
49 | def file_content_for_post(post)
50 | begin
51 | return Mustache.render(content_template, post.render_variables).encode(universal_newline: true)
52 | rescue => e
53 | puts "#{e.message} #{e.backtrace.join("\n")}"
54 | raise SitewriterError.new("template", "Unable to apply content template: #{e.message}", 500)
55 | end
56 | end
57 |
58 | def store_post(post)
59 | store.put(file_path_for_post(post), file_content_for_post(post), post_kind)
60 | return url_for_post(post)
61 | end
62 |
63 | def url_for_media(media)
64 | relative_url = media_url_pattern.expand(:ignore, media.render_variables)
65 | return URI.join(site.url, relative_url).to_s
66 | end
67 |
68 | def file_path_for_media(media)
69 | return media_path_pattern.expand(:ignore, media.render_variables)
70 | end
71 |
72 | def store_file(media)
73 | media_store.upload(file_path_for_media(media), media.file, "file")
74 | return url_for_media(media)
75 | end
76 |
77 | def attach_photo_url(post, url)
78 | # TODO: allow alt text in hash for JSON (spec 3.3.2)
79 | post.attach_url(:photo, url)
80 | end
81 |
82 | def attach_photo_media(post, media)
83 | file_flow = site.file_flow
84 | url = file_flow.store_file(media)
85 | post.attach_url(:photo, url)
86 | end
87 |
88 | end
89 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 | Bundler.require(:default, :development)
3 |
4 | # require 'json'
5 | # require 'time'
6 |
7 | if ENV['RACK_ENV'] != 'production'
8 | # require 'dotenv'
9 | Dotenv.load
10 | end
11 |
12 | DB = Sequel.connect(ENV['DATABASE_URL'])
13 | # DB[:posts].truncate
14 |
15 | # CONTENT_PATH = "#{File.dirname(__FILE__)}/../content"
16 | #
17 | # def parse(file)
18 | # data = File.read(file)
19 | # post = JSON.parse(data)
20 | # url = file.sub(CONTENT_PATH,'').sub(/\.json$/,'')
21 | #
22 | # DB[:posts].insert(url: url, data: data)
23 | #
24 | # print "."
25 | # end
26 | #
27 | # desc "Rebuild database cache from all content JSON files."
28 | # task :rebuild do
29 | # Dir.glob("#{CONTENT_PATH}/**/*.json").each do |file|
30 | # parse(file)
31 | # end
32 | # end
33 | #
34 | # desc "Rebuild database cache from this month's content JSON files."
35 | # task :recent do
36 | # year_month = Time.now.strftime('%Y/%m')
37 | # files = Dir.glob("#{CONTENT_PATH}/#{year_month}/**/*.json")
38 | # # need to rebuild all cites and cards because they're not organised by month
39 | # files += Dir.glob("#{CONTENT_PATH}/cite/**/*.json")
40 | # files += Dir.glob("#{CONTENT_PATH}/card/**/*.json")
41 | # files.each do |file|
42 | # parse(file)
43 | # end
44 | # end
45 | #
46 | # desc "Fetch a context and store."
47 | # task :context_fetch, :url do |t, args|
48 | # url = args[:url]
49 | # Transformative::Context.fetch(url)
50 | # end
51 |
52 | # via https://stackoverflow.com/questions/22800017/sequel-generate-migration
53 | namespace :db do
54 | require "sequel"
55 | Sequel.extension :migration
56 | # DB = Sequel.connect(ENV['DATABASE_URL'])
57 |
58 | desc "Prints current schema version"
59 | task :version do
60 | version = if DB.tables.include?(:schema_info)
61 | DB[:schema_info].first[:version]
62 | end || 0
63 |
64 | puts "Schema Version: #{version}"
65 | end
66 |
67 | desc "Run migrations"
68 | task :migrate, [:version] do |t, args|
69 | if args[:version]
70 | puts "Migrating to version #{args[:version]}"
71 | Sequel::Migrator.run(DB, "db/migrate", target: args[:version].to_i)
72 | else
73 | puts "Migrating to latest"
74 | Sequel::Migrator.run(DB, "db/migrate")
75 | end
76 | Rake::Task['db:version'].execute
77 | end
78 |
79 | desc "Perform rollback to specified target or full rollback as default"
80 | task :rollback, :target do |t, args|
81 | args.with_defaults(:target => 0)
82 |
83 | Sequel::Migrator.run(DB, "db/migrate", :target => args[:target].to_i)
84 | Rake::Task['db:version'].execute
85 | end
86 |
87 | desc "Perform migration reset (full rollback and migration)"
88 | task :reset do
89 | Sequel::Migrator.run(DB, "db/migrate", :target => 0)
90 | Sequel::Migrator.run(DB, "db/migrate")
91 | Rake::Task['db:version'].execute
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/auth.rb:
--------------------------------------------------------------------------------
1 | module Auth
2 | module_function
3 |
4 | def url_via_indieauth(our_url, code)
5 | # TODO: use our own endpoint instead of IndieAuth.com
6 | response = HTTParty.post('https://indieauth.com/auth', {
7 | body: {
8 | code: code,
9 | client_id: "#{our_url}",
10 | redirect_uri: "#{our_url}login"
11 | },
12 | headers: { 'Accept' => 'application/json' }
13 | })
14 | unless response.code.to_i == 200
15 | if result = JSON.parse(response.body)
16 | raise SitewriterError.new(result.error, result.error_description, response.code.to_i)
17 | else
18 | raise SitewriterError.new("indieauth", "Unrecognized IndieAuth error", 500)
19 | end
20 | end
21 | body = JSON.parse(response.body)
22 | if body['me']
23 | return body['me']
24 | else
25 | raise SitewriterError.new("indieauth", "Invalid IndieAuth response", 500)
26 | end
27 | end
28 |
29 | # TODO: don't assume we know the token endpoint!
30 | def verify_token_and_scope(token, scope)
31 | response = get_token_response(token, ENV['TOKEN_ENDPOINT'])
32 | unless response.code.to_i == 200
33 | raise ForbiddenError.new
34 | end
35 |
36 | response_hash = CGI.parse(response.parsed_response)
37 | if response_hash.key?('scope') && response_hash['scope'].is_a?(Array)
38 | scopes = response_hash['scope'][0].split(' ')
39 | return if scopes.include?(scope)
40 | # if we want to post and are allowed to create then go ahead
41 | return if scope == 'post' && scopes.include?('create')
42 | end
43 | raise InsufficientScope.new
44 | end
45 |
46 | def get_token_response(token, token_endpoint)
47 | HTTParty.get(
48 | token_endpoint,
49 | headers: {
50 | 'Accept' => 'application/x-www-form-urlencoded',
51 | 'Authorization' => "Bearer #{token}"
52 | })
53 | end
54 |
55 | def verify_github_signature(body, header_signature)
56 | signature = 'sha1=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'),
57 | ENV['GITHUB_SECRET'], body)
58 | unless Rack::Utils.secure_compare(signature, header_signature)
59 | raise ForbiddenError.new("GitHub webhook signatures did not match.")
60 | end
61 | end
62 |
63 | class NoTokenError < SitewriterError
64 | def initialize(message="Micropub endpoint did not return an access token.")
65 | super("unauthorized", message, 401)
66 | end
67 | end
68 |
69 | class InsufficientScope < SitewriterError
70 | def initialize(message="The user does not have sufficient scope to perform this action.")
71 | super("insufficient_scope", message, 401)
72 | end
73 | end
74 |
75 | class ForbiddenError < SitewriterError
76 | def initialize(message="The authenticated user does not have permission" +
77 | " to perform this request.")
78 | super("forbidden", message, 403)
79 | end
80 | end
81 |
82 | end
83 |
--------------------------------------------------------------------------------
/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/posts/checkin.rb:
--------------------------------------------------------------------------------
1 | class Checkin < Post
2 |
3 | VARIABLES_CATALOG = {
4 | name: 'name of venue',
5 | url: 'venue URL',
6 |
7 | latitude: 'venue latitude',
8 | longitude: 'venue longitude',
9 |
10 | address: 'venue street address',
11 | locality: 'venue city',
12 | region: 'venue region',
13 | country: 'venue country',
14 | postal: 'venue postal code',
15 |
16 | tel: 'venue telephone number'
17 | }
18 |
19 | def initialize(properties, url=nil)
20 | super(properties, url)
21 | @name = ''
22 | @url = ''
23 | @latitude = ''
24 | @longitude = ''
25 | @address = ''
26 | @locality = ''
27 | @region = ''
28 | @country = ''
29 | @postal = ''
30 | @telephone = ''
31 | if properties.key?('checkin')
32 | if properties['checkin'][0].respond_to?(:key) && properties['checkin'][0].key?('type') && properties['checkin'][0]['type'][0] == 'h-card'
33 | checkin_props = properties['checkin'][0]['properties']
34 | if checkin_props.key?('name')
35 | @name = checkin_props['name'][0]
36 | else
37 | @name = ''
38 | end
39 | if checkin_props.key?('url')
40 | @url = checkin_props['url'][0]
41 | else
42 | @url = ''
43 | end
44 | if checkin_props.key?('latitude')
45 | @latitude = checkin_props['latitude'][0]
46 | else
47 | @latitude = ''
48 | end
49 | if checkin_props.key?('longitude')
50 | @longitude = checkin_props['longitude'][0]
51 | else
52 | @longitude = ''
53 | end
54 | if checkin_props.key?('street-address')
55 | @address = checkin_props['street-address'][0]
56 | else
57 | @address = ''
58 | end
59 | if checkin_props.key?('locality')
60 | @locality = checkin_props['locality'][0]
61 | else
62 | @locality = ''
63 | end
64 | if checkin_props.key?('region')
65 | @region = checkin_props['region'][0]
66 | else
67 | @region = ''
68 | end
69 | if checkin_props.key?('country')
70 | @country = checkin_props['country'][0]
71 | else
72 | @country = ''
73 | end
74 | if checkin_props.key?('postal-code')
75 | @postal = checkin_props['postal-code'][0]
76 | else
77 | @postal = ''
78 | end
79 | if checkin_props.key?('tel')
80 | @telephone = checkin_props['tel'][0]
81 | else
82 | @telephone = ''
83 | end
84 | # default slug
85 | @slug = @name || @locality || @region || @country
86 | else
87 | raise "Checkin without an h-card"
88 | end
89 | # this should be unreachable
90 | end
91 | end
92 |
93 | def kind
94 | 'checkin'
95 | end
96 |
97 | def render_variables
98 | return super().merge({
99 | name: @name,
100 | url: @url,
101 |
102 | latitude: @latitude,
103 | longitude: @longitude,
104 |
105 | address: @address,
106 | locality: @locality,
107 | region: @region,
108 | country: @country,
109 | postal: @postal,
110 |
111 | telephone: @telephone
112 | })
113 | end
114 |
115 | end
116 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | addressable (2.7.0)
5 | public_suffix (>= 2.0.2, < 5.0)
6 | backports (3.15.0)
7 | buftok (0.2.0)
8 | builder (3.2.4)
9 | concurrent-ruby (1.1.5)
10 | crass (1.0.6)
11 | domain_name (0.5.20190701)
12 | unf (>= 0.0.5, < 1.0.0)
13 | dotenv (2.7.5)
14 | equalizer (0.0.11)
15 | faraday (0.17.1)
16 | multipart-post (>= 1.2, < 3)
17 | http (3.3.0)
18 | addressable (~> 2.3)
19 | http-cookie (~> 1.0)
20 | http-form_data (~> 2.0)
21 | http_parser.rb (~> 0.6.0)
22 | http-cookie (1.0.3)
23 | domain_name (~> 0.5)
24 | http-form_data (2.1.1)
25 | http_parser.rb (0.6.0)
26 | httparty (0.15.7)
27 | multi_xml (>= 0.5.2)
28 | json (2.3.0)
29 | link_header (0.0.8)
30 | memoizable (0.4.2)
31 | thread_safe (~> 0.3, >= 0.3.1)
32 | microformats (4.1.0)
33 | json (~> 2.1)
34 | nokogiri (~> 1.8, >= 1.8.3)
35 | mini_portile2 (2.4.0)
36 | moneta (1.2.1)
37 | multi_json (1.14.1)
38 | multi_xml (0.6.0)
39 | multipart-post (2.1.1)
40 | mustache (1.1.1)
41 | mustermann (1.0.3)
42 | naught (1.1.0)
43 | nio4r (2.5.2)
44 | nokogiri (1.10.8)
45 | mini_portile2 (~> 2.4.0)
46 | nokogumbo (2.0.2)
47 | nokogiri (~> 1.8, >= 1.8.4)
48 | octokit (4.14.0)
49 | sawyer (~> 0.8.0, >= 0.5.3)
50 | pg (1.1.4)
51 | proxies (0.2.3)
52 | public_suffix (4.0.1)
53 | puma (4.3.5)
54 | nio4r (~> 2.0)
55 | rack (2.2.3)
56 | rack-contrib (2.1.0)
57 | rack (~> 2.0)
58 | rack-protection (2.0.7)
59 | rack
60 | rack-ssl (1.4.1)
61 | rack
62 | rake (13.0.1)
63 | redcarpet (3.5.0)
64 | redis (4.1.3)
65 | s3 (0.3.28)
66 | addressable
67 | proxies
68 | sanitize (5.2.1)
69 | crass (~> 1.0.2)
70 | nokogiri (>= 1.8.0)
71 | nokogumbo (~> 2.0)
72 | sawyer (0.8.2)
73 | addressable (>= 2.3.5)
74 | faraday (> 0.8, < 2.0)
75 | sentry-raven (2.13.0)
76 | faraday (>= 0.7.6, < 1.0)
77 | sequel (5.27.0)
78 | sequel_pg (1.12.2)
79 | pg (>= 0.18.0)
80 | sequel (>= 4.38.0)
81 | shotgun (0.9.2)
82 | rack (>= 1.0)
83 | simple_oauth (0.3.1)
84 | sinatra (2.0.7)
85 | mustermann (~> 1.0)
86 | rack (~> 2.0)
87 | rack-protection (= 2.0.7)
88 | tilt (~> 2.0)
89 | sinatra-contrib (2.0.7)
90 | backports (>= 2.8.2)
91 | multi_json
92 | mustermann (~> 1.0)
93 | rack-protection (= 2.0.7)
94 | sinatra (= 2.0.7)
95 | tilt (~> 2.0)
96 | thread_safe (0.3.6)
97 | tilt (2.0.10)
98 | twitter (6.2.0)
99 | addressable (~> 2.3)
100 | buftok (~> 0.2.0)
101 | equalizer (~> 0.0.11)
102 | http (~> 3.0)
103 | http-form_data (~> 2.0)
104 | http_parser.rb (~> 0.6.0)
105 | memoizable (~> 0.4.0)
106 | multipart-post (~> 2.0)
107 | naught (~> 1.0)
108 | simple_oauth (~> 0.3.0)
109 | tzinfo (2.0.0)
110 | concurrent-ruby (~> 1.0)
111 | tzinfo-data (1.2019.3)
112 | tzinfo (>= 1.0.0)
113 | unf (0.1.4)
114 | unf_ext
115 | unf_ext (0.0.7.6)
116 | webmention (0.1.6)
117 | httparty (~> 0.15.5)
118 | json
119 | link_header (~> 0.0.8)
120 | nokogiri
121 | will_paginate (3.2.1)
122 |
123 | PLATFORMS
124 | ruby
125 |
126 | DEPENDENCIES
127 | builder
128 | dotenv
129 | httparty
130 | microformats
131 | moneta
132 | mustache
133 | mustermann
134 | nokogiri
135 | octokit
136 | puma
137 | rack-contrib
138 | rack-ssl
139 | rake (>= 2.0.6)
140 | redcarpet
141 | redis
142 | s3
143 | sanitize
144 | sentry-raven
145 | sequel_pg
146 | shotgun
147 | sinatra
148 | sinatra-contrib
149 | twitter
150 | tzinfo
151 | tzinfo-data
152 | webmention
153 | will_paginate
154 |
155 | RUBY VERSION
156 | ruby 2.6.5p114
157 |
158 | BUNDLED WITH
159 | 1.17.2
160 |
--------------------------------------------------------------------------------
/models/github.rb:
--------------------------------------------------------------------------------
1 | # module Transformative
2 | class Github < Store
3 |
4 | def type_desc
5 | return "GitHub"
6 | end
7 |
8 | def name
9 | return "GitHub (#{github_full_repo})"
10 | end
11 |
12 | def put(filename, content, description="post")
13 | puts "put: filename=#{filename}"
14 | # content = JSON.pretty_generate(data)
15 | if existing = get_file(filename)
16 | unless Base64.decode64(existing['content']) == content
17 | update(existing['sha'], filename, content, description)
18 | end
19 | else
20 | create(filename, content, description)
21 | end
22 | end
23 |
24 | def create(filename, content, description)
25 | octokit.create_contents(
26 | github_full_repo,
27 | filename,
28 | "New #{description} (via sitewriter.net)",
29 | content
30 | )
31 | end
32 |
33 | def update(sha, filename, content, description)
34 | octokit.update_contents(
35 | github_full_repo,
36 | filename,
37 | "Updated #{description} (via sitewriter.net)",
38 | sha,
39 | content
40 | )
41 | end
42 |
43 | def upload(filename, file, description="file")
44 | octokit.create_contents(
45 | github_full_repo,
46 | filename,
47 | "New #{description} (via sitewriter.net)",
48 | {file: file}
49 | )
50 | end
51 |
52 | # broken
53 | def get(filename)
54 | file_content = get_file_content(filename)
55 | data = JSON.parse(file_content)
56 | url = filename.sub(/\.json$/, '')
57 | klass = Post.class_from_type(data['type'][0])
58 | klass.new(data['properties'], url)
59 | end
60 |
61 | def get_url(url)
62 | relative_url = Utils.relative_url(url)
63 | get("#{relative_url}.json")
64 | end
65 |
66 | def exists_url?(url)
67 | relative_url = Utils.relative_url(url)
68 | get_file("#{relative_url}.json") != nil
69 | end
70 |
71 | def get_file(filename)
72 | begin
73 | octokit.contents(github_full_repo, { path: filename })
74 | rescue Octokit::NotFound
75 | end
76 | end
77 |
78 | def get_file_content(filename)
79 | base64_content = octokit.contents(
80 | github_full_repo,
81 | { path: filename }
82 | ).content
83 | Base64.decode64(base64_content)
84 | end
85 |
86 | # def webhook(commits)
87 | # commits.each do |commit|
88 | # process_files(commit['added']) if commit['added'].any?
89 | # process_files(commit['modified'], true) if commit['modified'].any?
90 | # end
91 | # end
92 | #
93 | # def process_files(files, modified=false)
94 | # files.each do |file|
95 | # file_content = get_file_content(file)
96 | # url = "/" + file.sub(/\.json$/,'')
97 | # data = JSON.parse(file_content)
98 | # klass = Post.class_from_type(data['type'][0])
99 | # post = klass.new(data['properties'], url)
100 | #
101 | # if %w( h-entry h-event ).include?(data['type'][0])
102 | # if modified
103 | # existing_webmention_client =
104 | # ::Webmention::Client.new(post.absolute_url)
105 | # begin
106 | # existing_webmention_client.crawl
107 | # rescue OpenURI::HTTPError
108 | # end
109 | # Cache.put(post)
110 | # existing_webmention_client.send_mentions
111 | # else
112 | # Cache.put(post)
113 | # end
114 | # ::Webmention::Client.new(post.absolute_url).send_mentions
115 | # Utils.ping_pubsubhubbub
116 | # Context.fetch_contexts(post)
117 | # else
118 | # Cache.put(post)
119 | # end
120 | # end
121 | # end
122 |
123 | def github_full_repo
124 | "#{user}/#{location}"
125 | end
126 |
127 | def octokit
128 | @octokit ||= Octokit::Client.new(access_token: key)
129 | # @octokit ||= case (ENV['RACK_ENV'] || 'development').to_sym
130 | # when :production
131 | # Octokit::Client.new(access_token: key)
132 | # else
133 | # FileSystem.new
134 | # end
135 | end
136 |
137 | end
138 | # end
139 |
--------------------------------------------------------------------------------
/public/js/codemirror/yaml.js:
--------------------------------------------------------------------------------
1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others
2 | // Distributed under an MIT license: https://codemirror.net/LICENSE
3 |
4 | (function(mod) {
5 | if (typeof exports == "object" && typeof module == "object") // CommonJS
6 | mod(require("../../lib/codemirror"));
7 | else if (typeof define == "function" && define.amd) // AMD
8 | define(["../../lib/codemirror"], mod);
9 | else // Plain browser env
10 | mod(CodeMirror);
11 | })(function(CodeMirror) {
12 | "use strict";
13 |
14 | CodeMirror.defineMode("yaml", function() {
15 |
16 | var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
17 | var keywordRegex = new RegExp("\\b(("+cons.join(")|(")+"))$", 'i');
18 |
19 | return {
20 | token: function(stream, state) {
21 | var ch = stream.peek();
22 | var esc = state.escaped;
23 | state.escaped = false;
24 | /* comments */
25 | if (ch == "#" && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
26 | stream.skipToEnd();
27 | return "comment";
28 | }
29 |
30 | if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))
31 | return "string";
32 |
33 | if (state.literal && stream.indentation() > state.keyCol) {
34 | stream.skipToEnd(); return "string";
35 | } else if (state.literal) { state.literal = false; }
36 | if (stream.sol()) {
37 | state.keyCol = 0;
38 | state.pair = false;
39 | state.pairStart = false;
40 | /* document start */
41 | if(stream.match(/---/)) { return "def"; }
42 | /* document end */
43 | if (stream.match(/\.\.\./)) { return "def"; }
44 | /* array list item */
45 | if (stream.match(/\s*-\s+/)) { return 'meta'; }
46 | }
47 | /* inline pairs/lists */
48 | if (stream.match(/^(\{|\}|\[|\])/)) {
49 | if (ch == '{')
50 | state.inlinePairs++;
51 | else if (ch == '}')
52 | state.inlinePairs--;
53 | else if (ch == '[')
54 | state.inlineList++;
55 | else
56 | state.inlineList--;
57 | return 'meta';
58 | }
59 |
60 | /* list seperator */
61 | if (state.inlineList > 0 && !esc && ch == ',') {
62 | stream.next();
63 | return 'meta';
64 | }
65 | /* pairs seperator */
66 | if (state.inlinePairs > 0 && !esc && ch == ',') {
67 | state.keyCol = 0;
68 | state.pair = false;
69 | state.pairStart = false;
70 | stream.next();
71 | return 'meta';
72 | }
73 |
74 | /* start of value of a pair */
75 | if (state.pairStart) {
76 | /* block literals */
77 | if (stream.match(/^\s*(\||\>)\s*/)) { state.literal = true; return 'meta'; };
78 | /* references */
79 | if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) { return 'variable-2'; }
80 | /* numbers */
81 | if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) { return 'number'; }
82 | if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) { return 'number'; }
83 | /* keywords */
84 | if (stream.match(keywordRegex)) { return 'keyword'; }
85 | }
86 |
87 | /* pairs (associative arrays) -> key */
88 | if (!state.pair && stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^,\[\]{}#&*!|>'"%@`])[^#]*?(?=\s*:($|\s))/)) {
89 | state.pair = true;
90 | state.keyCol = stream.indentation();
91 | return "atom";
92 | }
93 | if (state.pair && stream.match(/^:\s*/)) { state.pairStart = true; return 'meta'; }
94 |
95 | /* nothing found, continue */
96 | state.pairStart = false;
97 | state.escaped = (ch == '\\');
98 | stream.next();
99 | return null;
100 | },
101 | startState: function() {
102 | return {
103 | pair: false,
104 | pairStart: false,
105 | keyCol: 0,
106 | inlinePairs: 0,
107 | inlineList: 0,
108 | literal: false,
109 | escaped: false
110 | };
111 | },
112 | lineComment: "#",
113 | fold: "indent"
114 | };
115 | });
116 |
117 | CodeMirror.defineMIME("text/x-yaml", "yaml");
118 | CodeMirror.defineMIME("text/yaml", "yaml");
119 |
120 | });
121 |
--------------------------------------------------------------------------------
/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 | 'name' => [properties['name'][0].strip],
48 | 'published' => [published.utc.iso8601],
49 | 'content' => [properties['content'][0]['value']],
50 | 'author' => [author.properties['url'][0]]
51 | }
52 | if properties.key?('photo')
53 | hash['photo'] = properties['photo']
54 | end
55 | if properties.key?('start')
56 | hash['start'] = properties['start']
57 | end
58 | if properties.key?('end')
59 | hash['end'] = properties['end']
60 | end
61 | if properties.key?('location')
62 | hash['location'] = properties['location']
63 | end
64 | cite = Cite.new(hash)
65 | [cite, author]
66 | end
67 |
68 | def parse_twitter(url)
69 | tweet_id = url.split('/').last
70 | tweet = twitter_client.status(tweet_id)
71 | cite_properties = {
72 | 'url' => [url],
73 | 'content' => [tweet.text.dup],
74 | 'author' => ["https://twitter.com/#{tweet.user.screen_name}"],
75 | 'published' => [Time.parse(tweet.created_at.to_s).utc]
76 | }
77 | # does the tweet have photo(s)?
78 | if tweet.media.any?
79 | cite_properties['photo'] = tweet.media.map { |m| m.media_url.to_s }
80 | end
81 | # replace t.co links with expanded versions
82 | tweet.urls.each do |u|
83 | cite_properties['content'][0].sub!(u.url.to_s, u.expanded_url.to_s)
84 | end
85 | cite = Cite.new(cite_properties)
86 | author_properties = {
87 | 'url' => ["https://twitter.com/#{tweet.user.screen_name}"],
88 | 'name' => [tweet.user.name],
89 | 'photo' => ["#{tweet.user.profile_image_url.scheme}://" +
90 | tweet.user.profile_image_url.host +
91 | tweet.user.profile_image_url.path]
92 | }
93 | # TODO: copy the photo somewhere else and reference it
94 | author = Card.new(author_properties)
95 | [cite, author]
96 | end
97 |
98 | def parse_instagram(url)
99 | url = tidy_instagram_url(url)
100 | json = HTTParty.get(
101 | "http://api.instagram.com/oembed?url=#{CGI::escape(url)}").body
102 | body = JSON.parse(json)
103 | cite_properties = {
104 | 'url' => [url],
105 | 'author' => [body['author_url']],
106 | 'photo' => [body['thumbnail_url']],
107 | 'content' => [body['title']]
108 | }
109 | cite = Cite.new(cite_properties)
110 | author_properties = {
111 | 'url' => [body['author_url']],
112 | 'name' => [body['author_name']]
113 | }
114 | author = Card.new(author_properties)
115 | [cite, author]
116 | end
117 |
118 | # strip cruft from URL, e.g. #liked-by-xxxx or modal=true from instagram
119 | def tidy_instagram_url(url)
120 | uri = URI.parse(url)
121 | uri.fragment = nil
122 | uri.query = nil
123 | uri.to_s
124 | end
125 |
126 | def find_first_hentry_or_hevent(items)
127 | items.each do |item|
128 | if item['type'][0] == 'h-entry' || item['type'][0] == 'h-event'
129 | return item
130 | end
131 | end
132 | end
133 |
134 | def twitter_client
135 | Utils.twitter_client
136 | end
137 |
138 | end
139 | end
--------------------------------------------------------------------------------
/lib/micropub.rb:
--------------------------------------------------------------------------------
1 | module Micropub
2 | module_function # why?
3 |
4 | # TODO: handle JSON requests
5 | def create(params, timezone=nil)
6 | if params.key?('h')
7 | # form-encoded
8 | mf_type = 'h-'+params['h'].to_s
9 | safe_properties = sanitize_properties(params)
10 | services = params.key?('mp-syndicate-to') ?
11 | Array(params['mp-syndicate-to']) : []
12 | elsif params.key?('type') && params['type'].is_a?(Array)
13 | # JSON
14 | mf_type = params['type'][0].to_s
15 | safe_properties = sanitize_properties(params['properties'])
16 | services = params['properties'].key?('mp-syndicate-to') ?
17 | params['properties']['mp-syndicate-to'] : []
18 | # check_if_syndicated(params['properties'])
19 | end
20 | safe_properties['type'] = [mf_type]
21 | # wrap each non-array value in an array
22 | deep_props = Hash[ safe_properties.map { |k, v| [k, Array(v)] } ]
23 | puts "👑 deep_props: #{deep_props.inspect}"
24 | post_type = Post.type_from_properties(deep_props)
25 | puts "👑 post_type: #{post_type}"
26 | post = Post.new_for_type(post_type, deep_props, timezone)
27 | # puts "👑 post: #{post.inspect}"
28 |
29 | post.set_explicit_slug(params)
30 | # post.syndicate(services) if services.any?
31 | # Store.save(post)
32 | return post
33 | end
34 |
35 | def create_media(params)
36 | # puts "🖼 #{params}"
37 | return Media.new(params)
38 | end
39 |
40 | def action(properties)
41 | post = Store.get_url(properties['url'])
42 |
43 | case properties['action'].to_sym
44 | when :update
45 | if properties.key?('replace')
46 | verify_hash(properties, 'replace')
47 | post.replace(properties['replace'])
48 | end
49 | if properties.key?('add')
50 | verify_hash(properties, 'add')
51 | post.add(properties['add'])
52 | end
53 | if properties.key?('delete')
54 | verify_array_or_hash(properties, 'delete')
55 | post.remove(properties['delete'])
56 | end
57 | when :delete
58 | post.delete
59 | when :undelete
60 | post.undelete
61 | end
62 |
63 | if properties.key?('mp-syndicate-to') && properties['mp-syndicate-to'].any?
64 | post.syndicate(properties['mp-syndicate-to'])
65 | end
66 | post.set_updated
67 | Store.save(post)
68 | end
69 |
70 | def verify_hash(properties, key)
71 | unless properties[key].is_a?(Hash)
72 | raise InvalidRequestError.new(
73 | "Invalid request: the '#{key}' property should be a hash.")
74 | end
75 | end
76 |
77 | def verify_array_or_hash(properties, key)
78 | unless properties[key].is_a?(Array) || properties[key].is_a?(Hash)
79 | raise InvalidRequestError.new(
80 | "Invalid request: the '#{key}' property should be an array or hash.")
81 | end
82 | end
83 |
84 | # has this post already been syndicated, perhaps via a pesos method?
85 | # def check_if_syndicated(properties)
86 | # if properties.key?('syndication') &&
87 | # Cache.find_via_syndication(properties['syndication']).any?
88 | # raise ConflictError.new
89 | # end
90 | # end
91 |
92 | def sanitize_properties(properties)
93 | Hash[
94 | properties.map { |k, v|
95 | unless k.start_with?('mp-') || k == 'access_token' || k == 'h' ||
96 | k == 'syndicate-to'
97 | [k, v]
98 | end
99 | }.compact
100 | ]
101 | end
102 |
103 | class ForbiddenError < SitewriterError
104 | def initialize(message="The authenticated user does not have permission to perform this request.")
105 | super("forbidden", message, 403)
106 | end
107 | end
108 |
109 | class InsufficientScopeError < SitewriterError
110 | def initialize(message="The scope of this token does not meet the requirements for this request.")
111 | super("insufficient_scope", message, 401)
112 | end
113 | end
114 |
115 | class InvalidRequestError < SitewriterError
116 | def initialize(message="The request is missing a required parameter, or there was a problem with a value of one of the parameters.")
117 | super("invalid_request", message, 400)
118 | end
119 | end
120 |
121 | class TypeError < SitewriterError
122 | def initialize(message="The request did not match any known post type.")
123 | super("server_error", message, 422)
124 | end
125 | end
126 |
127 | # not on-spec but it feels right
128 | class ContentError < SitewriterError
129 | def initialize(message="The request includes content that cannot be accepted for writing.")
130 | super("unaccepted_content", message, 422)
131 | end
132 | end
133 |
134 | class NotFoundError < SitewriterError
135 | def initialize(message="The post with the requested URL was not found.")
136 | super("not_found", message, 400)
137 | end
138 | end
139 |
140 | class ConflictError < SitewriterError
141 | def initialize(
142 | message="The post has already been created and syndicated.")
143 | super("conflict", message, 409)
144 | end
145 | end
146 |
147 | end
148 |
--------------------------------------------------------------------------------
/public/js/codemirror/multiplex.js:
--------------------------------------------------------------------------------
1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others
2 | // Distributed under an MIT license: https://codemirror.net/LICENSE
3 |
4 | (function(mod) {
5 | if (typeof exports == "object" && typeof module == "object") // CommonJS
6 | mod(require("../../lib/codemirror"));
7 | else if (typeof define == "function" && define.amd) // AMD
8 | define(["../../lib/codemirror"], mod);
9 | else // Plain browser env
10 | mod(CodeMirror);
11 | })(function(CodeMirror) {
12 | "use strict";
13 |
14 | CodeMirror.multiplexingMode = function(outer /*, others */) {
15 | // Others should be {open, close, mode [, delimStyle] [, innerStyle]} objects
16 | var others = Array.prototype.slice.call(arguments, 1);
17 |
18 | function indexOf(string, pattern, from, returnEnd) {
19 | if (typeof pattern == "string") {
20 | var found = string.indexOf(pattern, from);
21 | return returnEnd && found > -1 ? found + pattern.length : found;
22 | }
23 | var m = pattern.exec(from ? string.slice(from) : string);
24 | return m ? m.index + from + (returnEnd ? m[0].length : 0) : -1;
25 | }
26 |
27 | return {
28 | startState: function() {
29 | return {
30 | outer: CodeMirror.startState(outer),
31 | innerActive: null,
32 | inner: null
33 | };
34 | },
35 |
36 | copyState: function(state) {
37 | return {
38 | outer: CodeMirror.copyState(outer, state.outer),
39 | innerActive: state.innerActive,
40 | inner: state.innerActive && CodeMirror.copyState(state.innerActive.mode, state.inner)
41 | };
42 | },
43 |
44 | token: function(stream, state) {
45 | if (!state.innerActive) {
46 | var cutOff = Infinity, oldContent = stream.string;
47 | for (var i = 0; i < others.length; ++i) {
48 | var other = others[i];
49 | var found = indexOf(oldContent, other.open, stream.pos);
50 | if (found == stream.pos) {
51 | if (!other.parseDelimiters) stream.match(other.open);
52 | state.innerActive = other;
53 |
54 | // Get the outer indent, making sure to handle CodeMirror.Pass
55 | var outerIndent = 0;
56 | if (outer.indent) {
57 | var possibleOuterIndent = outer.indent(state.outer, "", "");
58 | if (possibleOuterIndent !== CodeMirror.Pass) outerIndent = possibleOuterIndent;
59 | }
60 |
61 | state.inner = CodeMirror.startState(other.mode, outerIndent);
62 | return other.delimStyle && (other.delimStyle + " " + other.delimStyle + "-open");
63 | } else if (found != -1 && found < cutOff) {
64 | cutOff = found;
65 | }
66 | }
67 | if (cutOff != Infinity) stream.string = oldContent.slice(0, cutOff);
68 | var outerToken = outer.token(stream, state.outer);
69 | if (cutOff != Infinity) stream.string = oldContent;
70 | return outerToken;
71 | } else {
72 | var curInner = state.innerActive, oldContent = stream.string;
73 | if (!curInner.close && stream.sol()) {
74 | state.innerActive = state.inner = null;
75 | return this.token(stream, state);
76 | }
77 | var found = curInner.close ? indexOf(oldContent, curInner.close, stream.pos, curInner.parseDelimiters) : -1;
78 | if (found == stream.pos && !curInner.parseDelimiters) {
79 | stream.match(curInner.close);
80 | state.innerActive = state.inner = null;
81 | return curInner.delimStyle && (curInner.delimStyle + " " + curInner.delimStyle + "-close");
82 | }
83 | if (found > -1) stream.string = oldContent.slice(0, found);
84 | var innerToken = curInner.mode.token(stream, state.inner);
85 | if (found > -1) stream.string = oldContent;
86 |
87 | if (found == stream.pos && curInner.parseDelimiters)
88 | state.innerActive = state.inner = null;
89 |
90 | if (curInner.innerStyle) {
91 | if (innerToken) innerToken = innerToken + " " + curInner.innerStyle;
92 | else innerToken = curInner.innerStyle;
93 | }
94 |
95 | return innerToken;
96 | }
97 | },
98 |
99 | indent: function(state, textAfter, line) {
100 | var mode = state.innerActive ? state.innerActive.mode : outer;
101 | if (!mode.indent) return CodeMirror.Pass;
102 | return mode.indent(state.innerActive ? state.inner : state.outer, textAfter, line);
103 | },
104 |
105 | blankLine: function(state) {
106 | var mode = state.innerActive ? state.innerActive.mode : outer;
107 | if (mode.blankLine) {
108 | mode.blankLine(state.innerActive ? state.inner : state.outer);
109 | }
110 | if (!state.innerActive) {
111 | for (var i = 0; i < others.length; ++i) {
112 | var other = others[i];
113 | if (other.open === "\n") {
114 | state.innerActive = other;
115 | state.inner = CodeMirror.startState(other.mode, mode.indent ? mode.indent(state.outer, "", "") : 0);
116 | }
117 | }
118 | } else if (state.innerActive.close === "\n") {
119 | state.innerActive = state.inner = null;
120 | }
121 | },
122 |
123 | electricChars: outer.electricChars,
124 |
125 | innerMode: function(state) {
126 | return state.inner ? {state: state.inner, mode: state.innerActive.mode} : {state: state.outer, mode: outer};
127 | }
128 | };
129 | };
130 |
131 | });
132 |
--------------------------------------------------------------------------------
/views/flow_edit.erb:
--------------------------------------------------------------------------------
1 | <%
2 | content_vars = Post.variables_for_type(@flow.post_kind)
3 | path_vars = content_vars.reject{|k,v| [:content, :categories, :has_photos, :photos].include? k }
4 | %>
5 |
12 |
13 | <%= @flow.name %>
14 |
15 |
16 | <%= Post.description_for_type(@flow.post_kind) %>
17 |
18 |
19 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
144 |
--------------------------------------------------------------------------------
/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.get_author(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 | hash = {
103 | 'url' => [properties['url'][0]],
104 | 'name' => [properties['name'][0].strip],
105 | 'published' => [published.utc.iso8601],
106 | 'author' => [author_url],
107 | webmention_property(source, target) => [target]
108 | }
109 | if properties.key?('content')
110 | hash['content'] = [properties['content'][0]['value']]
111 | end
112 | if properties.key?('photo') && properties['photo'].any?
113 | hash['photo'] = properties['photo']
114 | end
115 | cite = Cite.new(hash)
116 | Store.save(cite)
117 | end
118 |
119 | def webmention_property(source, target)
120 | hash = Microformats.parse(source).to_h
121 | # use first entry
122 | entries = hash['items'].map { |i| i if i['type'].include?('h-entry') }
123 | entries.compact!
124 | return 'mention-of' unless entries.any?
125 | entry = entries[0]
126 | # find the webmention type, just one, in order of importance
127 | %w( in-reply-to repost-of like-of ).each do |type|
128 | if entry['properties'].key?(type)
129 | urls = entry['properties'][type].map do |t|
130 | if t.is_a?(Hash)
131 | if t.key?('type') &&
132 | t['type'].is_a?(Array) &&
133 | t['type'].any? &&
134 | t['type'][0] == 'h-cite' &&
135 | t['properties'].key?('url') &&
136 | t['properties']['url'].is_a?(Array) &&
137 | t['properties']['url'].any? &&
138 | Utils.valid_url?(t['properties']['url'][0])
139 | t['properties']['url'][0]
140 | end
141 | elsif t.is_a?(String) && Utils.valid_url?(t)
142 | t
143 | end
144 | end
145 | return type if urls.include?(target)
146 | end
147 | end
148 | # no type found so assume it was a simple mention
149 | 'mention-of'
150 | end
151 |
152 | def remove_webmention_if_exists(url)
153 | return unless cite = Cache.get_by_properties_url(url)
154 | %w( in-reply-to repost-of like-of mention-of ).each do |property|
155 | if cite.properties.key?(property)
156 | cite.properties.delete(property)
157 | end
158 | end
159 | Store.save(cite)
160 | end
161 |
162 | def send_notification(cite, author, target)
163 | author_name = author.properties['name'][0]
164 | author_url = author.properties['url'][0]
165 | type = cite.webmention_type
166 | Notification.send(
167 | "New #{type}",
168 | "New #{type} of #{target} from #{author_name} - #{author_url}",
169 | target)
170 | end
171 |
172 | end
173 |
174 | class WebmentionError < SitewriterError
175 | def initialize(type, message)
176 | super(type, message, 400)
177 | end
178 | end
179 |
180 | end
181 |
--------------------------------------------------------------------------------
/routes/micropub.rb:
--------------------------------------------------------------------------------
1 | class SiteWriter < Sinatra::Application
2 |
3 | post '/:domain/micropub/?' do
4 | # TODO: handle multipart requests
5 | site = find_site
6 | start_log(site)
7 | flows = site.flows_dataset
8 | # start by assuming this is a non-create action
9 | # if params.key?('action')
10 | # verify_action
11 | # require_auth
12 | # verify_url
13 | # post = Micropub.action(params)
14 | # status 204
15 | # elsif params.key?('file')
16 | if params.key?('file')
17 | # assume this a file (photo) upload
18 | @log[:request] = "#{request.media_type} (#{request.content_length} bytes)"
19 | require_auth
20 | media = Micropub.create_media(params[:file])
21 | @log[:kind] = 'file'
22 | flow = site.file_flow
23 | @log[:flow_id] = flow.id
24 | @log[:file] = flow.file_path_for_media(media)
25 | url = flow.store_file(media)
26 | @log[:url] = url
27 | @log[:status_code] = 202
28 | write_log
29 | headers 'Location' => url
30 | status 202
31 | else
32 | # assume this is a create
33 | request.body.rewind
34 | @log[:request] = request.body.read.dump
35 | require_auth
36 | site_tz = TZInfo::Timezone.get(site.timezone) || TZInfo::Timezone.get('UTC')
37 | # @log[:timezone] = site_tz.identifier
38 | post = Micropub.create(params, site_tz)
39 | raise Micropub::TypeError.new unless post
40 | @log[:kind] = post.kind
41 | flow = flows.where(post_kind: post.kind).first
42 | raise Micropub::ContentError.new(
43 | "Not configured to write posts of kind '#{post.kind}'."
44 | ) unless flow
45 | @log[:flow_id] = flow.id
46 | if params.key?(:photo)
47 | photo_urls = handle_photos(flow, post, params[:photo])
48 | @log[:photos] = photo_urls
49 | end
50 | @log[:file] = flow.file_path_for_post(post)
51 | url = flow.store_post(post)
52 | @log[:url] = url
53 | @log[:status_code] = 202
54 | write_log
55 | headers 'Location' => url
56 | status 202
57 | end
58 | end
59 |
60 | # TODO: syndication targets
61 | get '/:domain/micropub/?' do
62 | site = find_site
63 | # start_log(site)
64 | if params.key?('q')
65 | require_auth
66 | content_type :json
67 | case params[:q]
68 | when 'source'
69 |
70 | when 'config'
71 | {
72 | "media-endpoint" => "#{request.scheme}://#{request.host_with_port}/#{site.domain}/micropub",
73 | "syndicate-to" => []
74 | }.to_json
75 | when 'syndicate-to'
76 | "[]"
77 | else
78 | # Silently fail if query method is not supported
79 | end
80 | else
81 | 'Micropub endpoint'
82 | end
83 | end
84 |
85 | error SitewriterError do
86 | e = env['sinatra.error']
87 | json = {
88 | error: e.type,
89 | error_description: e.message
90 | }.to_json
91 | if @log.is_a? Hash
92 | @log[:status_code] = e.status
93 | @log[:error] = Sequel.pg_json(json)
94 | write_log
95 | end
96 | halt(e.status, { 'Content-Type' => 'application/json' }, json)
97 | end
98 |
99 | # error do
100 | # e = env['sinatra.error']
101 | # error_description = "Unexpected server error (#{e.class})."
102 | # if @log.is_a? Hash
103 | # error_description << " Details can be found in your activity log."
104 | # @log[:status_code] = 500
105 | # log_json = {
106 | # error: e.class,
107 | # error_description: e.message,
108 | # backtrace: e.backtrace
109 | # }.to_json
110 | # @log[:error] = Sequel.pg_json(log_json)
111 | # write_log
112 | # end
113 | # json = {
114 | # error: 'server_error',
115 | # error_description: error_description
116 | # }.to_json
117 | # halt(500, { 'Content-Type' => 'application/json' }, json)
118 | # end
119 |
120 | private
121 |
122 | def handle_photos(flow, post, params)
123 | urls = []
124 | if params.is_a?(Array)
125 | urls = params.map.with_index do |item, index|
126 | if item.is_a?(Array)
127 | handle_photos(flow, post, item)
128 | else
129 | puts "🖼🖼 #{item}"
130 | if valid_url?(item)
131 | flow.attach_photo_url(post, item)
132 | else
133 | media = Micropub.create_media(item)
134 | flow.attach_photo_media(post, media)
135 | end
136 | end
137 | end
138 | else
139 | puts "🖼🖼 #{params}"
140 | if valid_url?(params)
141 | flow.attach_photo_url(post, params)
142 | else
143 | media = Micropub.create_media(params)
144 | media.post_slug = post.slug
145 | flow.attach_photo_media(post, media)
146 | end
147 | end
148 | return urls
149 | end
150 |
151 | def valid_url?(url)
152 | puts "Is this a URL? #{url} "
153 | begin
154 | uri = URI.parse(url)
155 | puts "YES!\n"
156 | return true
157 | rescue URI::InvalidURIError
158 | puts "NO.\n"
159 | return false
160 | end
161 | end
162 |
163 | def start_log(site)
164 | # DB is defined in models/init
165 | @log = {
166 | started_at: Time.now(),
167 | site_id: site.id,
168 | ip: request.ip,
169 | user_agent: request.user_agent,
170 | properties: Sequel.pg_json(params),
171 | }
172 | end
173 |
174 | def write_log
175 | @log[:finished_at] = Time.now()
176 | DB[:log].insert(@log)
177 | end
178 |
179 | def require_auth
180 | return unless settings.production?
181 | token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || ""
182 | token.sub!(/^Bearer /,'')
183 | if token.empty?
184 | raise Auth::NoTokenError.new
185 | end
186 | scope = params.key?('action') ? params['action'] : 'post'
187 | Auth.verify_token_and_scope(token, scope)
188 | # TODO: check "me" for domain match
189 | end
190 |
191 | def verify_action
192 | valid_actions = %w( create update delete undelete )
193 | unless valid_actions.include?(params[:action])
194 | raise Micropub::InvalidRequestError.new(
195 | "The specified action ('#{params[:action]}') is not supported. " +
196 | "Valid actions are: #{valid_actions.join(' ')}."
197 | )
198 | end
199 | end
200 | #
201 | # def verify_url
202 | # unless params.key?('url') && !params[:url].empty? &&
203 | # Store.exists_url?(params[:url])
204 | # raise Micropub::InvalidRequestError.new(
205 | # "The specified URL ('#{params[:url]}') could not be found."
206 | # )
207 | # end
208 | # end
209 |
210 | # def render_syndication_targets
211 | # content_type :json
212 | # {}.to_json
213 | # end
214 |
215 | # def render_config
216 | # content_type :json
217 | # {
218 | # "media-endpoint" => "#{ENV['SITE_URL']}micropub",
219 | # "syndicate-to" => settings.syndication_targets
220 | # }.to_json
221 | # end
222 |
223 | # this is probably broken now
224 | def render_source
225 | content_type :json
226 | relative_url = Utils.relative_url(params[:url])
227 | not_found unless post = Store.get("#{relative_url}.json")
228 | data = if params.key?('properties')
229 | properties = {}
230 | Array(params[:properties]).each do |property|
231 | if post.properties.key?(property)
232 | properties[property] = post.properties[property]
233 | end
234 | end
235 | { 'type' => [post.h_type], 'properties' => properties }
236 | else
237 | post.data
238 | end
239 | data.to_json
240 | end
241 |
242 | end
243 |
--------------------------------------------------------------------------------
/routes/manage.rb:
--------------------------------------------------------------------------------
1 | class SiteWriter < Sinatra::Application
2 | helpers Sinatra::LinkHeader
3 |
4 | enable :sessions
5 |
6 | get '/syslog/?' do
7 | env = ENV['RACK_ENV'].to_sym || :development
8 | halt(404) unless (session[:domain] == 'hans.gerwitz.com') || (env == :development)
9 | @log = DB[:log].reverse_order(:started_at)
10 | erb :syslog
11 | end
12 |
13 | get '/login' do
14 | if params.key?('code') # this is probably an indieauth callback
15 | url = Auth.url_via_indieauth("#{request.scheme}://#{request.host_with_port}/", params[:code])
16 | login_url(url)
17 | end
18 | if session[:domain]
19 | redirect "/#{session[:domain]}/"
20 | else
21 | # it didn't work
22 | redirect '/'
23 | end
24 | end
25 |
26 | get '/logout' do
27 | session.clear
28 | redirect '/'
29 | end
30 |
31 | get '/:domain' do
32 | redirect "/#{params[:domain]}/"
33 | end
34 |
35 | get '/:domain/' do
36 | @site = auth_site
37 | erb :site_status
38 | end
39 |
40 | get '/:domain/settings' do
41 | @site = auth_site
42 | erb :site_settings
43 | end
44 |
45 | post '/:domain/settings?' do
46 | @site = auth_site
47 | # puts "☣️ Updating #{@site.domain}"
48 | @site.update_fields(params, [
49 | :timezone
50 | ])
51 | # puts("updated timezone to #{@site.timezone}")
52 | redirect "/#{@site.domain}/settings"
53 | end
54 |
55 | get '/:domain/posting' do
56 | @site = auth_site
57 | site_flows = @site.flows_dataset
58 | @flows = Post::TYPES.map do |kind|
59 | flow = site_flows.where(post_kind: kind.to_s).exclude(store_id: nil).first
60 | if flow
61 | {
62 | kind: kind,
63 | flow: flow
64 | }
65 | else
66 | { kind: kind }
67 | end
68 | end
69 | erb :site_posting
70 | end
71 |
72 | get '/:domain/uploading' do
73 | @site = auth_site
74 | site_flows = @site.flows_dataset
75 | erb :site_uploading
76 | end
77 |
78 | get '/:domain/stores/new' do
79 | @site = auth_site
80 | if params.key?('type_id')
81 | type_id = params['type_id'].to_i
82 | else
83 | type_id = 1 # github
84 | end
85 | store_class = Store.sti_class_from_sti_key(type_id)
86 | @store = store_class.create(site_id: @site.id)
87 | erb :store_edit
88 | end
89 |
90 | get '/:domain/stores/:id' do
91 | @site = auth_site
92 | @store = Store.find(id: params[:id].to_i, site_id: @site.id)
93 | erb :store_edit
94 | end
95 |
96 | post '/:domain/stores' do
97 | @site = auth_site
98 |
99 | store = Store.first(id: params[:id].to_i, site_id: @site.id)
100 |
101 | # if params.key?('type_id')
102 | # type_id = params['type_id'].to_i
103 | # store_class = Store.sti_class_from_sti_key(type_id)
104 | # # puts "type: #{type_id}, class: #{store_class}"
105 | # store = store_class.create(site_id: @site.id)
106 | store.update_fields(params, [
107 | :location,
108 | :user,
109 | :key
110 | ])
111 | if params.key?('flow_id')
112 | flow_id = params['flow_id'].to_i
113 | flow = Flow.first(id: flow_id)
114 | flow.store = store
115 | flow.save
116 | end
117 | # else
118 | # raise SitewriterError.new("bad_request", "Can't POST a store without a type")
119 | # end
120 | redirect "/#{@site.domain}/posting"
121 | end
122 |
123 | get '/:domain/stores/:id/delete' do
124 | @site = auth_site
125 | @store = Store.find(id: params[:id].to_i, site_id: @site.id)
126 | @store.destroy
127 | redirect "/#{@site.domain}/posting"
128 | end
129 |
130 | get '/:domain/flows/new' do
131 | @site = auth_site
132 | @flow = Flow.find_or_create(site_id: @site.id, post_kind: params['post_kind'].to_s)
133 | if @flow.store.nil?
134 | @flow.update(store_id: @site.default_store.id)
135 | end
136 | # flow.update_fields(params, [:post_kind])
137 | redirect "/#{@site.domain}/flows/#{@flow.id}"
138 | end
139 |
140 | get '/:domain/flows/media' do
141 | @site = auth_site
142 | @flow = @site.file_flow
143 | if @flow.nil?
144 | @flow = Flow.create(site_id: @site.id, allow_media: true, media_store_id: @site.default_store.id)
145 | @site.update(file_flow_id: @flow.id)
146 | puts "☣️ Created new file flow [#{@flow.id}] for #{@site.domain}"
147 | end
148 | # if @flow.media_store.nil?
149 | # @flow.update(media_store_id: @site.default_store.id)
150 | # end
151 | erb :flow_media
152 | end
153 |
154 | post '/:domain/flows/media' do
155 | @site = auth_site
156 | flow = @site.file_flow
157 | puts "☣️ Updating file flow #{flow.id}"
158 | flow.update(post_kind: nil)
159 | flow.update_fields(params, [
160 | :media_path_template,
161 | :media_url_template
162 | ])
163 | redirect "/#{@site.domain}/uploading"
164 | end
165 |
166 |
167 | get '/:domain/flows/:id' do
168 | @site = auth_site
169 | @flow = Flow.first(id: params[:id].to_i, site_id: @site.id)
170 | erb :flow_edit
171 | end
172 |
173 | post '/:domain/flows' do
174 | @site = auth_site
175 | flow = Flow.first(id: params[:id].to_i, site_id: @site.id)
176 | flow.update_fields(params, [
177 | # :name,
178 | :path_template,
179 | :url_template,
180 | :content_template,
181 | :allow_media,
182 | :media_store_id,
183 | :media_path_template,
184 | :media_url_template
185 | # :allow_meta
186 | ])
187 | redirect "/#{@site.domain}/posting"
188 | end
189 |
190 | get '/:domain/flows/:id/delete' do
191 | @site = auth_site
192 | @flow = Flow.find(id: params[:id].to_i, site_id: @site.id)
193 | @flow.destroy
194 | redirect "/#{@site.domain}/posting"
195 | end
196 |
197 | not_found do
198 | status 404
199 | erb :'404'
200 | end
201 |
202 | error do
203 | status 500
204 | erb :'500'
205 | end
206 |
207 | private
208 |
209 | # login with domain from url
210 | def login_url(url)
211 | domain = URI.parse(url).host.downcase
212 | @site = Site.find_or_create(domain: domain)
213 | @site.url = url
214 | @site.save
215 | session[:domain] = domain
216 | if domain == 'hans.gerwitz.com'
217 | session[:admin] = true
218 | else
219 | session[:admin] = false
220 | end
221 | end
222 |
223 | def find_site(domain = nil)
224 | domain ||= params[:domain]
225 | if domain
226 | site = Site.first(domain: domain.to_s)
227 | if site.nil?
228 | raise SitewriterError.new("bad_request", "No site found for '#{domain}'")
229 | else
230 | return site
231 | end
232 | else
233 | not_found
234 | end
235 | end
236 |
237 | def auth_site(domain = nil)
238 | # return if ENV['RACK_ENV'].to_sym == :development
239 | domain ||= params[:domain]
240 | env = ENV['RACK_ENV'] || 'development'
241 | if (domain == session[:domain]) || session[:admin] || (env.to_sym == :development)
242 | return find_site(domain)
243 | else
244 | login_site(domain)
245 | end
246 | end
247 |
248 | def login_site(domain = nil)
249 | auth_host = "indieauth.com"
250 | auth_path = "/auth"
251 |
252 | domain ||= params[:domain]
253 | auth_query = URI.encode_www_form(
254 | client_id: "#{request.scheme}://#{ request.host_with_port }/",
255 | redirect_uri: "#{request.scheme}://#{ request.host_with_port }/login",
256 | me: domain
257 | )
258 | redirect URI::HTTPS.build(
259 | host: auth_host,
260 | path: auth_path,
261 | query: auth_query
262 | ), 302
263 | end
264 |
265 | end
266 |
--------------------------------------------------------------------------------
/public/css/normalize.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Correct the font size and margin on `h1` elements within `section` and
29 | * `article` contexts in Chrome, Firefox, and Safari.
30 | */
31 |
32 | h1 {
33 | font-size: 2em;
34 | margin: 0.67em 0;
35 | }
36 |
37 | /* Grouping content
38 | ========================================================================== */
39 |
40 | /**
41 | * 1. Add the correct box sizing in Firefox.
42 | * 2. Show the overflow in Edge and IE.
43 | */
44 |
45 | hr {
46 | box-sizing: content-box; /* 1 */
47 | height: 0; /* 1 */
48 | overflow: visible; /* 2 */
49 | }
50 |
51 | /**
52 | * 1. Correct the inheritance and scaling of font size in all browsers.
53 | * 2. Correct the odd `em` font sizing in all browsers.
54 | */
55 |
56 | pre {
57 | font-family: monospace, monospace; /* 1 */
58 | font-size: 1em; /* 2 */
59 | }
60 |
61 | /* Text-level semantics
62 | ========================================================================== */
63 |
64 | /**
65 | * Remove the gray background on active links in IE 10.
66 | */
67 |
68 | a {
69 | background-color: transparent;
70 | }
71 |
72 | /**
73 | * 1. Remove the bottom border in Chrome 57-
74 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
75 | */
76 |
77 | abbr[title] {
78 | border-bottom: none; /* 1 */
79 | text-decoration: underline; /* 2 */
80 | text-decoration: underline dotted; /* 2 */
81 | }
82 |
83 | /**
84 | * Add the correct font weight in Chrome, Edge, and Safari.
85 | */
86 |
87 | b,
88 | strong {
89 | font-weight: bolder;
90 | }
91 |
92 | /**
93 | * 1. Correct the inheritance and scaling of font size in all browsers.
94 | * 2. Correct the odd `em` font sizing in all browsers.
95 | */
96 |
97 | code,
98 | kbd,
99 | samp {
100 | font-family: monospace, monospace; /* 1 */
101 | font-size: 1em; /* 2 */
102 | }
103 |
104 | /**
105 | * Add the correct font size in all browsers.
106 | */
107 |
108 | small {
109 | font-size: 80%;
110 | }
111 |
112 | /**
113 | * Prevent `sub` and `sup` elements from affecting the line height in
114 | * all browsers.
115 | */
116 |
117 | sub,
118 | sup {
119 | font-size: 75%;
120 | line-height: 0;
121 | position: relative;
122 | vertical-align: baseline;
123 | }
124 |
125 | sub {
126 | bottom: -0.25em;
127 | }
128 |
129 | sup {
130 | top: -0.5em;
131 | }
132 |
133 | /* Embedded content
134 | ========================================================================== */
135 |
136 | /**
137 | * Remove the border on images inside links in IE 10.
138 | */
139 |
140 | img {
141 | border-style: none;
142 | }
143 |
144 | /* Forms
145 | ========================================================================== */
146 |
147 | /**
148 | * 1. Change the font styles in all browsers.
149 | * 2. Remove the margin in Firefox and Safari.
150 | */
151 |
152 | button,
153 | input,
154 | optgroup,
155 | select,
156 | textarea {
157 | font-family: inherit; /* 1 */
158 | font-size: 100%; /* 1 */
159 | line-height: 1.15; /* 1 */
160 | margin: 0; /* 2 */
161 | }
162 |
163 | /**
164 | * Show the overflow in IE.
165 | * 1. Show the overflow in Edge.
166 | */
167 |
168 | button,
169 | input { /* 1 */
170 | overflow: visible;
171 | }
172 |
173 | /**
174 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
175 | * 1. Remove the inheritance of text transform in Firefox.
176 | */
177 |
178 | button,
179 | select { /* 1 */
180 | text-transform: none;
181 | }
182 |
183 | /**
184 | * Correct the inability to style clickable types in iOS and Safari.
185 | */
186 |
187 | button,
188 | [type="button"],
189 | [type="reset"],
190 | [type="submit"] {
191 | -webkit-appearance: button;
192 | }
193 |
194 | /**
195 | * Remove the inner border and padding in Firefox.
196 | */
197 |
198 | button::-moz-focus-inner,
199 | [type="button"]::-moz-focus-inner,
200 | [type="reset"]::-moz-focus-inner,
201 | [type="submit"]::-moz-focus-inner {
202 | border-style: none;
203 | padding: 0;
204 | }
205 |
206 | /**
207 | * Restore the focus styles unset by the previous rule.
208 | */
209 |
210 | button:-moz-focusring,
211 | [type="button"]:-moz-focusring,
212 | [type="reset"]:-moz-focusring,
213 | [type="submit"]:-moz-focusring {
214 | outline: 1px dotted ButtonText;
215 | }
216 |
217 | /**
218 | * Correct the padding in Firefox.
219 | */
220 |
221 | fieldset {
222 | padding: 0.35em 0.75em 0.625em;
223 | }
224 |
225 | /**
226 | * 1. Correct the text wrapping in Edge and IE.
227 | * 2. Correct the color inheritance from `fieldset` elements in IE.
228 | * 3. Remove the padding so developers are not caught out when they zero out
229 | * `fieldset` elements in all browsers.
230 | */
231 |
232 | legend {
233 | box-sizing: border-box; /* 1 */
234 | color: inherit; /* 2 */
235 | display: table; /* 1 */
236 | max-width: 100%; /* 1 */
237 | padding: 0; /* 3 */
238 | white-space: normal; /* 1 */
239 | }
240 |
241 | /**
242 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
243 | */
244 |
245 | progress {
246 | vertical-align: baseline;
247 | }
248 |
249 | /**
250 | * Remove the default vertical scrollbar in IE 10+.
251 | */
252 |
253 | textarea {
254 | overflow: auto;
255 | }
256 |
257 | /**
258 | * 1. Add the correct box sizing in IE 10.
259 | * 2. Remove the padding in IE 10.
260 | */
261 |
262 | [type="checkbox"],
263 | [type="radio"] {
264 | box-sizing: border-box; /* 1 */
265 | padding: 0; /* 2 */
266 | }
267 |
268 | /**
269 | * Correct the cursor style of increment and decrement buttons in Chrome.
270 | */
271 |
272 | [type="number"]::-webkit-inner-spin-button,
273 | [type="number"]::-webkit-outer-spin-button {
274 | height: auto;
275 | }
276 |
277 | /**
278 | * 1. Correct the odd appearance in Chrome and Safari.
279 | * 2. Correct the outline style in Safari.
280 | */
281 |
282 | [type="search"] {
283 | -webkit-appearance: textfield; /* 1 */
284 | outline-offset: -2px; /* 2 */
285 | }
286 |
287 | /**
288 | * Remove the inner padding in Chrome and Safari on macOS.
289 | */
290 |
291 | [type="search"]::-webkit-search-decoration {
292 | -webkit-appearance: none;
293 | }
294 |
295 | /**
296 | * 1. Correct the inability to style clickable types in iOS and Safari.
297 | * 2. Change font properties to `inherit` in Safari.
298 | */
299 |
300 | ::-webkit-file-upload-button {
301 | -webkit-appearance: button; /* 1 */
302 | font: inherit; /* 2 */
303 | }
304 |
305 | /* Interactive
306 | ========================================================================== */
307 |
308 | /*
309 | * Add the correct display in Edge, IE 10+, and Firefox.
310 | */
311 |
312 | details {
313 | display: block;
314 | }
315 |
316 | /*
317 | * Add the correct display in all browsers.
318 | */
319 |
320 | summary {
321 | display: list-item;
322 | }
323 |
324 | /* Misc
325 | ========================================================================== */
326 |
327 | /**
328 | * Add the correct display in IE 10+.
329 | */
330 |
331 | template {
332 | display: none;
333 | }
334 |
335 | /**
336 | * Add the correct display in IE 10.
337 | */
338 |
339 | [hidden] {
340 | display: none;
341 | }
342 |
--------------------------------------------------------------------------------
/lib/post.rb:
--------------------------------------------------------------------------------
1 | class Post
2 | require 'yaml'
3 |
4 | TYPES_CATALOG = YAML.load_file(File.join(__dir__, 'post_types.yml'))
5 |
6 | TYPES = TYPES_CATALOG.keys.select{|k| !k.start_with?('_') }
7 |
8 | VARIABLES_CATALOG = {
9 | content: 'post content',
10 |
11 | slug: 'post slug (using hyphens)',
12 | slug_underscore: 'post slug (using underscores)',
13 |
14 | utc_datetime: 'publication time in UTC (RFC 3339 format)',
15 | utc_epoch: 'publication time as seconds since 1970-01-01',
16 |
17 | datetime: 'publication time (RFC 3339 format)',
18 | date: 'publication date (YYYY-MM-DD)',
19 | year: 'publication year (YYYY)',
20 | month: 'publication month (01-12)',
21 | day: 'day of publication month (01-31)',
22 | hour: 'hour of publication (00-23)',
23 | year_month: 'year and month (YYYY-MM)',
24 | minute: 'minute of publication',
25 | second: 'second of publication',
26 |
27 | categories: 'list of categories (a.k.a. tags)',
28 | # first_category: 'the first catagory',
29 |
30 | has_photos: 'true if there are any photo attachments',
31 | photos: 'list of attached photos'
32 | # photos_urls: 'list of attached photos'
33 | # photos_markdown: 'list of attached photos'
34 | # photos_html: 'list of attached photos'
35 | }
36 |
37 | def initialize(properties, timezone: nil)
38 | @properties = properties
39 | @timezone = timezone
40 |
41 | if @properties.key?('category')
42 | @categories = @properties['category']
43 | else
44 | @categories = []
45 | end
46 | @photos = []
47 | end
48 |
49 | def kind
50 | "unknown"
51 | end
52 |
53 | def attach_url(type, url)
54 | case type
55 | when :photo
56 | @photos << {url: url}
57 | else
58 | raise "Unknown URL type #{type}"
59 | end
60 | end
61 |
62 | # get publication time as a DateTime and apply timezone
63 | def timify
64 | # no client sends 'published' but we'll prepare for it
65 | if @properties.key?('published')
66 | utc_time = Time.iso8601(@properties['published'].first)
67 | else
68 | utc_time = Time.now.utc
69 | end
70 | if @timezone
71 | # local_time = @timezone.utc_to_local(utc_time)
72 | utc_total_offset = @timezone.period_for_utc(utc_time).utc_total_offset
73 | local_time = utc_time.getlocal(utc_total_offset)
74 | else
75 | local_time = utc_time
76 | end
77 |
78 | return {local: local_time, utc: utc_time}
79 | end
80 | # memoize
81 | def both_times
82 | @times ||= timify
83 | end
84 | def local_time
85 | both_times[:local]
86 | end
87 | def utc_time
88 | both_times[:utc]
89 | end
90 |
91 | # TODO memoize this
92 | def render_variables
93 | return {
94 | slug: slug,
95 | slug_underscore: slug_underscore,
96 |
97 | utc_datetime: utc_time.to_datetime.rfc3339,
98 | utc_epoch: utc_time.to_i,
99 |
100 | datetime: local_time.strftime('%Y-%m-%dT%H:%M:%S%:z'),
101 | date: local_time.strftime('%Y-%m-%d'),
102 | year: local_time.strftime('%Y'),
103 | month: local_time.strftime('%m'),
104 | day: local_time.strftime('%d'),
105 | hour: local_time.strftime('%H'),
106 | minute: local_time.strftime('%M'),
107 | second: local_time.strftime('%S'),
108 | year_month: local_time.strftime('%Y-%m'),
109 |
110 | categories: @categories,
111 | # first_category: @categories.first || '',
112 |
113 | content: content,
114 |
115 | has_photos: @photos.any?,
116 | photos: @photos
117 | # photos_urls: @photos.map(|p| p[:url])
118 | # photos_markdown: @photos.map(|p| "
119 | # photos_html: @photos.map(|p| " ")
120 | }
121 | # }.merge(@properties)
122 | end
123 |
124 | # memoize
125 | def slug
126 | @slug ||= slugify('-')
127 | end
128 |
129 | # memoize
130 | def slug_underscore
131 | @slug_underscore ||= slugify('_')
132 | end
133 |
134 | def content
135 | if @properties.key?('content')
136 | if @properties['content'][0].is_a?(Hash) &&
137 | @properties['content'][0].key?('html')
138 | @properties['content'][0]['html']
139 | else
140 | @properties['content'][0]
141 | end
142 | elsif @properties.key?('summary')
143 | @properties['summary'][0]
144 | end
145 | end
146 |
147 | def slugify(spacer='-')
148 | @raw_slug ||= slugify_raw
149 | if @raw_slug.empty?
150 | return utc_time.strftime("%d#{spacer}%H%M%S")
151 | else
152 | return @raw_slug.downcase.gsub(/[^\w-]/, ' ').strip.gsub(' ', spacer).gsub(/[-_]+/,spacer).split(spacer)[0..5].join(spacer)
153 | end
154 | end
155 |
156 | def slugify_raw
157 | content = ''
158 | if @properties.key?('name')
159 | content = @properties['name'][0]
160 | end
161 | if content.empty? && @properties.key?('summary')
162 | content = @properties['summary'][0]
163 | end
164 | if content.empty? && @properties.key?('content')
165 | if @properties['content'][0].is_a?(Hash) && @properties['content'][0].key?('html')
166 | content = @properties['content'][0]['html']
167 | else
168 | content = @properties['content'][0]
169 | end
170 | end
171 | return content
172 | end
173 |
174 | def set_explicit_slug(mp_params)
175 | if mp_params.key?('properties')
176 | return unless mp_params['properties'].key?('mp-slug')
177 | mp_slug = mp_params['properties']['mp-slug'][0]
178 | else
179 | return unless mp_params.key?('mp-slug')
180 | mp_slug = mp_params['mp-slug']
181 | end
182 | @slug = mp_slug.strip.downcase.gsub(/[^\w-]/, '-')
183 | end
184 |
185 | def syndicate(services)
186 | # only syndicate if this is an entry or event
187 | return unless ['h-entry','h-event'].include?(h_type)
188 |
189 | # iterate over the mp-syndicate-to services
190 | new_syndications = services.map { |service|
191 | # have we already syndicated to this service?
192 | unless @properties.key?('syndication') &&
193 | @properties['syndication'].map { |s|
194 | s.start_with?(service)
195 | }.include?(true)
196 | Syndication.send(self, service)
197 | end
198 | }.compact
199 |
200 | return if new_syndications.empty?
201 | # add to syndication list
202 | @properties['syndication'] ||= []
203 | @properties['syndication'] += new_syndications
204 | end
205 |
206 | def self.class_for_type(type = :unknown)
207 | class_name = TYPES_CATALOG.dig(type.to_s, 'class').to_s
208 | if Object.const_defined?(class_name)
209 | return Object.const_get(class_name)
210 | else
211 | return Post
212 | end
213 | end
214 |
215 | def self.new_for_type(type, props, timezone=nil)
216 | klass = class_for_type(type)
217 | return klass.new(props, timezone: timezone)
218 | end
219 |
220 | # derived from: https://indieweb.org/post-type-discovery
221 | # see README for a description
222 | def self.type_from_properties(props)
223 | post_type = ''
224 | mf_type = ''
225 | if props.key?('type')
226 | mf_type = props['type'][0].to_s
227 | if mf_type == 'h-event'
228 | post_type = :event
229 | elsif mf_type == 'h-entry'
230 | if props.key?('in-reply-to')
231 | post_type = :reply
232 | elsif props.key?('repost-of')
233 | post_type = :repost
234 | elsif props.key?('bookmark-of')
235 | post_type = :bookmark
236 | elsif props.key?('checkin') || props.key?('u-checkin')
237 | post_type = :checkin
238 | elsif props.key?('like-of')
239 | post_type = :like
240 | elsif props.key?('video')
241 | post_type = :video
242 | elsif props.key?('photo')
243 | post_type = :photo
244 | else
245 | # does it have a title?
246 | if props.key?('name')
247 | title = props['name'][0]
248 | if title.empty?
249 | post_type = :note
250 | else
251 | post_type = :article
252 | end
253 | else
254 | post_type = :note
255 | end
256 | end
257 | end
258 | end
259 | return post_type
260 | end
261 |
262 | def self.description_for_type(type = :unknown)
263 | "#{TYPES_CATALOG[type.to_s]['description']} Read more.
"
264 | end
265 |
266 | def self.variables_for_type(type)
267 | VARIABLES_CATALOG.merge(class_for_type(type)::VARIABLES_CATALOG).select{|k,v| !v.nil?}
268 | end
269 |
270 | def self.type_supports_attachments?(type)
271 | TYPES_CATALOG[type.to_s]['attachments']
272 | end
273 |
274 | end
275 |
--------------------------------------------------------------------------------
/public/css/codemirror.css:
--------------------------------------------------------------------------------
1 | /* BASICS */
2 |
3 | .CodeMirror {
4 | /* Set height, width, borders, and global font properties here */
5 | font-family: monospace;
6 | height: 300px;
7 | color: black;
8 | direction: ltr;
9 | }
10 |
11 | /* PADDING */
12 |
13 | .CodeMirror-lines {
14 | padding: 4px 0; /* Vertical padding around content */
15 | }
16 | .CodeMirror pre {
17 | padding: 0 4px; /* Horizontal padding of content */
18 | }
19 |
20 | .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
21 | background-color: white; /* The little square between H and V scrollbars */
22 | }
23 |
24 | /* GUTTER */
25 |
26 | .CodeMirror-gutters {
27 | border-right: 1px solid #ddd;
28 | background-color: #f7f7f7;
29 | white-space: nowrap;
30 | }
31 | .CodeMirror-linenumbers {}
32 | .CodeMirror-linenumber {
33 | padding: 0 3px 0 5px;
34 | min-width: 20px;
35 | text-align: right;
36 | color: #999;
37 | white-space: nowrap;
38 | }
39 |
40 | .CodeMirror-guttermarker { color: black; }
41 | .CodeMirror-guttermarker-subtle { color: #999; }
42 |
43 | /* CURSOR */
44 |
45 | .CodeMirror-cursor {
46 | border-left: 1px solid black;
47 | border-right: none;
48 | width: 0;
49 | }
50 | /* Shown when moving in bi-directional text */
51 | .CodeMirror div.CodeMirror-secondarycursor {
52 | border-left: 1px solid silver;
53 | }
54 | .cm-fat-cursor .CodeMirror-cursor {
55 | width: auto;
56 | border: 0 !important;
57 | background: #7e7;
58 | }
59 | .cm-fat-cursor div.CodeMirror-cursors {
60 | z-index: 1;
61 | }
62 | .cm-fat-cursor-mark {
63 | background-color: rgba(20, 255, 20, 0.5);
64 | -webkit-animation: blink 1.06s steps(1) infinite;
65 | -moz-animation: blink 1.06s steps(1) infinite;
66 | animation: blink 1.06s steps(1) infinite;
67 | }
68 | .cm-animate-fat-cursor {
69 | width: auto;
70 | border: 0;
71 | -webkit-animation: blink 1.06s steps(1) infinite;
72 | -moz-animation: blink 1.06s steps(1) infinite;
73 | animation: blink 1.06s steps(1) infinite;
74 | background-color: #7e7;
75 | }
76 | @-moz-keyframes blink {
77 | 0% {}
78 | 50% { background-color: transparent; }
79 | 100% {}
80 | }
81 | @-webkit-keyframes blink {
82 | 0% {}
83 | 50% { background-color: transparent; }
84 | 100% {}
85 | }
86 | @keyframes blink {
87 | 0% {}
88 | 50% { background-color: transparent; }
89 | 100% {}
90 | }
91 |
92 | /* Can style cursor different in overwrite (non-insert) mode */
93 | .CodeMirror-overwrite .CodeMirror-cursor {}
94 |
95 | .cm-tab { display: inline-block; text-decoration: inherit; }
96 |
97 | .CodeMirror-rulers {
98 | position: absolute;
99 | left: 0; right: 0; top: -50px; bottom: -20px;
100 | overflow: hidden;
101 | }
102 | .CodeMirror-ruler {
103 | border-left: 1px solid #ccc;
104 | top: 0; bottom: 0;
105 | position: absolute;
106 | }
107 |
108 | /* DEFAULT THEME */
109 |
110 | .cm-s-default .cm-header {color: blue;}
111 | .cm-s-default .cm-quote {color: #090;}
112 | .cm-negative {color: #d44;}
113 | .cm-positive {color: #292;}
114 | .cm-header, .cm-strong {font-weight: bold;}
115 | .cm-em {font-style: italic;}
116 | .cm-link {text-decoration: underline;}
117 | .cm-strikethrough {text-decoration: line-through;}
118 |
119 | .cm-s-default .cm-keyword {color: #708;}
120 | .cm-s-default .cm-atom {color: #219;}
121 | .cm-s-default .cm-number {color: #164;}
122 | .cm-s-default .cm-def {color: #00f;}
123 | .cm-s-default .cm-variable,
124 | .cm-s-default .cm-punctuation,
125 | .cm-s-default .cm-property,
126 | .cm-s-default .cm-operator {}
127 | .cm-s-default .cm-variable-2 {color: #05a;}
128 | .cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
129 | .cm-s-default .cm-comment {color: #a50;}
130 | .cm-s-default .cm-string {color: #a11;}
131 | .cm-s-default .cm-string-2 {color: #f50;}
132 | .cm-s-default .cm-meta {color: #555;}
133 | .cm-s-default .cm-qualifier {color: #555;}
134 | .cm-s-default .cm-builtin {color: #30a;}
135 | .cm-s-default .cm-bracket {color: #997;}
136 | .cm-s-default .cm-tag {color: #170;}
137 | .cm-s-default .cm-attribute {color: #00c;}
138 | .cm-s-default .cm-hr {color: #999;}
139 | .cm-s-default .cm-link {color: #00c;}
140 |
141 | .cm-s-default .cm-error {color: #f00;}
142 | .cm-invalidchar {color: #f00;}
143 |
144 | .CodeMirror-composing { border-bottom: 2px solid; }
145 |
146 | /* Default styles for common addons */
147 |
148 | div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;}
149 | div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
150 | .CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
151 | .CodeMirror-activeline-background {background: #e8f2ff;}
152 |
153 | /* STOP */
154 |
155 | /* The rest of this file contains styles related to the mechanics of
156 | the editor. You probably shouldn't touch them. */
157 |
158 | .CodeMirror {
159 | position: relative;
160 | overflow: hidden;
161 | background: white;
162 | }
163 |
164 | .CodeMirror-scroll {
165 | overflow: scroll !important; /* Things will break if this is overridden */
166 | /* 30px is the magic margin used to hide the element's real scrollbars */
167 | /* See overflow: hidden in .CodeMirror */
168 | margin-bottom: -30px; margin-right: -30px;
169 | padding-bottom: 30px;
170 | height: 100%;
171 | outline: none; /* Prevent dragging from highlighting the element */
172 | position: relative;
173 | }
174 | .CodeMirror-sizer {
175 | position: relative;
176 | border-right: 30px solid transparent;
177 | }
178 |
179 | /* The fake, visible scrollbars. Used to force redraw during scrolling
180 | before actual scrolling happens, thus preventing shaking and
181 | flickering artifacts. */
182 | .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
183 | position: absolute;
184 | z-index: 6;
185 | display: none;
186 | }
187 | .CodeMirror-vscrollbar {
188 | right: 0; top: 0;
189 | overflow-x: hidden;
190 | overflow-y: scroll;
191 | }
192 | .CodeMirror-hscrollbar {
193 | bottom: 0; left: 0;
194 | overflow-y: hidden;
195 | overflow-x: scroll;
196 | }
197 | .CodeMirror-scrollbar-filler {
198 | right: 0; bottom: 0;
199 | }
200 | .CodeMirror-gutter-filler {
201 | left: 0; bottom: 0;
202 | }
203 |
204 | .CodeMirror-gutters {
205 | position: absolute; left: 0; top: 0;
206 | min-height: 100%;
207 | z-index: 3;
208 | }
209 | .CodeMirror-gutter {
210 | white-space: normal;
211 | height: 100%;
212 | display: inline-block;
213 | vertical-align: top;
214 | margin-bottom: -30px;
215 | }
216 | .CodeMirror-gutter-wrapper {
217 | position: absolute;
218 | z-index: 4;
219 | background: none !important;
220 | border: none !important;
221 | }
222 | .CodeMirror-gutter-background {
223 | position: absolute;
224 | top: 0; bottom: 0;
225 | z-index: 4;
226 | }
227 | .CodeMirror-gutter-elt {
228 | position: absolute;
229 | cursor: default;
230 | z-index: 4;
231 | }
232 | .CodeMirror-gutter-wrapper ::selection { background-color: transparent }
233 | .CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent }
234 |
235 | .CodeMirror-lines {
236 | cursor: text;
237 | min-height: 1px; /* prevents collapsing before first draw */
238 | }
239 | .CodeMirror pre {
240 | /* Reset some styles that the rest of the page might have set */
241 | -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
242 | border-width: 0;
243 | background: transparent;
244 | font-family: inherit;
245 | font-size: inherit;
246 | margin: 0;
247 | white-space: pre;
248 | word-wrap: normal;
249 | line-height: inherit;
250 | color: inherit;
251 | z-index: 2;
252 | position: relative;
253 | overflow: visible;
254 | -webkit-tap-highlight-color: transparent;
255 | -webkit-font-variant-ligatures: contextual;
256 | font-variant-ligatures: contextual;
257 | }
258 | .CodeMirror-wrap pre {
259 | word-wrap: break-word;
260 | white-space: pre-wrap;
261 | word-break: normal;
262 | }
263 |
264 | .CodeMirror-linebackground {
265 | position: absolute;
266 | left: 0; right: 0; top: 0; bottom: 0;
267 | z-index: 0;
268 | }
269 |
270 | .CodeMirror-linewidget {
271 | position: relative;
272 | z-index: 2;
273 | padding: 0.1px; /* Force widget margins to stay inside of the container */
274 | }
275 |
276 | .CodeMirror-widget {}
277 |
278 | .CodeMirror-rtl pre { direction: rtl; }
279 |
280 | .CodeMirror-code {
281 | outline: none;
282 | }
283 |
284 | /* Force content-box sizing for the elements where we expect it */
285 | .CodeMirror-scroll,
286 | .CodeMirror-sizer,
287 | .CodeMirror-gutter,
288 | .CodeMirror-gutters,
289 | .CodeMirror-linenumber {
290 | -moz-box-sizing: content-box;
291 | box-sizing: content-box;
292 | }
293 |
294 | .CodeMirror-measure {
295 | position: absolute;
296 | width: 100%;
297 | height: 0;
298 | overflow: hidden;
299 | visibility: hidden;
300 | }
301 |
302 | .CodeMirror-cursor {
303 | position: absolute;
304 | pointer-events: none;
305 | }
306 | .CodeMirror-measure pre { position: static; }
307 |
308 | div.CodeMirror-cursors {
309 | visibility: hidden;
310 | position: relative;
311 | z-index: 3;
312 | }
313 | div.CodeMirror-dragcursors {
314 | visibility: visible;
315 | }
316 |
317 | .CodeMirror-focused div.CodeMirror-cursors {
318 | visibility: visible;
319 | }
320 |
321 | .CodeMirror-selected { background: #d9d9d9; }
322 | .CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; }
323 | .CodeMirror-crosshair { cursor: crosshair; }
324 | .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; }
325 | .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; }
326 |
327 | .cm-searching {
328 | background-color: #ffa;
329 | background-color: rgba(255, 255, 0, .4);
330 | }
331 |
332 | /* Used to force a border model for a node */
333 | .cm-force-border { padding-right: .1px; }
334 |
335 | @media print {
336 | /* Hide the cursor when printing */
337 | .CodeMirror div.CodeMirror-cursors {
338 | visibility: hidden;
339 | }
340 | }
341 |
342 | /* See issue #2901 */
343 | .cm-tab-wrap-hack:after { content: ''; }
344 |
345 | /* Help users use markselection to safely style text background */
346 | span.CodeMirror-selectedtext { background: none; }
347 |
--------------------------------------------------------------------------------
/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 | else
82 | host
83 | end
84 | end
85 |
86 | def webmention_type(post)
87 | if post.properties.key?('in-reply-to')
88 | 'reply'
89 | elsif post.properties.key?('repost-of')
90 | 'repost'
91 | elsif post.properties.key?('like-of')
92 | 'like'
93 | else
94 | 'mention'
95 | end
96 | end
97 |
98 | def webmention_type_p_class(type)
99 | case type
100 | when "reply","comment"
101 | return "p-comment" # via http://microformats.org/wiki/comment-brainstorming#microformats2_p-comment_h-entry
102 | when "like"
103 | return "p-like"
104 | when "repost"
105 | return "p-repost"
106 | when "mention"
107 | "p-mention"
108 | end
109 | end
110 | def post_type_u_class(type)
111 | case type
112 | when "reply", "rsvp"
113 | return "u-in-reply-to"
114 | when "repost"
115 | return "u-repost-of u-repost"
116 | when "like"
117 | return "u-like-of u-like"
118 | end
119 | end
120 | def context_class(post)
121 | if post.properties.key?('in-reply-to')
122 | "u-in-reply-to"
123 | elsif post.properties.key?('repost-of')
124 | "u-repost-of"
125 | elsif post.properties.key?('like-of')
126 | "u-like-of"
127 | end
128 | end
129 | def post_type_p_class(post)
130 | if post.properties.key?('in-reply-to')
131 | "p-in-reply-to"
132 | elsif post.properties.key?('repost-of')
133 | "p-repost-of"
134 | elsif post.properties.key?('like-of')
135 | "p-like-of"
136 | elsif post.properties.key?('rsvp')
137 | "p-rsvp"
138 | end
139 | end
140 | def context_tag(post)
141 | if post.h_type == 'h-entry'
142 | case post.entry_type
143 | when "reply", "rsvp"
144 | property = "in-reply-to"
145 | klass = "u-in-reply-to"
146 | when "repost"
147 | property = "repost-of"
148 | klass = "u-repost-of"
149 | when "like"
150 | property = "like-of"
151 | klass = "u-like-of"
152 | when "bookmark"
153 | property = "bookmark-of"
154 | klass = "u-bookmark-of"
155 | else
156 | return
157 | end
158 | tags = post.properties[property].map do |url|
159 | " "
160 | end
161 | tags.join('')
162 | end
163 | end
164 |
165 | def webmention_type_icon(type)
166 | case type
167 | when 'reply'
168 | return " "
169 | when 'repost'
170 | return " "
171 | when 'like'
172 | return " "
173 | else
174 | return " "
175 | end
176 | end
177 |
178 | def webmention_type_text(type)
179 | case type
180 | when 'reply'
181 | return "replied to this"
182 | when 'repost'
183 | return "reposted this"
184 | when 'like'
185 | return "liked this"
186 | else
187 | return "mentioned this"
188 | end
189 | end
190 |
191 | def host_link(url)
192 | host = URI.parse(url).host.downcase
193 | case host
194 | when "twitter.com","mobile.twitter.com"
195 | host_text = " Twitter"
196 | when "instagram.com", "www.instagram.com"
197 | host_text = " Instagram"
198 | else
199 | host_text = host
200 | end
201 | "#{host_text} "
202 | end
203 |
204 | def post_summary(content, num=200)
205 | summary = Sanitize.clean(content).to_s.strip[0...num]
206 | if summary.size == num
207 | summary = summary.strip + "…"
208 | end
209 | summary
210 | end
211 |
212 | def post_split(content)
213 | paragraph_ends = content.split(/\n\n/)
214 | return content unless paragraph_ends.size > 3
215 | paragraph_ends.first + " " +
216 | "Read full post… "
217 | end
218 |
219 | def filter_all(content)
220 | content = link_twitter(content)
221 | content = link_hashtags(content)
222 | content
223 | end
224 |
225 | def link_urls(content)
226 | content.gsub /((https?:\/\/|www\.)([-\w\.]+)+(:\d+)?(\/([\w\/_\.\+\-]*(\?\S+)?)?)?)/, %Q{\\1 }
227 | end
228 | def link_twitter(content)
229 | content.gsub /\B@(\w*[a-zA-Z0-9_-]+)\w*/i, %Q{@\\1 \\2}
230 | end
231 | def link_hashtags(content)
232 | return content
233 | # hashtags => link internally
234 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 }
235 | end
236 | def link_hashtags_twitter(content)
237 | # hashtags => link to twitter search
238 | content.gsub /\B#(\w*[a-zA-Z0-9]+)\w*/i, %Q{ #\\1 }
239 | end
240 |
241 | def force_https_author_profile(photo_url, base_url)
242 | url = URI.join(base_url, photo_url).to_s
243 | if url.start_with?('http://pbs.twimg.com')
244 | url.gsub 'http://pbs.twimg.com', 'https://pbs.twimg.com'
245 | elsif !url.start_with?('https')
246 | camo_image(url)
247 | else
248 | url
249 | end
250 | end
251 |
252 | # from https://github.com/atmos/camo/blob/master/test/proxy_test.rb
253 | def hexenc(image_url)
254 | image_url.to_enum(:each_byte).map { |byte| "%02x" % byte }.join
255 | end
256 | def camo_image(image_url)
257 | hexdigest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'),
258 | ENV['CAMO_KEY'], image_url)
259 | encoded_image_url = hexenc(image_url)
260 | "#{ENV['CAMO_URL']}#{hexdigest}/#{encoded_image_url}"
261 | end
262 |
263 | def is_tweet?(post)
264 | url = post.properties['url'][0]
265 | url.start_with?('https://twitter.com') ||
266 | url.start_with?('https://mobile.twitter.com')
267 | end
268 |
269 | def host_link(url)
270 | host = URI.parse(url).host.downcase
271 | case host
272 | when "twitter.com","mobile.twitter.com"
273 | host_text = " Twitter"
274 | when "instagram.com", "www.instagram.com"
275 | host_text = " Instagram"
276 | else
277 | host_text = host
278 | end
279 | "#{host_text} "
280 | end
281 |
282 | def valid_url?(url)
283 | Utils.valid_url?(url)
284 | end
285 |
286 | def nav(path, text)
287 | if path == request.path_info
288 | "#{text} "
289 | else
290 | "#{text} "
291 | end
292 | end
293 |
294 | def page_title(post)
295 | if post.h_type == 'h-event'
296 | return post.properties['name'][0] || 'Event'
297 | end
298 |
299 | case post.properties['entry-type'][0]
300 | when 'article'
301 | post.properties['name'][0] || 'Article'
302 | when 'bookmark'
303 | post.properties['name'][0] || 'Bookmark'
304 | when 'repost'
305 | "Repost #{post.url}"
306 | when 'like'
307 | "Like #{post.url}"
308 | when 'rsvp'
309 | "RSVP #{post.url}"
310 | when 'checkin'
311 | "Check-in"
312 | else
313 | post_summary(post.content, 100)
314 | end
315 | end
316 |
317 | def rss_description(post)
318 | content = ""
319 | if post.properties.key?('photo')
320 | post.properties['photo'].each do |photo|
321 | src = photo.is_a?(Hash) ? photo['value'] : photo
322 | content += "
"
323 | end
324 | end
325 | unless post.content.nil?
326 | content += markdown(post.content)
327 | end
328 | content
329 | end
330 |
331 | def jsonfeed(posts)
332 | feed = {
333 | "version" => "https://jsonfeed.org/version/1",
334 | "title" => "Barry Frost",
335 | "home_page_url" => ENV['SITE_URL'],
336 | "feed_url" => "#{ENV['SITE_URL']}feed.json",
337 | "author" => {
338 | "name" => "Barry Frost",
339 | "url" => "https://barryfrost.com/",
340 | "avatar" => "#{ENV['SITE_URL']}barryfrost.jpg"
341 | },
342 | "items" => []
343 | }
344 | posts.each do |post|
345 | item = {
346 | "id" => post.url,
347 | "url" => URI.join(ENV['SITE_URL'], post.url),
348 | "date_published" => post.properties['published'][0]
349 | }
350 | if post.properties.key?('updated')
351 | item["date_modified"] = post.properties['updated'][0]
352 | end
353 | if post.properties.key?('name')
354 | item["title"] = post.properties['name'][0]
355 | end
356 | if post.properties['entry-type'][0] == 'bookmark'
357 | item["title"] = "Bookmark: " + item["title"]
358 | item["external_url"] = post.properties['bookmark-of'][0]
359 | end
360 | if post.properties.key?('content')
361 | if post.properties['content'][0].is_a?(Hash)
362 | item["content_html"] = post.properties['content'][0]['html']
363 | elsif !post.properties['content'][0].empty?
364 | item["content_html"] = filter_markdown(post.properties['content'][0])
365 | end
366 | end
367 | if post.properties.key?('photo')
368 | photo = post.properties['photo'][0]
369 | item["image"] = photo.is_a?(Hash) ? photo['value'] : photo
370 | end
371 | if post.properties.key?('category')
372 | item["tags"] = post.properties['category']
373 | end
374 | feed["items"] << item
375 | end
376 | feed.to_json
377 | end
378 |
379 | end
380 | end
--------------------------------------------------------------------------------
/public/css/milligram.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Milligram v1.3.0
3 | * https://milligram.github.io
4 | *
5 | * Copyright (c) 2017 CJ Patoilo
6 | * Licensed under the MIT license
7 | *
8 | * Colors modified for Sitewriter
9 | */
10 |
11 | *,
12 | *:after,
13 | *:before {
14 | box-sizing: inherit;
15 | }
16 |
17 | html {
18 | box-sizing: border-box;
19 | font-size: 62.5%;
20 | }
21 |
22 | body {
23 | color: #606c76;
24 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
25 | font-size: 1.6em;
26 | font-weight: 300;
27 | letter-spacing: .01em;
28 | line-height: 1.6;
29 | }
30 |
31 | blockquote {
32 | border-left: 0.3rem solid #d1d1d1;
33 | margin-left: 0;
34 | margin-right: 0;
35 | padding: 1rem 1.5rem;
36 | }
37 |
38 | blockquote *:last-child {
39 | margin-bottom: 0;
40 | }
41 |
42 | .button,
43 | button,
44 | input[type='button'],
45 | input[type='reset'],
46 | input[type='submit'] {
47 | background-color: #2980B9;
48 | border: 0.1rem solid #2980B9;
49 | border-radius: .4rem;
50 | color: #fff;
51 | cursor: pointer;
52 | display: inline-block;
53 | font-size: 1.1rem;
54 | font-weight: 700;
55 | height: 3.8rem;
56 | letter-spacing: .1rem;
57 | line-height: 3.8rem;
58 | padding: 0 3.0rem;
59 | text-align: center;
60 | text-decoration: none;
61 | text-transform: uppercase;
62 | white-space: nowrap;
63 | }
64 |
65 | .button:focus, .button:hover,
66 | button:focus,
67 | button:hover,
68 | input[type='button']:focus,
69 | input[type='button']:hover,
70 | input[type='reset']:focus,
71 | input[type='reset']:hover,
72 | input[type='submit']:focus,
73 | input[type='submit']:hover {
74 | background-color: #606c76;
75 | border-color: #606c76;
76 | color: #fff;
77 | outline: 0;
78 | }
79 |
80 | .button[disabled],
81 | button[disabled],
82 | input[type='button'][disabled],
83 | input[type='reset'][disabled],
84 | input[type='submit'][disabled] {
85 | cursor: default;
86 | opacity: .5;
87 | }
88 |
89 | .button[disabled]:focus, .button[disabled]:hover,
90 | button[disabled]:focus,
91 | button[disabled]:hover,
92 | input[type='button'][disabled]:focus,
93 | input[type='button'][disabled]:hover,
94 | input[type='reset'][disabled]:focus,
95 | input[type='reset'][disabled]:hover,
96 | input[type='submit'][disabled]:focus,
97 | input[type='submit'][disabled]:hover {
98 | background-color: #2980B9;
99 | border-color: #2980B9;
100 | }
101 |
102 | .button.button-outline,
103 | button.button-outline,
104 | input[type='button'].button-outline,
105 | input[type='reset'].button-outline,
106 | input[type='submit'].button-outline {
107 | background-color: transparent;
108 | color: #2980B9;
109 | }
110 |
111 | .button.button-outline:focus, .button.button-outline:hover,
112 | button.button-outline:focus,
113 | button.button-outline:hover,
114 | input[type='button'].button-outline:focus,
115 | input[type='button'].button-outline:hover,
116 | input[type='reset'].button-outline:focus,
117 | input[type='reset'].button-outline:hover,
118 | input[type='submit'].button-outline:focus,
119 | input[type='submit'].button-outline:hover {
120 | background-color: transparent;
121 | border-color: #606c76;
122 | color: #606c76;
123 | }
124 |
125 | .button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover,
126 | button.button-outline[disabled]:focus,
127 | button.button-outline[disabled]:hover,
128 | input[type='button'].button-outline[disabled]:focus,
129 | input[type='button'].button-outline[disabled]:hover,
130 | input[type='reset'].button-outline[disabled]:focus,
131 | input[type='reset'].button-outline[disabled]:hover,
132 | input[type='submit'].button-outline[disabled]:focus,
133 | input[type='submit'].button-outline[disabled]:hover {
134 | border-color: inherit;
135 | color: #2980B9;
136 | }
137 |
138 | .button.button-clear,
139 | button.button-clear,
140 | input[type='button'].button-clear,
141 | input[type='reset'].button-clear,
142 | input[type='submit'].button-clear {
143 | background-color: transparent;
144 | border-color: transparent;
145 | color: #2980B9;
146 | }
147 |
148 | .button.button-clear:focus, .button.button-clear:hover,
149 | button.button-clear:focus,
150 | button.button-clear:hover,
151 | input[type='button'].button-clear:focus,
152 | input[type='button'].button-clear:hover,
153 | input[type='reset'].button-clear:focus,
154 | input[type='reset'].button-clear:hover,
155 | input[type='submit'].button-clear:focus,
156 | input[type='submit'].button-clear:hover {
157 | background-color: transparent;
158 | border-color: transparent;
159 | color: #606c76;
160 | }
161 |
162 | .button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover,
163 | button.button-clear[disabled]:focus,
164 | button.button-clear[disabled]:hover,
165 | input[type='button'].button-clear[disabled]:focus,
166 | input[type='button'].button-clear[disabled]:hover,
167 | input[type='reset'].button-clear[disabled]:focus,
168 | input[type='reset'].button-clear[disabled]:hover,
169 | input[type='submit'].button-clear[disabled]:focus,
170 | input[type='submit'].button-clear[disabled]:hover {
171 | color: #2980B9;
172 | }
173 |
174 | code {
175 | background: #ECF0F1;
176 | border-radius: .4rem;
177 | font-size: 86%;
178 | margin: 0 .2rem;
179 | padding: .2rem .5rem;
180 | white-space: nowrap;
181 | }
182 |
183 | pre {
184 | background: #ECF0F1;
185 | border-left: 0.3rem solid #2980B9;
186 | overflow-y: hidden;
187 | white-space: pre-wrap;
188 | }
189 |
190 | pre > code {
191 | border-radius: 0;
192 | display: block;
193 | padding: 1rem 1.5rem;
194 | white-space: pre;
195 | }
196 |
197 | hr {
198 | border: 0;
199 | border-top: 0.1rem solid #ECF0F1;
200 | margin: 3.0rem 0;
201 | }
202 |
203 | input[type='email'],
204 | input[type='number'],
205 | input[type='password'],
206 | input[type='search'],
207 | input[type='tel'],
208 | input[type='text'],
209 | input[type='url'],
210 | textarea,
211 | select {
212 | -webkit-appearance: none;
213 | -moz-appearance: none;
214 | appearance: none;
215 | background-color: transparent;
216 | border: 0.1rem solid #d1d1d1;
217 | border-radius: .4rem;
218 | box-shadow: none;
219 | box-sizing: inherit;
220 | height: 3.8rem;
221 | padding: .6rem 1.0rem;
222 | width: 100%;
223 | }
224 |
225 | input[type='email']:focus,
226 | input[type='number']:focus,
227 | input[type='password']:focus,
228 | input[type='search']:focus,
229 | input[type='tel']:focus,
230 | input[type='text']:focus,
231 | input[type='url']:focus,
232 | textarea:focus,
233 | select:focus {
234 | border-color: #2980B9;
235 | outline: 0;
236 | }
237 |
238 | select {
239 | background: url('data:image/svg+xml;utf8, ') center right no-repeat;
240 | padding-right: 3.0rem;
241 | }
242 |
243 | select:focus {
244 | background-image: url('data:image/svg+xml;utf8, ');
245 | }
246 |
247 | textarea {
248 | min-height: 6.5rem;
249 | }
250 |
251 | label,
252 | legend {
253 | display: block;
254 | font-size: 1.6rem;
255 | font-weight: 700;
256 | margin-bottom: .5rem;
257 | }
258 |
259 | fieldset {
260 | border-width: 0;
261 | padding: 0;
262 | }
263 |
264 | input[type='checkbox'],
265 | input[type='radio'] {
266 | display: inline;
267 | }
268 |
269 | .label-inline {
270 | display: inline-block;
271 | font-weight: normal;
272 | margin-left: .5rem;
273 | }
274 |
275 | .container {
276 | margin: 0 auto;
277 | max-width: 112.0rem;
278 | padding: 0 2.0rem;
279 | position: relative;
280 | width: 100%;
281 | }
282 |
283 | .row {
284 | display: flex;
285 | flex-direction: column;
286 | padding: 0;
287 | width: 100%;
288 | }
289 |
290 | .row.row-no-padding {
291 | padding: 0;
292 | }
293 |
294 | .row.row-no-padding > .column {
295 | padding: 0;
296 | }
297 |
298 | .row.row-wrap {
299 | flex-wrap: wrap;
300 | }
301 |
302 | .row.row-top {
303 | align-items: flex-start;
304 | }
305 |
306 | .row.row-bottom {
307 | align-items: flex-end;
308 | }
309 |
310 | .row.row-center {
311 | align-items: center;
312 | }
313 |
314 | .row.row-stretch {
315 | align-items: stretch;
316 | }
317 |
318 | .row.row-baseline {
319 | align-items: baseline;
320 | }
321 |
322 | .row .column {
323 | display: block;
324 | flex: 1 1 auto;
325 | margin-left: 0;
326 | max-width: 100%;
327 | width: 100%;
328 | }
329 |
330 | .row .column.column-offset-10 {
331 | margin-left: 10%;
332 | }
333 |
334 | .row .column.column-offset-20 {
335 | margin-left: 20%;
336 | }
337 |
338 | .row .column.column-offset-25 {
339 | margin-left: 25%;
340 | }
341 |
342 | .row .column.column-offset-33, .row .column.column-offset-34 {
343 | margin-left: 33.3333%;
344 | }
345 |
346 | .row .column.column-offset-50 {
347 | margin-left: 50%;
348 | }
349 |
350 | .row .column.column-offset-66, .row .column.column-offset-67 {
351 | margin-left: 66.6666%;
352 | }
353 |
354 | .row .column.column-offset-75 {
355 | margin-left: 75%;
356 | }
357 |
358 | .row .column.column-offset-80 {
359 | margin-left: 80%;
360 | }
361 |
362 | .row .column.column-offset-90 {
363 | margin-left: 90%;
364 | }
365 |
366 | .row .column.column-10 {
367 | flex: 0 0 10%;
368 | max-width: 10%;
369 | }
370 |
371 | .row .column.column-20 {
372 | flex: 0 0 20%;
373 | max-width: 20%;
374 | }
375 |
376 | .row .column.column-25 {
377 | flex: 0 0 25%;
378 | max-width: 25%;
379 | }
380 |
381 | .row .column.column-33, .row .column.column-34 {
382 | flex: 0 0 33.3333%;
383 | max-width: 33.3333%;
384 | }
385 |
386 | .row .column.column-40 {
387 | flex: 0 0 40%;
388 | max-width: 40%;
389 | }
390 |
391 | .row .column.column-50 {
392 | flex: 0 0 50%;
393 | max-width: 50%;
394 | }
395 |
396 | .row .column.column-60 {
397 | flex: 0 0 60%;
398 | max-width: 60%;
399 | }
400 |
401 | .row .column.column-66, .row .column.column-67 {
402 | flex: 0 0 66.6666%;
403 | max-width: 66.6666%;
404 | }
405 |
406 | .row .column.column-75 {
407 | flex: 0 0 75%;
408 | max-width: 75%;
409 | }
410 |
411 | .row .column.column-80 {
412 | flex: 0 0 80%;
413 | max-width: 80%;
414 | }
415 |
416 | .row .column.column-90 {
417 | flex: 0 0 90%;
418 | max-width: 90%;
419 | }
420 |
421 | .row .column .column-top {
422 | align-self: flex-start;
423 | }
424 |
425 | .row .column .column-bottom {
426 | align-self: flex-end;
427 | }
428 |
429 | .row .column .column-center {
430 | -ms-grid-row-align: center;
431 | align-self: center;
432 | }
433 |
434 | @media (min-width: 40rem) {
435 | .row {
436 | flex-direction: row;
437 | margin-left: -1.0rem;
438 | width: calc(100% + 2.0rem);
439 | }
440 | .row .column {
441 | margin-bottom: inherit;
442 | padding: 0 1.0rem;
443 | }
444 | }
445 |
446 | a {
447 | color: #2980B9;
448 | text-decoration: none;
449 | }
450 |
451 | a:focus, a:hover {
452 | color: #606c76;
453 | }
454 |
455 | dl,
456 | ol,
457 | ul {
458 | list-style: none;
459 | margin-top: 0;
460 | padding-left: 0;
461 | }
462 |
463 | dl dl,
464 | dl ol,
465 | dl ul,
466 | ol dl,
467 | ol ol,
468 | ol ul,
469 | ul dl,
470 | ul ol,
471 | ul ul {
472 | font-size: 90%;
473 | margin: 1.5rem 0 1.5rem 3.0rem;
474 | }
475 |
476 | ol {
477 | list-style: decimal inside;
478 | }
479 |
480 | ul {
481 | list-style: circle inside;
482 | }
483 |
484 | .button,
485 | button,
486 | dd,
487 | dt,
488 | li {
489 | margin-bottom: 1.0rem;
490 | }
491 |
492 | fieldset,
493 | input,
494 | select,
495 | textarea {
496 | margin-bottom: 1.5rem;
497 | }
498 |
499 | blockquote,
500 | dl,
501 | figure,
502 | form,
503 | ol,
504 | p,
505 | pre,
506 | table,
507 | ul {
508 | margin-bottom: 2.5rem;
509 | }
510 |
511 | table {
512 | border-spacing: 0;
513 | width: 100%;
514 | }
515 |
516 | td,
517 | th {
518 | border-bottom: 0.1rem solid #e1e1e1;
519 | padding: 1.2rem 1.5rem;
520 | text-align: left;
521 | }
522 |
523 | td:first-child,
524 | th:first-child {
525 | padding-left: 0;
526 | }
527 |
528 | td:last-child,
529 | th:last-child {
530 | padding-right: 0;
531 | }
532 |
533 | b,
534 | strong {
535 | font-weight: bold;
536 | }
537 |
538 | p {
539 | margin-top: 0;
540 | }
541 |
542 | h1,
543 | h2,
544 | h3,
545 | h4,
546 | h5,
547 | h6 {
548 | font-weight: 300;
549 | letter-spacing: -.1rem;
550 | margin-bottom: 2.0rem;
551 | margin-top: 0;
552 | }
553 |
554 | h1 {
555 | font-size: 4.6rem;
556 | line-height: 1.2;
557 | }
558 |
559 | h2 {
560 | font-size: 3.6rem;
561 | line-height: 1.25;
562 | }
563 |
564 | h3 {
565 | font-size: 2.8rem;
566 | line-height: 1.3;
567 | }
568 |
569 | h4 {
570 | font-size: 2.2rem;
571 | letter-spacing: -.08rem;
572 | line-height: 1.35;
573 | }
574 |
575 | h5 {
576 | font-size: 1.8rem;
577 | letter-spacing: -.05rem;
578 | line-height: 1.5;
579 | }
580 |
581 | h6 {
582 | font-size: 1.6rem;
583 | letter-spacing: 0;
584 | line-height: 1.4;
585 | }
586 |
587 | img {
588 | max-width: 100%;
589 | }
590 |
591 | .clearfix:after {
592 | clear: both;
593 | content: ' ';
594 | display: table;
595 | }
596 |
597 | .float-left {
598 | float: left;
599 | }
600 |
601 | .float-right {
602 | float: right;
603 | }
604 |
--------------------------------------------------------------------------------