├── .dockerignore ├── .gitignore ├── .gitmodules ├── .rspec ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── changelog.md ├── config.ru ├── config └── properties.json ├── docker-compose.yml ├── lib ├── micropublish.rb └── micropublish │ ├── auth.rb │ ├── compare.rb │ ├── endpoints_finder.rb │ ├── helpers.rb │ ├── micropub.rb │ ├── post.rb │ ├── request.rb │ ├── server.rb │ └── version.rb ├── public ├── css │ ├── bootstrap-tokenfield.css │ └── micropublish.css ├── help.md ├── manifest.json ├── micropublish-demo.gif └── scripts │ ├── bootstrap-tokenfield.min.js │ ├── jquery.ns-autogrow.min.js │ ├── micropublish.js │ ├── trix.js │ └── twitter-text.js ├── spec ├── micropublish │ ├── auth_spec.rb │ ├── compare_spec.rb │ ├── endpoints_finder_spec.rb │ └── server_spec.rb ├── micropublish_spec.rb └── spec_helper.rb └── views ├── dashboard.erb ├── delete.erb ├── form.erb ├── layout.erb ├── login.erb ├── preview.erb ├── redirect.erb ├── static.erb └── undelete.erb /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .bundle 4 | vendor/bundle 5 | TODO.md 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/twitter-text"] 2 | path = vendor/twitter-text 3 | url = https://github.com/twitter/twitter-text.git 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.5 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # --> Stage 1: Runtime and Build Base Image 4 | ARG RUBY_VERSION=3.3 5 | FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base 6 | WORKDIR /app 7 | SHELL [ "/bin/bash", "-c" ] 8 | 9 | # Install base packages 10 | RUN apt-get update -qq && \ 11 | apt-get install --no-install-recommends -y curl libjemalloc2 libvips && \ 12 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 13 | 14 | # Set production environment 15 | ENV BUNDLE_DEPLOYMENT="1" \ 16 | BUNDLE_PATH="/usr/local/bundle" 17 | 18 | #ENV BUNDLE_WITHOUT="development" 19 | 20 | 21 | # --> Stage 2: Build Environment 22 | FROM base AS build 23 | 24 | # Install packages needed to build gems 25 | RUN apt-get update -qq && \ 26 | apt-get install --no-install-recommends -y build-essential curl git pkg-config libyaml-dev && \ 27 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 28 | 29 | # Install application gems 30 | COPY Gemfile Gemfile.lock ./ 31 | RUN bundle install && \ 32 | rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*# Throw-away build stage to reduce size of final image 33 | 34 | COPY . . 35 | 36 | # --> Stage 3: Actual application deployment 37 | FROM base 38 | COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" 39 | COPY --from=build /app /app 40 | 41 | RUN groupadd --system --gid 1000 app && \ 42 | useradd app --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 43 | chown -R 1000:1000 . 44 | 45 | USER 1000:1000 46 | 47 | ENV RACK_ENV=production 48 | ENV REDIS_URL=redis://localhost:6379 49 | ENV FORCE_SSL=0 50 | 51 | CMD [ "bundle", "exec", "puma", "-b", "tcp://0.0.0.0:9292" ] 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '~> 3.3.0' 4 | 5 | gem 'sinatra' 6 | gem 'puma' 7 | gem 'rack-contrib' 8 | gem 'rack-ssl' 9 | gem 'link_header' 10 | gem 'httparty' 11 | gem 'nokogiri' 12 | gem 'foreman' 13 | gem 'kramdown' 14 | gem 'redis' 15 | gem 'rackup' 16 | 17 | group :development do 18 | gem 'dotenv' 19 | end 20 | 21 | group :test do 22 | gem 'rack-test' 23 | gem 'rspec' 24 | gem 'webmock' 25 | end 26 | 27 | group :production do 28 | gem 'sentry-raven' 29 | end 30 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.7) 5 | public_suffix (>= 2.0.2, < 7.0) 6 | base64 (0.2.0) 7 | bigdecimal (3.1.8) 8 | connection_pool (2.4.1) 9 | crack (1.0.0) 10 | bigdecimal 11 | rexml 12 | csv (3.3.0) 13 | diff-lcs (1.5.1) 14 | dotenv (3.1.4) 15 | faraday (2.12.0) 16 | faraday-net_http (>= 2.0, < 3.4) 17 | json 18 | logger 19 | faraday-net_http (3.3.0) 20 | net-http 21 | foreman (0.88.1) 22 | hashdiff (1.1.1) 23 | httparty (0.22.0) 24 | csv 25 | mini_mime (>= 1.0.0) 26 | multi_xml (>= 0.5.2) 27 | json (2.8.1) 28 | kramdown (2.4.0) 29 | rexml 30 | link_header (0.0.8) 31 | logger (1.6.1) 32 | mini_mime (1.1.5) 33 | mini_portile2 (2.8.7) 34 | multi_xml (0.7.1) 35 | bigdecimal (~> 3.1) 36 | mustermann (3.0.3) 37 | ruby2_keywords (~> 0.0.1) 38 | net-http (0.5.0) 39 | uri 40 | nio4r (2.7.4) 41 | nokogiri (1.16.7) 42 | mini_portile2 (~> 2.8.2) 43 | racc (~> 1.4) 44 | public_suffix (6.0.1) 45 | puma (6.4.3) 46 | nio4r (~> 2.0) 47 | racc (1.8.1) 48 | rack (3.1.8) 49 | rack-contrib (2.5.0) 50 | rack (< 4) 51 | rack-protection (4.0.0) 52 | base64 (>= 0.1.0) 53 | rack (>= 3.0.0, < 4) 54 | rack-session (2.0.0) 55 | rack (>= 3.0.0) 56 | rack-ssl (1.4.1) 57 | rack 58 | rack-test (2.1.0) 59 | rack (>= 1.3) 60 | rackup (2.2.0) 61 | rack (>= 3) 62 | redis (5.3.0) 63 | redis-client (>= 0.22.0) 64 | redis-client (0.22.2) 65 | connection_pool 66 | rexml (3.3.9) 67 | rspec (3.13.0) 68 | rspec-core (~> 3.13.0) 69 | rspec-expectations (~> 3.13.0) 70 | rspec-mocks (~> 3.13.0) 71 | rspec-core (3.13.2) 72 | rspec-support (~> 3.13.0) 73 | rspec-expectations (3.13.3) 74 | diff-lcs (>= 1.2.0, < 2.0) 75 | rspec-support (~> 3.13.0) 76 | rspec-mocks (3.13.2) 77 | diff-lcs (>= 1.2.0, < 2.0) 78 | rspec-support (~> 3.13.0) 79 | rspec-support (3.13.1) 80 | ruby2_keywords (0.0.5) 81 | sentry-raven (3.1.2) 82 | faraday (>= 1.0) 83 | sinatra (4.0.0) 84 | mustermann (~> 3.0) 85 | rack (>= 3.0.0, < 4) 86 | rack-protection (= 4.0.0) 87 | rack-session (>= 2.0.0, < 3) 88 | tilt (~> 2.0) 89 | tilt (2.4.0) 90 | uri (1.0.1) 91 | webmock (3.24.0) 92 | addressable (>= 2.8.0) 93 | crack (>= 0.3.2) 94 | hashdiff (>= 0.4.0, < 2.0.0) 95 | 96 | PLATFORMS 97 | ruby 98 | 99 | DEPENDENCIES 100 | dotenv 101 | foreman 102 | httparty 103 | kramdown 104 | link_header 105 | nokogiri 106 | puma 107 | rack-contrib 108 | rack-ssl 109 | rack-test 110 | rackup 111 | redis 112 | rspec 113 | sentry-raven 114 | sinatra 115 | webmock 116 | 117 | RUBY VERSION 118 | ruby 3.3.5p100 119 | 120 | BUNDLED WITH 121 | 2.5.16 122 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: rackup -s puma -p $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micropublish 2 | 3 | Micropublish is a [Micropub][] client that you can use to create, update, 4 | delete and undelete content on your Micropub-enabled site. 5 | A live install of Micropublish is running at [https://micropublish.net][mp] 6 | 7 | Micropublish demo 10 | 11 | --- 12 | 13 | ## Features 14 | 15 | - Create, update, delete and undelete posts on your site. 16 | - Templates for creating Notes, Articles, RSVPs, Bookmarks, Replies, Reposts 17 | and Likes. 18 | - Use `x-www-form-urlencoded` (form-encoded) or JSON Micropub request methods. 19 | - Preview the request that will be sent to your server before it's sent. 20 | - Supports multiple values for URL properties (e.g. `in-reply-to[]`). 21 | - Customize types, icons, defaults, ordering and required properties. 22 | - Mobile-first. All views have been designed and optimized for mobile. 23 | - JavaScript is not required and you can happily use Micropublish without it. 24 | The user interface is progressively enhanced when JavaScript is enabled. 25 | - Full errors and feedback displayed from your endpoints. 26 | - Supports the `post-status` property 27 | [proposed as a Micropub extension][post-status]. 28 | 29 | --- 30 | 31 | ## Requirements 32 | 33 | There are a number of requirements that your site's Micropub endpoint must meet 34 | in order to work with Micropublish. 35 | 36 | To learn more about setting up a Micropub 37 | endpoint, read the 38 | [Creating a Micropub endpoint][micropub-endpoint] page on the 39 | [IndieWeb wiki][indieweb] and the latest [Micropub specification][micropub]. 40 | 41 | Below is what Micropublish expects from your server. 42 | 43 | 44 | ### Endpoint discovery 45 | 46 | When you enter your site's URL and click "Sign in" Micropublish will attempt to 47 | find three endpoints in either your site's HTTP response header or in its 48 | HTML ``: 49 | 50 | - Authorization endpoint 51 | - Token endpoint 52 | - Micropub endpoint 53 | 54 | Your site should expose these endpoints similar to the following examples. 55 | 56 | HTTP response header links: 57 | 58 | Link: ; rel="authorization_endpoint" 59 | Link: ; rel="token_endpoint" 60 | Link: ; rel="micropub" 61 | 62 | HTML document `` links: 63 | 64 | 65 | 66 | 67 | 68 | ### Authorization 69 | 70 | Micropublish will attempt to authenticate you via [web sign-in][signin] using 71 | your authorization endpoint. This ensures you are the owner of the site/domain 72 | that you entered. A recommended way of setting this up is by delegating to 73 | [IndieAuth.com][]. 74 | 75 | When you have succesfully signed in, Micropublish will attempt to 76 | authorize via OAuth 2.0 against your server's token endpoint to obtain an 77 | access token. 78 | 79 | When signing in you must specify the scope Micropublish should request from 80 | your endpoint depending on what features you support. With the `post` scope 81 | only the post creation action will be available. 82 | For the editing/deleting/undeleting functionality, 83 | your site's Micropub endpoint must support `create`, `update`, `delete` and 84 | `undelete`. These scopes will be requested from your token endpoint. 85 | 86 | ### Configuration 87 | 88 | When authorized, Micropublish will attempt to fetch your Micropub configuration 89 | from your Micropub endpoint using `?q=config`. 90 | 91 | It expects to find your `syndicate-to` list with `uid` and `name` keys for each 92 | syndication target. Any other properties are currently ignored. 93 | 94 | { 95 | "syndicate-to": [ 96 | { 97 | "uid": "https://twitter.com/barryf", 98 | "name": "barryf on Twitter" 99 | } 100 | ] 101 | } 102 | 103 | ### Methods 104 | 105 | Micropublish supports either `x-www-form-urlencoded` (form-encoded) or 106 | JSON-encoded requests when creating new posts or deleting/undeleting posts. 107 | You can specify which method your server accepts/prefers on the dashboard. 108 | 109 | Note: as required in the Micropub specification, new articles and all updates 110 | must be sent via the JSON method. 111 | 112 | --- 113 | 114 | ## Customize 115 | 116 | You can customize the dashboard shortcuts, properties and defaults you prefer 117 | via the `config/properties.json` configuration file. 118 | 119 | - `known` is a list of the properties Micropublish currently understands. 120 | These are referenced in the application. 121 | - `default` is a list of the properties which will be included in the form 122 | for all new posts. 123 | - `types` is an ordered list of types (e.g. note, article and reply) which 124 | correspond to the icon shortcuts on the dashboard. You can specify a `name`, 125 | `icon` (from [Font Awesome][fa]), appropriate `properties` from the `known` 126 | list and which properties are `required` to send the request. 127 | 128 | --- 129 | 130 | ## Hosting 131 | 132 | Feel free to use the live version at [https://micropublish.net][mp]. 133 | Alternatively, you can host Micropublish yourself. 134 | 135 | I recommend running this on Heroku using the _Deploy to Heroku_ button. 136 | 137 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/barryf/micropublish) 138 | 139 | If you host on your own server you will need to define the `COOKIE_SECRET` 140 | environment variable. You should use a long, secure, (ideally) random string 141 | to help secure data stored in the user's cookie. 142 | 143 | ### Docker 144 | 145 | There is a [Dockerfile](./Dockerfile) and a [docker-compose](./docker-compose.yml) file. 146 | 147 | A local dev environment can be started like so: 148 | 149 | ```sh 150 | COOKIE_SECRET=super_secret_and_super_unique_and_super_random_jkafhakdhaskhdakdncaskjhadljfaskdbnl \ 151 | RACK_ENV=development \ 152 | DEV_ME=http://myhostnameorlocalhost:4000 \ 153 | DEV_MICROPUB=http://myhostnameorlocalhost:8000/micropub \ 154 | DEV_SCOPE="create update delete undelete" \ 155 | DEV_TOKEN=XXXXXXXXXXXXXXXX \ 156 | docker compose up --build 157 | ``` 158 | 159 | - The `DEV_` envs are only used with `RACK_ENV=development`. 160 | - There is also a `FORCE_SSL` variable which will force redirection to `https://` if set to `1`. 161 | By default it is expected that a reverse proxy is taking care of that. 162 | 163 | --- 164 | 165 | ## Bookmarklets 166 | 167 | Drag these links to your bookmarks bar and you can quickly perform each action 168 | on a page you are viewing, with the relevant properties already filled: 169 | 170 | - ✏︎ Edit 171 | - ✖︎ Delete 172 | - ✔︎ Undelete 173 | - ↩ Reply 174 | - ♺ Repost 175 | - ❤ Like 176 | - ✔︎ RSVP 177 | - ✂ Bookmark 178 | 179 | The URLs are hard-coded to the [https://micropublish.net][mp] install so you 180 | will need to update if you are hosting Micropublish yourself. 181 | 182 | --- 183 | 184 | ## Help 185 | 186 | If you would like any help with setting up your Micropub endpoint, your best 187 | option is to 188 | [join the `#indieweb` channel][irc] on libera.chat. 189 | 190 | For Micropublish specific help, [get in touch][bfcontact] and I'll be happy 191 | to help. 192 | 193 | To file any bugs or suggestions for Micropublish, please log an issue in the 194 | [GitHub repo][repo]. If you wish to make any improvements please fork and send 195 | a pull request through GitHub. 196 | 197 | 198 | [micropub]: https://micropub.net 199 | [indieauth.com]: https://indieauth.com 200 | [micropub-endpoint]: https://indieweb.org/micropub-endpoint 201 | [indieweb]: https://indieweb.org 202 | [fa]: http://fontawesome.io/ 203 | [signin]: http://indieweb.org/How_to_set_up_web_sign-in_on_your_own_domain 204 | [repo]: https://github.com/barryf/micropublish 205 | [irc]: http://indieweb.org/IRC 206 | [bf]: https://barryfrost.com 207 | [bfcontact]: https://barryfrost.com/contact 208 | [mp]: https://micropublish.net 209 | [post-status]: https://indieweb.org/Micropub-extensions#Post_Status 210 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Micropublish", 3 | "description": "A Micropub client that you can use to create, update, delete and undelete content on your Micropub-enabled site.", 4 | "repository": "https://github.com/barryf/micropublish", 5 | "env": { 6 | "COOKIE_SECRET": { 7 | "description": "A long, secure, (ideally) random string used to secure data stored in the user's cookie.", 8 | "value": "" 9 | }, 10 | "FORCE_SSL": { 11 | "description": "Enable redirect from http:// to https:// - highly recommended if no reverse proxy is used which would take care of TLS termination", 12 | "value": "1" 13 | } 14 | }, 15 | "addons": [ 16 | "heroku-redis:hobby-dev" 17 | ], 18 | "stack": "heroku-20" 19 | } 20 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project (from version 2.3.0 onwards) will be 4 | documented in this file. 5 | 6 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 7 | and this project adheres to 8 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [2.12.0] - 2025-05-19 11 | 12 | ### Added 13 | 14 | - Support has been added for running Micropublish and redis with Docker using 15 | the `docker-compose.yml` file. 16 | - Optionally pass in `DEV_` environment variables if running in development 17 | mode (see new section in `README.md` for details). 18 | - Thanks to @perryflynn for contributing these changes. 19 | 20 | ## [2.11.0] - 2025-02-22 21 | 22 | ### Added 23 | 24 | - Introduces a dark mode that is enabled based on the system setting. This uses 25 | overrides to the Bootstrap library to implement the dark mode via CSS 26 | variables. 27 | 28 | ## [2.10.1] - 2025-02-11 29 | 30 | ### Fixed 31 | 32 | - Fixed Post Type Discovery for photo posts by looking for `photo` property 33 | when editing. Thanks to @hacdias for submitting the PR to fix this. 34 | 35 | ## [2.10.0] - 2025-02-09 36 | 37 | ### Added 38 | 39 | - Extended endpoint discovery to allow servers to specify their IndieAuth Server 40 | Metadata endpoint as an alternative to the existing (legacy) headers/body 41 | methods. Thanks to @gRegorLove for raising this. 42 | 43 | ## [2.9.1] - 2024-11-09 44 | 45 | ### Changed 46 | 47 | - Upgrade to Ruby 3.3.5 from 2.6.8. Render (the host for micropublish.net) had 48 | announced EOL for versions <3.1.0. Shotgun, which was used to auto-reload in 49 | development mode, is incompatible so it's been replaced with rackup. A more 50 | permanent (auto-reloading) solution is needed. 51 | - For session management a stronger secret (>64 bytes) is now needed. 52 | 53 | ## [2.9.0] - 2024-08-26 54 | 55 | ### Changed 56 | 57 | - Upgrade the [Trix](https://trix-editor.org) editor and support inline photo 58 | uploads in articles. This requires a media endpoint to be defined in your 59 | server's Micropub config. 60 | Many thanks to @gorka for contributing this enhancement. 61 | 62 | ## [2.8.0] - 2022-03-12 63 | 64 | ### Added 65 | 66 | - Support media uploads for the `photo` property. Requires server to return a 67 | media endpoint in its config. 68 | Many thanks to @hacdias for contributing this feature. 69 | 70 | ### Changed 71 | 72 | - Use `JSONBodyParser` instead of deprecated `PostBodyContentTypeParser`. 73 | 74 | ## [2.7.0] - 2022-02-12 75 | 76 | ### Added 77 | 78 | - New "food" and "drink" post types - thanks @hacdias. Your server must specify 79 | these types in its config properties to see them on the dashboard. 80 | - Location property with geo-location support - thanks @hacdias. Your server 81 | must specify `location` in its config for a type's properties to see the field 82 | when creating a post. 83 | 84 | ## [2.6.1] - 2021-11-22 85 | 86 | ### Changed 87 | 88 | - `client_id` sent to auth endpoint [must include the path element](https://indieauth.spec.indieweb.org/#client-identifier). 89 | Micropublish now includes a path ("/"). 90 | 91 | ## [2.6.0] - 2021-11-15 92 | 93 | ### Added 94 | 95 | - [Use Redis to cache servers' config data](https://github.com/barryf/micropublish/issues/86). 96 | If there is a `REDIS_URL` environment variable defined Micropublish will use 97 | Redis as a cache to avoid repeated lookups for config from a server. This is 98 | optional: if there is no instance defined Micropublish will fetch config on 99 | each request. Config is cached for 24 hours. 100 | 101 | ### Changed 102 | 103 | - Updates Puma to non-vulnerable version 5.5.2 104 | 105 | ## [2.5.4.1] - 2021-06-06 106 | 107 | ### Fixed 108 | 109 | - Fixed Post Type Discovery for listen posts by looking for `listen-of` property 110 | when editing. 111 | 112 | ## [2.5.4] - 2021-06-05 113 | 114 | ### Added 115 | 116 | - Support creating listen posts (using `listen-of` URL field). 117 | 118 | ## [2.5.3] - 2021-05-28 119 | 120 | ### Changed 121 | 122 | - [Don't cache Micropub config in session](https://github.com/barryf/micropublish/issues/76). 123 | Some servers' Micropub config responses are larger than 4K which is too big 124 | for cookie storage. 125 | 126 | ## [2.5.2] - 2021-05-26 127 | 128 | ### Added 129 | 130 | - [Add photo page and field](https://github.com/barryf/micropublish/issues/73). 131 | For now this won't support the uploading of photos, just specifying existing 132 | photo URLs. 133 | 134 | ### Fixed 135 | 136 | - Fix tests via webmock 137 | - Update gems 138 | 139 | ## [2.5.1] - 2021-04-03 140 | 141 | ### Fixed 142 | 143 | - [Ignore unknown/unsupported properties from server](https://github.com/barryf/micropublish/pull/68). 144 | If your server specifies `post-type` properties and includes properties that 145 | Micropublish didn't know, when updating a post using one of these properties 146 | it was possible for them to be marked as removed in the update request. 147 | 148 | ## [2.5.0] - 2021-03-28 149 | 150 | ### Changed 151 | 152 | - Character counter now works for fields other than `content`. If a `summary` 153 | field is available this will now include a counter. 154 | - Support changing article content field type: (Trix) Editor, HTML or Text. 155 | The options available depends on whether you're creating or updating an 156 | article -- you cannot switch to Text if you're editing an HTML article. 157 | - When redirecting after creating/updating a post, the URL parameter is now 158 | passed in the query-string, instead of in the session. This is intended as 159 | a short-term fix for large sessions, as discussed in Issue #62. 160 | 161 | ## [2.4.5] - 2021-03-01 162 | 163 | ### Changed 164 | 165 | - Upgrade Ruby to 2.6.6, required for the 166 | [Heroku-20](https://devcenter.heroku.com/articles/heroku-20-stack) stack. 167 | The previous stack (Heroku-16) is deprecated. 168 | 169 | ## [2.4.4] - 2021-03-01 170 | 171 | ### Changed 172 | 173 | - [Present authorized scopes to user, post-auth](https://github.com/barryf/micropublish/pull/63). 174 | Previously the scopes that were requested were assumed to have been granted by 175 | the server. Thanks to @jamietanna. 176 | 177 | ## [2.4.3] - 2021-02-09 178 | 179 | ### Changed 180 | 181 | - [Autoconfiguration of fields requires manually editing the `published` property](https://github.com/barryf/micropublish/issues/59) 182 | - If the `published` field is specified in the properties for a post-type, 183 | default the value to the current timestamp in ISO8601 UTC format. 184 | 185 | ## [2.4.2] - 2021-01-31 186 | 187 | ### Added 188 | 189 | - Support [Channels](https://github.com/indieweb/micropub-extensions/issues/40) 190 | Micropub extension. If the server supports channels, a new field with 191 | checkboxes is added to the form and a `mp-channel` property is sent with the 192 | Micropub request. 193 | 194 | ## [2.4.1] - 2020-12-14 195 | 196 | ### Changed 197 | 198 | - [Fix code challenge generator](https://github.com/barryf/micropublish/commit/c42324a2a61523942f484b51d3d7e3b87f5fbef7) 199 | - The previous version did not adhere to [RFC 7636](https://tools.ietf.org/html/rfc7636#appendix-A) for the code challenge. 200 | 201 | ## [2.4.0] - 2020-12-13 202 | 203 | ### Added 204 | 205 | - [Improve IndieAuth spec compliance](https://github.com/barryf/micropublish/issues/54) 206 | - [Query for supported properties, for a supported post-type](https://github.com/barryf/micropublish/issues/51) 207 | 208 | ### Changed 209 | 210 | - As part of "Query for supported properties..." above, in the `config/properties.json` file the `default` object is now ignored. Properties are explicity defined for each `post-type`. 211 | 212 | ## [2.3.0] - 2020-10-12 213 | 214 | ### Added 215 | 216 | - [Filter syndication targets by post-type, specify checked as appropriate](https://github.com/barryf/micropublish/issues/45) 217 | - [Raw content instead of HTML for Articles](https://github.com/barryf/micropublish/issues/42) 218 | - [Support `visibility` property](https://github.com/barryf/micropublish/issues/36) 219 | - [Support `post-status` property](https://github.com/barryf/micropublish/issues/35) 220 | - [Add granular scopes to login/auth](https://github.com/barryf/micropublish/issues/33) 221 | 222 | ### Changed 223 | 224 | - [Make JSON the default post creation method](https://github.com/barryf/micropublish/issues/41) 225 | - Only show edit, delete or undelete controls if scope allows 226 | - Added `draft` scope to login form 227 | - Force `post-status` to `draft` when using (only) draft scope 228 | - Bump kramdown from 2.1.0 to 2.3.0 229 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) 3 | 4 | env = ENV['RACK_ENV'].to_sym 5 | 6 | require "bundler/setup" 7 | Bundler.require(:default, env) 8 | 9 | Dotenv.load unless env == :production 10 | 11 | # optionally use sentry in production 12 | if env == :production && ENV.key?('SENTRY_DSN') 13 | Raven.configure do |config| 14 | config.dsn = ENV['SENTRY_DSN'] 15 | config.processors -= [Raven::Processor::PostData] 16 | end 17 | use Raven::Rack 18 | end 19 | 20 | # optionally use redis to cache server config 21 | if ENV.key?('REDIS_URL') 22 | $redis = Redis.new( 23 | url: ENV['REDIS_URL'], 24 | ssl_params: { 25 | verify_mode: OpenSSL::SSL::VERIFY_NONE 26 | } 27 | ) 28 | end 29 | 30 | # automatically parse json in the body 31 | use Rack::JSONBodyParser 32 | 33 | require 'micropublish' 34 | run Micropublish::Server 35 | -------------------------------------------------------------------------------- /config/properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "known": [ 3 | "in-reply-to", 4 | "repost-of", 5 | "like-of", 6 | "bookmark-of", 7 | "rsvp", 8 | "name", 9 | "content", 10 | "summary", 11 | "published", 12 | "category", 13 | "mp-syndicate-to", 14 | "syndication", 15 | "mp-slug", 16 | "checkin", 17 | "post-status", 18 | "visibility", 19 | "mp-channel", 20 | "photo", 21 | "listen-of", 22 | "ate", 23 | "drank", 24 | "location" 25 | ], 26 | "types": { 27 | "h-entry": { 28 | "note": { 29 | "name": "Note", 30 | "icon": "comment", 31 | "properties": [ 32 | "content", 33 | "category", 34 | "post-status", 35 | "visibility" 36 | ], 37 | "required": [ 38 | "content" 39 | ] 40 | }, 41 | "article": { 42 | "name": "Article", 43 | "icon": "file-text", 44 | "properties": [ 45 | "name", 46 | "content", 47 | "category", 48 | "post-status", 49 | "visibility" 50 | ], 51 | "required": [ 52 | "name", 53 | "content" 54 | ] 55 | }, 56 | "rsvp": { 57 | "name": "RSVP", 58 | "icon": "calendar-check-o", 59 | "properties": [ 60 | "in-reply-to", 61 | "rsvp", 62 | "content", 63 | "category", 64 | "post-status", 65 | "visibility" 66 | ], 67 | "required": [ 68 | "in-reply-to", 69 | "rsvp" 70 | ] 71 | }, 72 | "bookmark": { 73 | "name": "Bookmark", 74 | "icon": "bookmark", 75 | "properties": [ 76 | "bookmark-of", 77 | "name", 78 | "content", 79 | "category", 80 | "post-status", 81 | "visibility" 82 | ], 83 | "required": [ 84 | "bookmark-of", 85 | "name" 86 | ] 87 | }, 88 | "reply": { 89 | "name": "Reply", 90 | "icon": "reply", 91 | "properties": [ 92 | "in-reply-to", 93 | "content", 94 | "category", 95 | "post-status", 96 | "visibility" 97 | ], 98 | "required": [ 99 | "in-reply-to", 100 | "content" 101 | ] 102 | }, 103 | "repost": { 104 | "name": "Repost", 105 | "icon": "retweet", 106 | "properties": [ 107 | "repost-of", 108 | "content", 109 | "category", 110 | "post-status", 111 | "visibility" 112 | ], 113 | "required": [ 114 | "repost-of" 115 | ] 116 | }, 117 | "like": { 118 | "name": "Like", 119 | "icon": "heart", 120 | "properties": [ 121 | "like-of", 122 | "category", 123 | "post-status", 124 | "visibility" 125 | ], 126 | "required": [ 127 | "like-of" 128 | ] 129 | }, 130 | "checkin": { 131 | "name": "Check-in", 132 | "icon": "map-marker", 133 | "properties": [ 134 | "checkin", 135 | "content", 136 | "category", 137 | "post-status", 138 | "visibility" 139 | ], 140 | "required": [ 141 | "checkin" 142 | ] 143 | }, 144 | "photo": { 145 | "name": "Photo", 146 | "icon": "photo", 147 | "properties": [ 148 | "photo", 149 | "content", 150 | "category", 151 | "post-status", 152 | "visibility" 153 | ], 154 | "required": [ 155 | "photo" 156 | ] 157 | }, 158 | "listen": { 159 | "name": "Listen", 160 | "icon": "headphones", 161 | "properties": [ 162 | "listen-of", 163 | "content", 164 | "category", 165 | "post-status", 166 | "visibility" 167 | ], 168 | "required": [ 169 | "listen-of" 170 | ] 171 | }, 172 | "food": { 173 | "name": "Food", 174 | "icon": "cutlery", 175 | "properties": [ 176 | "ate", 177 | "photo", 178 | "content", 179 | "category", 180 | "post-status", 181 | "visibility" 182 | ], 183 | "required": [ 184 | "ate" 185 | ] 186 | }, 187 | "drink": { 188 | "name": "Drink", 189 | "icon": "coffee", 190 | "properties": [ 191 | "drank", 192 | "photo", 193 | "content", 194 | "category", 195 | "post-status", 196 | "visibility" 197 | ], 198 | "required": [ 199 | "drank" 200 | ] 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | restart: always 4 | image: redis:7-alpine 5 | networks: 6 | internal_network: 7 | aliases: 8 | - redis 9 | healthcheck: 10 | test: ['CMD', 'redis-cli', 'ping'] 11 | volumes: 12 | - micropublish-redis:/data 13 | 14 | web: 15 | build: . 16 | restart: always 17 | networks: 18 | - external_network 19 | - internal_network 20 | ports: 21 | - '9292:9292' 22 | environment: 23 | REDIS_URL: 'redis://redis:6379' 24 | COOKIE_SECRET: ${COOKIE_SECRET:-super_secret_and_super_unique_and_super_random_jkafhakdhaskhdakdncaskjhadljfaskdbnl} 25 | RACK_ENV: ${RACK_ENV:-production} 26 | DEV_ME: ${DEV_ME:-http://localhost:4000} 27 | DEV_MICROPUB: ${DEV_MICROPUB:-http://localhost:8000/micropub} 28 | DEV_SCOPE: ${DEV_SCOPE:-create update delete undelete} 29 | DEV_TOKEN: ${DEV_TOKEN:-} 30 | depends_on: 31 | - redis 32 | 33 | networks: 34 | external_network: 35 | internal_network: 36 | internal: true 37 | 38 | volumes: 39 | micropublish-redis: 40 | -------------------------------------------------------------------------------- /lib/micropublish.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | 3 | class MicropublishError < StandardError 4 | attr_reader :type, :message, :body 5 | def initialize(type, message, body=nil) 6 | @type = type 7 | @message = message 8 | @body = body 9 | super(message) 10 | end 11 | end 12 | 13 | end 14 | 15 | require_relative 'micropublish/version' 16 | 17 | require_relative 'micropublish/auth' 18 | require_relative 'micropublish/compare' 19 | require_relative 'micropublish/endpoints_finder' 20 | require_relative 'micropublish/post' 21 | require_relative 'micropublish/request' 22 | require_relative 'micropublish/micropub' 23 | require_relative 'micropublish/helpers' 24 | 25 | require_relative 'micropublish/server' 26 | -------------------------------------------------------------------------------- /lib/micropublish/auth.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | 3 | module Micropublish 4 | class Auth 5 | 6 | def initialize(me, code, redirect_uri, client_id, code_verifier) 7 | @me = me 8 | @code = code 9 | @redirect_uri = redirect_uri 10 | @client_id = client_id 11 | @code_verifier = code_verifier 12 | end 13 | 14 | def callback 15 | # validate the parameters 16 | unless Auth.valid_uri?(@me) 17 | raise AuthError.new( 18 | "Missing or invalid value for \"me\": \"#{@me}\".") 19 | end 20 | if @code.nil? || @code.empty? 21 | raise AuthError.new("The \"code\" parameter must not be blank.") 22 | end 23 | 24 | # find micropub and token endpoints 25 | endpoints_finder = EndpointsFinder.new(@me) 26 | endpoints = endpoints_finder.find_links 27 | 28 | # check we've found all the endpoints we want 29 | endpoints_finder.validate! 30 | 31 | # find out if we're allowed a token to post, the scopes for that token and what "me" to use 32 | token, scope, me = get_token_and_scopes_and_me(endpoints[:token_endpoint]) 33 | 34 | # if me does not match original me, check authorization endpoints match 35 | confirm_auth_server(me, endpoints[:authorization_endpoint]) 36 | 37 | # return hash of endpoints and the token with the "me" 38 | endpoints.merge(token: token, scope: scope, me: me) 39 | end 40 | 41 | def get_token_and_scopes_and_me(token_endpoint) 42 | response = HTTParty.post(token_endpoint, body: { 43 | code: @code, 44 | redirect_uri: @redirect_uri, 45 | client_id: @client_id, 46 | grant_type: 'authorization_code', 47 | code_verifier: @code_verifier 48 | }) 49 | unless (200...300).include?(response.code) 50 | raise AuthError.new("#{response.code} received from token endpoint. Body: #{response.body}") 51 | end 52 | # try json first 53 | begin 54 | response_hash = JSON.parse(response.body) 55 | access_token = response_hash.key?('access_token') ? 56 | response_hash['access_token'] : nil 57 | scope = response_hash.key?('scope') ? response_hash['scope'] : nil 58 | me = response_hash.key?('me') ? response_hash['me'] : nil 59 | rescue JSON::ParserError => e 60 | # assume form-encoded 61 | response_hash = CGI.parse(response.parsed_response) 62 | access_token = response_hash.key?('access_token') ? 63 | response_hash['access_token'].first : nil 64 | scope = response_hash.key?('scope') ? response_hash['scope'].first : nil 65 | me = response_hash.key?('me') ? response_hash['me'].first : nil 66 | end 67 | unless access_token 68 | raise AuthError.new("No 'access_token' returned from token endpoint.") 69 | end 70 | unless scope 71 | raise AuthError.new("No 'scope' param returned from token endpoint.") 72 | end 73 | unless me 74 | raise AuthError.new("No 'me' param returned from token endpoint.") 75 | end 76 | [access_token, scope, me] 77 | end 78 | 79 | # https://indieauth.spec.indieweb.org/#authorization-server-confirmation 80 | def confirm_auth_server(me, authorization_endpoint) 81 | # we can continue if original me matches me from token endpoint 82 | return if @me == me 83 | # otherwise we need to check me's auth endpoint matches 84 | endpoints_finder = EndpointsFinder.new(me) 85 | endpoints = endpoints_finder.find_links 86 | if !endpoints.key?(:authorization_endpoint) || 87 | endpoints[:authorization_endpoint] != authorization_endpoint 88 | raise AuthError.new( 89 | "Authorizarion server for profile URL (me) returned from token " + 90 | "endpoint does not match original profile's authorization server.") 91 | end 92 | end 93 | 94 | def self.generate_code_challenge(code_verifier) 95 | Base64.urlsafe_encode64( 96 | Digest::SHA256.digest(code_verifier) 97 | ).gsub(/=/, '') 98 | end 99 | 100 | def self.valid_uri?(u) 101 | begin 102 | uri = URI.parse(u) 103 | uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) 104 | rescue URI::InvalidURIError 105 | end 106 | end 107 | 108 | end 109 | 110 | class AuthError < MicropublishError 111 | def initialize(message) 112 | super("auth", message) 113 | end 114 | end 115 | 116 | end -------------------------------------------------------------------------------- /lib/micropublish/compare.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | class Compare 3 | 4 | def initialize(existing, submitted, known) 5 | @existing = existing 6 | @submitted = submitted 7 | @known = known 8 | sanitize 9 | end 10 | 11 | def sanitize 12 | # remove unknown properties: these should be unchanged 13 | @existing.each { |k,v| @existing.delete(k) unless @known.include?(k) } 14 | 15 | # remove properties starting with _ or mp- (used internally) 16 | @submitted.each { |k,v| @submitted.delete(k) if k.start_with?('_','mp-') } 17 | end 18 | 19 | def diff_properties 20 | diff = { 21 | replace: {}, 22 | add: {}, 23 | delete: [] 24 | } 25 | diff_replaced!(diff) 26 | diff_added!(diff) 27 | diff_removed!(diff) 28 | diff.delete(:replace) if diff[:replace].empty? 29 | diff.delete(:add) if diff[:add].empty? 30 | diff.delete(:delete) if diff[:delete].empty? 31 | diff 32 | end 33 | 34 | def diff_removed!(diff) 35 | @existing.each do |prop,v| 36 | if !@submitted.key?(prop) || @submitted[prop].empty? 37 | diff[:delete] = [] unless diff[:delete].is_a?(Array) 38 | diff[:delete] << prop 39 | elsif !(@existing[prop].size == 1 && @submitted[prop].size == 1) 40 | d = @existing[prop] - @submitted[prop] 41 | unless d.empty? 42 | diff[:delete] = {} unless diff[:delete].is_a?(Hash) 43 | diff[:delete][prop] = d 44 | end 45 | end 46 | end 47 | end 48 | 49 | def diff_added!(diff) 50 | @submitted.each do |prop,v| 51 | if !@existing.key?(prop) 52 | diff[:add][prop] = @submitted[prop] 53 | elsif !(@existing[prop].size == 1 && @submitted[prop].size == 1) 54 | a = @submitted[prop] - @existing[prop] 55 | unless a.empty? 56 | diff[:add][prop] = a 57 | end 58 | end 59 | end 60 | end 61 | 62 | def diff_replaced!(diff) 63 | @submitted.each do |prop,v| 64 | if @existing.key?(prop) && @existing[prop] != @submitted[prop] && 65 | @existing[prop].size == 1 && @submitted[prop].size == 1 66 | diff[:replace][prop] = @submitted[prop] 67 | end 68 | end 69 | end 70 | 71 | end 72 | end -------------------------------------------------------------------------------- /lib/micropublish/endpoints_finder.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | class EndpointsFinder 3 | 4 | RELS = %w( micropub authorization_endpoint token_endpoint ) 5 | 6 | attr_reader :links 7 | 8 | def initialize(url) 9 | @url = url 10 | @links = {} 11 | end 12 | 13 | def get_url(url) 14 | begin 15 | response = HTTParty.get(url) 16 | rescue SocketError => e 17 | raise AuthError.new("Client could not connect to \"#{url}\".") 18 | end 19 | unless (200...300).include?(response.code) 20 | raise AuthError.new("#{response.code} status returned from \"#{url}\".") 21 | end 22 | response 23 | end 24 | 25 | def find_links 26 | response = get_url(@url) 27 | find_metadata_links(response) 28 | find_header_links(response) 29 | find_body_links(response) 30 | @links unless @links.empty? 31 | end 32 | 33 | def find_metadata_url(response) 34 | header_links = LinkHeader.parse(response.headers['Link']) 35 | link = header_links.find_link(['rel', 'indieauth-metadata']) 36 | if link.respond_to?('href') && !link.href.nil? && !link.href.empty? 37 | return URI.join(@url, link.href).to_s 38 | end 39 | html_links = Nokogiri::HTML(response.body).css('link') 40 | html_links.each do |link| 41 | if link[:rel] == 'indieauth-metadata' && !link[:href].empty? 42 | return URI.join(@url, link[:href]).to_s 43 | end 44 | end 45 | return 46 | end 47 | 48 | def find_metadata_links(doc_response) 49 | return unless metadata_url = find_metadata_url(doc_response) 50 | metadata_response = get_url(metadata_url) 51 | begin 52 | body = JSON.parse(metadata_response.body) 53 | rescue JSON::ParserError 54 | raise AuthError.new("Could not parse server metadata JSON at #{metadata_url}.") 55 | end 56 | RELS.each do |rel| 57 | if body.key?(rel) && !body[rel].nil? && !body[rel].empty? && !@links.key?(rel.to_sym) 58 | absolute_url = URI.join(@url, body[rel]).to_s 59 | @links[rel.to_sym] = absolute_url 60 | end 61 | end 62 | end 63 | 64 | def find_header_links(response) 65 | links = LinkHeader.parse(response.headers['Link']) 66 | RELS.each do |rel| 67 | link = links.find_link(['rel', rel]) 68 | if link.respond_to?('href') && !link.href.nil? && !link.href.empty? && !@links.key?(rel.to_sym) 69 | absolute_url = URI.join(@url, link.href).to_s 70 | @links[rel.to_sym] = absolute_url 71 | end 72 | end 73 | end 74 | 75 | def find_body_links(response) 76 | links = Nokogiri::HTML(response.body).css('link') 77 | links.each do |link| 78 | if RELS.include?(link[:rel]) && !@links.key?(link[:rel].to_sym) && 79 | !link[:href].nil? && !link[:href].empty? 80 | absolute_url = URI.join(@url, link[:href]).to_s 81 | @links[link[:rel].to_sym] = absolute_url 82 | end 83 | end 84 | end 85 | 86 | def validate! 87 | RELS.each do |link| 88 | unless @links.key?(link.to_sym) 89 | raise AuthError.new( 90 | "Client could not find \"#{link}\" in server metadata, headers or body from \"#{@url}\".") 91 | end 92 | end 93 | end 94 | 95 | end 96 | end -------------------------------------------------------------------------------- /lib/micropublish/helpers.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | module Helpers 3 | 4 | def h(text) 5 | Rack::Utils.escape_html(text) 6 | end 7 | 8 | def flash_message 9 | if session.key?('flash') && !session[:flash].empty? 10 | content = %Q{ 11 |
12 | #{session[:flash][:message]} 13 |
14 | } 15 | session.delete('flash') 16 | content 17 | end 18 | end 19 | 20 | def default_format 21 | if session.key?('format') && session[:format] == :form 22 | :form 23 | else 24 | :json 25 | end 26 | end 27 | 28 | def autogrow_script(id) 29 | %Q{ 30 | 35 | } 36 | end 37 | 38 | def tokenfield_script(id) 39 | %Q{ 40 | 45 | } 46 | end 47 | 48 | def tweet_reply_prefix(tweet_url) 49 | tweet_match = tweet_url.to_s.match(/twitter\.com\/([A-Za-z0-9_]+)\//) 50 | tweet_match.nil? ? "" : "@#{tweet_match[1]} " 51 | end 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/micropublish/micropub.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | class Micropub 3 | attr_reader :token 4 | 5 | def initialize(micropub, token) 6 | @micropub = micropub 7 | @token = token 8 | end 9 | 10 | def config 11 | query('config', { q: 'config' }) 12 | end 13 | 14 | def channels 15 | data = query('channel', { q: 'channel' }) || config 16 | data['channels'] if data.is_a?(Hash) && data.key?('channels') 17 | end 18 | 19 | def syndicate_to(subtype = nil) 20 | params = { q: 'syndicate-to' } 21 | params['post-type'] = subtype if subtype 22 | data = query(params.to_s, params) || config 23 | data['syndicate-to'] if data.is_a?(Hash) && data.key?('syndicate-to') 24 | end 25 | 26 | def media_endpoint 27 | config['media-endpoint'] if config 28 | end 29 | 30 | def source_all(url) 31 | body = get_source(url) 32 | Post.new(body['type'], body['properties']) 33 | end 34 | 35 | def source_properties(url, properties) 36 | body = get_source(url, properties) 37 | # assume h-entry 38 | Post.new(['h-entry'], body['properties']) 39 | end 40 | 41 | def get_source(url, properties=nil) 42 | validate_url!(url) 43 | query = { q: 'source', url: url } 44 | query['properties[]'] = properties if properties 45 | uri = URI(@micropub) 46 | uri.query = (uri.query.nil? ? "" : uri.query + "&") + URI.encode_www_form(query) 47 | response = HTTParty.get(uri.to_s, headers: headers) 48 | 49 | begin 50 | body = JSON.parse(response.body) 51 | body['properties'] = convert_content_images_to_trix(body['properties']) 52 | rescue JSON::ParserError 53 | raise MicropubError.new("There was an error retrieving the source " + 54 | "for \"#{url}\" from your endpoint. Please ensure you enter the " + 55 | "URL for a valid MF2 post.") 56 | end 57 | 58 | if body.key?('error_description') 59 | raise MicropubError.new("Micropub server returned an error: " + 60 | "\"#{body['error_description']}\".") 61 | elsif body.key?('error') 62 | raise MicropubError.new("Micropub server returned an unspecified " + 63 | "error. Please check your server's logs for details.") 64 | end 65 | 66 | body 67 | end 68 | 69 | def convert_content_images_to_trix(properties) 70 | return properties unless properties.has_key?("content") && properties["content"]&.first["html"] 71 | 72 | doc = Nokogiri::HTML5.fragment(properties["content"]&.first["html"]) 73 | 74 | doc.css('img').each do |image| 75 | image_src = image['src'] 76 | image_alt = image['alt'] 77 | 78 | attachment = CGI.escapeHTML({ 79 | "contentType": "image", 80 | "href": "#{image_src}?content-disposition=attachment", 81 | "url": image_src 82 | }.to_json) 83 | 84 | attributes = CGI.escapeHTML({ 85 | caption: image_alt, 86 | presentation: "gallery" 87 | }.to_json) 88 | 89 | figure = %Q( 90 |
95 |
#{image_alt}
96 |
97 | ) 98 | 99 | image.replace(figure) 100 | end 101 | 102 | properties["content"]&.first["html"] = doc.to_html 103 | properties 104 | end 105 | 106 | def validate_url!(url) 107 | unless Auth.valid_uri?(url) 108 | raise MicropubError.new("\"#{url}\" is not a valid URL.") 109 | end 110 | end 111 | 112 | def headers 113 | { 114 | 'Authorization' => "Bearer #{@token}", 115 | 'Content-Type' => 'application/json; charset=utf-8', 116 | 'Accept' => 'application/json' 117 | } 118 | end 119 | 120 | def query(suffix, params) 121 | key = @micropub + '_' + suffix 122 | data = cache_get(key) 123 | unless data 124 | data = begin 125 | response = HTTParty.get( 126 | @micropub, 127 | query: params, 128 | headers: headers 129 | ) 130 | JSON.parse(response.body) 131 | rescue 132 | end 133 | cache_set(key, data) 134 | end 135 | data 136 | end 137 | 138 | def cache_get(key) 139 | return unless $redis 140 | data = $redis.get(key) 141 | return unless data 142 | JSON.parse(data) 143 | end 144 | 145 | def cache_set(key, value) 146 | return unless $redis 147 | json = value.to_json 148 | expiry_seconds = 60 * 60 * 24 # 1 day 149 | $redis.set(key, json, ex: expiry_seconds) 150 | end 151 | 152 | def cache_clear 153 | return unless $redis 154 | keys = $redis.keys("#{@micropub}*") 155 | $redis.del(*keys) unless keys.empty? 156 | end 157 | 158 | def self.find_commands(params) 159 | Hash[params.map { |k,v| [k,v] if k.start_with?('mp-') }.compact] 160 | end 161 | 162 | end 163 | 164 | class MicropubError < MicropublishError 165 | def initialize(message, body=nil) 166 | super("micropub", message, body) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/micropublish/post.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | class Post 3 | 4 | attr_reader :type, :properties 5 | 6 | def initialize(type, properties) 7 | @type = type 8 | @properties = properties 9 | end 10 | 11 | def self.properties_from_params(params) 12 | props = {} 13 | params.keys.each do |param| 14 | next if params[param].nil? || params[param].empty? || 15 | params[param] == [""] 16 | if param.start_with?('_') 17 | next 18 | elsif param == 'mp-syndicate-to' 19 | props[param] = params[param] 20 | elsif param == 'category' 21 | props['category'] = params['category'].is_a?(Array) ? 22 | params['category'] : params['category'].split(/,\s?/) 23 | elsif param == 'content' 24 | props['content'] = Array(params[param]) 25 | else 26 | props[param] = if params[param].is_a?(Array) && 27 | params[param][0].is_a?(String) 28 | params[param][0].split(/\s+/) 29 | else 30 | Array(params[param]) 31 | end 32 | end 33 | end 34 | 35 | remove_trix_attributes(props) 36 | end 37 | 38 | def validate_properties!(required=[]) 39 | # ensure url arrays only contain urls 40 | %w( in-reply-to repost-of like-of bookmark-of listen-of syndication ).each do 41 | |url_property| 42 | if @properties.key?(url_property) 43 | @properties[url_property].each do |url| 44 | unless Auth.valid_uri?(url) 45 | raise MicropublishError.new('post', 46 | "\"#{url}\" is not a valid URL. #{url_property} " + 47 | "accepts only one or more URLs separated by whitespace.") 48 | end 49 | end 50 | end 51 | end 52 | # check all required properties have been provided 53 | required.each do |property| 54 | if !@properties.key?(property) || 55 | (property == 'checkin' && 56 | (@properties['checkin'][0]['properties']['name'][0].empty? || 57 | @properties['checkin'][0]['properties']['latitude'][0].empty? || 58 | @properties['checkin'][0]['properties']['longitude'][0].empty?)) 59 | raise MicropublishError.new('post', 60 | "#{property} is required for the form to be " + 61 | "submitted. Please enter a value for this property.") 62 | end 63 | end 64 | end 65 | 66 | def h_type 67 | @type[0].gsub(/^h\-/,'') 68 | end 69 | 70 | def to_form_encoded 71 | props = Hash[@properties.map { |k,v| v.size > 1 ? ["#{k}[]", v] : [k,v] }] 72 | query = { h: h_type }.merge(props) 73 | URI.encode_www_form(query) 74 | end 75 | 76 | def to_json(pretty=false) 77 | hash = { type: @type, properties: @properties } 78 | pretty ? JSON.pretty_generate(hash) : hash.to_json 79 | end 80 | 81 | def diff_properties(submitted) 82 | diff = { 83 | replace: {}, 84 | add: {}, 85 | delete: [] 86 | } 87 | diff_removed!(diff, submitted) 88 | diff_added!(diff, submitted) 89 | diff_replaced!(diff, submitted) 90 | diff 91 | end 92 | 93 | def diff_removed!(diff, submitted) 94 | @properties.keys.each do |prop| 95 | if !submitted.key?(prop) || submitted[prop].empty? 96 | diff[:delete] << prop 97 | end 98 | end 99 | diff.delete(:delete) if diff[:delete].empty? 100 | end 101 | 102 | def diff_added!(diff, submitted) 103 | submitted.keys.each do |prop| 104 | if !@properties.key?(prop) 105 | diff[:add][prop] = submitted[prop].is_a?(Array) ? submitted[prop] : 106 | [submitted[prop]] 107 | end 108 | end 109 | diff.delete(:add) if diff[:add].empty? 110 | end 111 | 112 | def diff_replaced!(diff, submitted) 113 | submitted.keys.each do |prop| 114 | if @properties.key?(prop) && @properties[prop] != submitted[prop] 115 | diff[:replace][prop] = submitted[prop].is_a?(Array) ? submitted[prop] : 116 | [submitted[prop]] 117 | end 118 | end 119 | diff.delete(:replace) if diff[:replace].empty? 120 | end 121 | 122 | def entry_type 123 | if @properties.key?('rsvp') && 124 | %w(yes no maybe interested).include?(@properties['rsvp'][0]) 125 | 'rsvp' 126 | elsif @properties.key?('in-reply-to') && 127 | Auth.valid_uri?(@properties['in-reply-to'][0]) 128 | 'reply' 129 | elsif @properties.key?('repost-of') && 130 | Auth.valid_uri?(@properties['repost-of'][0]) 131 | 'repost' 132 | elsif @properties.key?('like-of') && 133 | Auth.valid_uri?(@properties['like-of'][0]) 134 | 'like' 135 | elsif @properties.key?('bookmark-of') && 136 | Auth.valid_uri?(@properties['bookmark-of'][0]) 137 | 'bookmark' 138 | elsif @properties.key?('listen-of') && 139 | Auth.valid_uri?(@properties['listen-of'][0]) 140 | 'listen' 141 | elsif @properties.key?('photo') && @properties['photo'].is_a?(Array) && 142 | @properties['photo'].size > 0 143 | 'photo' 144 | elsif @properties.key?('name') && !@properties['name'].empty? && 145 | !content_start_with_name? 146 | 'article' 147 | elsif @properties.key?('checkin') 148 | 'checkin' 149 | else 150 | 'note' 151 | end 152 | end 153 | 154 | def content_start_with_name? 155 | return unless @properties.key?('content') && @properties.key?('name') 156 | content = @properties['content'][0].is_a?(Hash) && 157 | @properties['content'][0].key?('html') ? 158 | @properties['content'][0]['html'] : @properties['content'][0] 159 | content_tidy = content.strip.gsub(/\s+/, " ") 160 | name_tidy = @properties['name'][0].strip.gsub(/\s+/, " ") 161 | content_tidy.start_with?(name_tidy) 162 | end 163 | 164 | private 165 | 166 | def self.remove_trix_attributes(hash) 167 | return hash unless hash.has_key?("content") && hash["content"]&.first["html"] 168 | 169 | doc = Nokogiri::HTML5.fragment(hash["content"]&.first["html"]) 170 | 171 | doc.css('figure[data-trix-attachment]').each do |attachment| 172 | image_src = attachment.at_css('img')['src'] if attachment.at_css('img') 173 | figcaption = attachment.at_css('figcaption').content.strip if attachment.at_css('figcaption') 174 | 175 | if image_src 176 | img_tag = Nokogiri::XML::Node.new('img', doc) 177 | img_tag['src'] = image_src 178 | img_tag['alt'] = figcaption if figcaption 179 | 180 | attachment.replace(img_tag) 181 | end 182 | end 183 | 184 | hash["content"]&.first["html"] = doc.to_html 185 | 186 | hash 187 | end 188 | 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/micropublish/request.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | class Request 3 | 4 | def initialize(micropub, token, is_json=false) 5 | @micropub = micropub 6 | @token = token 7 | @is_json = is_json 8 | end 9 | 10 | def create(post) 11 | body = if @is_json 12 | { type: post.type, properties: post.properties } 13 | else 14 | # flatten single value arrays 15 | { h: post.h_type }.merge( 16 | Hash[post.properties.map { |k,v| [k, v.size == 1 ? v[0] : v] }] 17 | ) 18 | end 19 | response = send(body) 20 | case response.code.to_i 21 | when 201, 202 22 | response.headers['location'] 23 | else 24 | handle_error(response.body) 25 | end 26 | end 27 | 28 | def update(url, diff, mp_commands) 29 | body = { action: 'update', url: url }.merge(diff).merge(mp_commands) 30 | response = send(body, true) 31 | case response.code.to_i 32 | when 201 33 | response.headers['location'] 34 | when 200, 204 35 | url 36 | else 37 | handle_error(response.body) 38 | end 39 | end 40 | 41 | def delete(url) 42 | body = { action: 'delete', url: url } 43 | response = send(body) 44 | case response.code.to_i 45 | when 200, 204 46 | url 47 | else 48 | handle_error(response.body) 49 | end 50 | end 51 | 52 | def undelete(url) 53 | body = { action: 'undelete', url: url } 54 | response = send(body) 55 | case response.code.to_i 56 | when 201 57 | response.headers['location'] 58 | when 200, 204 59 | url 60 | else 61 | handle_error(response.body) 62 | end 63 | end 64 | 65 | def upload(file) 66 | response = HTTParty.post( 67 | @micropub, 68 | body: { 69 | file: file 70 | }, 71 | headers: { 'Authorization' => "Bearer #{@token}" } 72 | ) 73 | 74 | case response.code.to_i 75 | when 201 76 | response.headers['location'] 77 | else 78 | handle_error(response.body) 79 | end 80 | end 81 | 82 | private 83 | 84 | def send(body, is_json=@is_json) 85 | headers = { 'Authorization' => "Bearer #{@token}" } 86 | if is_json 87 | body = body.to_json 88 | headers['Content-Type'] = 'application/json; charset=utf-8' 89 | end 90 | HTTParty.post( 91 | @micropub, 92 | body: body, 93 | headers: headers 94 | ) 95 | end 96 | 97 | def handle_error(response_body) 98 | raise MicropublishError.new('request', 99 | "There was an error making a request to your Micropub endpoint. " + 100 | "The error received was: #{response_body}") 101 | end 102 | 103 | end 104 | end -------------------------------------------------------------------------------- /lib/micropublish/server.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'base64' 3 | require 'digest' 4 | 5 | module Micropublish 6 | class Server < Sinatra::Application 7 | 8 | configure do 9 | helpers Helpers 10 | 11 | use Rack::SSL if ENV['FORCE_SSL'] == '1' 12 | 13 | root_path = "#{File.dirname(__FILE__)}/../../" 14 | set :views, "#{root_path}views" 15 | set :public_folder, "#{root_path}public" 16 | set :properties, 17 | JSON.parse(File.read("#{root_path}config/properties.json")) 18 | set :readme, File.read("#{root_path}README.md") 19 | set :changelog, File.read("#{root_path}/changelog.md") 20 | set :help, File.read("#{public_folder}/help.md") 21 | 22 | set :server, :puma 23 | 24 | # use a cookie that lasts for 30 days 25 | secret = ENV['COOKIE_SECRET'] || SecureRandom.hex(64) 26 | use Rack::Session::Cookie, secret: secret, expire_after: 2_592_000 27 | end 28 | 29 | before do 30 | unless settings.production? 31 | session[:me] = ENV['DEV_ME'] || 'http://localhost:4444/' 32 | session[:micropub] = ENV['DEV_MICROPUB'] || 'http://localhost:3333/micropub' 33 | session[:scope] = ENV['DEV_SCOPE'] || 'create update delete undelete' 34 | session[:token] = ENV['DEV_TOKEN'] || nil 35 | end 36 | end 37 | 38 | get '/' do 39 | if logged_in? 40 | @title = "Dashboard" 41 | @types = post_types 42 | erb :dashboard 43 | else 44 | @title = "Sign in" 45 | @about = markdown(settings.readme) 46 | erb :login 47 | end 48 | end 49 | 50 | get '/auth' do 51 | begin 52 | unless params.key?('me') && !params[:me].empty? && 53 | Auth.valid_uri?(params[:me]) 54 | raise "Missing or invalid value for \"me\": \"#{h params[:me]}\"." 55 | end 56 | unless params.key?('scope') && ( 57 | params[:scope].include?('create') || 58 | params[:scope].include?('post') || 59 | params[:scope].include?('draft')) 60 | raise "You must specify a valid scope, including at least one of " + 61 | "\"create\", \"post\" or \"draft\"." 62 | end 63 | unless endpoints = EndpointsFinder.new(params[:me]).find_links 64 | raise "Client could not find expected endpoints at \"#{h params[:me]}\"." 65 | end 66 | rescue => e 67 | redirect_flash('/', 'danger', e.message) 68 | end 69 | # define random state string 70 | session[:state] = SecureRandom.alphanumeric(20) 71 | # store scope - will be needed to limit functionality on dashboard 72 | session[:scope] = params[:scope].join(' ') 73 | # store me - we don't want to trust this in callback 74 | session[:me] = params[:me] 75 | # code challenge from code verified 76 | session[:code_verifier] = SecureRandom.alphanumeric(100) 77 | code_challenge = Auth.generate_code_challenge(session[:code_verifier]) 78 | # redirect to auth endpoint 79 | query = URI.encode_www_form({ 80 | me: session[:me], 81 | client_id: request.base_url + "/", 82 | state: session[:state], 83 | scope: session[:scope], 84 | redirect_uri: "#{request.base_url}/auth/callback", 85 | response_type: "code", 86 | code_challenge: code_challenge, 87 | code_challenge_method: "S256" 88 | }) 89 | redirect "#{endpoints[:authorization_endpoint]}?#{query}" 90 | end 91 | 92 | get '/auth/callback' do 93 | unless session.key?(:me) && session.key?(:state) && session.key?(:scope) 94 | redirect_flash('/', 'info', "Session has timed out. Please try again.") 95 | end 96 | unless params.key?(:state) && params[:state] == session[:state] 97 | session.clear 98 | redirect_flash('/', 'info', "Callback \"state\" parameter is missing or does not match.") 99 | end 100 | auth = Auth.new( 101 | session[:me], 102 | params[:code], 103 | "#{request.base_url}/auth/callback", 104 | request.base_url + "/", 105 | session[:code_verifier] 106 | ) 107 | endpoints_and_token_and_scope_and_me = auth.callback 108 | # login and token grant was successful so store in session with the scope for the token and the me 109 | session.merge!(endpoints_and_token_and_scope_and_me) 110 | redirect_flash('/', 'success', %Q{You are now signed in successfully 111 | as "#{endpoints_and_token_and_scope_and_me[:me]}". 112 | Submit content to your site via Micropub using the links 113 | below. Please 114 | read the docs for 115 | more information and help.}) 116 | end 117 | 118 | get '/new' do 119 | redirect '/new/h-entry/note' 120 | end 121 | get '/new/h-entry' do 122 | redirect '/new/h-entry/note' 123 | end 124 | 125 | get %r{/new/h\-entry/(note|article|bookmark|reply|repost|like|rsvp|checkin|photo|listen|food|drink)} do 126 | |subtype| 127 | require_session 128 | render_new(subtype) 129 | end 130 | 131 | post '/new' do 132 | require_session 133 | begin 134 | @post = Post.new([params[:_type]], Post.properties_from_params(params)) 135 | required_properties = post_types[params[:_subtype]]['required'] 136 | @post.validate_properties!(required_properties) 137 | # articles must be sent as json because content is an object 138 | format = params[:_subtype] == 'article' ? :json : default_format 139 | if params.key?('_preview') 140 | if format == :json 141 | @content = h @post.to_json(true) 142 | else 143 | @content = h format_form_encoded(@post.to_form_encoded) 144 | end 145 | if request.xhr? 146 | @content 147 | else 148 | erb :preview 149 | end 150 | else 151 | url = new_request.create(@post) 152 | redirect_post(url) 153 | end 154 | rescue MicropublishError => e 155 | if request.xhr? 156 | status 500 157 | e.message 158 | else 159 | set_flash('danger', e.message) 160 | render_new(params[:_subtype]) 161 | end 162 | end 163 | end 164 | 165 | get '/edit' do 166 | require_session 167 | redirect "/delete?url=#{params[:url]}" if params.key?('delete') 168 | redirect "/undelete?url=#{params[:url]}" if params.key?('undelete') 169 | redirect "/edit-all?url=#{params[:url]}" if params.key?('edit-all') 170 | 171 | subtype = micropub.source_all(params[:url]).entry_type 172 | if post_types.key?(subtype) 173 | render_edit(subtype) 174 | else 175 | render_edit_all 176 | end 177 | end 178 | 179 | get %r{/edit/h\-entry/(note|article|bookmark|reply|repost|like|rsvp|checkin|photo|listen|food|drink)} do 180 | |subtype| 181 | require_session 182 | render_edit(subtype) 183 | end 184 | 185 | get '/edit-all' do 186 | require_session 187 | render_edit_all 188 | end 189 | 190 | post '/edit' do 191 | require_session 192 | begin 193 | subtype = params[:_subtype] 194 | submitted_properties = Post.properties_from_params(params) 195 | @post = Post.new([params[:_type]], submitted_properties) 196 | @post.validate_properties! 197 | original_properties = if params.key?('_all') 198 | micropub.source_all(params[:_url]).properties 199 | else 200 | micropub.source_properties(params[:_url], 201 | subtype_edit_properties(subtype)).properties 202 | end 203 | mp_commands = Micropub.find_commands(params) 204 | known_properties = post_types.key?(subtype) ? 205 | settings.properties['known'].select{ |p| (post_types[subtype]['properties'] + %w(syndication published)).any?(p) } : 206 | settings.properties['known'] 207 | diff = Compare.new(original_properties, submitted_properties, 208 | known_properties).diff_properties 209 | if params.key?('_preview') 210 | hash = { 211 | action: 'update', 212 | url: params[:_url] 213 | }.merge(diff).merge(mp_commands) 214 | @content = h JSON.pretty_generate(hash) 215 | if request.xhr? 216 | @content 217 | else 218 | erb :preview 219 | end 220 | else 221 | url = new_request.update(params[:_url], diff, mp_commands) 222 | redirect_post(url) 223 | end 224 | rescue MicropublishError => e 225 | if request.xhr? 226 | status 500 227 | e.message 228 | else 229 | set_flash('danger', e.message) 230 | params.key?('_all') ? render_edit_all : render_edit(params[:_subtype]) 231 | end 232 | end 233 | end 234 | 235 | get '/delete' do 236 | require_session 237 | require_url 238 | @title = "Delete post at #{params[:url]}" 239 | erb :delete 240 | end 241 | 242 | post '/delete' do 243 | require_session 244 | url = new_request.delete(params[:url]) 245 | redirect params[:url] 246 | end 247 | 248 | get '/undelete' do 249 | require_session 250 | require_url 251 | @title = "Undelete post at #{params[:url]}" 252 | erb :undelete 253 | end 254 | 255 | post '/undelete' do 256 | require_session 257 | url = new_request.undelete(params[:url]) 258 | redirect params[:url] 259 | end 260 | 261 | post '/settings' do 262 | if params.key?('format') && ['json','form'].include?(params[:format]) 263 | session[:format] = params[:format].to_sym 264 | format_label = params[:format] == 'json' ? 'JSON' : 'form-encoded' 265 | session[:flash] = { 266 | type: 'info', 267 | message: "Format setting updated to \"#{format_label}\". New posts " + 268 | "will be sent using #{format_label} format." 269 | } 270 | end 271 | redirect '/' 272 | end 273 | 274 | post '/logout' do 275 | logout! 276 | end 277 | 278 | get '/about' do 279 | @content = markdown(settings.readme) 280 | # use a better heading for the about page 281 | @content.sub!('

Micropublish

', 282 | '

About

') 283 | @title = "About" 284 | erb :static 285 | end 286 | 287 | get '/changelog' do 288 | @content = markdown(settings.changelog) 289 | @title = "Changelog" 290 | erb :static 291 | end 292 | 293 | get '/redirect' do 294 | require_session 295 | redirect '/' unless params.key?('url') 296 | @url = params['url'] 297 | # HTTP request to see if post exists yet 298 | response = HTTParty.get(@url) 299 | case response.code.to_i 300 | when 200 301 | redirect @url 302 | when 404 303 | erb :redirect 304 | else 305 | redirect_flash('/', 'danger', "There was an error redirecting to your" + 306 | " new post's URL (#{@url}). Status code #{h(response.code)}." 307 | ) 308 | end 309 | end 310 | 311 | post '/media' do 312 | require_session 313 | 314 | unless params[:file] && params[:file][:tempfile] 315 | status 400 316 | return "No file was submitted." 317 | end 318 | 319 | # if no media endpoint, fall back to micropub endpoint 320 | url = media_endpoint || session[:micropub] 321 | 322 | new_request = Request.new(url, session[:token], false) 323 | location = new_request.upload(params[:file][:tempfile]) 324 | 325 | location 326 | end 327 | 328 | helpers do 329 | def micropub 330 | require_session 331 | Micropub.new(session[:micropub], session[:token]) 332 | end 333 | 334 | def set_flash(type, message) 335 | session[:flash] = { type: type, message: message } 336 | end 337 | 338 | def redirect_flash(url, type, message) 339 | set_flash(type, message) 340 | redirect url 341 | end 342 | 343 | def syndicate_to(subtype = nil) 344 | micropub.syndicate_to(subtype) || [] 345 | end 346 | 347 | def channels 348 | session[:channels] ||= micropub.channels 349 | end 350 | 351 | def media_endpoint 352 | micropub.media_endpoint 353 | end 354 | 355 | def logged_in? 356 | session.key?(:micropub) 357 | end 358 | 359 | def require_session 360 | redirect '/' unless logged_in? 361 | end 362 | 363 | def require_url 364 | begin 365 | micropub.validate_url!(params[:url]) 366 | rescue MicropublishError => e 367 | redirect_flash('/', 'danger', e.message) 368 | end 369 | end 370 | 371 | def logout! 372 | micropub.cache_clear 373 | session.clear 374 | redirect '/' 375 | end 376 | 377 | def new_request 378 | require_session 379 | Request.new(session[:micropub], session[:token], default_format == :json) 380 | end 381 | 382 | def format_form_encoded(content) 383 | content.gsub(/&/,"\n&") 384 | end 385 | 386 | def render_new(subtype) 387 | @type = 'h-entry' 388 | @subtype = subtype 389 | @subtype_label = post_types[subtype]['name'] 390 | @subtype_icon = post_types[subtype]['icon'] 391 | @title = "New #{@subtype_label} (#{@type})" 392 | @post ||= Post.new(@type, Post.properties_from_params(params)) 393 | @properties = post_types[subtype]['properties'] 394 | @required = post_types[subtype]['required'] 395 | @action_url = '/new' 396 | @action_label = "Create" 397 | @media = !media_endpoint.nil? 398 | # insert @username at start of content if replying to a tweet 399 | if @subtype == 'reply' && params.key?('in-reply-to') && 400 | !@post.properties.key?('content') 401 | @post.properties['content'] = 402 | [tweet_reply_prefix(params['in-reply-to'])] 403 | end 404 | # default to current timestamp if using a published field 405 | if @properties.include?('published') 406 | @post.properties['published'] = [Time.now.utc.iso8601] 407 | end 408 | erb :form 409 | end 410 | 411 | def subtype_edit_properties(subtype) 412 | # for micropub.rocks only return content and category 413 | return %w(content category) if params.key?('rocks') 414 | 415 | post_types[subtype]['properties'] + %w(syndication published) 416 | end 417 | 418 | def render_edit(subtype) 419 | begin 420 | @post ||= micropub.source_properties(params[:url], 421 | subtype_edit_properties(subtype)) 422 | rescue MicropubError => e 423 | redirect_flash('/', 'danger', e.message) 424 | end 425 | @subtype = subtype 426 | @subtype_label = post_types[subtype]['name'] 427 | @subtype_icon = post_types[subtype]['icon'] 428 | @title = "Edit #{@subtype_label} at #{params[:url] || params[:_url]}" 429 | @type = 'h-entry' 430 | @properties = subtype_edit_properties(subtype) 431 | @required = post_types[subtype]['required'] 432 | @edit = true 433 | @action_url = '/edit' 434 | @action_label = "Update" 435 | @media = !media_endpoint.nil? 436 | erb :form 437 | end 438 | 439 | def render_edit_all 440 | begin 441 | @post ||= micropub.source_all(params[:url]) 442 | rescue MicropubError => e 443 | redirect_flash('/', 'danger', e.message) 444 | end 445 | @title = "Edit post at #{params[:url] || params[:_url]}" 446 | @type = 'h-entry' 447 | @properties = [] 448 | @required = [] 449 | @edit = true 450 | @all = true 451 | @action_url = '/edit' 452 | @action_label = 'Update' 453 | erb :form 454 | end 455 | 456 | def redirect_post(url) 457 | encoded_url = CGI.escape(url) 458 | redirect "/redirect?url=#{encoded_url}" 459 | end 460 | end 461 | 462 | def config 463 | micropub.config 464 | end 465 | 466 | def post_types 467 | setting_types = settings.properties['types']['h-entry'] 468 | if config.is_a?(Hash) && config.key?('post-types') && 469 | config['post-types'].is_a?(Array) 470 | h_entry = {} 471 | config['post-types'].each do |type| 472 | # skip if we don't support type 473 | next unless setting_types.key?(type['type']) 474 | default_type = setting_types[type['type']] 475 | h_entry[type['type']] = { 476 | 'name' => type['name'], 477 | 'icon' => default_type['icon'] 478 | } 479 | h_entry[type['type']]['properties'] = 480 | if type.key?('properties') && type['properties'].is_a?(Array) 481 | type['properties'] 482 | else 483 | default_type['properties'] 484 | end 485 | h_entry[type['type']]['required'] = 486 | if type.key?('required-properties') && 487 | type['required-properties'].is_a?(Array) 488 | type['required-properties'] 489 | else 490 | default_type['required'] 491 | end 492 | end 493 | h_entry 494 | else 495 | setting_types 496 | end 497 | end 498 | 499 | error do 500 | @error = env['sinatra.error'] 501 | header = %Q{ 502 |

← Back

503 |

Something went wrong


504 |
505 | #{@error.message} 506 |
507 | } 508 | @content = header + markdown(settings.help) 509 | erb :static 510 | end 511 | 512 | end 513 | end 514 | -------------------------------------------------------------------------------- /lib/micropublish/version.rb: -------------------------------------------------------------------------------- 1 | module Micropublish 2 | 3 | VERSION = "2.12.0" 4 | 5 | end 6 | -------------------------------------------------------------------------------- /public/css/bootstrap-tokenfield.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * bootstrap-tokenfield 3 | * https://github.com/sliptree/bootstrap-tokenfield 4 | * Copyright 2013-2014 Sliptree and other contributors; Licensed MIT 5 | */ 6 | 7 | /* Light mode (default) styles */ 8 | :root { 9 | --tokenfield-bg: #fff; 10 | --tokenfield-border: #ccc; 11 | --token-bg: #ededed; 12 | --token-border: #d9d9d9; 13 | --token-hover-border: #b9b9b9; 14 | --token-active-border: #66afe9; 15 | --token-duplicate-border: #ebccd1; 16 | --token-invalid-border: #d9534f; 17 | --token-text: #333; 18 | --token-close: #000; 19 | --tokenfield-disabled-bg: #eee; 20 | --tokenfield-focus-border: #66afe9; 21 | --tokenfield-warning-border: #66512c; 22 | --tokenfield-error-border: #843534; 23 | --tokenfield-success-border: #2b542c; 24 | } 25 | 26 | /* Dark mode styles */ 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | --tokenfield-bg: var(--input-bg, #2d2d2d); 30 | --tokenfield-border: var(--input-border, #404040); 31 | --token-bg: var(--nav-bg, #3d3d3d); 32 | --token-border: var(--border-color, #404040); 33 | --token-hover-border: var(--link-color, #63a9e8); 34 | --token-active-border: var(--link-color, #63a9e8); 35 | --token-duplicate-border: #dc3545; 36 | --token-invalid-border: #dc3545; 37 | --token-text: var(--text-color, #e0e0e0); 38 | --token-close: var(--text-color, #e0e0e0); 39 | --tokenfield-disabled-bg: var(--nav-bg, #3d3d3d); 40 | --tokenfield-focus-border: var(--link-color, #63a9e8); 41 | --tokenfield-warning-border: #ffc107; 42 | --tokenfield-error-border: #dc3545; 43 | --tokenfield-success-border: #28a745; 44 | } 45 | } 46 | 47 | @-webkit-keyframes blink { 48 | 0% { border-color: var(--token-border); } 49 | 100% { border-color: var(--token-duplicate-border); } 50 | } 51 | 52 | @-moz-keyframes blink { 53 | 0% { border-color: var(--token-border); } 54 | 100% { border-color: var(--token-duplicate-border); } 55 | } 56 | 57 | @keyframes blink { 58 | 0% { border-color: var(--token-border); } 59 | 100% { border-color: var(--token-duplicate-border); } 60 | } 61 | 62 | .tokenfield { 63 | height: auto; 64 | min-height: 34px; 65 | padding-bottom: 0; 66 | background-color: var(--tokenfield-bg); 67 | border-color: var(--tokenfield-border); 68 | } 69 | 70 | .tokenfield.focus { 71 | border-color: var(--tokenfield-focus-border); 72 | outline: 0; 73 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); 74 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6); 75 | } 76 | 77 | .tokenfield .token { 78 | -webkit-box-sizing: border-box; 79 | -moz-box-sizing: border-box; 80 | box-sizing: border-box; 81 | -webkit-border-radius: 3px; 82 | -moz-border-radius: 3px; 83 | border-radius: 3px; 84 | display: inline-block; 85 | border: 1px solid var(--token-border); 86 | background-color: var(--token-bg); 87 | white-space: nowrap; 88 | margin: -1px 5px 5px 0; 89 | height: 22px; 90 | vertical-align: top; 91 | cursor: default; 92 | color: var(--token-text); 93 | } 94 | 95 | .tokenfield .token:hover { 96 | border-color: var(--token-hover-border); 97 | } 98 | 99 | .tokenfield .token.active { 100 | border-color: var(--token-active-border); 101 | } 102 | 103 | .tokenfield .token.duplicate { 104 | border-color: var(--token-duplicate-border); 105 | -webkit-animation-name: blink; 106 | animation-name: blink; 107 | -webkit-animation-duration: 0.1s; 108 | animation-duration: 0.1s; 109 | -webkit-animation-direction: normal; 110 | animation-direction: normal; 111 | -webkit-animation-timing-function: ease; 112 | animation-timing-function: ease; 113 | -webkit-animation-iteration-count: infinite; 114 | animation-iteration-count: infinite; 115 | } 116 | 117 | .tokenfield .token.invalid { 118 | background: none; 119 | border: 1px solid transparent; 120 | -webkit-border-radius: 0; 121 | -moz-border-radius: 0; 122 | border-radius: 0; 123 | border-bottom: 1px dotted var(--token-invalid-border); 124 | } 125 | 126 | .tokenfield .token.invalid.active { 127 | background: var(--token-bg); 128 | border: 1px solid var(--token-bg); 129 | -webkit-border-radius: 3px; 130 | -moz-border-radius: 3px; 131 | border-radius: 3px; 132 | } 133 | 134 | .tokenfield .token .token-label { 135 | display: inline-block; 136 | overflow: hidden; 137 | text-overflow: ellipsis; 138 | padding-left: 4px; 139 | vertical-align: top; 140 | } 141 | 142 | .tokenfield .token .close { 143 | font-family: Arial; 144 | display: inline-block; 145 | line-height: 100%; 146 | font-size: 1.1em; 147 | line-height: 1.49em; 148 | margin-left: 5px; 149 | float: none; 150 | height: 100%; 151 | vertical-align: top; 152 | padding-right: 4px; 153 | color: var(--token-close); 154 | opacity: 0.7; 155 | } 156 | 157 | .tokenfield .token-input { 158 | background: none; 159 | width: 60px; 160 | min-width: 60px; 161 | border: 0; 162 | height: 20px; 163 | padding: 0; 164 | margin-bottom: 6px; 165 | -webkit-box-shadow: none; 166 | box-shadow: none; 167 | color: var(--token-text); 168 | } 169 | 170 | .tokenfield .token-input:focus { 171 | border-color: transparent; 172 | outline: 0; 173 | -webkit-box-shadow: none; 174 | box-shadow: none; 175 | } 176 | 177 | .tokenfield.disabled { 178 | cursor: not-allowed; 179 | background-color: var(--tokenfield-disabled-bg); 180 | opacity: 0.7; 181 | } 182 | 183 | .tokenfield.disabled .token-input { 184 | cursor: not-allowed; 185 | } 186 | 187 | .tokenfield.disabled .token:hover { 188 | cursor: not-allowed; 189 | border-color: var(--token-border); 190 | } 191 | 192 | .tokenfield.disabled .token:hover .close { 193 | cursor: not-allowed; 194 | opacity: 0.2; 195 | } 196 | 197 | /* Bootstrap validation states */ 198 | .has-warning .tokenfield.focus { 199 | border-color: var(--tokenfield-warning-border); 200 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #c0a16b; 201 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #c0a16b; 202 | } 203 | 204 | .has-error .tokenfield.focus { 205 | border-color: var(--tokenfield-error-border); 206 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #ce8483; 207 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #ce8483; 208 | } 209 | 210 | .has-success .tokenfield.focus { 211 | border-color: var(--tokenfield-success-border); 212 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #67b168; 213 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px #67b168; 214 | } 215 | 216 | /* Size variations */ 217 | .tokenfield.input-sm, 218 | .input-group-sm .tokenfield { 219 | min-height: 30px; 220 | padding-bottom: 0; 221 | } 222 | 223 | .input-group-sm .token, 224 | .tokenfield.input-sm .token { 225 | height: 20px; 226 | margin-bottom: 4px; 227 | } 228 | 229 | .input-group-sm .token-input, 230 | .tokenfield.input-sm .token-input { 231 | height: 18px; 232 | margin-bottom: 5px; 233 | } 234 | 235 | .tokenfield.input-lg, 236 | .input-group-lg .tokenfield { 237 | height: auto; 238 | min-height: 45px; 239 | padding-bottom: 4px; 240 | } 241 | 242 | .input-group-lg .token, 243 | .tokenfield.input-lg .token { 244 | height: 25px; 245 | } 246 | 247 | .input-group-lg .token-label, 248 | .tokenfield.input-lg .token-label { 249 | line-height: 23px; 250 | } 251 | 252 | .input-group-lg .token .close, 253 | .tokenfield.input-lg .token .close { 254 | line-height: 1.3em; 255 | } 256 | 257 | .input-group-lg .token-input, 258 | .tokenfield.input-lg .token-input { 259 | height: 23px; 260 | line-height: 23px; 261 | margin-bottom: 6px; 262 | vertical-align: top; 263 | } 264 | 265 | /* RTL support */ 266 | .tokenfield.rtl { 267 | direction: rtl; 268 | text-align: right; 269 | } 270 | 271 | .tokenfield.rtl .token { 272 | margin: -1px 0 5px 5px; 273 | } 274 | 275 | .tokenfield.rtl .token .token-label { 276 | padding-left: 0; 277 | padding-right: 4px; 278 | } 279 | -------------------------------------------------------------------------------- /public/css/micropublish.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #ffffff; 3 | --text-color: #333333; 4 | --nav-bg: #f8f8f8; 5 | --nav-border: #e7e7e7; 6 | --link-color: #337ab7; 7 | --code-bg: #f5f5f5; 8 | --border-color: #ddd; 9 | --help-text: #737373; 10 | --input-bg: #ffffff; 11 | --input-border: #ccc; 12 | --btn-default-bg: #ffffff; 13 | --btn-default-border: #ccc; 14 | --panel-bg: #ffffff; 15 | --well-bg: #f5f5f5; 16 | } 17 | 18 | @media (prefers-color-scheme: dark) { 19 | :root { 20 | --bg-color: #1a1a1a; 21 | --text-color: #e0e0e0; 22 | --nav-bg: #3d3d3d; 23 | --nav-border: #404040; 24 | --link-color: #63a9e8; 25 | --code-bg: #2d2d2d; 26 | --border-color: #404040; 27 | --help-text: #b0b0b0; 28 | --input-bg: #2d2d2d; 29 | --input-border: #404040; 30 | --btn-default-bg: #2d2d2d; 31 | --btn-default-border: #404040; 32 | --panel-bg: #2d2d2d; 33 | --well-bg: #2d2d2d; 34 | } 35 | } 36 | 37 | /* Base styles */ 38 | body { 39 | padding-bottom: 1em; 40 | background-color: var(--bg-color); 41 | color: var(--text-color); 42 | } 43 | 44 | /* Bootstrap navbar overrides */ 45 | .navbar-default { 46 | background-color: var(--nav-bg); 47 | border-color: var(--nav-border); 48 | } 49 | 50 | .navbar-default .navbar-brand, 51 | .navbar-default .navbar-nav > li > a { 52 | color: var(--text-color); 53 | } 54 | 55 | .navbar-default .navbar-brand:hover, 56 | .navbar-default .navbar-nav > li > a:hover { 57 | color: var(--link-color); 58 | } 59 | 60 | /* Bootstrap form overrides */ 61 | .form-control { 62 | background-color: var(--input-bg); 63 | border-color: var(--input-border); 64 | color: var(--text-color); 65 | } 66 | 67 | .form-control:focus { 68 | border-color: var(--link-color); 69 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); 70 | } 71 | 72 | /* Bootstrap button overrides */ 73 | .btn-default { 74 | background-color: var(--btn-default-bg); 75 | border-color: var(--btn-default-border); 76 | color: var(--text-color); 77 | } 78 | 79 | .btn-default:hover, 80 | .btn-default:focus { 81 | background-color: var(--nav-bg); 82 | border-color: var(--nav-border); 83 | color: var(--text-color); 84 | } 85 | 86 | /* Bootstrap panel overrides */ 87 | .panel { 88 | background-color: var(--panel-bg); 89 | border-color: var(--border-color); 90 | } 91 | 92 | .panel-default > .panel-heading { 93 | background-color: var(--nav-bg); 94 | border-color: var(--border-color); 95 | color: var(--text-color); 96 | } 97 | 98 | .panel-footer { 99 | background-color: var(--nav-bg); 100 | border-top-color: var(--border-color); 101 | color: var(--text-color); 102 | } 103 | 104 | /* Bootstrap well overrides */ 105 | .well { 106 | background-color: var(--well-bg); 107 | border-color: var(--border-color); 108 | color: var(--text-color); 109 | } 110 | 111 | /* Bootstrap alert overrides */ 112 | .alert { 113 | background-color: var(--nav-bg); 114 | border-color: var(--border-color); 115 | color: var(--text-color); 116 | } 117 | 118 | /* Bootstrap modal overrides */ 119 | .modal-content { 120 | background-color: var(--panel-bg); 121 | border-color: var(--border-color); 122 | } 123 | 124 | .modal-header { 125 | background-color: var(--nav-bg); 126 | border-bottom-color: var(--border-color); 127 | color: var(--text-color); 128 | } 129 | 130 | .modal-body { 131 | color: var(--text-color); 132 | } 133 | 134 | .modal-footer { 135 | background-color: var(--nav-bg); 136 | border-top-color: var(--border-color); 137 | } 138 | 139 | .modal-backdrop { 140 | background-color: #000; 141 | } 142 | 143 | .close { 144 | color: var(--text-color); 145 | opacity: 0.7; 146 | } 147 | 148 | .close:hover { 149 | color: var(--text-color); 150 | opacity: 1; 151 | } 152 | 153 | /* Code and pre overrides */ 154 | pre, code { 155 | background-color: var(--code-bg); 156 | border-color: var(--border-color); 157 | color: var(--text-color); 158 | } 159 | 160 | /* Link overrides */ 161 | a { 162 | color: var(--link-color); 163 | } 164 | 165 | a:hover, a:focus { 166 | color: var(--link-color); 167 | text-decoration: underline; 168 | } 169 | 170 | /* Original micropublish.css styles */ 171 | .input-array input { 172 | margin-bottom: 3px; 173 | } 174 | 175 | .add-remove-buttons { 176 | text-align: right; 177 | } 178 | 179 | .new-buttons { 180 | padding-bottom: 5px; 181 | padding-right: 10px; 182 | } 183 | 184 | .new-buttons a { 185 | margin-bottom: 10px; 186 | margin-right: 5px; 187 | width: 90px; 188 | color: var(--link-color); 189 | } 190 | 191 | .new-buttons span { 192 | font-size: 180%; 193 | line-height: 1.5em; 194 | } 195 | 196 | trix-editor { 197 | min-height: 160px !important; 198 | background-color: var(--input-bg) !important; 199 | color: var(--text-color) !important; 200 | } 201 | 202 | .logout { 203 | float: right; 204 | } 205 | 206 | #helpable-toggle { 207 | float: right; 208 | font-weight: normal; 209 | } 210 | 211 | .helpable .help-block { 212 | display: none; 213 | } 214 | 215 | .help-block { 216 | color: var(--help-text); 217 | } 218 | 219 | .help-block code { 220 | background: none; 221 | color: inherit; 222 | padding: 2px 0; 223 | } 224 | 225 | #static .badge { 226 | cursor: move !important; 227 | } 228 | 229 | #static img { 230 | margin-left: 20px; 231 | } 232 | 233 | #preview-content .alert, 234 | #preview-content pre { 235 | margin-bottom: 0; 236 | } 237 | 238 | .required { 239 | color: red; 240 | } 241 | 242 | @media screen and (max-width: 700px) { 243 | form.logout { float: none; } 244 | #static img { 245 | float: none !important; 246 | margin-left: 0; 247 | max-width: 100%; 248 | } 249 | } -------------------------------------------------------------------------------- /public/help.md: -------------------------------------------------------------------------------- 1 | **Micropublish encountered an error and cannot continue. Please read the help 2 | information below, or [read the docs](/about) for more advice and examples 3 | on getting set up.** 4 | 5 | --- 6 | 7 | #### Missing or invalid value for "me": "{URL}". 8 | 9 | You must specify a valid URL when logging in. The `me` value in the query string 10 | was rejected. 11 | 12 | Your URL must begin with `http://` or `https://`, e.g. `https://barryfrost.com`. 13 | 14 | Passing just the domain isn't accepted, e.g. `barryfrost.com`. 15 | 16 | --- 17 | 18 | #### Client could not connect to "{URL}". 19 | 20 | Check the URL you have entered when logging in. Is it a public, valid URL? 21 | Micropublish must be able to reach your URL over the internet. 22 | 23 | - Is your URL private, e.g. `http://localhost/` or `http://127.0.0.1/`? If so 24 | Micropublish cannot connect because your URL is not publcly available. 25 | 26 | - If you have specified `https` and your domain's SSL certificate is not valid 27 | then you will not be able to continue. 28 | 29 | While testing, I recommend using [ngrok](https://ngrok.com/) to open a 30 | tunnel to your device and expose a URL that Micropublish can see. 31 | 32 | --- 33 | 34 | #### You must specify a valid scope. 35 | 36 | Micropublish expects that the `scope` value is one of the following two options: 37 | 38 | - `post` 39 | - `create update delete undelete` 40 | 41 | Please check the value you are passing in the query string. 42 | 43 | --- 44 | 45 | #### Client could not find expected endpoints at {URL}. 46 | 47 | No endpoints were found in either your server's response body or header. 48 | 49 | You should specify the following values in either the header or body and 50 | Micropublish will parse them. 51 | 52 | - `micropub` -- your server's Micropub endpoint 53 | - `authorization_endpoint` -- your authorization server, e.g. 54 | `https://indieauth.com/auth` 55 | - `token_endpoint` -- your token server, e.g. 56 | `https://tokens.indieauth.com/token` 57 | 58 | --- 59 | 60 | #### Client could not find {ENDPOINT} in body or header from {URL}" 61 | 62 | One of the endpoints from the section above was missing when Micropublish 63 | parsed your URL. 64 | 65 | Please ensure that the endpoint is included in either your response header or 66 | the body. 67 | 68 | --- 69 | 70 | #### {CODE} status returned from {URL}. 71 | 72 | An error code was received from your server when trying to discover your 73 | endpoints. 74 | 75 | Is your server publicly accessible and correctly responding to requests? There 76 | may be an issue with your server that needs attention. 77 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Micropublish", 3 | "short_name": "Micropublish", 4 | "description": "Micropublish is a Micropub client that you can use to create, update, delete and undelete content on your Micropub-enabled site." 5 | } -------------------------------------------------------------------------------- /public/micropublish-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryf/micropublish/c44b3be794b766c088b2220e2c67e7abb462a7d9/public/micropublish-demo.gif -------------------------------------------------------------------------------- /public/scripts/bootstrap-tokenfield.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * bootstrap-tokenfield 0.12.1 3 | * https://github.com/sliptree/bootstrap-tokenfield 4 | * Copyright 2013-2014 Sliptree and other contributors; Licensed MIT 5 | */ 6 | 7 | !function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof exports?module.exports=global.window&&global.window.$?a(global.window.$):function(b){if(!b.$&&!b.fn)throw new Error("Tokenfield requires a window object with jQuery or a jQuery instance");return a(b.$||b)}:a(jQuery,window)}(function(a,b){"use strict";var c=function(c,d){var e=this;this.$element=a(c),this.textDirection=this.$element.css("direction"),this.options=a.extend(!0,{},a.fn.tokenfield.defaults,{tokens:this.$element.val()},this.$element.data(),d),this._delimiters="string"==typeof this.options.delimiter?[this.options.delimiter]:this.options.delimiter,this._triggerKeys=a.map(this._delimiters,function(a){return a.charCodeAt(0)}),this._firstDelimiter=this._delimiters[0];var f=a.inArray(" ",this._delimiters),g=a.inArray("-",this._delimiters);f>=0&&(this._delimiters[f]="\\s"),g>=0&&(delete this._delimiters[g],this._delimiters.unshift("-"));var h=["\\","$","[","{","^",".","|","?","*","+","(",")"];a.each(this._delimiters,function(b,c){var d=a.inArray(c,h);d>=0&&(e._delimiters[b]="\\"+c)});var i,j=b&&"function"==typeof b.getMatchedCSSRules?b.getMatchedCSSRules(c):null,k=c.style.width,l=this.$element.width();j&&a.each(j,function(a,b){b.style.width&&(i=b.style.width)});var m="rtl"===a("body").css("direction")?"right":"left",n={position:this.$element.css("position")};n[m]=this.$element.css(m),this.$element.data("original-styles",n).data("original-tabindex",this.$element.prop("tabindex")).css("position","absolute").css(m,"-10000px").prop("tabindex",-1),this.$wrapper=a('
'),this.$element.hasClass("input-lg")&&this.$wrapper.addClass("input-lg"),this.$element.hasClass("input-sm")&&this.$wrapper.addClass("input-sm"),"rtl"===this.textDirection&&this.$wrapper.addClass("rtl");var o=this.$element.prop("id")||(new Date).getTime()+""+Math.floor(100*(1+Math.random()));this.$input=a('').appendTo(this.$wrapper).prop("placeholder",this.$element.prop("placeholder")).prop("id",o+"-tokenfield").prop("tabindex",this.$element.data("original-tabindex"));var p=a('label[for="'+this.$element.prop("id")+'"]');if(p.length&&p.prop("for",this.$input.prop("id")),this.$copyHelper=a('').css("position","absolute").css(m,"-10000px").prop("tabindex",-1).prependTo(this.$wrapper),k?this.$wrapper.css("width",k):i?this.$wrapper.css("width",i):this.$element.parents(".form-inline").length&&this.$wrapper.width(l),(this.$element.prop("disabled")||this.$element.parents("fieldset[disabled]").length)&&this.disable(),this.$element.prop("readonly")&&this.readonly(),this.$mirror=a(''),this.$input.css("min-width",this.options.minWidth+"px"),a.each(["fontFamily","fontSize","fontWeight","fontStyle","letterSpacing","textTransform","wordSpacing","textIndent"],function(a,b){e.$mirror[0].style[b]=e.$input.css(b)}),this.$mirror.appendTo("body"),this.$wrapper.insertBefore(this.$element),this.$element.prependTo(this.$wrapper),this.update(),this.setTokens(this.options.tokens,!1,!this.$element.val()&&this.options.tokens),this.listen(),!a.isEmptyObject(this.options.autocomplete)){var q="rtl"===this.textDirection?"right":"left",r=a.extend({minLength:this.options.showAutocompleteOnFocus?0:null,position:{my:q+" top",at:q+" bottom",of:this.$wrapper}},this.options.autocomplete);this.$input.autocomplete(r)}if(!a.isEmptyObject(this.options.typeahead)){var s=this.options.typeahead,t={minLength:this.options.showAutocompleteOnFocus?0:null},u=a.isArray(s)?s:[s,s];u[0]=a.extend({},t,u[0]),this.$input.typeahead.apply(this.$input,u),this.typeahead=!0}};c.prototype={constructor:c,createToken:function(b,c){var d=this;if(b="string"==typeof b?{value:b,label:b}:a.extend({},b),"undefined"==typeof c&&(c=!0),b.value=a.trim(b.value.toString()),b.label=b.label&&b.label.length?a.trim(b.label):b.value,!(!b.value.length||!b.label.length||b.label.length<=this.options.minLength||this.options.limit&&this.getTokens().length>=this.options.limit)){var e=a.Event("tokenfield:createtoken",{attrs:b});if(this.$element.trigger(e),e.attrs&&!e.isDefaultPrevented()){var f=a('
').append('').append('×').data("attrs",b);this.$input.hasClass("tt-input")?this.$input.parent().before(f):this.$input.before(f),this.$input.css("width",this.options.minWidth+"px");var g=f.find(".token-label"),h=f.find(".close");return this.maxTokenWidth||(this.maxTokenWidth=this.$wrapper.width()-h.outerWidth()-parseInt(h.css("margin-left"),10)-parseInt(h.css("margin-right"),10)-parseInt(f.css("border-left-width"),10)-parseInt(f.css("border-right-width"),10)-parseInt(f.css("padding-left"),10)-parseInt(f.css("padding-right"),10),parseInt(g.css("border-left-width"),10)-parseInt(g.css("border-right-width"),10)-parseInt(g.css("padding-left"),10)-parseInt(g.css("padding-right"),10),parseInt(g.css("margin-left"),10)-parseInt(g.css("margin-right"),10)),g.text(b.label).css("max-width",this.maxTokenWidth),f.on("mousedown",function(){return d._disabled||d._readonly?!1:(d.preventDeactivation=!0,void 0)}).on("click",function(a){return d._disabled||d._readonly?!1:(d.preventDeactivation=!1,a.ctrlKey||a.metaKey?(a.preventDefault(),d.toggle(f)):(d.activate(f,a.shiftKey,a.shiftKey),void 0))}).on("dblclick",function(){return d._disabled||d._readonly||!d.options.allowEditing?!1:(d.edit(f),void 0)}),h.on("click",a.proxy(this.remove,this)),this.$element.trigger(a.Event("tokenfield:createdtoken",{attrs:b,relatedTarget:f.get(0)})),c&&this.$element.val(this.getTokensList()).trigger(a.Event("change",{initiator:"tokenfield"})),this.update(),this.$element.get(0)}}},setTokens:function(b,c,d){if(b){c||this.$wrapper.find(".token").remove(),"undefined"==typeof d&&(d=!0),"string"==typeof b&&(b=this._delimiters.length?b.split(new RegExp("["+this._delimiters.join("")+"]")):[b]);var e=this;return a.each(b,function(a,b){e.createToken(b,d)}),this.$element.get(0)}},getTokenData:function(b){var c=b.map(function(){var b=a(this);return b.data("attrs")}).get();return 1==c.length&&(c=c[0]),c},getTokens:function(b){var c=this,d=[],e=b?".active":"";return this.$wrapper.find(".token"+e).each(function(){d.push(c.getTokenData(a(this)))}),d},getTokensList:function(b,c,d){b=b||this._firstDelimiter,c="undefined"!=typeof c&&null!==c?c:this.options.beautify;var e=b+(c&&" "!==b?" ":"");return a.map(this.getTokens(d),function(a){return a.value}).join(e)},getInput:function(){return this.$input.val()},listen:function(){var c=this;this.$element.on("change",a.proxy(this.change,this)),this.$wrapper.on("mousedown",a.proxy(this.focusInput,this)),this.$input.on("focus",a.proxy(this.focus,this)).on("blur",a.proxy(this.blur,this)).on("paste",a.proxy(this.paste,this)).on("keydown",a.proxy(this.keydown,this)).on("keypress",a.proxy(this.keypress,this)).on("keyup",a.proxy(this.keyup,this)),this.$copyHelper.on("focus",a.proxy(this.focus,this)).on("blur",a.proxy(this.blur,this)).on("keydown",a.proxy(this.keydown,this)).on("keyup",a.proxy(this.keyup,this)),this.$input.on("keypress",a.proxy(this.update,this)).on("keyup",a.proxy(this.update,this)),this.$input.on("autocompletecreate",function(){var b=a(this).data("ui-autocomplete").menu.element,d=c.$wrapper.outerWidth()-parseInt(b.css("border-left-width"),10)-parseInt(b.css("border-right-width"),10);b.css("min-width",d+"px")}).on("autocompleteselect",function(a,b){return c.createToken(b.item)&&(c.$input.val(""),c.$input.data("edit")&&c.unedit(!0)),!1}).on("typeahead:selected typeahead:autocompleted",function(a,b){c.createToken(b)&&(c.$input.typeahead("val",""),c.$input.data("edit")&&c.unedit(!0))}),a(b).on("resize",a.proxy(this.update,this))},keydown:function(b){function c(a){if(e.$input.is(document.activeElement)){if(e.$input.val().length>0)return;a+="All";var c=e.$input.hasClass("tt-input")?e.$input.parent()[a](".token:first"):e.$input[a](".token:first");if(!c.length)return;e.preventInputFocus=!0,e.preventDeactivation=!0,e.activate(c),b.preventDefault()}else e[a](b.shiftKey),b.preventDefault()}function d(c){if(b.shiftKey){if(e.$input.is(document.activeElement)){if(e.$input.val().length>0)return;var d=e.$input.hasClass("tt-input")?e.$input.parent()[c+"All"](".token:first"):e.$input[c+"All"](".token:first");if(!d.length)return;e.activate(d)}var f="prev"===c?"next":"prev",g="prev"===c?"first":"last";e.$firstActiveToken[f+"All"](".token").each(function(){e.deactivate(a(this))}),e.activate(e.$wrapper.find(".token:"+g),!0,!0),b.preventDefault()}}if(this.focused){var e=this;switch(b.keyCode){case 8:if(!this.$input.is(document.activeElement))break;this.lastInputValue=this.$input.val();break;case 37:c("rtl"===this.textDirection?"next":"prev");break;case 38:d("prev");break;case 39:c("rtl"===this.textDirection?"prev":"next");break;case 40:d("next");break;case 65:if(this.$input.val().length>0||!b.ctrlKey&&!b.metaKey)break;this.activateAll(),b.preventDefault();break;case 9:case 13:if(this.$input.data("ui-autocomplete")&&this.$input.data("ui-autocomplete").menu.element.find("li:has(a.ui-state-focus), li.ui-state-focus").length)break;if(this.$input.hasClass("tt-input")&&this.$wrapper.find(".tt-cursor").length)break;if(this.$input.hasClass("tt-input")&&this.$wrapper.find(".tt-hint").val()&&this.$wrapper.find(".tt-hint").val().length)break;if(this.$input.is(document.activeElement)&&this.$input.val().length||this.$input.data("edit"))return this.createTokensFromInput(b,this.$input.data("edit"));if(13===b.keyCode){if(!this.$copyHelper.is(document.activeElement)||1!==this.$wrapper.find(".token.active").length)break;if(!e.options.allowEditing)break;this.edit(this.$wrapper.find(".token.active"))}}this.lastKeyDown=b.keyCode}},keypress:function(b){return-1!==a.inArray(b.which,this._triggerKeys)&&this.$input.is(document.activeElement)?(this.$input.val()&&this.createTokensFromInput(b),!1):void 0},keyup:function(a){if(this.preventInputFocus=!1,this.focused){switch(a.keyCode){case 8:if(this.$input.is(document.activeElement)){if(this.$input.val().length||this.lastInputValue.length&&8===this.lastKeyDown)break;this.preventDeactivation=!0;var b=this.$input.hasClass("tt-input")?this.$input.parent().prevAll(".token:first"):this.$input.prevAll(".token:first");if(!b.length)break;this.activate(b)}else this.remove(a);break;case 46:this.remove(a,"next")}this.lastKeyUp=a.keyCode}},focus:function(){this.focused=!0,this.$wrapper.addClass("focus"),this.$input.is(document.activeElement)&&(this.$wrapper.find(".active").removeClass("active"),this.$firstActiveToken=null,this.options.showAutocompleteOnFocus&&this.search())},blur:function(a){this.focused=!1,this.$wrapper.removeClass("focus"),this.preventDeactivation||this.$element.is(document.activeElement)||(this.$wrapper.find(".active").removeClass("active"),this.$firstActiveToken=null),!this.preventCreateTokens&&(this.$input.data("edit")&&!this.$input.is(document.activeElement)||this.options.createTokensOnBlur)&&this.createTokensFromInput(a),this.preventDeactivation=!1,this.preventCreateTokens=!1},paste:function(a){var b=this;b.options.allowPasting&&setTimeout(function(){b.createTokensFromInput(a)},1)},change:function(a){"tokenfield"!==a.initiator&&this.setTokens(this.$element.val())},createTokensFromInput:function(a,b){if(!(this.$input.val().lengththis.$firstActiveToken.index():!1;if(c)return this.deactivate(b)}var d=this.$wrapper.find(".active:first"),e=d.prevAll(".token:first");return e.length||(e=this.$wrapper.find(".token:first")),e.length||a?(this.activate(e,a),void 0):(this.$input.focus(),void 0)},activate:function(b,c,d,e){if(b){if("undefined"==typeof e)var e=!0;if(d)var c=!0;if(this.$copyHelper.focus(),c||(this.$wrapper.find(".active").removeClass("active"),e?this.$firstActiveToken=b:delete this.$firstActiveToken),d&&this.$firstActiveToken){var f=this.$firstActiveToken.index()-2,g=b.index()-2,h=this;this.$wrapper.find(".token").slice(Math.min(f,g)+1,Math.max(f,g)).each(function(){h.activate(a(this),!0)})}b.addClass("active"),this.$copyHelper.val(this.getTokensList(null,null,!0)).select()}},activateAll:function(){var b=this;this.$wrapper.find(".token").each(function(c){b.activate(a(this),0!==c,!1,!1)})},deactivate:function(a){a&&(a.removeClass("active"),this.$copyHelper.val(this.getTokensList(null,null,!0)).select())},toggle:function(a){a&&(a.toggleClass("active"),this.$copyHelper.val(this.getTokensList(null,null,!0)).select())},edit:function(b){if(b){var c=b.data("attrs"),d={attrs:c,relatedTarget:b.get(0)},e=a.Event("tokenfield:edittoken",d);if(this.$element.trigger(e),!e.isDefaultPrevented()){b.find(".token-label").text(c.value);var f=b.outerWidth(),g=this.$input.hasClass("tt-input")?this.$input.parent():this.$input;b.replaceWith(g),this.preventCreateTokens=!0,this.$input.val(c.value).select().data("edit",!0).width(f),this.update(),this.$element.trigger(a.Event("tokenfield:editedtoken",d))}}},unedit:function(a){var b=this.$input.hasClass("tt-input")?this.$input.parent():this.$input;if(b.appendTo(this.$wrapper),this.$input.data("edit",!1),this.$mirror.text(""),this.update(),a){var c=this;setTimeout(function(){c.$input.focus()},1)}},remove:function(b,c){if(!(this.$input.is(document.activeElement)||this._disabled||this._readonly)){var d="click"===b.type?a(b.target).closest(".token"):this.$wrapper.find(".token.active");if("click"!==b.type){if(!c)var c="prev";if(this[c](),"prev"===c)var e=0===d.first().prevAll(".token:first").length}var f={attrs:this.getTokenData(d),relatedTarget:d.get(0)},g=a.Event("tokenfield:removetoken",f);if(this.$element.trigger(g),!g.isDefaultPrevented()){var h=a.Event("tokenfield:removedtoken",f),i=a.Event("change",{initiator:"tokenfield"});d.remove(),this.$element.val(this.getTokensList()).trigger(h).trigger(i),(!this.$wrapper.find(".token").length||"click"===b.type||e)&&this.$input.focus(),this.$input.css("width",this.options.minWidth+"px"),this.update(),b.preventDefault(),b.stopPropagation()}}},update:function(){var a=this.$input.val(),b=parseInt(this.$input.css("padding-left"),10),c=parseInt(this.$input.css("padding-right"),10),d=b+c;if(this.$input.data("edit")){if(a||(a=this.$input.prop("placeholder")),a===this.$mirror.text())return;this.$mirror.text(a);var e=this.$mirror.width()+10;if(e>this.$wrapper.width())return this.$input.width(this.$wrapper.width());this.$input.width(e)}else{var f="rtl"===this.textDirection?this.$input.offset().left+this.$input.outerWidth()-this.$wrapper.offset().left-parseInt(this.$wrapper.css("padding-left"),10)-d-1:this.$wrapper.offset().left+this.$wrapper.width()+parseInt(this.$wrapper.css("padding-left"),10)-this.$input.offset().left-d;isNaN(f)?this.$input.width("100%"):this.$input.width(f)}},focusInput:function(b){if(!(a(b.target).closest(".token").length||a(b.target).closest(".token-input").length||a(b.target).closest(".tt-dropdown-menu").length)){var c=this;setTimeout(function(){c.$input.focus()},0)}},search:function(){this.$input.data("ui-autocomplete")&&this.$input.autocomplete("search")},disable:function(){this.setProperty("disabled",!0)},enable:function(){this.setProperty("disabled",!1)},readonly:function(){this.setProperty("readonly",!0)},writeable:function(){this.setProperty("readonly",!1)},setProperty:function(a,b){this["_"+a]=b,this.$input.prop(a,b),this.$element.prop(a,b),this.$wrapper[b?"addClass":"removeClass"](a)},destroy:function(){this.$element.val(this.getTokensList()),this.$element.css(this.$element.data("original-styles")),this.$element.prop("tabindex",this.$element.data("original-tabindex"));var b=a('label[for="'+this.$input.prop("id")+'"]');b.length&&b.prop("for",this.$element.prop("id")),this.$element.insertBefore(this.$wrapper),this.$element.removeData("original-styles").removeData("original-tabindex").removeData("bs.tokenfield"),this.$wrapper.remove(),this.$mirror.remove();var c=this.$element;return c}};var d=a.fn.tokenfield;return a.fn.tokenfield=function(b,d){var e,f=[];Array.prototype.push.apply(f,arguments);var g=this.each(function(){var g=a(this),h=g.data("bs.tokenfield"),i="object"==typeof b&&b;"string"==typeof b&&h&&h[b]?(f.shift(),e=h[b].apply(h,f)):h||"string"==typeof b||d||(g.data("bs.tokenfield",h=new c(this,i)),g.trigger("tokenfield:initialize"))});return"undefined"!=typeof e?e:g},a.fn.tokenfield.defaults={minWidth:60,minLength:0,allowEditing:!0,allowPasting:!0,limit:0,autocomplete:{},typeahead:{},showAutocompleteOnFocus:!1,createTokensOnBlur:!1,delimiter:",",beautify:!0,inputType:"text"},a.fn.tokenfield.Constructor=c,a.fn.tokenfield.noConflict=function(){return a.fn.tokenfield=d,this},c}); -------------------------------------------------------------------------------- /public/scripts/jquery.ns-autogrow.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Non-Sucking Autogrow 1.1.6 3 | license: MIT 4 | author: Roman Pushkin 5 | https://github.com/ro31337/jquery.ns-autogrow 6 | */ 7 | (function(){var e;!function(t,l){return t.fn.autogrow=function(i){if(null==i&&(i={}),null==i.horizontal&&(i.horizontal=!0),null==i.vertical&&(i.vertical=!0),null==i.debugx&&(i.debugx=-1e4),null==i.debugy&&(i.debugy=-1e4),null==i.debugcolor&&(i.debugcolor="yellow"),null==i.flickering&&(i.flickering=!0),null==i.postGrowCallback&&(i.postGrowCallback=function(){}),null==i.verticalScrollbarWidth&&(i.verticalScrollbarWidth=e()),i.horizontal!==!1||i.vertical!==!1)return this.filter("textarea").each(function(){var e,n,r,o,a,c,d;if(e=t(this),!e.data("autogrow-enabled"))return e.data("autogrow-enabled"),a=e.height(),c=e.width(),o=1*e.css("lineHeight")||0,e.hasVerticalScrollBar=function(){return e[0].clientHeight
').css({position:"absolute",display:"inline-block","background-color":i.debugcolor,top:i.debugy,left:i.debugx,"max-width":e.css("max-width"),padding:e.css("padding"),fontSize:e.css("fontSize"),fontFamily:e.css("fontFamily"),fontWeight:e.css("fontWeight"),lineHeight:e.css("lineHeight"),resize:"none","word-wrap":"break-word"}).appendTo(document.body),i.horizontal===!1?n.css({width:e.width()}):(r=e.css("font-size"),n.css("padding-right","+="+r),n.normalPaddingRight=n.css("padding-right")),d=function(t){return function(l){var r,d,s;return d=t.value.replace(/&/g,"&").replace(//g,">").replace(/\n /g,"
 ").replace(/"/g,""").replace(/'/g,"'").replace(/\n$/,"
 ").replace(/\n/g,"
").replace(/ {2,}/g,function(e){return Array(e.length-1).join(" ")+" "}),/(\n|\r)/.test(t.value)&&(d+="
",i.flickering===!1&&(d+="
")),n.html(d),i.vertical===!0&&(r=Math.max(n.height()+o,a),e.height(r)),i.horizontal===!0&&(n.css("padding-right",n.normalPaddingRight),i.vertical===!1&&e.hasVerticalScrollBar()&&n.css("padding-right","+="+i.verticalScrollbarWidth+"px"),s=Math.max(n.outerWidth(),c),e.width(s)),i.postGrowCallback(e)}}(this),e.change(d).keyup(d).keydown(d),t(l).resize(d),d()})}}(window.jQuery,window),e=function(){var e,t,l,i;return e=document.createElement("p"),e.style.width="100%",e.style.height="200px",t=document.createElement("div"),t.style.position="absolute",t.style.top="0px",t.style.left="0px",t.style.visibility="hidden",t.style.width="200px",t.style.height="150px",t.style.overflow="hidden",t.appendChild(e),document.body.appendChild(t),l=e.offsetWidth,t.style.overflow="scroll",i=e.offsetWidth,l===i&&(i=t.clientWidth),document.body.removeChild(t),l-i}}).call(this); -------------------------------------------------------------------------------- /public/scripts/micropublish.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | 3 | $('#preview').on('click', function() { 4 | $('#preview-modal').modal(); 5 | $('#preview-content').html(""); 6 | $.ajax({ 7 | data: $('#form').serialize(), 8 | type: 'post', 9 | url: $('#form').attr('action') + "?_preview=1", 10 | success: function(data) { 11 | var content = "
" + data + "
"; 12 | $('#preview-content').html(content); 13 | }, 14 | error: function(xhr, desc, error) { 15 | var content = "
" + xhr.responseText + 16 | "
" 17 | $('#preview-content').html(content); 18 | } 19 | }); 20 | return false 21 | }); 22 | 23 | function count_chars(id) { 24 | $('#' + id + '_count').html( 25 | " " + 26 | twttr.txt.getTweetLength( 27 | $('#' + id).val() 28 | ) 29 | ); 30 | } 31 | if ($('#content_count').length) { 32 | $('#content').on('change keyup', function() { count_chars('content'); }); 33 | count_chars('content'); 34 | } 35 | if ($('#summary_count').length) { 36 | $('#summary').on('change keyup', function() { count_chars('summary'); }); 37 | count_chars('summary'); 38 | } 39 | 40 | $('#helpable-toggle').on('click', function() { 41 | $('.helpable .help-block').slideToggle(); 42 | }); 43 | 44 | $('#settings-format-form input').on('click', function() { 45 | $('#settings-format-form').submit(); 46 | }); 47 | 48 | // progressively enhance if js is available 49 | $('.helpable .help-block').css({ display: "none" }); 50 | $('#content-html').css({ display: "none" }); 51 | $('trix-editor').css({ display: "block" }); 52 | 53 | function getLocation(callback) { 54 | navigator.geolocation.getCurrentPosition(function(position) { 55 | var latitude = (Math.round(position.coords.latitude * 100000) / 100000); 56 | var longitude = (Math.round(position.coords.longitude * 100000) / 100000); 57 | var accuracy = (Math.round(position.coords.accuracy * 100000) / 100000); 58 | 59 | callback(latitude, longitude, accuracy) 60 | 61 | }, function(err){ 62 | if(err.code == 1) { 63 | alert("The website was not able to get permission"); 64 | } else if(err.code == 2) { 65 | alert("Location information was unavailable"); 66 | } else if(err.code == 3) { 67 | alert("Timed out getting location"); 68 | } 69 | }) 70 | } 71 | 72 | $('#find_location').on('click', function() { 73 | getLocation(function (latitude, longitude) { 74 | $("#latitude").val(latitude); 75 | $("#longitude").val(longitude); 76 | }) 77 | return false 78 | }); 79 | 80 | if ($('#auto_location').length) { 81 | function getAutoLocation () { 82 | return localStorage.getItem('autoLocation') === 'true' 83 | } 84 | 85 | function fillLocation() { 86 | if (!getAutoLocation()) { 87 | return 88 | } 89 | 90 | getLocation(function(latitude, longitude, accuracy) { 91 | $('#location').val(`geo:${latitude},${longitude};u=${accuracy}`) 92 | }) 93 | } 94 | 95 | $('#auto_location').prop('checked', getAutoLocation()) 96 | 97 | $('#auto_location').on('change', function(event) { 98 | localStorage.setItem('autoLocation', event.target.checked) 99 | fillLocation() 100 | }) 101 | 102 | fillLocation() 103 | } 104 | 105 | $('#upload_photo').on('click', function() { 106 | $('#photo_file').click(); 107 | return false; 108 | }); 109 | 110 | $('#photo_file').on('change', function(event) { 111 | var fd = new FormData(); 112 | var files = event.target.files[0]; 113 | fd.append('file', files); 114 | 115 | $.ajax({ 116 | url: '/media', 117 | type: 'post', 118 | data: fd, 119 | contentType: false, 120 | processData: false, 121 | success: function(response){ 122 | var val = $('#photo').val() + '\n' + response; 123 | val = val.trim(); 124 | $('#photo').val(val); 125 | $('#photo').attr('rows', val.split('\n').length || 1); 126 | }, 127 | error: function(xhr, desc, error) { 128 | alert(xhr.responseText); 129 | } 130 | }); 131 | }); 132 | }); 133 | 134 | $.fn.countdown = function(duration) { 135 | var container = $(this[0]); 136 | var countdown = setInterval(function() { 137 | if (--duration) { 138 | container.html( 139 | "Redirecting in " + duration + " second" + (duration != 1 ? "s" : "") 140 | ); 141 | } else { 142 | container.html("Redirecting…"); 143 | clearInterval(countdown); 144 | document.location = document.location; 145 | } 146 | }, 1000); 147 | } 148 | 149 | -------------------------------------------------------------------------------- /public/scripts/trix.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const mediaEndpointEnabled = document.querySelector("#content-html")?.dataset?.mediaEndpointEnabled !== undefined; 3 | const trixFileTools = document.querySelector(".trix-button-group--file-tools"); 4 | 5 | if (!mediaEndpointEnabled && trixFileTools) { 6 | trixFileTools.style.display = "none"; 7 | 8 | document.addEventListener("trix-file-accept", (event) => { 9 | event.preventDefault(); 10 | }) 11 | } 12 | 13 | function uploadFileAttachment(attachment) { 14 | uploadFile(attachment.file, setProgress, setAttributes); 15 | 16 | function setProgress(progress) { 17 | attachment.setUploadProgress(progress); 18 | } 19 | 20 | function setAttributes(attributes) { 21 | attachment.setAttributes(attributes); 22 | } 23 | } 24 | 25 | function uploadFile(file, progressCallback, successCallback) { 26 | var formData = createFormData(file); 27 | var xhr = new XMLHttpRequest(); 28 | 29 | xhr.open("POST", '/media', true); 30 | 31 | xhr.upload.addEventListener("progress", function (event) { 32 | var progress = (event.loaded / event.total) * 100; 33 | progressCallback(progress); 34 | }); 35 | 36 | xhr.addEventListener("load", function (event) { 37 | if (xhr.status == 200) { 38 | var attributes = { 39 | url: xhr.response, 40 | href: xhr.response + "?content-disposition=attachment", 41 | }; 42 | successCallback(attributes); 43 | } 44 | }); 45 | 46 | xhr.send(formData); 47 | } 48 | 49 | function createFormData(file) { 50 | var data = new FormData(); 51 | data.append("Content-Type", file.type); 52 | data.append("file", file); 53 | return data; 54 | } 55 | 56 | if (!mediaEndpointEnabled) { 57 | return; 58 | } 59 | 60 | addEventListener("trix-attachment-add", (event) => { 61 | if (event.attachment.file) { 62 | uploadFileAttachment(event.attachment); 63 | } 64 | }); 65 | })(); 66 | -------------------------------------------------------------------------------- /spec/micropublish/auth_spec.rb: -------------------------------------------------------------------------------- 1 | describe Micropublish::Auth do 2 | 3 | before do 4 | # from https://tools.ietf.org/html/rfc7636#appendix-A 5 | @code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 6 | @expected_code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" 7 | end 8 | 9 | context "given a random string as a code verifier" do 10 | describe "#generate_code_challenge" do 11 | it "should generate a code challenge in the expected format" do 12 | code_challenge = Micropublish::Auth.generate_code_challenge(@code_verifier) 13 | expect(code_challenge).to eql(@expected_code_challenge) 14 | end 15 | end 16 | end 17 | 18 | end -------------------------------------------------------------------------------- /spec/micropublish/compare_spec.rb: -------------------------------------------------------------------------------- 1 | describe Micropublish::Compare do 2 | 3 | before do 4 | @compare = Micropublish::Compare.new( 5 | { 6 | 'content' => ['Existing content.'], 7 | 'category' => ['indieweb','micropub'] 8 | }, 9 | { 10 | 'content' => ['New content.'], 11 | 'name' => ['New name.'], 12 | 'category' => ['indieweb','new'] 13 | }, 14 | [ 15 | "in-reply-to", 16 | "repost-of", 17 | "like-of", 18 | "bookmark-of", 19 | "rsvp", 20 | "name", 21 | "content", 22 | "summary", 23 | "published", 24 | "category", 25 | "mp-syndicate-to", 26 | "syndication", 27 | "mp-slug" 28 | ] 29 | ) 30 | end 31 | 32 | context "given existing and submitted hashes containing properties" do 33 | 34 | describe "#diff_removed!" do 35 | it "should show that properties have been removed." do 36 | diff = { replace: {}, add: {}, delete: {} } 37 | @compare.diff_removed!(diff) 38 | expect(diff[:delete]).to eql({ 'category' => ['micropub'] }) 39 | end 40 | end 41 | 42 | describe "#diff_added!" do 43 | it "should show that properties have been added." do 44 | diff = { add: {} } 45 | @compare.diff_added!(diff) 46 | expect(diff[:add]).to eql({ 47 | 'name' => ['New name.'], 48 | 'category' => ['new'] 49 | }) 50 | end 51 | end 52 | 53 | describe "#diff_replaced!" do 54 | it "should show that properties have been replaced." do 55 | diff = { replace: {}, add: {}, delete: {} } 56 | @compare.diff_replaced!(diff) 57 | expect(diff[:replace]).to eql({ 'content' => ['New content.'] }) 58 | end 59 | end 60 | 61 | describe "#diff_properties" do 62 | it "should combine diffs from each of the three methods." do 63 | diff = @compare.diff_properties 64 | expect(diff).to eql({ 65 | delete: { 'category' => ['micropub'] }, 66 | add: { 'name' => ['New name.'], 'category' => ['new'] }, 67 | replace: { 'content' => ['New content.'] } 68 | }) 69 | end 70 | end 71 | 72 | end 73 | 74 | end -------------------------------------------------------------------------------- /spec/micropublish/endpoints_finder_spec.rb: -------------------------------------------------------------------------------- 1 | describe Micropublish::EndpointsFinder do 2 | 3 | LINKS = { 4 | micropub: 'https://api.barryfrost.com/micropub', 5 | authorization_endpoint: 'https://indieauth.com/auth', 6 | token_endpoint: 'https://tokens.indieauth.com/token' 7 | } 8 | 9 | before do 10 | stub_request(:get, 'https://example-metadata-body.com/').to_return(status: 200, 11 | body: ' 12 | 13 | ', 14 | headers: {} 15 | ) 16 | stub_request(:get, 'https://example-metadata-headers.com/').to_return(status: 200, 17 | body: '', 18 | headers: { 19 | "Link" => '; rel="indieauth-metadata"' 20 | } 21 | ) 22 | stub_request(:get, 'https://example-headers.com/').to_return(status: 200, 23 | body: '', 24 | headers: { 25 | "Link" => '; rel="micropub", ; rel="authorization_endpoint", ; rel="token_endpoint"' 26 | } 27 | ) 28 | stub_request(:get, 'https://example-body.com/').to_return(status: 200, 29 | body: ' 30 | 31 | 32 | 33 | ', 34 | headers: {} 35 | ) 36 | stub_request(:get, 'https://barryfrost.com/indieauth-metadata').to_return( 37 | status: 200, 38 | body: { 39 | authorization_endpoint: 'https://indieauth.com/auth', 40 | token_endpoint: 'https://tokens.indieauth.com/token' 41 | }.to_json 42 | ) 43 | stub_request(:get, 'https://example.com/').to_return(status: 200) 44 | end 45 | 46 | context "given a website url" do 47 | 48 | describe "#get_url" do 49 | it "should retrieve a successful response from a valid url" do 50 | url = 'https://example.com/' 51 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 52 | response = endpoints_finder.get_url(url) 53 | expect(response.code.to_i).to eql(200) 54 | end 55 | end 56 | 57 | describe "#find_metadata_links" do 58 | it "should return authorization_endpoint and token_endpoint from metadata link in body)" do 59 | url = 'https://example-metadata-body.com/' 60 | indieauth_links = LINKS.dup 61 | indieauth_links.delete(:micropub) 62 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 63 | response = endpoints_finder.get_url(url) 64 | endpoints_finder.find_metadata_links(response) 65 | expect(endpoints_finder.links).to eql(indieauth_links) 66 | end 67 | it "should return authorization_endpoint and token_endpoint from metadata link in headers" do 68 | url = 'https://example-metadata-headers.com/' 69 | indieauth_links = LINKS.dup 70 | indieauth_links.delete(:micropub) 71 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 72 | response = endpoints_finder.get_url(url) 73 | endpoints_finder.find_metadata_links(response) 74 | expect(endpoints_finder.links).to eql(indieauth_links) 75 | end 76 | end 77 | 78 | describe "#find_header_links" do 79 | it "should return micropub, authorization_endpoint and token_endpoint from header" do 80 | url = 'https://example-headers.com/' 81 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 82 | response = endpoints_finder.get_url(url) 83 | endpoints_finder.find_header_links(response) 84 | expect(endpoints_finder.links).to eql(LINKS) 85 | end 86 | end 87 | 88 | describe "#find_body_links" do 89 | it "should return micropub, authorization_endpoint and token_endpoint from body" do 90 | url = 'https://example-body.com/' 91 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 92 | response = endpoints_finder.get_url(url) 93 | endpoints_finder.find_body_links(response) 94 | expect(endpoints_finder.links).to eql(LINKS) 95 | end 96 | end 97 | 98 | describe "#validate" do 99 | it "should ensure we have the three required endpoints" do 100 | url = 'https://example-body.com/' 101 | endpoints_finder = Micropublish::EndpointsFinder.new(url) 102 | endpoints_finder.find_links 103 | expect { endpoints_finder.validate! }.to_not raise_error 104 | end 105 | end 106 | 107 | end 108 | 109 | end -------------------------------------------------------------------------------- /spec/micropublish/server_spec.rb: -------------------------------------------------------------------------------- 1 | describe Micropublish::Server do 2 | end -------------------------------------------------------------------------------- /spec/micropublish_spec.rb: -------------------------------------------------------------------------------- 1 | describe Micropublish do 2 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | 4 | ENV['RACK_ENV'] = 'test' 5 | 6 | require "bundler/setup" 7 | Bundler.require(:default, :test) 8 | 9 | require 'rack/test' 10 | require 'rspec' 11 | require 'webmock/rspec' 12 | require 'micropublish' 13 | 14 | module RSpecMixin 15 | include Rack::Test::Methods 16 | def app 17 | Micropublish::Server 18 | end 19 | end 20 | 21 | RSpec.configure do |c| 22 | c.include RSpecMixin 23 | c.mock_with :rspec do |mocks| 24 | mocks.verify_doubled_constant_names = true 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /views/dashboard.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Create a new post

4 |
5 | 23 | 41 |
42 | 43 | <% if session[:scope].split(' ').any? { |s| ['update', 'delete', 'undelete'].include?(s) } %> 44 |
45 |
46 |
47 |

Modify an existing post

48 |
49 |
50 | 51 |

52 | Enter the absolute URL of a post on your site. 53 |

54 |
55 | 72 |
73 |
74 | <% end %> -------------------------------------------------------------------------------- /views/delete.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Delete post?

4 |
5 |
6 |
7 |
8 | 10 |
11 |
12 | 13 | Cancel 14 |
15 |
16 |
17 |
-------------------------------------------------------------------------------- /views/form.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <% if @all %><% end %> 5 | 6 |
7 | 8 |
9 |

10 | <% if @all %> 11 | All fields 12 | <% else %> 13 | 14 | <% end %> 15 | <%= @subtype_label || "" %> 16 |

17 |
18 | 19 |
20 | 21 | <% if params.key?('url') || params.key?('_url') %> 22 |
23 | 24 | 26 |
27 | <% end %> 28 | 29 | <% if @all || @properties.include?('in-reply-to') %> 30 |
31 | 35 | 38 |

39 | Enter the URL of the post to which you are replying. 40 | You can enter multiple URLs separated by whitespace if your server supports this. 41 | in-reply-to[] 42 |

43 |
44 | <%= autogrow_script('in-reply-to') %> 45 | <% end %> 46 | 47 | <% if @all || @properties.include?('repost-of') %> 48 |
49 | 53 | 56 |

57 | Enter the URL of the post that you are reposting. 58 | You can enter multiple URLs separated by whitespace if your server supports this. 59 | repost-of[] 60 |

61 |
62 | <%= autogrow_script('repost-of') %> 63 | <% end %> 64 | 65 | <% if @all || @properties.include?('like-of') %> 66 |
67 | 71 | 74 |

75 | Enter the URL of the post that you like. 76 | You can enter multiple URLs separated by whitespace if your server supports this. 77 | like-of[] 78 |

79 |
80 | <%= autogrow_script('like-of') %> 81 | <% end %> 82 | 83 | <% if @all || @properties.include?('bookmark-of') %> 84 |
85 | 89 | 92 |

93 | Enter the URL of the page you wish to bookmark. 94 | You can enter multiple URLs separated by whitespace if your server supports this. 95 | bookmark-of[] 96 |

97 |
98 | <%= autogrow_script('bookmark-of') %> 99 | <% end %> 100 | 101 | <% if @all || @properties.include?('listen-of') %> 102 |
103 | 107 | 110 |

111 | Enter the URL of the podcast you listened to. 112 | You can enter multiple URLs separated by whitespace if your server supports this. 113 | listen-of[] 114 |

115 |
116 | <%= autogrow_script('listen-of') %> 117 | <% end %> 118 | 119 | <% if @all || @properties.include?('ate') %> 120 | 121 |
122 | 126 | 127 | required<% end %> 129 | value="<%= h @post.properties['ate'][0]['properties']['name'][0] if @post.properties.key?('ate') %>"> 130 | 131 |

132 | Enter the name of the food you ate. 133 | ate 134 |

135 |
136 | <% end %> 137 | 138 | <% if @all || @properties.include?('drank') %> 139 | 140 |
141 | 145 | 146 | required<% end %> 148 | value="<%= h @post.properties['drank'][0]['properties']['name'][0] if @post.properties.key?('drank') %>"> 149 | 150 |

151 | Enter the name of the drink you drank. 152 | drank 153 |

154 |
155 | <% end %> 156 | 157 | <% if @all || @properties.include?('photo') %> 158 |
159 | 163 | 166 | 167 | <% if @media %> 168 | 169 | 170 | <% end %> 171 | 172 |

173 | Enter the URL of the photo. 174 | You can enter multiple URLs separated by whitespace if your server supports this. 175 | <% if @media %> 176 |
177 | Alternatively, click the "Upload" button to send a file to your media endpoint. 178 | The photo field will then be filled with the URL that is returned. 179 | <% end %> 180 | photo[] 181 |

182 |
183 | <%= autogrow_script('photo') %> 184 | <% end %> 185 | 186 | <% if @all || @properties.include?('rsvp') %> 187 | <% rsvp = @post.properties.key?('rsvp') ? @post.properties['rsvp'][0] : '' %> 188 |
189 | 193 |
194 | 197 | 200 | 203 | 206 |

207 | Select your response to the event. 208 | rsvp 209 |

210 |
211 | <% end %> 212 | 213 | <% if @properties.include?('checkin') %> 214 | 215 |
216 | 220 | required<% end %> 222 | value="<%= h @post.properties['checkin'][0]['properties']['name'][0] if @post.properties.key?('checkin') %>"> 223 |

224 | Enter the check-in location's name in free text. 225 | Check-ins are defined as h-card objects. 226 | checkin{name} 227 |

228 |
229 |
230 |
231 | 235 | required<% end %> 238 | value="<%= h @post.properties['checkin'][0]['properties']['latitude'][0] if @post.properties.key?('checkin') && @post.properties['checkin'].is_a?(Array) %>"> 239 |   240 | 244 | required<% end %> 247 | value="<%= h @post.properties['checkin'][0]['properties']['longitude'][0] if @post.properties.key?('checkin') && @post.properties['checkin'].is_a?(Array) %>"> 248 | 251 |
252 |

253 | Enter the latitude and longitude coordinates for the check-in. 254 | checkin{latitude} 255 | checkin{longitude} 256 |

257 |
258 | <% end %> 259 | 260 | <% if @all || @properties.include?('name') %> 261 |
262 | 266 | required<% end %> 268 | value="<%= h @post.properties['name'][0] if @post.properties.key?('name') %>"> 269 |

270 | Enter a name/title for this post. 271 | name 272 |

273 |
274 | <% end %> 275 | 276 | <% if @all || @properties.include?('content') %> 277 |
278 | 282 |   283 | <% if (@edit && @post.properties.key?('content') && !@post.properties['content'][0].is_a?(Hash)) || (@properties.include?('content') && @subtype != 'article') || (@properties.include?('content') && params.key?('format') && params['format'] == 'text') %> 284 | <% if @subtype == 'article' && !@edit %> 285 | Editor | 286 | HTML | 287 | Text 288 | <% end %> 289 |
290 | 291 |
292 | 295 |

296 | Enter content for this post. 297 | You should use plain text or markup if your server supports this. 298 | content 299 |

300 | <%= autogrow_script('content') %> 301 | <% elsif (@edit && @post.properties.key?('content') && @post.properties['content'][0].is_a?(Hash)) || (@properties.include?('content') && @subtype == 'article') %> 302 | <% if !params.key?('format') || params['format'] == 'editor' %> 303 | Editor 304 | <% else %> 305 | Editor 306 | <% end %> | 307 | <% if params.key?('format') && params['format'] == 'html' %> 308 | HTML 309 | <% else %> 310 | HTML 311 | <% end %> 312 | <% unless @edit %> 313 | | Text 314 | <% end %> 315 | <% content_html_value = @post.properties.key?('content') && @post.properties['content'][0].is_a?(Hash) && @post.properties['content'][0].key?('html') ? h(@post.properties['content'][0]['html']) : "" %> 316 | <% if params.key?('format') && params['format'] == 'html' %> 317 | 318 | <%= autogrow_script('content') %> 319 | <% else %> 320 | 321 | 322 | <% end %> 323 |

324 | Enter content for this post. 325 | You may use rich content via the embedded 326 | Trix editor or, if your 327 | browser does not have JavaScript enabled, you can directly enter 328 | HTML. 329 | content[][html] 330 |

331 | <% end %> 332 |
333 | <% end %> 334 | 335 | <% if @all || @properties.include?('summary') %> 336 |
337 | 341 |
342 | 343 |
344 | 347 |

348 | Enter a text summary for this post. 349 | summary 350 |

351 |
352 | <%= autogrow_script('summary') %> 353 | <% end %> 354 | 355 | <% if @all || @properties.include?('category') %> 356 |
357 | 361 | required<% end %> 363 | value="<%= h @post.properties['category'].join(', ') if @post.properties.key?('category') %>"> 364 |

365 | Enter categories/tags and separate with commas. 366 | You may also enter URLs if your server supports them. 367 | category[] 368 |

369 | <%= tokenfield_script('category') %> 370 |
371 | <% end %> 372 | 373 | <% if @all || @properties.include?('location') %> 374 |
375 | 382 | 383 | required<% end %> 385 | value="<%= h @post.properties['location'][0] if @post.properties.key?('location') %>"> 386 | 387 |

388 | Enter a location for the post. If you check the "Auto-detect?" checkbox, a Geo URI 389 | will be generated for you. The browser will remember your choice for the next time. 390 | location 391 |

392 |
393 | <% end %> 394 | 395 | <% if @all || @properties.include?('post-status') || @properties.include?('visibility') %> 396 |
397 |
398 | <% scopes_array = session[:scope].split(' ') %> 399 | <% if !scopes_array.include?('create') || scopes_array == ['draft'] %> 400 |
401 | 402 | 405 |
406 | <% elsif @all || @properties.include?('post-status') %> 407 |
408 | 412 | 418 |
419 | <% end %> 420 | <% if @all || @properties.include?('visibility') %> 421 |
422 | 426 | 433 |
434 | <% end %> 435 |
436 |

437 | <% if @all || @properties.include?('post-status') %> 438 | Choose whether your post should be published or made a draft. 439 | post-status 440 | <% end %> 441 | <% if @all || @properties.include?('visibility') %> 442 | Select a visibility setting to indicate to your server whether 443 | this post should be made public, excluded from lists or be hidden 444 | as a private post. 445 | visibility 446 | <% end %> 447 | NB: Please check whether your server supports these values, or leave 448 | blank (default). 449 |

450 |
451 | <% end %> 452 | 453 |
454 | 458 |
459 | <% syndicate_to(@subtype).each do |syndication| %> 460 | 468 | <% end %> 469 |

470 | Choose a service to syndicate to from your endpoint's 471 | syndicate-to list. If your services do not appear here then 472 | please read the docs to ensure 473 | you are using the correct format. 474 | mp-syndicate-to[] 475 |

476 |
477 | 478 | <% if !@edit %> 479 |
480 | 484 | required<% end %> 486 | value="<%= h @post.properties['mp-slug'][0] if @post.properties.key?('mp-slug') %>"> 487 |

488 | Enter a short slug you would like your server to use (if supported). 489 | mp-slug 490 |

491 |
492 | <% end %> 493 | 494 | <% if @edit %>
<% end %> 495 | 496 | <% if @all || @edit || @properties.include?('syndication') %> 497 |
498 | 502 | 505 |

506 | Enter URL(s) of alternative locations for this post separated by 507 | whitespace. 508 | syndication[] 509 |

510 |
511 | <%= autogrow_script('syndication') %> 512 | <% end %> 513 | 514 | <% if @all || @edit || @properties.include?('published') %> 515 |
516 | 520 | required<% end %> 522 | value="<%= h @post.properties['published'][0] if @post.properties.key?('published') %>"> 523 |

524 | Enter a datetime for when this post was (or will be) published. 525 | The suggested format is YYYY-MM-DDTHH:MM:SSZ, 526 | e.g. 2016-11-21T12:34:56Z for a post at 12:34:56 UTC on 21 November 527 | 2016. 528 | published 529 |

530 |
531 | <% end %> 532 | 533 | <% if channels && channels.is_a?(Array) %> 534 |
535 | 539 |
540 | <% channels.each do |channel| %> 541 | <% if channel.is_a?(Hash) %> 542 | <% uid = channel['uid']; name = channel['name'] %> 543 | <% else %> 544 | <% uid = channel; name = channel %> 545 | <% end %> 546 | 553 | <% end %> 554 |

555 | Choose optional channels from your server's list of channels. 556 | mp-channel[] 557 |

558 |
559 | <% end %> 560 | 561 |
562 | 563 | 573 | 574 |
575 |
576 | 577 | 593 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Micropublish<% if @title %>: <%= @title %><% end %> 8 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 34 | 35 |
36 |
37 |
38 | <%= flash_message %> 39 | <%= yield %> 40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 |
48 | <% if logged_in? %> 49 |
50 |

51 | <%= session[:me] %>  52 | <%= session[:scope] %>  53 | 56 |

57 |
58 | <% end %> 59 | About 60 | · 61 | Changelog 62 |
63 |
64 |
65 | 66 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /views/login.erb: -------------------------------------------------------------------------------- 1 |

2 | Create or modify posts on your 3 | Micropub-compatible website. 4 | Find out more about the requirements. 5 |

6 | 7 |
8 |
9 |

Sign in

10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 | 19 |
20 |
21 |
22 | 23 |
24 | 28 | 32 | 36 | 40 | 44 | 48 |
49 |
50 |
51 |
52 | 53 |
54 |
55 |
56 | 57 |
58 |
59 | -------------------------------------------------------------------------------- /views/preview.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Preview

4 |
5 |
6 |
<%= @content %>
7 |
8 |
-------------------------------------------------------------------------------- /views/redirect.erb: -------------------------------------------------------------------------------- 1 |
2 | 404 Not Found was received from your server. 3 | This may be because your server has not finished creating the post yet. 4 | Micropublish will try to redirect again shortly. 5 |
6 | 7 |
8 |
9 |
10 |

11 | 12 |

13 |

Redirecting in 3 seconds

14 |
15 |

16 | <%= @url %> 17 |

18 |
19 |
20 |
21 | 22 | 27 | -------------------------------------------------------------------------------- /views/static.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= @content %> 3 |
-------------------------------------------------------------------------------- /views/undelete.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Undelete post?

4 |
5 |
6 |
7 |
8 | 10 |
11 |
12 | 13 | Cancel 14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------