├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app.rb ├── config.ru ├── db └── migrate │ ├── 001_create_sites.rb │ ├── 002_create_stores.rb │ ├── 003_create_flows.rb │ ├── 004_add_flow_media_templates.rb │ ├── 005_update_flow_type_to_kind.rb │ ├── 006_create_log.rb │ ├── 007_add_file_flow_to_sites.rb │ ├── 008_add_settings_to_sites.rb │ ├── 009_add_file_to_log.rb │ └── 010_add_photos_to_log.rb ├── lib ├── _transformative │ ├── authorship.rb │ ├── context.rb │ ├── media_s3.rb │ ├── notification.rb │ ├── syndication.rb │ ├── twitter.rb │ ├── utils.rb │ ├── view_helper.rb │ └── webmention.rb ├── auth.rb ├── generators.yml ├── init.rb ├── media.rb ├── micropub.rb ├── post.rb ├── post_types.yml └── posts │ ├── _entry.rb │ ├── article.rb │ ├── audio.rb │ ├── bookmark.rb │ ├── checkin.rb │ ├── event.rb │ ├── like.rb │ ├── note.rb │ ├── photo.rb │ ├── reply.rb │ ├── repost.rb │ └── video.rb ├── models ├── file_system.rb ├── flow.rb ├── github.rb ├── init.rb ├── site.rb └── store.rb ├── public ├── css │ ├── codemirror.css │ ├── milligram.css │ ├── normalize.css │ └── styles.css ├── favicon.ico ├── images │ ├── article.svg │ ├── attachment.svg │ ├── audio.svg │ ├── bookmark.svg │ ├── checkin.svg │ ├── event.svg │ ├── file.svg │ ├── like.svg │ ├── note.svg │ ├── photo.svg │ ├── reply.svg │ ├── repost.svg │ ├── setup.svg │ └── video.svg └── js │ └── codemirror │ ├── codemirror.js │ ├── markdown.js │ ├── multiplex.js │ ├── yaml-frontmatter.js │ └── yaml.js ├── routes ├── init.rb ├── manage.rb ├── micropub.rb ├── pages.rb └── webmention.rb ├── script ├── console ├── migrate └── server └── views ├── 404.erb ├── 500.erb ├── flow_edit.erb ├── flow_media.erb ├── gallery.erb ├── index.erb ├── layout.erb ├── site_posting.erb ├── site_settings.erb ├── site_status.erb ├── site_uploading.erb ├── store_edit.erb ├── syslog.erb └── tools.erb /.gitignore: -------------------------------------------------------------------------------- 1 | # Used by dotenv library to load environment variables. 2 | .env 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.5 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: script/migrate 2 | web: rackup -s puma -p $PORT 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/_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/_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 | -------------------------------------------------------------------------------- /lib/_transformative/notification.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Notification 3 | module_function 4 | 5 | def send(title, message, url) 6 | if ENV.key?('PUSHOVER_USER') && ENV.key?('PUSHOVER_TOKEN') 7 | pushover(title, message, url) 8 | end 9 | end 10 | 11 | def pushover(title, message, url) 12 | response = HTTParty.post('https://api.pushover.net/1/messages.json', { 13 | body: { 14 | token: ENV['PUSHOVER_TOKEN'], 15 | user: ENV['PUSHOVER_USER'], 16 | title: title, 17 | message: message, 18 | url: url 19 | } 20 | }) 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/_transformative/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/_transformative/twitter.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Twitter 3 | module_function 4 | 5 | TWITTER_STATUS_REGEX = 6 | /^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)$/ 7 | 8 | def update(post) 9 | status = get_status(post) 10 | return if status.empty? 11 | 12 | options = {} 13 | if reply_tweet_id = get_reply(post) 14 | options[:in_reply_to_status_id] = reply_tweet_id 15 | end 16 | if media = get_media(post) 17 | media_ids = media.map { |file| client.upload(file) } 18 | options[:media_ids] = media_ids.join(',') 19 | end 20 | 21 | tweet = client.update(status, options) 22 | "https://twitter.com/#{tweet.user.screen_name}/status/#{tweet.id}" 23 | end 24 | 25 | def get_status(post) 26 | # prioritise summary, name (+ url) then content 27 | # TODO: ellipsize 28 | if post.properties.key?('summary') 29 | post.properties['summary'][0] 30 | elsif post.properties.key?('name') 31 | "#{post.properties['name'][0]}: #{post.absolute_url}" 32 | elsif post.properties.key?('content') 33 | if post.properties['content'][0].is_a?(Hash) && 34 | post.properties['content'][0].key?('html') 35 | Sanitize.fragment(post.properties['content'][0]['html']).strip 36 | else 37 | post.properties['content'][0] 38 | end 39 | else 40 | "" 41 | end 42 | end 43 | 44 | def get_reply(post) 45 | # use first twitter url from in-reply-to list 46 | if post.properties.key?('in-reply-to') && 47 | post.properties['in-reply-to'].is_a?(Array) 48 | post.properties['in-reply-to'].each do |url| 49 | if tweet_id = tweet_id_from_url(url) 50 | return tweet_id 51 | end 52 | end 53 | end 54 | end 55 | 56 | def get_media(post) 57 | if post.properties.key?('photo') && 58 | post.properties['photo'].is_a?(Array) 59 | post.properties['photo'].map do |photo| 60 | if photo.is_a?(Hash) 61 | open(photo['value']) 62 | else 63 | open(photo) 64 | end 65 | end 66 | end 67 | end 68 | 69 | def retweet(urls) 70 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 71 | tweet = client.retweet(tweet_id) 72 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 73 | end 74 | 75 | def favorite(urls) 76 | return unless tweet_id = find_first_tweet_id_from_urls(urls) 77 | tweet = client.favorite(tweet_id) 78 | "https://twitter.com/#{tweet[0].user.screen_name}/status/#{tweet[0].id}" 79 | end 80 | 81 | def tweet_id_from_url(url) 82 | return unless tweet_parts = url.match(TWITTER_STATUS_REGEX) 83 | tweet_parts[3] 84 | end 85 | 86 | def find_first_tweet_id_from_urls(urls) 87 | urls.each do |url| 88 | if tweet_id = tweet_id_from_url(url) 89 | return tweet_id 90 | end 91 | end 92 | end 93 | 94 | def client 95 | Utils.twitter_client 96 | end 97 | 98 | end 99 | end -------------------------------------------------------------------------------- /lib/_transformative/utils.rb: -------------------------------------------------------------------------------- 1 | module Transformative 2 | module Utils 3 | module_function 4 | 5 | def valid_url?(url) 6 | begin 7 | uri = URI.parse(url) 8 | uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) 9 | rescue URI::InvalidURIError 10 | end 11 | end 12 | 13 | def slugify_url(url) 14 | url.to_s.downcase.gsub(/[^a-z0-9\-]/,' ').strip.gsub(/\s+/,'/') 15 | end 16 | 17 | def relative_url(url) 18 | url.sub!(ENV['SITE_URL'], '') 19 | url.start_with?('/') ? url : "/#{url}" 20 | end 21 | 22 | def ping_pubsubhubbub 23 | HTTParty.post(ENV['PUBSUBHUBBUB_HUB'], { 24 | body: { 25 | "hub.mode": "publish", 26 | "hub.url": ENV['SITE_URL'] 27 | } 28 | }) 29 | end 30 | 31 | def twitter_client 32 | @twitter_client ||= ::Twitter::REST::Client.new do |config| 33 | config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] 34 | config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] 35 | config.access_token = ENV['TWITTER_ACCESS_TOKEN'] 36 | config.access_token_secret = ENV['TWITTER_ACCESS_TOKEN_SECRET'] 37 | end 38 | end 39 | 40 | end 41 | end -------------------------------------------------------------------------------- /lib/_transformative/view_helper.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Transformative 4 | module ViewHelper 5 | 6 | def h(text) 7 | Rack::Utils.escape_html(text) 8 | end 9 | 10 | def filter_markdown(content) 11 | return "" if content.nil? 12 | Redcarpet::Render::SmartyPants.render( 13 | Redcarpet::Markdown.new( 14 | Redcarpet::Render::HTML, autolink: true 15 | ).render(content) 16 | ) 17 | end 18 | 19 | def post_type_icon(post) 20 | icon = case post.properties['entry-type'][0] 21 | when 'article' 22 | "file-text" 23 | when 'bookmark' 24 | "bookmark" 25 | when 'photo' 26 | "camera" 27 | when 'reply' 28 | "reply" 29 | when 'repost' 30 | "retweet" 31 | when 'like' 32 | "heart" 33 | when 'rsvp' 34 | if post.properties.key?('rsvp') 35 | case post.properties['rsvp'][0] 36 | when 'yes', true 37 | 'calendar-check-o' 38 | when 'no', false 39 | 'calendar-times-o' 40 | else 41 | 'calendar-o' 42 | end 43 | else 44 | "calendar" 45 | end 46 | when 'checkin' 47 | "compass" 48 | else 49 | "comment" 50 | end 51 | "" 52 | end 53 | 54 | def context_prefix(entry) 55 | if entry.properties.key?('in-reply-to') 56 | "In reply to" 57 | elsif entry.properties.key?('repost-of') 58 | "Reposted" 59 | elsif entry.properties.key?('like-of') 60 | "Liked" 61 | elsif entry.properties.key?('rsvp') 62 | "RSVP to" 63 | end 64 | end 65 | 66 | def syndication_text(syndication) 67 | host = URI.parse(syndication).host 68 | case host 69 | when 'twitter.com', 'mobile.twitter.com' 70 | " Twitter" 71 | when 'instagram.com', 'www.instagram.com' 72 | " Instagram" 73 | when 'facebook.com', 'www.facebook.com' 74 | " Facebook" 75 | when 'swarmapp.com', 'www.swarmapp.com' 76 | " Swarm" 77 | when 'news.indieweb.org' 78 | "IndieNews" 79 | when 'medium.com' 80 | " Medium" 81 | 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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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| "![](#{p[:url]}") 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gerwitz/sitewriter/8553bada12e870fd1ea06ffe5e57c2fbb852404a/public/favicon.ico -------------------------------------------------------------------------------- /public/images/article.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to reportAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_report 7 | -------------------------------------------------------------------------------- /public/images/attachment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to clipAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_clip 7 | -------------------------------------------------------------------------------- /public/images/audio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to singAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_sing 7 | -------------------------------------------------------------------------------- /public/images/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to bookmarkAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_bookmark 7 | -------------------------------------------------------------------------------- /public/images/checkin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to locateAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_locate 7 | -------------------------------------------------------------------------------- /public/images/event.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to scheduleAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_schedule 7 | -------------------------------------------------------------------------------- /public/images/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to saveAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_save 7 | -------------------------------------------------------------------------------- /public/images/like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to likeAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_like 8 | -------------------------------------------------------------------------------- /public/images/note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to writeAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_write 7 | -------------------------------------------------------------------------------- /public/images/photo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to photographAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_photograph 7 | -------------------------------------------------------------------------------- /public/images/reply.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to chatAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_chat 7 | -------------------------------------------------------------------------------- /public/images/repost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to syncAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_sync 7 | -------------------------------------------------------------------------------- /public/images/setup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to turnAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_turn 7 | -------------------------------------------------------------------------------- /public/images/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | to filmAn 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+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_film 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /script/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | bundle exec rake db:migrate 3 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | bundle exec rackup 3 | -------------------------------------------------------------------------------- /views/404.erb: -------------------------------------------------------------------------------- 1 |
    2 | 3 |

    404 Not Found

    4 |
    5 | 6 |

    The page or post was not found.

    7 |
    8 | 9 |
    10 | -------------------------------------------------------------------------------- /views/500.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

    500 Error

    9 |

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

    10 | 11 | -------------------------------------------------------------------------------- /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 |
    20 |
    21 | 22 |
    23 | 26 |
    27 | 28 |
      29 | <% path_vars.each do |v, desc| %> 30 |
    • <%= v %>
    • 33 | <% end %> 34 |
    35 |
    36 |
    37 |
    38 | 41 |
    42 | 43 |
      44 | <% path_vars.each do |v, desc| %> 45 |
    • <%= v %>
    • 48 | <% end %> 49 |
    50 |
    51 |
    52 |
    53 | 56 | 57 |
    58 | <%= @flow.post_kind %> variables: (hover for description) 59 |
    60 |
      61 | <% content_vars.each do |v, desc| %> 62 |
    • <%= v %>
    • 67 | <% end %> 68 |
    69 | 70 | example: 71 |
    ---
     72 | date: {{datetime}}
     73 | slug: {{slug}}
     74 | categories:
     75 | - microblog
     76 | {{#categories}}
     77 | - {{.}}
     78 | {{/categories}}
     79 | ---
     80 | {{{content}}}
     81 | 
    82 |
    83 | 84 | <% if Post.type_supports_attachments?(@flow.post_kind) %> 85 | 86 |

    Attachments

    87 |
    88 | 91 |
    92 | 93 | example: assets/:year_month/:day-:slug.:extension 94 |
    95 |
    96 |
    97 | 100 | 101 | example: assets/:year_month/:day-:slug.:extension 102 |
    103 | 104 | <% end %> 105 | 106 |
    107 | 108 |
    109 | Cancel 110 | Disable 111 | 112 |
    113 |
    114 |
    115 | 116 | 117 | 118 | 119 | 120 | 121 | 144 | -------------------------------------------------------------------------------- /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 |
    19 |
    20 | 21 | 22 |
    23 | 28 | example: assets/:year_month/:day-:slug.:extension 29 |
    30 |
    31 |
    32 | 37 | example: assets/:year_month/:day-:slug.:extension 38 |
    39 | 40 |
    41 | Cancel 42 | 43 |
    44 |
    45 |
    46 | -------------------------------------------------------------------------------- /views/gallery.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gerwitz/sitewriter/8553bada12e870fd1ea06ffe5e57c2fbb852404a/views/gallery.erb -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | <% if session[:domain] %> 2 | 3 |

    Welcome back!

    4 | 5 |

    6 | You're authenticated as <%= session[:domain] %> 7 |

    8 |
    9 | 10 |
    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 |

    21 | 22 | "> 23 | "> 24 | 25 |
    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 | 48 | -------------------------------------------------------------------------------- /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 |
    21 | <%= session[:domain] %> 22 |
    23 | <% end %> 24 |
    25 |
    26 | <%= yield %> 27 |
    28 | 29 | 32 | 33 | -------------------------------------------------------------------------------- /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 | 29 | 32 | <% if flow %> 33 | 40 | <% else %> 41 | 45 | <% end %> 46 | 47 | <% end %> 48 |
    27 | 28 | 30 | <%= post_kind.capitalize %> 31 | 34 | file template: <%= flow.path_template %> 35 |
    36 | url template: <%= flow.url_template %> 37 |
    38 | Edit 39 | 42 | Enable 43 | 44 |
    49 | <% end %> 50 | -------------------------------------------------------------------------------- /views/site_settings.erb: -------------------------------------------------------------------------------- 1 | 8 | 9 |

    Settings

    10 |
    11 |
    12 | 20 |
    21 | Cancel 22 | 23 |
    24 |
    25 |
    26 | -------------------------------------------------------------------------------- /views/site_status.erb: -------------------------------------------------------------------------------- 1 | 8 | 9 |

    <%= @site.domain %>

    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 | 64 | 65 | <% end %> 66 |
    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 |
    67 | <% end %> 68 | -------------------------------------------------------------------------------- /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 | 24 | 27 | 34 | 35 |
    22 | 23 | 25 | All Files 26 | 28 | path template: <%= @site.file_flow.media_path_template %> 29 |
    30 | url template: <%= @site.file_flow.media_url_template %> 31 |
    32 | Edit 33 |
    36 | <% end %> 37 | -------------------------------------------------------------------------------- /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 |
    16 | 17 | <% if @flow %> 18 | 19 | <% end %> 20 | 25 | 26 | 27 | 28 | 29 | 30 | Cancel 31 |
    32 | -------------------------------------------------------------------------------- /views/syslog.erb: -------------------------------------------------------------------------------- 1 |

    Most recent 100 log entries

    2 | 3 | <% @log.first(100).each do |entry| %> 4 | 5 | 8 | 19 | 20 | <% end %> 21 |
    6 | <%= Site[entry[:site_id]].domain %> 7 | 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 |
    22 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------