A => .gitignore +4 -0
@@ 1,4 @@
+.env
+public/*
+spec/examples.txt
+tmp/*
A => Gemfile +12 -0
@@ 1,12 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
+git_source(:github) {|repo_name| "git@github.com:#{repo_name}" }
+
+gem 'sinatra'
+gem 'dotenv'
+gem 'indieauth-token-verification', github: 'srushe/indieauth-token-verification'
+gem 'mime-types'
+
A => Gemfile.lock +35 -0
@@ 1,35 @@
+GIT
+ remote: git@github.com:srushe/indieauth-token-verification
+ revision: 4396ba3e20b0b50d589c8b0b01e24404ccd083b5
+ specs:
+ indieauth-token-verification (0.2.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ dotenv (2.7.5)
+ mime-types (3.3)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2019.1009)
+ mustermann (1.0.3)
+ rack (2.0.7)
+ rack-protection (2.0.7)
+ rack
+ sinatra (2.0.7)
+ mustermann (~> 1.0)
+ rack (~> 2.0)
+ rack-protection (= 2.0.7)
+ tilt (~> 2.0)
+ tilt (2.0.10)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ dotenv
+ indieauth-token-verification!
+ mime-types
+ sinatra
+
+BUNDLED WITH
+ 1.16.3
A => LICENSE +21 -0
@@ 1,21 @@
+MIT License
+
+Copyright (c) 2019 Stephen Rushe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
A => README.md +71 -0
@@ 1,71 @@
+# Babbas - A Ruby IndieWeb Media Endpoint, for Micropub
+
+Babbas is a Media endpoint for use with an IndieWeb [Micropub](http://micropub.rocks/) endpoint. It is written in Ruby, as a [Sinatra](http://sinatrarb.com/) application, and supports IndieAuth authorisation, as well as being [content-addressable](https://en.wikipedia.org/wiki/Content-addressable_storage).
+
+## Requirements
+
+* Ruby - the latest version (or one version back).
+
+## Installation
+
+Babbas is currently used as the Media endpoint for my personal website (https://deeden.co.uk/) and, as such, is configured to work for me. It should work for someone else if properly configured (via `.env`). For my purposes I run Babbas through [Passenger](https://www.phusionpassenger.com/) on a [Dreamhost VPS](https://www.dreamhost.com/hosting/vps/).
+
+## Configuration
+
+### Environment variables
+
+Use of the application **requires** a number of environment variables to be specified, while others are optional. It does support [dotenv](https://github.com/bkeepers/dotenv) if that floats your boat.
+
+#### Required
+
+| Variable | Format | Purpose |
+| -------- | ------ | ------- |
+| ENDPOINT_URL | URL | The domain you want to serve media from. For example, I serve the files from `https://media.deeden.co.uk` |
+| ENDPOINT_BASE | Directory path | The base directory for where your media will be stored. |
+| DATA_DIRECTORY | Directory path | A more specific directory (within `ENDPOINT_BASE`) for where your media will be stored. |
+| TOKEN_ENDPOINT | URL | The token endpoint used to verify any IndieAuth token |
+| DOMAIN | URL | The domain any IndieAuth token will be verified to be valid for |
+
+##### `ENDPOINT_BASE` vs `DATA_DIRECTORY`
+
+What is the difference between `ENDPOINT_BASE` and `DATA_DIRECTORY`, you ask?
+
+Let's say that you want to store your media in the `/www/media/` directory, and have your media links served from the root of a site, such as `https://example.com/`. In that case you would set the following values (similar to what I use)...
+
+| Variable | Value |
+| -------- | ----- |
+| ENDPOINT_URL | `https://example.com/` |
+| ENDPOINT_BASE | `/www/media` |
+| DATA_DIRECTORY | None/Unset |
+
+However maybe you'd prefer to serve your media from a different path, such as `https://example.com/photos/`. In this case you could configure it as follows...
+
+| Variable | Value |
+| -------- | ----- |
+| ENDPOINT_URL | `https://example.com/` |
+| ENDPOINT_BASE | `/www/media` |
+| DATA_DIRECTORY | `photos` |
+
+This results in `photos` being included correctly included in both the path used to save the file, and the url used to refer to the file.
+
+##### Token Verification environment variables
+
+Babbas uses the [IndieAuth::TokenVerification](https://github.com/srushe/indieauth-token-verification/) ruby gem to verify an IndieAuth access token against a token endpoint, and the `TOKEN_ENDPOINT` and `DOMAIN` environment variables are required by that gem.
+
+`TOKEN_ENDPOINT` specifies the token endpoint to be used to validate the access token. Failure to specify `TOKEN_ENDPOINT` will result in a `IndieAuth::TokenVerification::MissingTokenEndpointError` error being raised.
+
+`DOMAIN` specifies the domain we expect to see in the response from the validated token. It should match that specified when the token was first generated (presumably your website URL). Failure to specify `DOMAIN` will result in a `IndieAuth::TokenVerification::MissingDomainError` error being raised.
+
+## Contributing
+
+While Babbas is written with a view to "[self-dogfooding](https://indieweb.org/selfdogfood)" I'm still happy for other people to use and contribute to the project. Bug reports and pull requests are welcome on GitHub at https://github.com/srushe/babbas. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
+
+## License
+
+This application is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
+
+## Babbas?
+
+![Babbas, a genie from Yonderland](static/babbas.png)
+
+Babbas is a genie from the TV series [Yonderland](https://en.wikipedia.org/wiki/Yonderland). He is tasked with looking after a video message for one of the lead characters, hence his use as the name for my media endpoint.
A => config.ru +5 -0
@@ 1,5 @@
+require 'dotenv/load'
+require 'sinatra'
+require './lib/babbas/application'
+
+run Babbas::Application
A => lib/babbas/application.rb +58 -0
@@ 1,58 @@
+require 'indie_auth/token_verification'
+require_relative 'file'
+
+module Babbas
+ class Application < Sinatra::Application
+ set :public_folder, ::File.dirname(__FILE__) + '/../../static'
+
+ get '/' do
+ send_file ::File.join(settings.public_folder, 'index.html')
+ end
+
+ post '/' do
+ verify_token('create')
+
+ save_file
+ end
+
+ private
+
+ def verify_token(scope=nil)
+ access_token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || ''
+ IndieAuth::TokenVerification.new(access_token).verify(scope)
+ rescue IndieAuth::TokenVerification::AccessTokenMissingError
+ send_error(status: 401, error: 'unauthorized', description: 'Access token missing or empty')
+ rescue IndieAuth::TokenVerification::MissingDomainError
+ send_error(status: 400, error: 'invalid_request', description: 'DOMAIN is not specified')
+ rescue IndieAuth::TokenVerification::MissingTokenEndpointError
+ send_error(status: 400, error: 'invalid_request', description: 'TOKEN_ENDPOINT is not specified')
+ rescue IndieAuth::TokenVerification::ForbiddenUserError
+ send_error(status: 403, error: 'forbidden', description: 'User does not have permission')
+ rescue IndieAuth::TokenVerification::IncorrectMeError
+ send_error(status: 401, error: 'insufficient_scope', description: 'The "me" value does match the expected DOMAIN')
+ rescue IndieAuth::TokenVerification::InsufficentScopeError
+ send_error(status: 401, error: 'insufficient_scope', description: 'The scope of this token does not meet the requirements for this request')
+ end
+
+ def send_error(status: 400, error: 'invalid_request', description:)
+ json = {
+ error: error,
+ error_description: description
+ }.to_json
+
+ halt(status, { 'Content-Type' => 'application/json' }, json)
+ end
+
+ def save_file
+ file = Babbas::File.new(params)
+
+ status 201
+ headers 'Location' => file.url
+ rescue Babbas::File::EmptyFileError
+ send_error(description: 'No saveable data was provided')
+ rescue Babbas::File::UnrecognisedTypeError => e
+ # TODO: Include list of valid types in error description.
+ send_error(description: "An unrecognisable file type was provided (#{e})")
+ end
+ end
+end
A => lib/babbas/file.rb +63 -0
@@ 1,63 @@
+require 'fileutils'
+require 'mime/types'
+
+module MediaEndpoint
+ class File
+ class EmptyFileError < StandardError; end
+ class UnrecognisedTypeError < StandardError; end
+
+ VALID_TYPES = %w[jpeg gif png]
+
+ attr_reader :params, :url
+
+ def initialize(params)
+ @params = params
+ save_file
+ end
+
+ def url
+ @url ||= [ENV['ENDPOINT_URL'].chomp('/'), file_path].join('/')
+ end
+
+ private
+
+ def save_file
+ validate
+ return url if ::File.exist?(file_path)
+ write_file
+ end
+
+ def write_file
+ params[:file][:tempfile].rewind
+ FileUtils.mkdir_p(::File.dirname(full_file_path))
+ FileUtils.install(params[:file][:tempfile].path, full_file_path, mode: 0644)
+ end
+
+ def file_path
+ @file_path ||= ::File.join([ENV['DATA_DIRECTORY'],
+ file_name[0..1],
+ "#{file_name}.#{extension}"].reject(&:empty?))
+ end
+
+ def full_file_path
+ @full_file_path ||= ::File.join(ENV['ENDPOINT_BASE'], file_path)
+ end
+
+ def file_name
+ @file_name ||= Digest::SHA256.hexdigest(params[:file][:tempfile].read)
+ end
+
+ def content_type
+ @content_type ||= params[:file][:type]
+ end
+
+ def extension
+ @extension ||= MIME::Types[content_type].first.preferred_extension
+ end
+
+ def validate
+ raise MediaEndpoint::File::EmptyFileError if params.dig(:file, :tempfile).size.zero?
+ raise MediaEndpoint::File::UnrecognisedTypeError, content_type unless VALID_TYPES.include?(extension)
+ end
+ end
+end
A => static/babbas.png +0 -0
A => static/index.html +24 -0
@@ 1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="x-ua-compatible" content="ie=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+ <title>Babbas - A Media Endpoint for Micropub</title>
+ <style type="text/css" media="screen">
+ body { margin: 0; font-family: sans-serif; }
+ #container { height: 100vh; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: left; }
+ h1 { font-size: 2em }
+
+ @media (max-width: 50em) {
+ .container { padding: 0 1em }
+ }
+ </style>
+</head>
+<body>
+ <div id="container">
+ <img alt="A photo of Babbas, a character from the TV series 'Yonderland'" src="babbas.png" />
+ <h1>Babbas - A Media Endpoint for Micropub</h1>
+ </div>
+</body>
+</html>