~srushe/babbas

28bbc492627ea349f3b13dc8ce7daf5792f33dbc — Stephen Rushe 1 year, 4 months ago
Initial release
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>